diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
index 35690030c9..03a66ea3a7 100644
--- a/packages/app/src/pages/session/composer/session-question-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-question-dock.tsx
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
>
}
>
-
{question()?.question}
+
+ {question()?.question}
+
{language.t("ui.question.singleHint")}}>
{language.t("ui.question.multiHint")}
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 24d122162a..3829f4488c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
- foreground: theme.background,
+ foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
index 5017b77b00..f9217ec403 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
+import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
+ const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
- const lines = createMemo(() => output().split("\n"))
- const overflow = createMemo(() => lines().length > 10)
+ const maxLines = 10
+ const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
- if (expanded() || !overflow()) return output()
- return [...lines().slice(0, 10), "…"].join("\n")
+ if (expanded() || !collapsed().overflow) return output()
+ return collapsed().output
})
return (
setExpanded((prev) => !prev) : undefined}
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
$ {props.message.command}
{limited()}
-
+
{expanded() ? "Click to collapse" : "Click to expand"}
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
+ const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
- const lines = createMemo(() => output().split("\n"))
const maxLines = 3
- const overflow = createMemo(() => lines().length > maxLines)
+ const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
- if (expanded() || !overflow()) return output()
- return [...lines().slice(0, maxLines), "…"].join("\n")
+ if (expanded() || !collapsed().overflow) return output()
+ return collapsed().output
})
return (
setExpanded((prev) => !prev) : undefined}
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
{limited()}
-
+
{expanded() ? "Click to collapse" : "Click to expand"}
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
+ const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
- const lines = createMemo(() => output().split("\n"))
- const overflow = createMemo(() => lines().length > 10)
+ const maxLines = 10
+ const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
- if (expanded() || !overflow()) return output()
- return [...lines().slice(0, 10), "…"].join("\n")
+ if (expanded() || !collapsed().overflow) return output()
+ return collapsed().output
})
return (
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
- onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
$ {command()}
{limited()}
-
+
{expanded() ? "Click to collapse" : "Click to expand"}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index ce651fdbe4..e8e29a40c9 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
+import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
- const lines = createMemo(() => output().split("\n"))
const maxLines = 3
- const overflow = createMemo(() => lines().length > maxLines)
+ const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
- if (expanded() || !overflow()) return output()
- return [...lines().slice(0, maxLines), "…"].join("\n")
+ if (expanded() || !collapsed().overflow) return output()
+ return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps) {
setExpanded((prev) => !prev) : undefined}
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
{limited()}
-
+
{expanded() ? "Click to collapse" : "Click to expand"}
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
+ const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
- const lines = createMemo(() => output().split("\n"))
- const overflow = createMemo(() => lines().length > 10)
+ const maxLines = 10
+ const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
+ const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
- if (expanded() || !overflow()) return output()
- return [...lines().slice(0, 10), "…"].join("\n")
+ if (expanded() || !collapsed().overflow) return output()
+ return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps) {
title={title()}
part={props.part}
spinner={isRunning()}
- onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
+ onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
$ {props.input.command}
{limited()}
-
+
{expanded() ? "Click to collapse" : "Click to expand"}
diff --git a/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts b/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts
new file mode 100644
index 0000000000..da8d739351
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts
@@ -0,0 +1,13 @@
+export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
+ const lines = output.split("\n")
+ if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
+ return { output, overflow: false }
+ }
+
+ const preview = lines.slice(0, maxLines).join("\n")
+ if (Array.from(preview).length > maxChars) {
+ return { output: Array.from(preview).slice(0, Math.max(0, maxChars - 1)).join("") + "…", overflow: true }
+ }
+
+ return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 0dd02d8129..78d574d8a1 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -935,7 +935,7 @@
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
- flex: 1;
+ flex-shrink: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 07b8022abe..acf0c9dee3 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -58,6 +58,30 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
+async function writeClipboard(text: string): Promise {
+ const body = typeof document === "undefined" ? undefined : document.body
+ if (body) {
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.setAttribute("readonly", "")
+ textarea.style.position = "fixed"
+ textarea.style.opacity = "0"
+ textarea.style.pointerEvents = "none"
+ body.appendChild(textarea)
+ textarea.select()
+ const copied = document.execCommand("copy")
+ body.removeChild(textarea)
+ if (copied) return true
+ }
+
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
+ if (!clipboard?.writeText) return false
+ return clipboard.writeText(text).then(
+ () => true,
+ () => false,
+ )
+}
+
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
@@ -1064,9 +1088,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
if (!content) return
- await navigator.clipboard.writeText(content)
- setState("copied", true)
- setTimeout(() => setState("copied", false), 2000)
+ if (await writeClipboard(content)) {
+ setState("copied", true)
+ setTimeout(() => setState("copied", false), 2000)
+ }
}
const revert = () => {
@@ -1490,9 +1515,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = text()
if (!content) return
- await navigator.clipboard.writeText(content)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ if (await writeClipboard(content)) {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
}
return (
@@ -1834,9 +1860,10 @@ ToolRegistry.register({
const handleCopy = async () => {
const content = text()
if (!content) return
- await navigator.clipboard.writeText(content)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ if (await writeClipboard(content)) {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
}
return (