Compare commits

...
Author SHA1 Message Date
Kit Langton 7d911b824d fix(tui): preserve shell preview boundaries 2026-07-16 14:05:41 -04:00
Kit Langton befd29212c fix(tui): align streaming shell output 2026-07-16 13:31:46 -04:00
3 changed files with 64 additions and 13 deletions
+10 -12
View File
@@ -66,7 +66,7 @@ import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { collapseToolOutput, collapseToolOutputParts } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
@@ -2384,11 +2384,10 @@ function Shell(props: ToolProps) {
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : ""))
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return content()
return collapsed().output
const collapsed = createMemo(() => collapseToolOutputParts(input(), output(), maxLines, maxChars()))
const visible = createMemo(() => {
if (expanded()) return { input: input(), output: output() }
return collapsed()
})
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => {
@@ -2413,18 +2412,17 @@ function Shell(props: ToolProps) {
<Show
when={isRunning()}
fallback={
<text>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
</text>
<text fg={themeV2.text()}>{visible().input}</text>
}
>
<Spinner color={color()}>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
<span style={{ fg: themeV2.text() }}>{visible().input}</span>
</Spinner>
</Show>
</Show>
<Show when={visible().output}>
{(value) => <text fg={themeV2.text.subdued()}>{value()}</text>}
</Show>
<Show when={shellID()}>
<StatusBadge>Background</StatusBadge>
</Show>
@@ -19,3 +19,26 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
return { output: preview, overflow: true }
}
export function collapseToolOutputParts(input: string, output: string, maxLines: number, maxChars: number) {
const separator = input && output ? "\n\n" : ""
const collapsed = collapseToolOutput(`${input}${separator}${output}`, maxLines, maxChars)
if (!collapsed.overflow) return { input, output, overflow: false }
const ellipsis = collapsed.output.endsWith("…") ? "…" : ""
const preview = ellipsis ? collapsed.output.slice(0, -1) : collapsed.output
const outputStart = input.length + separator.length
if (preview.length <= outputStart) {
return {
input: preview.slice(0, input.length) + ellipsis,
output: "",
overflow: true,
}
}
return {
input,
output: preview.slice(outputStart) + ellipsis,
overflow: true,
}
}
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { collapseToolOutput } from "../../../src/util/collapse-tool-output"
import { collapseToolOutput, collapseToolOutputParts } from "../../../src/util/collapse-tool-output"
test("limits command input and output to the same line budget", () => {
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n")
@@ -12,3 +12,33 @@ test("limits command input and output to the same line budget", () => {
expect(collapsed.output).toContain("command 8\n\noutput 1…")
expect(collapsed.output).not.toContain("output 2")
})
test.each([
{
name: "inside the command",
maxChars: 5,
expected: { input: "$ co…", output: "", overflow: true },
},
{
name: "after the command",
maxChars: 10,
expected: { input: "$ command…", output: "", overflow: true },
},
{
name: "on the first separator newline",
maxChars: 11,
expected: { input: "$ command…", output: "", overflow: true },
},
{
name: "on the second separator newline",
maxChars: 12,
expected: { input: "$ command…", output: "", overflow: true },
},
{
name: "inside the output",
maxChars: 15,
expected: { input: "$ command", output: "out…", overflow: true },
},
])("keeps the ellipsis with the visible $name", ({ maxChars, expected }) => {
expect(collapseToolOutputParts("$ command", "output", 10, maxChars)).toEqual(expected)
})