Parcourir la source

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

Sebastian il y a 2 mois
Parent
commit
fff0ec294c

+ 1 - 16
packages/opencode/src/cli/cmd/run/footer.command.tsx

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

+ 0 - 2
packages/opencode/src/cli/cmd/run/footer.menu.tsx

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

+ 0 - 5
packages/opencode/src/cli/cmd/run/footer.permission.tsx

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

+ 2 - 3
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

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

+ 2 - 9
packages/opencode/src/cli/cmd/run/footer.question.tsx

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

+ 0 - 1
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

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

+ 3 - 16
packages/opencode/src/cli/cmd/run/footer.view.tsx

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

+ 0 - 6
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

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

+ 0 - 1
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

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

+ 0 - 4
packages/opencode/src/cli/cmd/run/splash.ts

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

+ 55 - 15
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -1,6 +1,6 @@
 /** @jsxImportSource @opentui/solid */
 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 { createSignal } from "solid-js"
 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 () => {
   const surface = RGBA.fromHex("#123456")
   const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
@@ -255,7 +294,7 @@ test("direct footer composer area does not adopt footer surface", async () => {
 
   try {
     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())
     setTheme({
@@ -588,7 +627,7 @@ test("direct subagent panel renders active subagents", async () => {
   try {
     await app.renderOnce()
     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("Inspect auth flow")
@@ -626,7 +665,7 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
   try {
     await app.renderOnce()
     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("fix the auth test")
@@ -648,12 +687,12 @@ test.skip("direct footer recreates the frame across command panel transitions",
     await app.renderOnce()
 
     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 })
       await app.renderOnce()
 
       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 })
       await app.renderOnce()
       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 tinted = (RUN_THEME_FALLBACK.footer.status 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 mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
-    const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
-    const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
-    const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
-    const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
-    const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
+    const statusline = footerStatusline(app.renderer.root)
+    const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
+    const mode = statusItems[0]
+    const main = statusItems[1]
+    const spinner = main.getChildren()[0]
+    const model = statusItems[2]
+    const queued = statusItems[3]
+    const hint = statusItems.at(-1)!
 
     expect(spinner).toBeDefined()
     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 {
     await app.renderOnce()
     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("Search")
@@ -1319,7 +1359,7 @@ test("direct variant panel renders current variant selector", async () => {
   try {
     await app.renderOnce()
     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("Default")

+ 0 - 2
packages/tui/src/feature-plugins/system/diff-viewer.tsx

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

+ 11 - 5
packages/tui/src/routes/session/index.tsx

@@ -1607,7 +1607,13 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
 
   return (
     <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}>
           <ReasoningHeader
             toggleable={inMinimal()}
@@ -1684,7 +1690,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
   const { theme, syntax } = useTheme()
   return (
     <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
           syntaxStyle={syntax()}
           streaming={true}
@@ -1875,7 +1881,7 @@ function InlineTool(props: {
 
   return (
     <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}
       iconColor={props.iconColor}
       color={fg()}
@@ -2007,7 +2013,7 @@ function BlockTool(props: {
   const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
   return (
     <box
-      id={props.part ? "tool-block-" + props.part.id : undefined}
+      id={props.part ? `tool-block-${props.part.messageID}-${props.part.id}` : undefined}
       border={["left"]}
       paddingTop={1}
       paddingBottom={1}
@@ -2174,7 +2180,7 @@ function Read(props: ToolProps) {
       </InlineTool>
       <For each={loaded()}>
         {(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}>
               ↳ Loaded {pathFormatter.format(filepath)}
             </text>

+ 13 - 4
packages/tui/src/ui/dialog-select.tsx

@@ -238,9 +238,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     const option = selected()
     if (option) props.onMove?.(option)
     if (!scroll) return
-    const target = scroll.getChildren().find((child: { id?: string }) => {
-      return child.id === JSON.stringify(selected()?.value)
-    })
+    let remaining = store.selected
+    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
     const y = target.y - scroll.y
     if (center) {
@@ -553,7 +563,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                       const current = createMemo(() => isDeepEqual(option.value, props.current))
                       return (
                         <box
-                          id={JSON.stringify(option.value)}
                           flexDirection="column"
                           position="relative"
                           onMouseMove={() => {

+ 11 - 6
packages/tui/test/cli/tui/diff-viewer.test.tsx

@@ -1,7 +1,7 @@
 /** @jsxImportSource @opentui/solid */
 import { expect, test } from "bun:test"
 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 type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
 import type { Session } from "@opencode-ai/sdk/v2"
@@ -63,9 +63,9 @@ test("brackets navigate diff hunks", async () => {
   )
   try {
     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()
-    const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
+    const scroll = findScrollBox(viewer.app.renderer.root)!
     const initial = scroll.scrollTop
 
     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" } }
 
-function findRenderable(root: Renderable, id: string): Renderable | undefined {
-  if (root.id === id) return root
+function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
+  if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
   return root
     .getChildren()
-    .map((child) => findRenderable(child, id))
+    .map(findScrollBox)
     .find(Boolean)
 }
 
+function containsDiff(root: Renderable): boolean {
+  if (root instanceof DiffRenderable) return true
+  return root.getChildren().some(containsDiff)
+}
+
 const session = {
   id: "session-1",
   slug: "session-1",