Skip to content

Commit 5fb85a6

Browse files
authored
fix(tui): align wrapped inline tool rows (#28664)
1 parent b2a0635 commit 5fb85a6

3 files changed

Lines changed: 282 additions & 33 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,8 @@ const PART_MAPPING = {
15241524
reasoning: ReasoningPart,
15251525
}
15261526

1527+
const INLINE_TOOL_ICON_WIDTH = 2
1528+
15271529
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
15281530
const { theme } = useTheme()
15291531
const ctx = use()
@@ -1789,27 +1791,19 @@ function InlineTool(props: {
17891791
part: ToolPart
17901792
onClick?: () => void
17911793
}) {
1792-
const [margin, setMargin] = createSignal(0)
17931794
const { theme } = useTheme()
17941795
const ctx = use()
17951796
const sync = useSync()
17961797
const renderer = useRenderer()
17971798
const [hover, setHover] = createSignal(false)
1799+
const [errorExpanded, setErrorExpanded] = createSignal(false)
17981800

17991801
const permission = createMemo(() => {
18001802
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
18011803
if (!callID) return false
18021804
return callID === props.part.callID
18031805
})
18041806

1805-
const fg = createMemo(() => {
1806-
if (props.color) return props.color
1807-
if (permission()) return theme.warning
1808-
if (hover() && props.onClick) return theme.text
1809-
if (props.complete) return theme.textMuted
1810-
return theme.text
1811-
})
1812-
18131807
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
18141808

18151809
const denied = createMemo(
@@ -1820,53 +1814,134 @@ function InlineTool(props: {
18201814
error()?.includes("user dismissed"),
18211815
)
18221816

1817+
const failed = createMemo(() => Boolean(error() && !denied()))
1818+
const clickable = createMemo(() => Boolean(props.onClick || failed()))
1819+
const fg = createMemo(() => {
1820+
if (props.color) return props.color
1821+
if (permission()) return theme.warning
1822+
if (failed()) return theme.error
1823+
if (hover() && props.onClick) return theme.text
1824+
if (props.complete) return theme.textMuted
1825+
return theme.text
1826+
})
1827+
18231828
return (
1824-
<box
1825-
marginTop={margin()}
1826-
paddingLeft={3}
1827-
onMouseOver={() => props.onClick && setHover(true)}
1829+
<InlineToolRow
1830+
icon={props.icon}
1831+
iconColor={props.iconColor}
1832+
color={fg()}
1833+
errorColor={theme.error}
1834+
failed={failed()}
1835+
denied={Boolean(denied())}
1836+
error={error()}
1837+
errorExpanded={errorExpanded()}
1838+
complete={props.complete}
1839+
pending={props.pending}
1840+
spinner={props.spinner}
1841+
separateAfter={(id) =>
1842+
sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
1843+
}
1844+
onMouseOver={() => clickable() && setHover(true)}
18281845
onMouseOut={() => setHover(false)}
18291846
onMouseUp={() => {
18301847
if (renderer.getSelection()?.getSelectedText()) return
1848+
if (failed()) {
1849+
setErrorExpanded((value) => !value)
1850+
return
1851+
}
18311852
props.onClick?.()
18321853
}}
1854+
>
1855+
{props.children}
1856+
</InlineToolRow>
1857+
)
1858+
}
1859+
1860+
export function InlineToolRow(props: {
1861+
icon: string
1862+
iconColor?: RGBA
1863+
color?: RGBA
1864+
errorColor?: RGBA
1865+
failed?: boolean
1866+
denied?: boolean
1867+
error?: string
1868+
errorExpanded?: boolean
1869+
complete: any
1870+
pending: string
1871+
spinner?: boolean
1872+
children: JSX.Element
1873+
separateAfter?: (id: string | undefined) => boolean
1874+
onMouseOver?: () => void
1875+
onMouseOut?: () => void
1876+
onMouseUp?: () => void
1877+
}) {
1878+
const [margin, setMargin] = createSignal(0)
1879+
1880+
return (
1881+
<box
1882+
marginTop={margin()}
1883+
paddingLeft={3}
1884+
onMouseOver={props.onMouseOver}
1885+
onMouseOut={props.onMouseOut}
1886+
onMouseUp={props.onMouseUp}
18331887
renderBefore={function () {
18341888
const el = this as BoxRenderable
18351889
const parent = el.parent
18361890
if (!parent) {
18371891
return
18381892
}
1839-
if (el.height > 1) {
1840-
setMargin(1)
1841-
return
1842-
}
18431893
const children = parent.getChildren()
18441894
const index = children.indexOf(el)
18451895
const previous = children[index - 1]
1846-
if (!previous) {
1847-
setMargin(0)
1848-
return
1849-
}
1850-
if (previous.height > 1 || previous.id.startsWith("text-")) {
1851-
setMargin(1)
1852-
return
1853-
}
1896+
setMargin(
1897+
previous?.id.startsWith("text-") ||
1898+
previous?.id.startsWith("tool-block-") ||
1899+
props.separateAfter?.(previous?.id)
1900+
? 1
1901+
: 0,
1902+
)
18541903
}}
18551904
>
18561905
<Switch>
18571906
<Match when={props.spinner}>
1858-
<Spinner color={fg()} children={props.children} />
1907+
<Spinner color={props.color} children={props.children} />
18591908
</Match>
18601909
<Match when={true}>
1861-
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1862-
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
1863-
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1864-
</Show>
1865-
</text>
1910+
<Show
1911+
fallback={
1912+
<text
1913+
paddingLeft={3}
1914+
fg={props.color}
1915+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1916+
>
1917+
~ {props.pending}
1918+
</text>
1919+
}
1920+
when={props.complete}
1921+
>
1922+
<box flexDirection="row">
1923+
<text
1924+
width={INLINE_TOOL_ICON_WIDTH}
1925+
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
1926+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1927+
>
1928+
{props.icon}
1929+
</text>
1930+
<text
1931+
flexGrow={1}
1932+
fg={props.failed ? props.errorColor : props.color}
1933+
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
1934+
>
1935+
{props.children}
1936+
</text>
1937+
</box>
1938+
</Show>
18661939
</Match>
18671940
</Switch>
1868-
<Show when={error() && !denied()}>
1869-
<text fg={theme.error}>{error()}</text>
1941+
<Show when={props.failed && props.errorExpanded}>
1942+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
1943+
<text fg={props.errorColor}>{props.error}</text>
1944+
</box>
18701945
</Show>
18711946
</box>
18721947
)
@@ -1885,6 +1960,7 @@ function BlockTool(props: {
18851960
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
18861961
return (
18871962
<box
1963+
id={props.part ? "tool-block-" + props.part.id : undefined}
18881964
border={["left"]}
18891965
paddingTop={1}
18901966
paddingBottom={1}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2+
3+
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
4+
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
5+
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
6+
✱ Glob "**/*db*" in packages/opencode (6 matches)
7+
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
8+
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
9+
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
10+
Path\\.data|data =" in packages/opencode/src (115 matches)"
11+
`;
12+
13+
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
14+
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
15+
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
16+
✱ Glob "**/*db*" in packages/opencode (6 matches)
17+
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
18+
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
19+
No LSP server available for this file type.
20+
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
21+
Path\\.data|data =" in packages/opencode/src (115 matches)"
22+
`;
23+
24+
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
25+
"
26+
27+
# List files
28+
29+
$ ls
30+
31+
file.ts
32+
33+
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
34+
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
35+
✱ Glob "**/*db*" in packages/opencode (6 matches)
36+
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
37+
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
38+
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
39+
Path\\.data|data =" in packages/opencode/src (115 matches)"
40+
`;
41+
42+
exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = `
43+
"
44+
Check whether the next tool remains separated.
45+
46+
47+
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
48+
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
49+
✱ Glob "**/*db*" in packages/opencode (6 matches)
50+
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
51+
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
52+
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
53+
Path\\.data|data =" in packages/opencode/src (115 matches)"
54+
`;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { For } from "solid-js"
3+
import { testRender, type JSX } from "@opentui/solid"
4+
import { InlineToolRow } from "../../../src/cli/cmd/tui/routes/session/index"
5+
6+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
7+
8+
afterEach(() => {
9+
testSetup?.renderer.destroy()
10+
testSetup = undefined
11+
})
12+
13+
type ToolFixture = { icon: string; label: string; error?: string }
14+
15+
const tools: readonly ToolFixture[] = [
16+
{
17+
icon: "✱",
18+
label:
19+
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
20+
},
21+
{
22+
icon: "✱",
23+
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
24+
},
25+
{
26+
icon: "→",
27+
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
28+
},
29+
{
30+
icon: "→",
31+
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
32+
error: "No LSP server available for this file type.",
33+
},
34+
{
35+
icon: "✱",
36+
label:
37+
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
38+
},
39+
] as const
40+
41+
function ShellOutput() {
42+
return (
43+
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
44+
<text paddingLeft={3}># List files</text>
45+
<box gap={1}>
46+
<text>$ ls</text>
47+
<text>file.ts</text>
48+
</box>
49+
</box>
50+
)
51+
}
52+
53+
function UserMessage() {
54+
return (
55+
<box id="message-user">
56+
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
57+
<text>Check whether the next tool remains separated.</text>
58+
</box>
59+
</box>
60+
)
61+
}
62+
63+
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
64+
return (
65+
<box flexDirection="column" width={72}>
66+
<box flexDirection="column">
67+
{props.before === "shell" && <ShellOutput />}
68+
{props.before === "user" && <UserMessage />}
69+
<For each={tools}>
70+
{(item) => (
71+
<InlineToolRow
72+
icon={item.icon}
73+
complete={true}
74+
pending=""
75+
failed={Boolean(item.error)}
76+
error={item.error}
77+
errorExpanded={props.errorExpanded}
78+
separateAfter={(id) => id === "message-user"}
79+
>
80+
{item.label}
81+
</InlineToolRow>
82+
)}
83+
</For>
84+
</box>
85+
</box>
86+
)
87+
}
88+
89+
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
90+
testSetup = await testRender(component, options)
91+
await testSetup.renderOnce()
92+
await Bun.sleep(25)
93+
await testSetup.renderOnce()
94+
95+
return testSetup
96+
.captureCharFrame()
97+
.split("\n")
98+
.map((line) => line.trimEnd())
99+
.join("\n")
100+
.trimEnd()
101+
}
102+
103+
describe("TUI inline tool wrapping", () => {
104+
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
105+
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
106+
})
107+
108+
test("snapshots expanded tool errors under the tool text", async () => {
109+
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
110+
})
111+
112+
test("keeps separation after a shell output block", async () => {
113+
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
114+
})
115+
116+
test("keeps separation after a padded user message", async () => {
117+
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
118+
})
119+
})

0 commit comments

Comments
 (0)