fix(tui): align wrapped inline tool rows

This commit is contained in:
Kit Langton 2026-05-21 10:42:18 -04:00
parent 0cc55c11ac
commit 540525e98e
3 changed files with 253 additions and 5 deletions

View file

@ -1802,11 +1802,27 @@ function InlineTool(props: {
<Spinner color={fg()} children={props.children} />
</Match>
<Match when={true}>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show>
</text>
<Show
fallback={
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
~ {props.pending}
</text>
}
when={props.complete}
>
<box flexDirection="row" paddingLeft={3}>
<text
width={props.icon.length + 1}
fg={props.iconColor ?? fg()}
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<text flexGrow={1} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
{props.children}
</text>
</box>
</Show>
</Match>
</Switch>
<Show when={error() && !denied()}>

View file

@ -0,0 +1,58 @@
// 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`] = `
"CURRENT: measured height adds top margin after wrapped rows
* 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)
STABLE WRAP: no height-coupled margin
* 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)
HANGING INDENT: wrap aligns with tool text
* 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)
DETAIL ROWS: split identity from metadata
* Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA"
packages/opencode/src - 151 matches
* Glob "**/*db*"
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 ="
packages/opencode/src - 115 matches
COMPACT: truncate middle, never wrap
* Grep "OPENCODE.*DB|d...dir|xdg|APPDATA" - packages/openc...c - 151
* Glob "**/*db*" - packages/opencode - 6 matches
-> Read packages/openco...rc/storage/db.ts - offset=1, limit=130
-> Read packages/opencode/src/index.ts - offset=1, limit=100
* Grep "export const O...th\\.data|data =" - packages/openc...c - 115"
`;

View file

@ -0,0 +1,174 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSignal, For } from "solid-js"
import { testRender } from "@opentui/solid"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
testSetup?.renderer.destroy()
testSetup = undefined
})
const tools = [
{
kind: "grep",
icon: "*",
label:
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
target: '"OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA"',
meta: "packages/opencode/src - 151 matches",
},
{
kind: "glob",
icon: "*",
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
target: '"**/*db*"',
meta: "packages/opencode - 6 matches",
},
{
kind: "read",
icon: "->",
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
target: "packages/opencode/src/storage/db.ts",
meta: "offset=1, limit=130",
},
{
kind: "read",
icon: "->",
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
target: "packages/opencode/src/index.ts",
meta: "offset=1, limit=100",
},
{
kind: "grep",
icon: "*",
label:
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
target: '"export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data ="',
meta: "packages/opencode/src - 115 matches",
},
] as const
function CurrentInlineRow(props: { item: (typeof tools)[number]; index: number }) {
const [margin, setMargin] = createSignal(0)
return (
<box
id={`current-${props.index}`}
marginTop={margin()}
paddingLeft={3}
renderBefore={function () {
const parent = this.parent
if (!parent) return
if (this.height > 1) {
setMargin(1)
return
}
const previous = parent.getChildren()[parent.getChildren().indexOf(this) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) setMargin(1)
}}
>
<text paddingLeft={3}>
{props.item.icon} {props.item.label}
</text>
</box>
)
}
function StableInlineRow(props: { item: (typeof tools)[number] }) {
return (
<box paddingLeft={3}>
<text paddingLeft={3}>
{props.item.icon} {props.item.label}
</text>
</box>
)
}
function HangingIndentRow(props: { item: (typeof tools)[number] }) {
return (
<box paddingLeft={3} flexDirection="row">
<text width={props.item.icon.length + 1}>{props.item.icon}</text>
<text flexGrow={1}>{props.item.label}</text>
</box>
)
}
function DetailRow(props: { item: (typeof tools)[number] }) {
return (
<box paddingLeft={3}>
<text paddingLeft={3}>
{props.item.icon} {titlecase(props.item.kind)} {props.item.target}
</text>
<text paddingLeft={6}>{props.item.meta}</text>
</box>
)
}
function CompactRow(props: { item: (typeof tools)[number] }) {
return (
<box paddingLeft={3}>
<text paddingLeft={3} wrapMode="none">
{props.item.icon} {titlecase(props.item.kind)} {truncateMiddle(props.item.target, 34)} -{" "}
{truncateMiddle(props.item.meta, 32)}
</text>
</box>
)
}
function Fixture() {
return (
<box flexDirection="column" width={72}>
<text>CURRENT: measured height adds top margin after wrapped rows</text>
<box flexDirection="column">
<For each={tools}>{(item, index) => <CurrentInlineRow item={item} index={index()} />}</For>
</box>
<text marginTop={2}>STABLE WRAP: no height-coupled margin</text>
<box flexDirection="column">
<For each={tools}>{(item) => <StableInlineRow item={item} />}</For>
</box>
<text marginTop={2}>HANGING INDENT: wrap aligns with tool text</text>
<box flexDirection="column">
<For each={tools}>{(item) => <HangingIndentRow item={item} />}</For>
</box>
<text marginTop={2}>DETAIL ROWS: split identity from metadata</text>
<box flexDirection="column">
<For each={tools}>{(item) => <DetailRow item={item} />}</For>
</box>
<text marginTop={2}>COMPACT: truncate middle, never wrap</text>
<box flexDirection="column">
<For each={tools}>{(item) => <CompactRow item={item} />}</For>
</box>
</box>
)
}
describe("TUI inline tool wrapping", () => {
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
testSetup = await testRender(() => <Fixture />, { width: 72, height: 60 })
await testSetup.renderOnce()
await testSetup.renderOnce()
expect(
testSetup
.captureCharFrame()
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trimEnd(),
).toMatchSnapshot()
})
})
function titlecase(value: string) {
return value.slice(0, 1).toUpperCase() + value.slice(1)
}
function truncateMiddle(value: string, max: number) {
if (value.length <= max) return value
return value.slice(0, Math.floor((max - 3) / 2)) + "..." + value.slice(value.length - Math.ceil((max - 3) / 2))
}