fix(tui): prevent duplicate renderable IDs (#32110)

This commit is contained in:
Sebastian 2026-06-13 00:51:32 +02:00 committed by GitHub
parent cf2d1dd3e9
commit fff0ec294c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 98 additions and 95 deletions

View File

@ -202,7 +202,6 @@ function match<T extends PanelEntry>(query: string, entries: T[]) {
} }
function PanelShell(props: { function PanelShell(props: {
id: string
title: string title: string
countVisible?: boolean countVisible?: boolean
query: string query: string
@ -279,7 +278,7 @@ function PanelShell(props: {
</> </>
) )
return ( return (
<box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}> <box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{minimal() ? ( {minimal() ? (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}> <box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content} {content}
@ -299,7 +298,6 @@ function PanelShell(props: {
)} )}
{minimal() ? ( {minimal() ? (
<box <box
id={`${props.id}-bottom`}
width="100%" width="100%"
height={1} height={1}
border={false} border={false}
@ -317,7 +315,6 @@ function PanelShell(props: {
</box> </box>
) : ( ) : (
<box <box
id={`${props.id}-bottom`}
width="100%" width="100%"
height={1} height={1}
border={["left"]} border={["left"]}
@ -549,7 +546,6 @@ export function RunCommandMenuBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-command-panel"
title="Commands" title="Commands"
countVisible={false} countVisible={false}
query={query()} query={query()}
@ -565,7 +561,6 @@ export function RunCommandMenuBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-command-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}
@ -649,7 +644,6 @@ export function RunSubagentSelectBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-subagent-panel"
title="Select subagent" title="Select subagent"
query={query()} query={query()}
count={items().length} count={items().length}
@ -664,7 +658,6 @@ export function RunSubagentSelectBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-subagent-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}
@ -748,7 +741,6 @@ export function RunQueuedPromptSelectBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-queued-panel"
title="Queued prompts" title="Queued prompts"
query={query()} query={query()}
count={items().length} count={items().length}
@ -763,7 +755,6 @@ export function RunQueuedPromptSelectBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-queued-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}
@ -827,7 +818,6 @@ export function RunSkillSelectBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-skill-panel"
title="Skills" title="Skills"
query={query()} query={query()}
count={items().length} count={items().length}
@ -842,7 +832,6 @@ export function RunSkillSelectBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-skill-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}
@ -927,7 +916,6 @@ export function RunVariantSelectBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-variant-panel"
title="Select variant" title="Select variant"
query={query()} query={query()}
count={items().length} count={items().length}
@ -942,7 +930,6 @@ export function RunVariantSelectBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-variant-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}
@ -1050,7 +1037,6 @@ export function RunModelSelectBody(props: {
return ( return (
<PanelShell <PanelShell
id="run-direct-footer-model-panel"
title="Select model" title="Select model"
query={query()} query={query()}
count={items().length} count={items().length}
@ -1065,7 +1051,6 @@ export function RunModelSelectBody(props: {
chrome="minimal" chrome="minimal"
> >
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-model-list"
theme={props.theme} theme={props.theme}
items={items} items={items}
selected={menu.selected} selected={menu.selected}

View File

@ -115,7 +115,6 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
} }
export function RunFooterMenu(props: { export function RunFooterMenu(props: {
id?: string
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
items: Accessor<RunFooterMenuItem[]> items: Accessor<RunFooterMenuItem[]>
selected: Accessor<number> selected: Accessor<number>
@ -226,7 +225,6 @@ export function RunFooterMenu(props: {
} }
return ( return (
<box <box
id={props.id ?? "run-direct-footer-menu"}
width="100%" width="100%"
height={props.rows()} height={props.rows()}
backgroundColor={props.background ? props.theme().shade : transparent} backgroundColor={props.background ? props.theme().shade : transparent}

View File

@ -96,7 +96,6 @@ export function RejectField(props: {
return ( return (
<textarea <textarea
id="run-direct-footer-permission-reject"
width="100%" width="100%"
minHeight={1} minHeight={1}
maxHeight={3} maxHeight={3}
@ -259,14 +258,12 @@ export function RunPermissionBody(props: {
return ( return (
<box <box
id="run-direct-footer-permission-body"
width="100%" width="100%"
height="100%" height="100%"
flexDirection="column" flexDirection="column"
backgroundColor={props.theme.surface} backgroundColor={props.theme.surface}
> >
<box <box
id="run-direct-footer-permission-head"
flexDirection="column" flexDirection="column"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
@ -303,7 +300,6 @@ export function RunPermissionBody(props: {
fallback={ fallback={
<box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end"> <box width="100%" flexGrow={1} flexShrink={1} justifyContent="flex-end">
<box <box
id="run-direct-footer-permission-reject-bar"
flexDirection={narrow() ? "column" : "row"} flexDirection={narrow() ? "column" : "row"}
flexShrink={0} flexShrink={0}
backgroundColor={props.theme.line} backgroundColor={props.theme.line}
@ -433,7 +429,6 @@ export function RunPermissionBody(props: {
</box> </box>
<box <box
id="run-direct-footer-permission-actions"
flexDirection={narrow() ? "column" : "row"} flexDirection={narrow() ? "column" : "row"}
flexShrink={0} flexShrink={0}
backgroundColor={props.theme.pane} backgroundColor={props.theme.pane}

View File

@ -250,10 +250,9 @@ export function RunPromptBody(props: {
}) })
return ( return (
<box id="run-direct-footer-prompt" width="100%"> <box width="100%">
<box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}> <box paddingTop={1} paddingBottom={1} paddingRight={2}>
<textarea <textarea
id="run-direct-footer-composer"
width="100%" width="100%"
minHeight={TEXTAREA_MIN_ROWS} minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS} maxHeight={TEXTAREA_MAX_ROWS}

View File

@ -268,9 +268,8 @@ export function RunQuestionBody(props: {
}) })
return ( return (
<box id="run-direct-footer-question-body" width="100%" height="100%" flexDirection="column"> <box width="100%" height="100%" flexDirection="column">
<box <box
id="run-direct-footer-question-panel"
flexDirection="column" flexDirection="column"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
@ -281,14 +280,13 @@ export function RunQuestionBody(props: {
backgroundColor={props.theme.surface} backgroundColor={props.theme.surface}
> >
<Show when={!single()}> <Show when={!single()}>
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}> <box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<For each={props.request.questions}> <For each={props.request.questions}>
{(item, index) => { {(item, index) => {
const active = () => state().tab === index() const active = () => state().tab === index()
const answered = () => (state().answers[index()]?.length ?? 0) > 0 const answered = () => (state().answers[index()]?.length ?? 0) > 0
return ( return (
<box <box
id={`run-direct-footer-question-tab-${index()}`}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={active() ? props.theme.highlight : props.theme.surface} backgroundColor={active() ? props.theme.highlight : props.theme.surface}
@ -304,7 +302,6 @@ export function RunQuestionBody(props: {
}} }}
</For> </For>
<box <box
id="run-direct-footer-question-tab-confirm"
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface} backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
@ -382,7 +379,6 @@ export function RunQuestionBody(props: {
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
return ( return (
<box <box
id={`run-direct-footer-question-option-${index()}`}
flexDirection="column" flexDirection="column"
gap={0} gap={0}
onMouseOver={() => { onMouseOver={() => {
@ -428,7 +424,6 @@ export function RunQuestionBody(props: {
<Show when={questionCustom(props.request, state())}> <Show when={questionCustom(props.request, state())}>
<box <box
id="run-direct-footer-question-option-custom"
flexDirection="column" flexDirection="column"
gap={0} gap={0}
onMouseOver={() => { onMouseOver={() => {
@ -480,7 +475,6 @@ export function RunQuestionBody(props: {
> >
<box paddingLeft={3}> <box paddingLeft={3}>
<textarea <textarea
id="run-direct-footer-question-custom"
width="100%" width="100%"
minHeight={1} minHeight={1}
maxHeight={4} maxHeight={4}
@ -518,7 +512,6 @@ export function RunQuestionBody(props: {
</box> </box>
<box <box
id="run-direct-footer-question-actions"
flexDirection={narrow() ? "column" : "row"} flexDirection={narrow() ? "column" : "row"}
flexShrink={0} flexShrink={0}
gap={1} gap={1}

View File

@ -120,7 +120,6 @@ export function RunFooterSubagentBody(props: {
return ( return (
<box <box
id="run-direct-footer-subagent"
width="100%" width="100%"
height="100%" height="100%"
flexDirection="column" flexDirection="column"

View File

@ -621,7 +621,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return ( return (
<box <box
id="run-direct-footer-shell"
width="100%" width="100%"
height="100%" height="100%"
border={false} border={false}
@ -631,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
padding={0} padding={0}
> >
<Show when={panel() || inspecting()}> <Show when={panel() || inspecting()}>
<box id="run-direct-footer-panel-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" /> <box width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
</Show> </Show>
<Show <Show
@ -641,7 +640,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<For each={[promptView()]}> <For each={[promptView()]}>
{() => ( {() => (
<box <box
id="run-direct-footer-composer-frame"
width="100%" width="100%"
flexShrink={0} flexShrink={0}
border={panel() || prompt() ? false : ["left"]} border={panel() || prompt() ? false : ["left"]}
@ -656,7 +654,6 @@ export function RunFooterView(props: RunFooterViewProps) {
} }
> >
<box <box
id="run-direct-footer-composer-area"
width="100%" width="100%"
flexGrow={1} flexGrow={1}
paddingLeft={0} paddingLeft={0}
@ -666,7 +663,7 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor={panel() || prompt() ? "transparent" : theme().surface} backgroundColor={panel() || prompt() ? "transparent" : theme().surface}
gap={0} gap={0}
> >
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column"> <box width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
<Switch> <Switch>
<Match when={active().type === "prompt" && route().type === "composer"}> <Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody <RunPromptBody
@ -804,7 +801,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && menu()}> <Show when={!panel() && menu()}>
<RunFooterMenu <RunFooterMenu
id="run-direct-footer-complete"
theme={theme} theme={theme}
items={composer.options} items={composer.options}
selected={composer.selected} selected={composer.selected}
@ -818,7 +814,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={!panel() && !menu()}> <Show when={!panel() && !menu()}>
<box <box
id="run-direct-footer-statusline"
width="100%" width="100%"
height={1} height={1}
flexDirection="row" flexDirection="row"
@ -827,7 +822,6 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor={statuslineBackground()} backgroundColor={statuslineBackground()}
> >
<box <box
id="run-direct-footer-statusline-mode"
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={theme().statusAccent} backgroundColor={theme().statusAccent}
@ -839,7 +833,6 @@ export function RunFooterView(props: RunFooterViewProps) {
</box> </box>
<box <box
id="run-direct-footer-statusline-main"
flexDirection="row" flexDirection="row"
gap={1} gap={1}
flexGrow={1} flexGrow={1}
@ -850,13 +843,12 @@ export function RunFooterView(props: RunFooterViewProps) {
backgroundColor="transparent" backgroundColor="transparent"
> >
<Show when={busy() && !exiting()}> <Show when={busy() && !exiting()}>
<box id="run-direct-footer-status-spinner" flexShrink={0}> <box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} /> <spinner color={spin().color} frames={spin().frames} interval={40} />
</box> </box>
</Show> </Show>
<text <text
id="run-direct-footer-statusline-text"
fg={statusColor()} fg={statusColor()}
wrapMode="none" wrapMode="none"
truncate truncate
@ -874,7 +866,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={activityMeta().length > 0}> <Show when={activityMeta().length > 0}>
<box <box
id="run-direct-footer-statusline-meta"
paddingRight={1} paddingRight={1}
backgroundColor="transparent" backgroundColor="transparent"
flexShrink={1} flexShrink={1}
@ -888,7 +879,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={responsive().statusline.showModel && modelStatus()}> <Show when={responsive().statusline.showModel && modelStatus()}>
{(info) => ( {(info) => (
<box <box
id="run-direct-footer-statusline-model"
paddingRight={1} paddingRight={1}
backgroundColor="transparent" backgroundColor="transparent"
flexShrink={0} flexShrink={0}
@ -913,7 +903,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<For each={contextHints()}> <For each={contextHints()}>
{(hint, index) => ( {(hint, index) => (
<box <box
id={`run-direct-footer-statusline-${hint.kind}`}
paddingRight={1} paddingRight={1}
backgroundColor="transparent" backgroundColor="transparent"
flexShrink={0} flexShrink={0}
@ -933,7 +922,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={commandHint()}> <Show when={commandHint()}>
{(hint) => ( {(hint) => (
<box <box
id="run-direct-footer-statusline-hint"
paddingRight={1} paddingRight={1}
backgroundColor="transparent" backgroundColor="transparent"
flexShrink={0} flexShrink={0}
@ -955,7 +943,6 @@ export function RunFooterView(props: RunFooterViewProps) {
} }
> >
<box <box
id="run-direct-footer-subagent-frame"
width="100%" width="100%"
flexGrow={1} flexGrow={1}
flexShrink={1} flexShrink={1}

View File

@ -34,8 +34,6 @@ type ActiveEntry = {
rendered: boolean rendered: boolean
} }
let nextId = 0
function commitMarkdownBlocks(input: { function commitMarkdownBlocks(input: {
surface: ScrollbackSurface surface: ScrollbackSurface
renderable: MarkdownRenderable renderable: MarkdownRenderable
@ -152,12 +150,10 @@ export class RunScrollbackStream {
const surface = this.renderer.createScrollbackSurface({ const surface = this.renderer.createScrollbackSurface({
startOnNewLine: entryFlags(commit).startOnNewLine, startOnNewLine: entryFlags(commit).startOnNewLine,
}) })
const id = `run-scrollback-entry-${nextId++}`
const style = entryLook(commit, this.theme.entry) const style = entryLook(commit, this.theme.entry)
const renderable = const renderable =
body.type === "text" body.type === "text"
? new TextRenderable(surface.renderContext, { ? new TextRenderable(surface.renderContext, {
id,
content: "", content: "",
width: "100%", width: "100%",
wrapMode: "word", wrapMode: "word",
@ -166,7 +162,6 @@ export class RunScrollbackStream {
}) })
: body.type === "code" : body.type === "code"
? new CodeRenderable(surface.renderContext, { ? new CodeRenderable(surface.renderContext, {
id,
content: "", content: "",
filetype: body.filetype, filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme), syntaxStyle: entrySyntax(commit, this.theme),
@ -178,7 +173,6 @@ export class RunScrollbackStream {
treeSitterClient: this.treeSitterClient, treeSitterClient: this.treeSitterClient,
}) })
: new MarkdownRenderable(surface.renderContext, { : new MarkdownRenderable(surface.renderContext, {
id,
content: "", content: "",
syntaxStyle: entrySyntax(commit, this.theme), syntaxStyle: entrySyntax(commit, this.theme),
width: "100%", width: "100%",

View File

@ -322,7 +322,6 @@ export function entryWriter(input: {
export function spacerWriter(): ScrollbackWriter { export function spacerWriter(): ScrollbackWriter {
return (ctx: ScrollbackRenderContext) => ({ return (ctx: ScrollbackRenderContext) => ({
root: new TextRenderable(ctx.renderContext, { root: new TextRenderable(ctx.renderContext, {
id: "run-scrollback-spacer",
width: Math.max(1, Math.trunc(ctx.width)), width: Math.max(1, Math.trunc(ctx.width)),
height: 1, height: 1,
content: "", content: "",

View File

@ -45,8 +45,6 @@ type Cell = {
mark: "text" | "full" | "mix" | "top" mark: "text" | "full" | "mix" | "top"
} }
let id = 0
function cells(line: string): Cell[] { function cells(line: string): Cell[] {
const list: Cell[] = [] const list: Cell[] = []
for (const char of line) { for (const char of line) {
@ -117,7 +115,6 @@ function write(
root.add( root.add(
new TextRenderable(ctx.renderContext, { new TextRenderable(ctx.renderContext, {
id: `run-direct-splash-line-${id++}`,
position: "absolute", position: "absolute",
left: line.left, left: line.left,
top: line.top, top: line.top,
@ -246,7 +243,6 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
} }
const root = new BoxRenderable(ctx.renderContext, { const root = new BoxRenderable(ctx.renderContext, {
id: `run-direct-splash-${kind}-${id++}`,
position: "absolute", position: "absolute",
left: 0, left: 0,
top: 0, top: 0,

View File

@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { RGBA, type BoxRenderable } from "@opentui/core" import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid" import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
@ -248,6 +248,45 @@ function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
) )
} }
function child(root: BoxRenderable | RootRenderable, index: number) {
return root.getChildren()[index] as BoxRenderable
}
function boxPath(root: BoxRenderable | RootRenderable, name: string): BoxRenderable[] | undefined {
for (const item of root.getChildren()) {
if (item.constructor.name === name) return root instanceof BoxRenderable ? [root] : []
if (!(item instanceof BoxRenderable)) continue
const path = boxPath(item, name)
if (path) return root instanceof BoxRenderable ? [root, ...path] : path
}
}
function footerComposerFrame(root: BoxRenderable | RootRenderable) {
return boxPath(root, "TextareaRenderable")!.at(-5)!
}
function footerStatusline(root: BoxRenderable | RootRenderable) {
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
if (
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
)
return box
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
throw new Error("Footer statusline not found")
}
function panelMenu(root: BoxRenderable | RootRenderable) {
const panel = child(child(root, 0), 0)
const content = child(panel, 0)
return child(content.getChildren().at(-1) as BoxRenderable, 0)
}
test("direct footer composer area does not adopt footer surface", async () => { test("direct footer composer area does not adopt footer surface", async () => {
const surface = RGBA.fromHex("#123456") const surface = RGBA.fromHex("#123456")
const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK) const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
@ -255,7 +294,7 @@ test("direct footer composer area does not adopt footer surface", async () => {
try { try {
await app.renderOnce() await app.renderOnce()
const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable const area = child(footerComposerFrame(app.renderer.root), 0)
expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts()) expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
setTheme({ setTheme({
@ -588,7 +627,7 @@ test("direct subagent panel renders active subagents", async () => {
try { try {
await app.renderOnce() await app.renderOnce()
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select subagent") expect(frame).toContain("Select subagent")
expect(frame).toContain("Inspect auth flow") expect(frame).toContain("Inspect auth flow")
@ -626,7 +665,7 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
try { try {
await app.renderOnce() await app.renderOnce()
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable const list = panelMenu(app.renderer.root)
expect(frame).toContain("Queued prompts") expect(frame).toContain("Queued prompts")
expect(frame).toContain("fix the auth test") expect(frame).toContain("fix the auth test")
@ -648,12 +687,12 @@ test.skip("direct footer recreates the frame across command panel transitions",
await app.renderOnce() await app.renderOnce()
for (let index = 0; index < 3; index++) { for (let index = 0; index < 3; index++) {
const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable const composerFrame = footerComposerFrame(app.renderer.root)
app.mockInput.pressKey("p", { ctrl: true }) app.mockInput.pressKey("p", { ctrl: true })
await app.renderOnce() await app.renderOnce()
expect(app.captureCharFrame()).toContain("Commands") expect(app.captureCharFrame()).toContain("Commands")
expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame) expect(footerComposerFrame(app.renderer.root)).not.toBe(composerFrame)
app.mockInput.pressKey("c", { ctrl: true }) app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce() await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Commands") expect(app.captureCharFrame()).not.toContain("Commands")
@ -952,13 +991,14 @@ test("direct footer shows editable prompts and additional queued work while runn
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts() const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts() const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts() const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable const statusline = footerStatusline(app.renderer.root)
const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable const mode = statusItems[0]
const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner") const main = statusItems[1]
const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable const spinner = main.getChildren()[0]
const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable const model = statusItems[2]
const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable const queued = statusItems[3]
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined() expect(spinner).toBeDefined()
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation") expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
@ -1276,7 +1316,7 @@ test("direct model panel renders current model selector", async () => {
try { try {
await app.renderOnce() await app.renderOnce()
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select model") expect(frame).toContain("Select model")
expect(frame).toContain("Search") expect(frame).toContain("Search")
@ -1319,7 +1359,7 @@ test("direct variant panel renders current variant selector", async () => {
try { try {
await app.renderOnce() await app.renderOnce()
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable const list = panelMenu(app.renderer.root)
expect(frame).toContain("Select variant") expect(frame).toContain("Select variant")
expect(frame).toContain("Default") expect(frame).toContain("Default")

View File

@ -784,7 +784,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<Panel flexGrow={1} minHeight={0} border="none"> <Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} /> <Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
<scrollbox <scrollbox
id="diff-viewer-patches"
ref={(element: ScrollBoxRenderable) => (scroll = element)} ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1} flexGrow={1}
minHeight={0} minHeight={0}
@ -824,7 +823,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
{(patch) => ( {(patch) => (
<box border={patchLeftBorder()} borderColor={theme().border}> <box border={patchLeftBorder()} borderColor={theme().border}>
<diff <diff
id={`diff-viewer-patch-${entry.fileIndex}`}
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)} ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
diff={patch()} diff={patch()}
view={view()} view={view()}

View File

@ -1607,7 +1607,13 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
return ( return (
<Show when={content()}> <Show when={content()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}> <box
id={`text-${props.part.messageID}-${props.part.id}`}
paddingLeft={3}
marginTop={1}
flexDirection="column"
flexShrink={0}
>
<box onMouseUp={toggle}> <box onMouseUp={toggle}>
<ReasoningHeader <ReasoningHeader
toggleable={inMinimal()} toggleable={inMinimal()}
@ -1684,7 +1690,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
return ( return (
<Show when={props.part.text.trim()}> <Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}> <box id={`text-${props.part.messageID}-${props.part.id}`} paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown <markdown
syntaxStyle={syntax()} syntaxStyle={syntax()}
streaming={true} streaming={true}
@ -1875,7 +1881,7 @@ function InlineTool(props: {
return ( return (
<InlineToolRow <InlineToolRow
id={`tool-inline-${props.subagent ? "subagent-" : ""}${props.part.id}`} id={`tool-inline-${props.subagent ? "subagent-" : ""}${props.part.messageID}-${props.part.id}`}
icon={props.icon} icon={props.icon}
iconColor={props.iconColor} iconColor={props.iconColor}
color={fg()} color={fg()}
@ -2007,7 +2013,7 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return ( return (
<box <box
id={props.part ? "tool-block-" + props.part.id : undefined} id={props.part ? `tool-block-${props.part.messageID}-${props.part.id}` : undefined}
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
@ -2174,7 +2180,7 @@ function Read(props: ToolProps) {
</InlineTool> </InlineTool>
<For each={loaded()}> <For each={loaded()}>
{(filepath, index) => ( {(filepath, index) => (
<box id={`tool-inline-loaded-${props.part.id}-${index()}`} paddingLeft={3}> <box id={`tool-inline-loaded-${props.part.messageID}-${props.part.id}-${index()}`} paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}> <text paddingLeft={3} fg={theme.textMuted}>
Loaded {pathFormatter.format(filepath)} Loaded {pathFormatter.format(filepath)}
</text> </text>

View File

@ -238,9 +238,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected() const option = selected()
if (option) props.onMove?.(option) if (option) props.onMove?.(option)
if (!scroll) return if (!scroll) return
const target = scroll.getChildren().find((child: { id?: string }) => { let remaining = store.selected
return child.id === JSON.stringify(selected()?.value) let index = 0
}) // Locate the row by position because a unique renderable ID cannot currently be ensured.
for (const [category, options] of grouped()) {
if (category) index++
if (remaining < options.length) {
index += remaining
break
}
index += options.length
remaining -= options.length
}
const target = scroll.getChildren()[index]
if (!target) return if (!target) return
const y = target.y - scroll.y const y = target.y - scroll.y
if (center) { if (center) {
@ -553,7 +563,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const current = createMemo(() => isDeepEqual(option.value, props.current)) const current = createMemo(() => isDeepEqual(option.value, props.current))
return ( return (
<box <box
id={JSON.stringify(option.value)}
flexDirection="column" flexDirection="column"
position="relative" position="relative"
onMouseMove={() => { onMouseMove={() => {

View File

@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { Renderable, ScrollBoxRenderable } from "@opentui/core" import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid" import { testRender, useRenderer } from "@opentui/solid"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui" import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { Session } from "@opencode-ai/sdk/v2" import type { Session } from "@opencode-ai/sdk/v2"
@ -63,9 +63,9 @@ test("brackets navigate diff hunks", async () => {
) )
try { try {
await viewer.app.waitForFrame((frame) => frame.includes("const first")) await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches"))) await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush() await viewer.app.flush()
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop const initial = scroll.scrollTop
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]") expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
@ -178,14 +178,19 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } } const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
function findRenderable(root: Renderable, id: string): Renderable | undefined { function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
if (root.id === id) return root if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
return root return root
.getChildren() .getChildren()
.map((child) => findRenderable(child, id)) .map(findScrollBox)
.find(Boolean) .find(Boolean)
} }
function containsDiff(root: Renderable): boolean {
if (root instanceof DiffRenderable) return true
return root.getChildren().some(containsDiff)
}
const session = { const session = {
id: "session-1", id: "session-1",
slug: "session-1", slug: "session-1",