Skip to content

Commit 2b764e4

Browse files
Apply PR #28664: fix(tui): align wrapped inline tool rows
2 parents 11ece7d + 27d6920 commit 2b764e4

3 files changed

Lines changed: 228 additions & 28 deletions

File tree

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

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,8 @@ const PART_MAPPING = {
15251525
reasoning: ReasoningPart,
15261526
}
15271527

1528+
const INLINE_TOOL_ICON_WIDTH = 2
1529+
15281530
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
15291531
const { theme } = useTheme()
15301532
const ctx = use()
@@ -1796,21 +1798,14 @@ function InlineTool(props: {
17961798
const sync = useSync()
17971799
const renderer = useRenderer()
17981800
const [hover, setHover] = createSignal(false)
1801+
const [errorExpanded, setErrorExpanded] = createSignal(false)
17991802

18001803
const permission = createMemo(() => {
18011804
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
18021805
if (!callID) return false
18031806
return callID === props.part.callID
18041807
})
18051808

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

18161811
const denied = createMemo(
@@ -1821,14 +1816,29 @@ function InlineTool(props: {
18211816
error()?.includes("user dismissed"),
18221817
)
18231818

1819+
const failed = createMemo(() => Boolean(error() && !denied()))
1820+
const clickable = createMemo(() => Boolean(props.onClick || failed()))
1821+
const fg = createMemo(() => {
1822+
if (props.color) return props.color
1823+
if (permission()) return theme.warning
1824+
if (failed()) return theme.error
1825+
if (hover() && props.onClick) return theme.text
1826+
if (props.complete) return theme.textMuted
1827+
return theme.text
1828+
})
1829+
18241830
return (
18251831
<box
18261832
marginTop={margin()}
18271833
paddingLeft={3}
1828-
onMouseOver={() => props.onClick && setHover(true)}
1834+
onMouseOver={() => clickable() && setHover(true)}
18291835
onMouseOut={() => setHover(false)}
18301836
onMouseUp={() => {
18311837
if (renderer.getSelection()?.getSelectedText()) return
1838+
if (failed()) {
1839+
setErrorExpanded((value) => !value)
1840+
return
1841+
}
18321842
props.onClick?.()
18331843
}}
18341844
renderBefore={function () {
@@ -1837,37 +1847,48 @@ function InlineTool(props: {
18371847
if (!parent) {
18381848
return
18391849
}
1840-
if (el.height > 1) {
1841-
setMargin(1)
1842-
return
1843-
}
18441850
const children = parent.getChildren()
18451851
const index = children.indexOf(el)
18461852
const previous = children[index - 1]
1847-
if (!previous) {
1848-
setMargin(0)
1849-
return
1850-
}
1851-
if (previous.height > 1 || previous.id.startsWith("text-")) {
1852-
setMargin(1)
1853-
return
1854-
}
1853+
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
18551854
}}
18561855
>
18571856
<Switch>
18581857
<Match when={props.spinner}>
18591858
<Spinner color={fg()} children={props.children} />
18601859
</Match>
18611860
<Match when={true}>
1862-
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1863-
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
1864-
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1865-
</Show>
1866-
</text>
1861+
<Show
1862+
fallback={
1863+
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1864+
~ {props.pending}
1865+
</text>
1866+
}
1867+
when={props.complete}
1868+
>
1869+
<box flexDirection="row">
1870+
<text
1871+
width={INLINE_TOOL_ICON_WIDTH}
1872+
fg={failed() ? theme.error : (props.iconColor ?? fg())}
1873+
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
1874+
>
1875+
{props.icon}
1876+
</text>
1877+
<text
1878+
flexGrow={1}
1879+
fg={failed() ? theme.error : fg()}
1880+
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
1881+
>
1882+
{props.children}
1883+
</text>
1884+
</box>
1885+
</Show>
18671886
</Match>
18681887
</Switch>
1869-
<Show when={error() && !denied()}>
1870-
<text fg={theme.error}>{error()}</text>
1888+
<Show when={failed() && errorExpanded()}>
1889+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
1890+
<text fg={theme.error}>{error()}</text>
1891+
</box>
18711892
</Show>
18721893
</box>
18731894
)
@@ -1886,6 +1907,7 @@ function BlockTool(props: {
18861907
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
18871908
return (
18881909
<box
1910+
id={props.part ? "tool-block-" + props.part.id : undefined}
18891911
border={["left"]}
18901912
paddingTop={1}
18911913
paddingBottom={1}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
34+
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
35+
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
36+
✱ Glob "**/*db*" in packages/opencode (6 matches)
37+
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
38+
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
39+
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
40+
Path\\.data|data =" in packages/opencode/src (115 matches)"
41+
`;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { createSignal, For } from "solid-js"
3+
import { testRender } from "@opentui/solid"
4+
5+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
6+
7+
afterEach(() => {
8+
testSetup?.renderer.destroy()
9+
testSetup = undefined
10+
})
11+
12+
type ToolFixture = { icon: string; label: string; error?: string }
13+
14+
const INLINE_TOOL_ICON_WIDTH = 2
15+
16+
const tools: readonly ToolFixture[] = [
17+
{
18+
icon: "✱",
19+
label:
20+
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
21+
},
22+
{
23+
icon: "✱",
24+
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
25+
},
26+
{
27+
icon: "→",
28+
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
29+
},
30+
{
31+
icon: "→",
32+
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
33+
error: "No LSP server available for this file type.",
34+
},
35+
{
36+
icon: "✱",
37+
label:
38+
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
39+
},
40+
] as const
41+
42+
function InlineToolRow(props: { item: ToolFixture; errorExpanded?: boolean }) {
43+
const [margin, setMargin] = createSignal(0)
44+
45+
return (
46+
<box
47+
marginTop={margin()}
48+
paddingLeft={3}
49+
renderBefore={function () {
50+
const parent = this.parent
51+
if (!parent) return
52+
const previous = parent.getChildren()[parent.getChildren().indexOf(this) - 1]
53+
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
54+
}}
55+
>
56+
<box flexDirection="row">
57+
<text width={INLINE_TOOL_ICON_WIDTH}>{props.item.icon}</text>
58+
<text flexGrow={1}>{props.item.label}</text>
59+
</box>
60+
{props.item.error && props.errorExpanded && (
61+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
62+
<text>{props.item.error}</text>
63+
</box>
64+
)}
65+
</box>
66+
)
67+
}
68+
69+
function ShellOutput() {
70+
return (
71+
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
72+
<text paddingLeft={3}># List files</text>
73+
<box gap={1}>
74+
<text>$ ls</text>
75+
<text>file.ts</text>
76+
</box>
77+
</box>
78+
)
79+
}
80+
81+
function Fixture(props: { errorExpanded?: boolean; shellOutput?: boolean }) {
82+
return (
83+
<box flexDirection="column" width={72}>
84+
<box flexDirection="column">
85+
{props.shellOutput && <ShellOutput />}
86+
<For each={tools}>{(item) => <InlineToolRow item={item} errorExpanded={props.errorExpanded} />}</For>
87+
</box>
88+
</box>
89+
)
90+
}
91+
92+
describe("TUI inline tool wrapping", () => {
93+
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
94+
testSetup = await testRender(() => <Fixture />, { width: 72, height: 12 })
95+
await testSetup.renderOnce()
96+
await testSetup.renderOnce()
97+
98+
expect(
99+
testSetup
100+
.captureCharFrame()
101+
.split("\n")
102+
.map((line) => line.trimEnd())
103+
.join("\n")
104+
.trimEnd(),
105+
).toMatchSnapshot()
106+
})
107+
108+
test("snapshots expanded tool errors under the tool text", async () => {
109+
testSetup = await testRender(() => <Fixture errorExpanded />, { width: 72, height: 12 })
110+
await testSetup.renderOnce()
111+
await testSetup.renderOnce()
112+
113+
expect(
114+
testSetup
115+
.captureCharFrame()
116+
.split("\n")
117+
.map((line) => line.trimEnd())
118+
.join("\n")
119+
.trimEnd(),
120+
).toMatchSnapshot()
121+
})
122+
123+
test("keeps separation after a shell output block", async () => {
124+
testSetup = await testRender(() => <Fixture shellOutput />, { width: 72, height: 16 })
125+
await testSetup.renderOnce()
126+
await testSetup.renderOnce()
127+
128+
expect(
129+
testSetup
130+
.captureCharFrame()
131+
.split("\n")
132+
.map((line) => line.trimEnd())
133+
.join("\n")
134+
.trimEnd(),
135+
).toMatchSnapshot()
136+
})
137+
})

0 commit comments

Comments
 (0)