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 ae23d58bcc..162ad62a1e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1496,6 +1496,8 @@ const PART_MAPPING = { reasoning: ReasoningPart, } +const INLINE_TOOL_ICON_WIDTH = 2 + function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme } = useTheme() const ctx = use() @@ -1743,6 +1745,7 @@ function InlineTool(props: { const sync = useSync() const renderer = useRenderer() const [hover, setHover] = createSignal(false) + const [errorExpanded, setErrorExpanded] = createSignal(false) const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID @@ -1750,13 +1753,6 @@ function InlineTool(props: { return callID === props.part.callID }) - const fg = createMemo(() => { - if (permission()) return theme.warning - if (hover() && props.onClick) return theme.text - if (props.complete) return theme.textMuted - return theme.text - }) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) const denied = createMemo( @@ -1767,14 +1763,28 @@ function InlineTool(props: { error()?.includes("user dismissed"), ) + const failed = createMemo(() => Boolean(error() && !denied())) + const clickable = createMemo(() => Boolean(props.onClick || failed())) + const fg = createMemo(() => { + if (permission()) return theme.warning + if (failed()) return theme.error + if (hover() && props.onClick) return theme.text + if (props.complete) return theme.textMuted + return theme.text + }) + return ( props.onClick && setHover(true)} + onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return + if (failed()) { + setErrorExpanded((value) => !value) + return + } props.onClick?.() }} renderBefore={function () { @@ -1783,21 +1793,10 @@ function InlineTool(props: { if (!parent) { return } - if (el.height > 1) { - setMargin(1) - return - } const children = parent.getChildren() const index = children.indexOf(el) const previous = children[index - 1] - if (!previous) { - setMargin(0) - return - } - if (previous.height > 1 || previous.id.startsWith("text-")) { - setMargin(1) - return - } + setMargin(previous?.id.startsWith("text-") ? 1 : 0) }} > @@ -1805,15 +1804,37 @@ function InlineTool(props: { - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - + + ~ {props.pending} + + } + when={props.complete} + > + + + {props.icon} + + + {props.children} + + + - - {error()} + + + {error()} + ) diff --git a/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap new file mode 100644 index 0000000000..6cb0df1e2e --- /dev/null +++ b/packages/opencode/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -0,0 +1,22 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = ` +" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; + +exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = ` +" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. + *dir|xdg|APPDATA" in packages/opencode/src (151 matches) + ✱ Glob "**/*db*" in packages/opencode (6 matches) + → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] + → Read packages/opencode/src/index.ts [offset=1, limit=100] + No LSP server available for this file type. + ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. + Path\\.data|data =" in packages/opencode/src (115 matches)" +`; diff --git a/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx new file mode 100644 index 0000000000..c7aa4708ec --- /dev/null +++ b/packages/opencode/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createSignal, For } from "solid-js" +import { testRender } from "@opentui/solid" + +let testSetup: Awaited> | undefined + +afterEach(() => { + testSetup?.renderer.destroy() + testSetup = undefined +}) + +type ToolFixture = { icon: string; label: string; error?: string } + +const INLINE_TOOL_ICON_WIDTH = 2 + +const tools: readonly ToolFixture[] = [ + { + icon: "✱", + label: + 'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)', + }, + { + icon: "✱", + label: 'Glob "**/*db*" in packages/opencode (6 matches)', + }, + { + icon: "→", + label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]", + }, + { + icon: "→", + label: "Read packages/opencode/src/index.ts [offset=1, limit=100]", + error: "No LSP server available for this file type.", + }, + { + icon: "✱", + label: + 'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)', + }, +] as const + +function InlineToolRow(props: { item: ToolFixture; errorExpanded?: boolean }) { + const [margin, setMargin] = createSignal(0) + + return ( + + + {props.item.icon} + {props.item.label} + + {props.item.error && props.errorExpanded && ( + + {props.item.error} + + )} + + ) +} + +function Fixture(props: { errorExpanded?: boolean }) { + return ( + + + {(item) => } + + + ) +} + +describe("TUI inline tool wrapping", () => { + test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => { + testSetup = await testRender(() => , { width: 72, height: 12 }) + await testSetup.renderOnce() + await testSetup.renderOnce() + + expect( + testSetup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd(), + ).toMatchSnapshot() + }) + + test("snapshots expanded tool errors under the tool text", async () => { + testSetup = await testRender(() => , { width: 72, height: 12 }) + await testSetup.renderOnce() + await testSetup.renderOnce() + + expect( + testSetup + .captureCharFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd(), + ).toMatchSnapshot() + }) +})