Skip to content

Commit 49e38e3

Browse files
Apply PR #28664: fix(tui): align wrapped inline tool rows
2 parents 9187187 + 27d6920 commit 49e38e3

3 files changed

Lines changed: 227 additions & 28 deletions

File tree

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

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,8 @@ const PART_MAPPING = {
15351535
reasoning: ReasoningPart,
15361536
}
15371537

1538+
const INLINE_TOOL_ICON_WIDTH = 2
1539+
15381540
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
15391541
const { theme } = useTheme()
15401542
const ctx = use()
@@ -1806,21 +1808,14 @@ function InlineTool(props: {
18061808
const sync = useSync()
18071809
const renderer = useRenderer()
18081810
const [hover, setHover] = createSignal(false)
1811+
const [errorExpanded, setErrorExpanded] = createSignal(false)
18091812

18101813
const permission = createMemo(() => {
18111814
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
18121815
if (!callID) return false
18131816
return callID === props.part.callID
18141817
})
18151818

1816-
const fg = createMemo(() => {
1817-
if (props.color) return props.color
1818-
if (permission()) return theme.warning
1819-
if (hover() && props.onClick) return theme.text
1820-
if (props.complete) return theme.textMuted
1821-
return theme.text
1822-
})
1823-
18241819
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
18251820

18261821
const denied = createMemo(
@@ -1831,14 +1826,28 @@ function InlineTool(props: {
18311826
error()?.includes("user dismissed"),
18321827
)
18331828

1829+
const failed = createMemo(() => Boolean(error() && !denied()))
1830+
const clickable = createMemo(() => Boolean(props.onClick || failed()))
1831+
const fg = createMemo(() => {
1832+
if (permission()) return theme.warning
1833+
if (failed()) return theme.error
1834+
if (hover() && props.onClick) return theme.text
1835+
if (props.complete) return theme.textMuted
1836+
return theme.text
1837+
})
1838+
18341839
return (
18351840
<box
18361841
marginTop={margin()}
18371842
paddingLeft={3}
1838-
onMouseOver={() => props.onClick && setHover(true)}
1843+
onMouseOver={() => clickable() && setHover(true)}
18391844
onMouseOut={() => setHover(false)}
18401845
onMouseUp={() => {
18411846
if (renderer.getSelection()?.getSelectedText()) return
1847+
if (failed()) {
1848+
setErrorExpanded((value) => !value)
1849+
return
1850+
}
18421851
props.onClick?.()
18431852
}}
18441853
renderBefore={function () {
@@ -1847,37 +1856,48 @@ function InlineTool(props: {
18471856
if (!parent) {
18481857
return
18491858
}
1850-
if (el.height > 1) {
1851-
setMargin(1)
1852-
return
1853-
}
18541859
const children = parent.getChildren()
18551860
const index = children.indexOf(el)
18561861
const previous = children[index - 1]
1857-
if (!previous) {
1858-
setMargin(0)
1859-
return
1860-
}
1861-
if (previous.height > 1 || previous.id.startsWith("text-")) {
1862-
setMargin(1)
1863-
return
1864-
}
1862+
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
18651863
}}
18661864
>
18671865
<Switch>
18681866
<Match when={props.spinner}>
18691867
<Spinner color={fg()} children={props.children} />
18701868
</Match>
18711869
<Match when={true}>
1872-
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1873-
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
1874-
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1875-
</Show>
1876-
</text>
1870+
<Show
1871+
fallback={
1872+
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1873+
~ {props.pending}
1874+
</text>
1875+
}
1876+
when={props.complete}
1877+
>
1878+
<box flexDirection="row">
1879+
<text
1880+
width={INLINE_TOOL_ICON_WIDTH}
1881+
fg={failed() ? theme.error : (props.iconColor ?? fg())}
1882+
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
1883+
>
1884+
{props.icon}
1885+
</text>
1886+
<text
1887+
flexGrow={1}
1888+
fg={failed() ? theme.error : fg()}
1889+
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
1890+
>
1891+
{props.children}
1892+
</text>
1893+
</box>
1894+
</Show>
18771895
</Match>
18781896
</Switch>
1879-
<Show when={error() && !denied()}>
1880-
<text fg={theme.error}>{error()}</text>
1897+
<Show when={failed() && errorExpanded()}>
1898+
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
1899+
<text fg={theme.error}>{error()}</text>
1900+
</box>
18811901
</Show>
18821902
</box>
18831903
)
@@ -1896,6 +1916,7 @@ function BlockTool(props: {
18961916
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
18971917
return (
18981918
<box
1919+
id={props.part ? "tool-block-" + props.part.id : undefined}
18991920
border={["left"]}
19001921
paddingTop={1}
19011922
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)