From 611e48c4ac4224190e753cbbb8b67a407848a2a5 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 18 May 2026 16:34:34 +0530 Subject: [PATCH] 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 } +}