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 b3dc2a4a7d..5c0e5e73fe 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1524,6 +1524,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()
@@ -1789,12 +1791,12 @@ function InlineTool(props: {
part: ToolPart
onClick?: () => void
}) {
- const [margin, setMargin] = createSignal(0)
const { theme } = useTheme()
const ctx = use()
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
@@ -1802,14 +1804,6 @@ function InlineTool(props: {
return callID === props.part.callID
})
- const fg = createMemo(() => {
- if (props.color) return props.color
- 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(
@@ -1820,53 +1814,134 @@ function InlineTool(props: {
error()?.includes("user dismissed"),
)
+ const failed = createMemo(() => Boolean(error() && !denied()))
+ const clickable = createMemo(() => Boolean(props.onClick || failed()))
+ const fg = createMemo(() => {
+ if (props.color) return props.color
+ 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 (
+
+ sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
+ }
+ onMouseOver={() => clickable() && setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={() => {
+ if (renderer.getSelection()?.getSelectedText()) return
+ if (failed()) {
+ setErrorExpanded((value) => !value)
+ return
+ }
+ props.onClick?.()
+ }}
+ >
+ {props.children}
+
+ )
+}
+
+export function InlineToolRow(props: {
+ icon: string
+ iconColor?: RGBA
+ color?: RGBA
+ errorColor?: RGBA
+ failed?: boolean
+ denied?: boolean
+ error?: string
+ errorExpanded?: boolean
+ complete: any
+ pending: string
+ spinner?: boolean
+ children: JSX.Element
+ separateAfter?: (id: string | undefined) => boolean
+ onMouseOver?: () => void
+ onMouseOut?: () => void
+ onMouseUp?: () => void
+}) {
+ const [margin, setMargin] = createSignal(0)
+
return (
props.onClick && setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={() => {
- if (renderer.getSelection()?.getSelectedText()) return
- props.onClick?.()
- }}
+ onMouseOver={props.onMouseOver}
+ onMouseOut={props.onMouseOut}
+ onMouseUp={props.onMouseUp}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
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-") ||
+ previous?.id.startsWith("tool-block-") ||
+ props.separateAfter?.(previous?.id)
+ ? 1
+ : 0,
+ )
}}
>
-
+
-
- ~ {props.pending}>} when={props.complete}>
- {props.icon} {props.children}
-
-
+
+ ~ {props.pending}
+
+ }
+ when={props.complete}
+ >
+
+
+ {props.icon}
+
+
+ {props.children}
+
+
+
-
- {error()}
+
+
+ {props.error}
+
)
@@ -1885,6 +1960,7 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return (
> | undefined
+
+afterEach(() => {
+ testSetup?.renderer.destroy()
+ testSetup = undefined
+})
+
+type ToolFixture = { icon: string; label: string; error?: string }
+
+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 ShellOutput() {
+ return (
+
+ # List files
+
+ $ ls
+ file.ts
+
+
+ )
+}
+
+function UserMessage() {
+ return (
+
+
+ Check whether the next tool remains separated.
+
+
+ )
+}
+
+function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
+ return (
+
+
+ {props.before === "shell" && }
+ {props.before === "user" && }
+
+ {(item) => (
+ id === "message-user"}
+ >
+ {item.label}
+
+ )}
+
+
+
+ )
+}
+
+async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
+ testSetup = await testRender(component, options)
+ await testSetup.renderOnce()
+ await Bun.sleep(25)
+ await testSetup.renderOnce()
+
+ return testSetup
+ .captureCharFrame()
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .join("\n")
+ .trimEnd()
+}
+
+describe("TUI inline tool wrapping", () => {
+ test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot()
+ })
+
+ test("snapshots expanded tool errors under the tool text", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot()
+ })
+
+ test("keeps separation after a shell output block", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 16 })).toMatchSnapshot()
+ })
+
+ test("keeps separation after a padded user message", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot()
+ })
+})