From e94aecaa08e900b8c9c1fff36613ddba409b539f Mon Sep 17 00:00:00 2001 From: Kagura Date: Mon, 18 May 2026 16:38:40 +0800 Subject: [PATCH 1/5] fix(tui): use contrast-aware foreground for paste summary badge (#27969) Co-authored-by: Simon Klee --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, }, From fe143df1519c3429d6ddaf0c114eae2d58c2ecb4 Mon Sep 17 00:00:00 2001 From: SpiritChen51 Date: Mon, 18 May 2026 17:26:45 +0800 Subject: [PATCH 2/5] fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993) Co-authored-by: SpiritChen51 --- packages/ui/src/components/message-part.tsx | 42 ++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 07b8022abe..7bd72d7a93 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -58,6 +58,27 @@ 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 +1085,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 +1512,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 +1857,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 ( From 7afd477d1a1a147440f810c122d2f4b408e1b95c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 18 May 2026 09:28:20 +0000 Subject: [PATCH 3/5] chore: generate --- packages/ui/src/components/message-part.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7bd72d7a93..acf0c9dee3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -76,7 +76,10 @@ async function writeClipboard(text: string): Promise { const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard if (!clipboard?.writeText) return false - return clipboard.writeText(text).then(() => true, () => false) + return clipboard.writeText(text).then( + () => true, + () => false, + ) } function ShellSubmessage(props: { text: string; animate?: boolean }) { From 836a33198e6d761656441e398b432290d059e9e1 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 18 May 2026 17:55:27 +0800 Subject: [PATCH 4/5] fix(ui): fix question dock overflow and message part flex layout (#28142) --- .../app/src/pages/session/composer/session-question-dock.tsx | 4 +++- packages/ui/src/components/message-part.css | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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; From 611e48c4ac4224190e753cbbb8b67a407848a2a5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 18 May 2026 16:34:34 +0530 Subject: [PATCH 5/5] fix(tui): collapse long tool output lines (#28148) --- .../tui/feature-plugins/system/session-v2.tsx | 42 +++++++++++-------- .../src/cli/cmd/tui/routes/session/index.tsx | 27 ++++++------ .../cli/cmd/tui/util/collapse-tool-output.ts | 13 ++++++ 3 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts 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 } +}