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()
+ })
+})