Compare commits

...

2 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 { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking" import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll" 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 { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
@@ -2384,11 +2384,10 @@ function Shell(props: ToolProps) {
const maxLines = 10 const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : "")) const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${command()}` : ""))
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n")) const collapsed = createMemo(() => collapseToolOutputParts(input(), output(), maxLines, maxChars()))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars())) const visible = createMemo(() => {
const limited = createMemo(() => { if (expanded()) return { input: input(), output: output() }
if (expanded() || !collapsed().overflow) return content() return collapsed()
return collapsed().output
}) })
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow) const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const toggle = () => { const toggle = () => {
@@ -2413,18 +2412,17 @@ function Shell(props: ToolProps) {
<Show <Show
when={isRunning()} when={isRunning()}
fallback={ fallback={
<text> <text fg={themeV2.text()}>{visible().input}</text>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
</text>
} }
> >
<Spinner color={color()}> <Spinner color={color()}>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span> <span style={{ fg: themeV2.text() }}>{visible().input}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
</Spinner> </Spinner>
</Show> </Show>
</Show> </Show>
<Show when={visible().output}>
{(value) => <text fg={themeV2.text.subdued()}>{value()}</text>}
</Show>
<Show when={shellID()}> <Show when={shellID()}>
<StatusBadge>Background</StatusBadge> <StatusBadge>Background</StatusBadge>
</Show> </Show>
@@ -19,3 +19,26 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n
return { output: preview, overflow: true } 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 { 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", () => { test("limits command input and output to the same line budget", () => {
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n") 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).toContain("command 8\n\noutput 1…")
expect(collapsed.output).not.toContain("output 2") 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)
})