Selaa lähdekoodia

tui/mini: consolidate stream and panel internals (#37903)

Simon Klee 4 viikkoa sitten
vanhempi
sitoutus
75c7ac6a2c

+ 145 - 141
packages/cli/test/drive/mini-interactive.drive.mjs

@@ -1,158 +1,162 @@
-import { defineScript } from "opencode-drive"
+import { Effect } from "effect"
+import { defineScript, Llm } from "opencode-drive"
 import { mkdir } from "node:fs/promises"
 import path from "node:path"
 
 export default defineScript({
   launch: "manual",
-  setup({ config }) {
-    config.autoupdate = false
-  },
-  async run({ artifacts, llm, server, signal }) {
-    await configureServicePort(artifacts)
-    await server.launch()
+  config: { autoupdate: false },
+  run: ({ artifacts, llm, server }) =>
+    Effect.gen(function* () {
+      yield* Effect.promise(() => configureServicePort(artifacts))
+      yield* server.launch()
 
-    const registration = await serviceRegistration(artifacts)
-    const root = path.resolve(import.meta.dir, "../../../..")
-    const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
-    const session = `mini-stage2-${process.pid}`
-    const snapshots = path.join(artifacts, "mini-stage2")
-    await mkdir(snapshots, { recursive: true })
+      const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
+      const root = path.resolve(import.meta.dir, "../../../..")
+      const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
+      const session = `mini-stage2-${process.pid}`
+      const snapshots = path.join(artifacts, "mini-stage2")
+      yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
 
-    llm.queue(
-      llm.toolCall({
-        index: 0,
-        id: "mini-shell",
-        name: "shell",
-        input: { command: "printf 'drive-mini-tool-output\\n'" },
-      }),
-      llm.finish("tool-calls"),
-    )
-    llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
+      yield* llm.queue(
+        Llm.toolCall({
+          index: 0,
+          id: "mini-shell",
+          name: "shell",
+          input: { command: "printf 'drive-mini-tool-output\\n'" },
+        }),
+        Llm.finish("tool-calls"),
+      )
+      yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
 
-    const abort = () => {
-      void tmux(["kill-session", "-t", session], true).catch(() => {})
-    }
-    signal.addEventListener("abort", abort, { once: true })
-    try {
-      await tmux([
-        "new-session",
-        "-d",
-        "-s",
-        session,
-        "-x",
-        "140",
-        "-y",
-        "30",
-        "--",
-        "env",
-        `PWD=${path.join(artifacts, "files")}`,
-        `OPENCODE_PASSWORD=${registration.password}`,
-        `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
-        `OPENCODE_TEST_HOME=${artifacts}`,
-        `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
-        `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
-        `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
-        `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
-        "OPENCODE_DISABLE_AUTOUPDATE=1",
-        "OPENCODE_DIRECT_TRACE=1",
-        process.execPath,
-        "--conditions=browser",
-        `--preload=${preload}`,
-        path.join(root, "packages/cli/src/index.ts"),
-        "mini",
-        "--server",
-        registration.url,
-        "--model",
-        "simulation/gpt-sim-model",
-      ])
-      await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
+      const journey = Effect.gen(function* () {
+        yield* Effect.uninterruptible(
+          Effect.promise(() =>
+            tmux([
+              "new-session",
+              "-d",
+              "-s",
+              session,
+              "-x",
+              "140",
+              "-y",
+              "30",
+              "--",
+              "env",
+              `PWD=${path.join(artifacts, "files")}`,
+              `OPENCODE_PASSWORD=${registration.password}`,
+              `OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
+              `OPENCODE_TEST_HOME=${artifacts}`,
+              `XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
+              `XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
+              `XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
+              `XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
+              "OPENCODE_DISABLE_AUTOUPDATE=1",
+              "OPENCODE_DIRECT_TRACE=1",
+              process.execPath,
+              "--conditions=browser",
+              `--preload=${preload}`,
+              path.join(root, "packages/cli/src/index.ts"),
+              "mini",
+              "--server",
+              registration.url,
+              "--model",
+              "simulation/gpt-sim-model",
+            ]),
+          ),
+        )
+        yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
 
-      const first = await waitForPane(session, "OpenCode")
-      await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
-      if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
+        const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
+        if (first.includes("drive mini response complete"))
+          throw new Error("response rendered before prompt submission")
 
-      await waitForPane(session, "Simulated Model", 15_000)
-      await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
-      await Bun.sleep(100)
-      await tmux(["send-keys", "-H", "-t", session, "0d"])
-      const completed = await waitForPane(session, "drive mini response complete", 20_000)
-      if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
-      await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
+        yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
+        yield* Effect.sleep(100)
+        yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
+        const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
+        if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
 
-      await Bun.sleep(500)
-      const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
-      await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
-      await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
-      await waitForFile(
-        resizeOutput,
-        (value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
-      )
-      await tmux(["pipe-pane", "-t", session])
-      const resized = await captureVisiblePane(session)
-      if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
-      await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
+        yield* Effect.sleep(500)
+        const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
+        yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
+        yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
+        yield* Effect.promise(() =>
+          waitForFile(
+            resizeOutput,
+            (value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
+          ),
+        )
+        yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
+        const resized = yield* Effect.promise(() => captureVisiblePane(session))
+        if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
 
-      llm.queue(
-        llm.toolCall({
-          index: 0,
-          id: "mini-question",
-          name: "question",
-          input: {
-            questions: [
-              {
-                header: "Drive form",
-                question: "Choose the Mini Form answer",
-                options: [{ label: "Accepted", description: "Continue the run" }],
-                multiple: false,
-              },
-            ],
-          },
-        }),
-        llm.finish("tool-calls"),
-      )
-      llm.queue(llm.text("drive mini form complete"))
-      await tmux(["send-keys", "-t", session, "-l", "exercise the form"])
-      await tmux(["send-keys", "-H", "-t", session, "0d"])
-      await waitForPane(session, "Choose the Mini Form answer", 20_000)
-      await tmux(["send-keys", "-H", "-t", session, "0d"])
-      await waitForPane(session, "drive mini form complete", 20_000)
+        yield* llm.queue(
+          Llm.toolCall({
+            index: 0,
+            id: "mini-question",
+            name: "question",
+            input: {
+              questions: [
+                {
+                  header: "Drive form",
+                  question: "Choose the Mini Form answer",
+                  options: [{ label: "Accepted", description: "Continue the run" }],
+                  multiple: false,
+                },
+              ],
+            },
+          }),
+          Llm.finish("tool-calls"),
+        )
+        yield* llm.queue(Llm.text("drive mini form complete"))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
+        yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
+        yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
+        yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
+        yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
 
-      llm.queue(
-        llm.toolCall({
-          index: 0,
-          id: "mini-slow-shell",
-          name: "shell",
-          input: { command: "sleep 10" },
-        }),
-        llm.finish("tool-calls"),
-      )
-      await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
-      await Bun.sleep(100)
-      await tmux(["send-keys", "-H", "-t", session, "0d"])
-      await waitForPane(session, "$ sleep 10")
-      await tmux(["send-keys", "-t", session, "Escape"])
-      const armed = await waitForPane(session, "again to interrupt")
-      await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
-      await tmux(["send-keys", "-t", session, "Escape"])
-      const interrupted = await waitForPane(session, "Step interrupted", 10_000)
-      await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
-      if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
+        yield* llm.queue(
+          Llm.toolCall({
+            index: 0,
+            id: "mini-slow-shell",
+            name: "shell",
+            input: { command: "sleep 10" },
+          }),
+          Llm.finish("tool-calls"),
+        )
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
+        yield* Effect.sleep(100)
+        yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
+        yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
+        const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
+        const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
 
-      await tmux(["send-keys", "-t", session, "C-c"])
-      await waitForPane(session, "Press ctrl+c again to exit")
-      await tmux(["send-keys", "-t", session, "C-c"])
-      await waitForDeadPane(session)
-      const status = await paneDeadStatus(session)
-      if (status !== 0) throw new Error(`Mini exited with status ${status}`)
-      const exited = await capturePane(session)
-      if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
-        throw new Error("Mini exit splash was not rendered before teardown")
-      await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
-    } finally {
-      signal.removeEventListener("abort", abort)
-      await tmux(["kill-session", "-t", session], true)
-    }
-  },
+        yield* Effect.promise(async () => {
+          if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
+        })
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
+        yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
+        yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
+        yield* Effect.promise(() => waitForDeadPane(session))
+        const status = yield* Effect.promise(() => paneDeadStatus(session))
+        if (status !== 0) throw new Error(`Mini exited with status ${status}`)
+        const exited = yield* Effect.promise(() => capturePane(session))
+        if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
+          throw new Error("Mini exit splash was not rendered before teardown")
+        yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
+      })
+
+      yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
+    }),
 })
 
 /** @param {string[]} args */

+ 15 - 6
packages/tui/src/mini/demo.ts

@@ -263,11 +263,14 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
     { footer: state.footer },
     {
       commits,
-      footer: view
-        ? {
-            view,
-            patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
-          }
+      updates: view
+        ? [
+            {
+              type: "stream.patch" as const,
+              patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
+            },
+            { type: "stream.view" as const, view },
+          ]
         : undefined,
     },
   )
@@ -276,7 +279,13 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
 function clearBlocker(state: State): void {
   writeSessionOutput(
     { footer: state.footer },
-    { commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
+    {
+      commits: [],
+      updates: [
+        { type: "stream.patch", patch: { status: "" } },
+        { type: "stream.view", view: { type: "prompt" } },
+      ],
+    },
   )
 }
 

+ 215 - 335
packages/tui/src/mini/footer.command.tsx

@@ -48,8 +48,6 @@ type QueuedEntry = PanelEntry & {
   prompt: FooterQueuedPrompt
 }
 
-type MenuState = ReturnType<typeof createFooterMenuState>
-
 const PANEL_PAD = 2
 const PANEL_LIST_ROWS = 10
 const PANEL_FRAME_ROWS = 6
@@ -124,81 +122,137 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
   return "running"
 }
 
-function handleKey(input: {
-  event: KeyEvent
-  menu: MenuState
-  field: () => InputRenderable | undefined
-  setQuery: (value: string) => void
-  select: () => void
-  close: () => void
+function match<T extends PanelEntry>(query: string, entries: T[]) {
+  const text = query.trim()
+  if (!text) {
+    return entries
+  }
+
+  return fuzzysort
+    .go(text, entries, { keys: ["display", "category", "description", "keywords"] })
+    .map((item) => item.obj)
+}
+
+function createSearchablePanelController<T extends PanelEntry>(input: {
+  entries: Accessor<T[]>
+  limit: number
+  onClose: () => void
+  onSelect: (item: T) => void
+  isCurrent?: (item: T) => boolean
+  closeOnFirstUp?: boolean
+  onKey?: (event: KeyEvent, item: T | undefined) => boolean
+  onRows?: (rows: number) => void
 }) {
-  const name = input.event.name.toLowerCase()
-  const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super
+  let field: InputRenderable | undefined
+  const [query, setQuery] = createSignal("")
+  const items = createMemo<T[]>(() => match(query(), input.entries()))
+  const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
+  const selected = () => items()[menu.selected()]
 
-  if (name === "escape" || (ctrl && name === "c")) {
-    input.event.preventDefault()
-    input.close()
-    return
-  }
+  createEffect(() => {
+    query()
+    menu.reset()
+  })
 
-  if (name === "up" || (ctrl && name === "p")) {
-    input.event.preventDefault()
-    input.menu.move(-1)
-    return
-  }
+  createEffect(() => {
+    if (!input.isCurrent || query().trim()) {
+      return
+    }
 
-  if (name === "down" || (ctrl && name === "n")) {
-    input.event.preventDefault()
-    input.menu.move(1)
-    return
-  }
+    const index = items().findIndex(input.isCurrent)
+    if (index !== -1) {
+      menu.reveal(index)
+    }
+  })
 
-  if (name === "pageup") {
-    input.event.preventDefault()
-    input.menu.reveal(input.menu.selected() - PANEL_PAGE)
-    return
-  }
+  createEffect(() => {
+    input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
+  })
 
-  if (name === "pagedown") {
-    input.event.preventDefault()
-    input.menu.reveal(input.menu.selected() + PANEL_PAGE)
-    return
-  }
+  useKeyboard((event) => {
+    if (event.defaultPrevented) {
+      return
+    }
 
-  if (name === "home") {
-    input.event.preventDefault()
-    input.menu.reveal(0)
-    return
-  }
+    if (input.onKey?.(event, selected())) {
+      return
+    }
 
-  if (name === "end") {
-    input.event.preventDefault()
-    input.menu.reveal(Number.POSITIVE_INFINITY)
-    return
-  }
+    const name = event.name.toLowerCase()
+    if (input.closeOnFirstUp && name === "up" && menu.selected() === 0) {
+      event.preventDefault()
+      input.onClose()
+      return
+    }
 
-  if (name === "return") {
-    input.event.preventDefault()
-    input.select()
-    return
-  }
+    const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
+    if (name === "escape" || (ctrl && name === "c")) {
+      event.preventDefault()
+      input.onClose()
+      return
+    }
 
-  if (ctrl && name === "u") {
-    input.event.preventDefault()
-    input.setQuery("")
-    input.field()?.setText("")
-  }
-}
+    if (name === "up" || (ctrl && name === "p")) {
+      event.preventDefault()
+      menu.move(-1)
+      return
+    }
 
-function match<T extends PanelEntry>(query: string, entries: T[]) {
-  const text = query.trim()
-  if (!text) {
-    return entries
-  }
+    if (name === "down" || (ctrl && name === "n")) {
+      event.preventDefault()
+      menu.move(1)
+      return
+    }
 
-  return fuzzysort
-    .go(text, entries, { keys: ["display", "category", "description", "keywords"] })
-    .map((item) => item.obj)
+    if (name === "pageup") {
+      event.preventDefault()
+      menu.reveal(menu.selected() - PANEL_PAGE)
+      return
+    }
+
+    if (name === "pagedown") {
+      event.preventDefault()
+      menu.reveal(menu.selected() + PANEL_PAGE)
+      return
+    }
+
+    if (name === "home") {
+      event.preventDefault()
+      menu.reveal(0)
+      return
+    }
+
+    if (name === "end") {
+      event.preventDefault()
+      menu.reveal(Number.POSITIVE_INFINITY)
+      return
+    }
+
+    if (name === "return") {
+      event.preventDefault()
+      const item = selected()
+      if (item) {
+        input.onSelect(item)
+      }
+      return
+    }
+
+    if (ctrl && name === "u") {
+      event.preventDefault()
+      setQuery("")
+      field?.setText("")
+    }
+  })
+
+  return {
+    query,
+    setQuery,
+    items,
+    menu,
+    inputRef(input: InputRenderable) {
+      field = input
+    },
+  }
 }
 
 function PanelShell(props: {
@@ -350,8 +404,6 @@ export function RunCommandMenuBody(props: {
   onNew: () => void
   onExit: () => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
   const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
   const entries = createMemo<CommandEntry[]>(() => {
@@ -466,8 +518,6 @@ export function RunCommandMenuBody(props: {
       { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
     ]
   })
-  const items = createMemo<CommandEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
   const pick = (item: CommandEntry) => {
     if (item.action === "model") {
       props.onModel()
@@ -516,56 +566,39 @@ export function RunCommandMenuBody(props: {
 
     props.onCommand(item.name)
   }
-  const select = () => {
-    const item = items()[menu.selected()]
-    if (!item) {
-      return
-    }
-
-    pick(item)
-  }
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  const controller = createSearchablePanelController({
+    entries,
+    limit: PANEL_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: pick,
   })
 
   return (
     <PanelShell
       title="Commands"
       countVisible={false}
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
         rows={() => PANEL_LIST_ROWS}
         limit={PANEL_LIST_ROWS}
         empty="No results found"
         border={false}
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
-        grouped={!query().trim()}
+        grouped={!controller.query().trim()}
         background
         headerColor={props.theme().muted}
       />
@@ -581,8 +614,6 @@ export function RunSubagentSelectBody(props: {
   onSelect: (sessionID: string) => void
   onRows?: (rows: number) => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const entries = createMemo<SubagentEntry[]>(() =>
     props.tabs().map((item) => {
       const title = item.description || item.title || item.label
@@ -597,72 +628,35 @@ export function RunSubagentSelectBody(props: {
       }
     }),
   )
-  const items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
-  const select = () => {
-    const item = items()[menu.selected()]
-    if (!item) {
-      return
-    }
-
-    props.onSelect(item.sessionID)
-  }
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  createEffect(() => {
-    if (query().trim()) {
-      return
-    }
-
-    const index = items().findIndex((item) => item.current)
-    if (index !== -1) {
-      menu.reveal(index)
-    }
-  })
-
-  createEffect(() => {
-    props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
-      event.preventDefault()
-      props.onClose()
-      return
-    }
-
-    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  const controller = createSearchablePanelController({
+    entries,
+    limit: SUBAGENT_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onSelect(item.sessionID),
+    isCurrent: (item) => item.current,
+    closeOnFirstUp: true,
+    onRows: props.onRows,
   })
 
   return (
     <PanelShell
       title="Select subagent"
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
-        rows={menu.rows}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
+        rows={controller.menu.rows}
         limit={SUBAGENT_LIST_ROWS}
         empty="No subagents found"
         border={false}
@@ -683,8 +677,6 @@ export function RunQueuedPromptSelectBody(props: {
   onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
   onRows?: (rows: number) => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const entries = createMemo<QueuedEntry[]>(() =>
     props.prompts().map((prompt) => ({
       category: "",
@@ -694,72 +686,49 @@ export function RunQueuedPromptSelectBody(props: {
       prompt,
     })),
   )
-  const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
-  const selected = () => items()[menu.selected()]
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  createEffect(() => {
-    props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    const item = selected()
-    const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
-    if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
-      event.preventDefault()
-      props.onDelete(item.prompt)
-      return
-    }
+  const controller = createSearchablePanelController({
+    entries,
+    limit: SUBAGENT_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onEdit(item.prompt),
+    onRows: props.onRows,
+    onKey: (event, item) => {
+      const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
+      if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
+        event.preventDefault()
+        props.onDelete(item.prompt)
+        return true
+      }
 
-    if (item && ctrl && event.name === "e") {
-      event.preventDefault()
-      props.onEdit(item.prompt)
-      return
-    }
+      if (item && ctrl && event.name === "e") {
+        event.preventDefault()
+        props.onEdit(item.prompt)
+        return true
+      }
 
-    handleKey({
-      event,
-      menu,
-      field: () => field,
-      setQuery,
-      select: () => {
-        const item = selected()
-        if (item) props.onEdit(item.prompt)
-      },
-      close: props.onClose,
-    })
+      return false
+    },
   })
 
   return (
     <PanelShell
       title="Queued prompts"
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
-        rows={menu.rows}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
+        rows={controller.menu.rows}
         limit={SUBAGENT_LIST_ROWS}
         empty="No queued prompts"
         border={false}
@@ -778,8 +747,6 @@ export function RunSkillSelectBody(props: {
   onClose: () => void
   onSelect: (name: string) => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const entries = createMemo<SkillEntry[]>(() =>
     (props.commands() ?? [])
       .filter((item) => item.source === "skill")
@@ -792,50 +759,31 @@ export function RunSkillSelectBody(props: {
       }))
       .sort((a, b) => a.display.localeCompare(b.display)),
   )
-  const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
-  const select = () => {
-    const item = items()[menu.selected()]
-    if (!item) {
-      return
-    }
-
-    props.onSelect(item.name)
-  }
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  const controller = createSearchablePanelController({
+    entries,
+    limit: PANEL_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onSelect(item.name),
   })
 
   return (
     <PanelShell
       title="Skills"
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
         rows={() => PANEL_LIST_ROWS}
         limit={PANEL_LIST_ROWS}
         empty={props.commands() ? "No skills found" : "Skills loading"}
@@ -856,8 +804,6 @@ export function RunVariantSelectBody(props: {
   onClose: () => void
   onSelect: (variant: string | undefined) => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const entries = createMemo<VariantEntry[]>(() => [
     {
       category: "",
@@ -876,64 +822,32 @@ export function RunVariantSelectBody(props: {
       current: props.current() === variant,
     })),
   ])
-  const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
-  const pick = (item: VariantEntry) => {
-    props.onSelect(item.variant)
-  }
-  const select = () => {
-    const item = items()[menu.selected()]
-    if (!item) {
-      return
-    }
-
-    pick(item)
-  }
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  createEffect(() => {
-    if (query().trim()) {
-      return
-    }
-
-    const index = items().findIndex((item) => item.current)
-    if (index !== -1) {
-      menu.reveal(index)
-    }
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  const controller = createSearchablePanelController({
+    entries,
+    limit: PANEL_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onSelect(item.variant),
+    isCurrent: (item) => item.current,
   })
 
   return (
     <PanelShell
       title="Select variant"
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
         rows={() => PANEL_LIST_ROWS}
         limit={PANEL_LIST_ROWS}
         empty="No results found"
@@ -954,8 +868,6 @@ export function RunModelSelectBody(props: {
   onClose: () => void
   onSelect: (model: NonNullable<RunInput["model"]>) => void
 }) {
-  let field: InputRenderable | undefined
-  const [query, setQuery] = createSignal("")
   const entries = createMemo<ModelEntry[]>(() =>
     (props.providers() ?? [])
       .flatMap((provider) =>
@@ -997,71 +909,39 @@ export function RunModelSelectBody(props: {
         return a.display.localeCompare(b.display)
       }),
   )
-  const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
-  const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
-  const pick = (item: ModelEntry) => {
-    props.onSelect({ providerID: item.providerID, modelID: item.modelID })
-  }
-  const select = () => {
-    const item = items()[menu.selected()]
-    if (!item) {
-      return
-    }
-
-    pick(item)
-  }
-
-  createEffect(() => {
-    query()
-    menu.reset()
-  })
-
-  createEffect(() => {
-    if (query().trim()) {
-      return
-    }
-
-    const index = items().findIndex((item) => item.current)
-    if (index !== -1) {
-      menu.reveal(index)
-    }
-  })
-
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  const controller = createSearchablePanelController({
+    entries,
+    limit: PANEL_LIST_ROWS,
+    onClose: props.onClose,
+    onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
+    isCurrent: (item) => item.current,
   })
 
   return (
     <PanelShell
       title="Select model"
-      query={query()}
-      count={items().length}
+      query={controller.query()}
+      count={controller.items().length}
       total={entries().length}
       placeholder="Search"
       theme={props.theme}
-      inputRef={(input) => {
-        field = input
-      }}
-      onQuery={setQuery}
+      inputRef={controller.inputRef}
+      onQuery={controller.setQuery}
       dark
       chrome="minimal"
     >
       <RunFooterMenu
         theme={props.theme}
-        items={items}
-        selected={menu.selected}
-        offset={menu.offset}
+        items={controller.items}
+        selected={controller.menu.selected}
+        offset={controller.menu.offset}
         rows={() => PANEL_LIST_ROWS}
         limit={PANEL_LIST_ROWS}
         empty={props.providers() ? "No results found" : "Models loading"}
         border={false}
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
-        grouped={!query().trim()}
+        grouped={!controller.query().trim()}
         background
         headerColor={props.theme().muted}
       />

+ 86 - 0
packages/tui/src/mini/stream-v2.fragment.ts

@@ -0,0 +1,86 @@
+export type FragmentRef = {
+  messageID: string
+  partID: string
+}
+
+type FragmentState = {
+  text: string
+  projected?: string
+}
+
+export type FragmentUpdate = FragmentRef & {
+  key: string
+  previous: string
+  text: string
+}
+
+export type FragmentRestore =
+  | { type: "append"; suffix: string }
+  | { type: "covered" }
+  | { type: "conflict" }
+
+export function fragmentRef(messageID: string, kind: "text" | "reasoning", ordinal: number): FragmentRef {
+  return { messageID, partID: `${kind}:${ordinal}` }
+}
+
+export function createFragmentReconciler() {
+  const fragments = new Map<string, FragmentState>()
+  const key = (fragment: FragmentRef) => `${fragment.messageID}\u0000${fragment.partID}`
+
+  return {
+    clear() {
+      fragments.clear()
+    },
+    key,
+    value(fragment: FragmentRef) {
+      return fragments.get(key(fragment))?.text
+    },
+    project(fragment: FragmentRef, text: string, visible: boolean): FragmentUpdate {
+      const id = key(fragment)
+      const current = fragments.get(id)
+      fragments.set(id, {
+        text,
+        projected: visible ? text : current?.projected,
+      })
+      return { ...fragment, key: id, previous: current?.text ?? "", text }
+    },
+    delta(fragment: FragmentRef, delta: string): FragmentUpdate | undefined {
+      const id = key(fragment)
+      const current = fragments.get(id)
+      // Replay may start after an unseen prefix, so consume a covered chunk
+      // from anywhere in the remaining projection rather than only its start.
+      const covered = current?.projected?.indexOf(delta) ?? -1
+      if (current?.projected && covered >= 0) {
+        current.projected = current.projected.slice(covered + delta.length)
+        return
+      }
+      const previous = current?.text ?? ""
+      const text = previous + delta
+      fragments.set(id, { text, projected: current?.projected })
+      return { ...fragment, key: id, previous, text }
+    },
+    end(fragment: FragmentRef, text: string): FragmentUpdate {
+      const id = key(fragment)
+      const previous = fragments.get(id)?.text ?? ""
+      fragments.set(id, { text })
+      return { ...fragment, key: id, previous, text }
+    },
+    restore(fragment: FragmentRef, text: string): FragmentRestore {
+      const id = key(fragment)
+      const current = fragments.get(id)
+      if (!current) {
+        fragments.set(id, { text, projected: text })
+        return { type: "append", suffix: text }
+      }
+      if (text.startsWith(current.text)) {
+        const suffix = text.slice(current.text.length)
+        fragments.set(id, { text, projected: text })
+        return { type: "append", suffix }
+      }
+      if (current.text.startsWith(text)) return { type: "covered" }
+      return { type: "conflict" }
+    },
+  }
+}
+
+export type FragmentReconciler = ReturnType<typeof createFragmentReconciler>

+ 40 - 64
packages/tui/src/mini/stream-v2.subagent.ts

@@ -23,6 +23,7 @@ import type {
   SessionMessageInfo,
 } from "@opencode-ai/client/promise"
 import { Locale } from "../util/locale"
+import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
 import type {
   FooterSubagentDetail,
   FooterSubagentState,
@@ -105,10 +106,7 @@ type ChildState = {
   title?: string
   lastUpdatedAt: number
   frames: Frame[]
-  text: Map<string, string>
-  projectedText: Map<string, string>
-  reasoning: Map<string, string>
-  projectedReasoning: Map<string, string>
+  fragments: FragmentReconciler
   tools: Map<string, ToolTrack>
   toolSources: Map<string, SessionMessageAssistantTool>
   finishedTools: Set<string>
@@ -225,8 +223,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
   let blockerEpoch = 0
   let closed = false
   const active = (signal = input.signal) => !closed && !input.signal.aborted && !signal.aborted
-  const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
-
   const admitChild = (sessionID: string): ChildState | undefined => {
     const existing = children.get(sessionID)
     if (!existing && children.size >= FAMILY_LIST_LIMIT) return
@@ -238,10 +234,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       background: false,
       lastUpdatedAt: 0,
       frames: [],
-      text: new Map(),
-      projectedText: new Map(),
-      reasoning: new Map(),
-      projectedReasoning: new Map(),
+      fragments: createFragmentReconciler(),
       tools: new Map(),
       toolSources: new Map(),
       finishedTools: new Set(),
@@ -337,10 +330,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
 
   const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
     child.frames = []
-    child.text.clear()
-    child.projectedText.clear()
-    child.reasoning.clear()
-    child.projectedReasoning.clear()
+    child.fragments.clear()
     child.finishedTools.clear()
     child.toolSources.clear()
     child.messageIDs.clear()
@@ -356,33 +346,29 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       let reasoningOrdinal = 0
       for (const item of message.content) {
         if (item.type === "text") {
-          const id = `text:${textOrdinal++}`
-          const key = fragmentKey(message.id, id)
-          child.text.set(key, item.text)
-          child.projectedText.set(key, item.text)
-          setFrame(child, key, {
+          const fragment = fragmentRef(message.id, "text", textOrdinal++)
+          const update = child.fragments.project(fragment, item.text, true)
+          setFrame(child, update.key, {
             kind: "assistant",
             source: "assistant",
             text: item.text,
             phase: "progress",
             messageID: message.id,
-            partID: id,
+            partID: fragment.partID,
           })
           continue
         }
         if (item.type === "reasoning") {
-          const id = `reasoning:${reasoningOrdinal++}`
-          const key = fragmentKey(message.id, id)
-          child.reasoning.set(key, item.text)
-          child.projectedReasoning.set(key, item.text)
+          const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
+          const update = child.fragments.project(fragment, item.text, true)
           if (input.thinking)
-            setFrame(child, key, {
+            setFrame(child, update.key, {
               kind: "reasoning",
               source: "reasoning",
               text: `Thinking: ${item.text}`,
               phase: "progress",
               messageID: message.id,
-              partID: id,
+              partID: fragment.partID,
             })
           continue
         }
@@ -678,40 +664,35 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       return
     }
     if (event.type === "session.text.delta") {
-      const id = `text:${event.data.ordinal}`
-      const key = fragmentKey(event.data.assistantMessageID, id)
-      const projected = child.projectedText.get(key)
-      const covered = projected?.indexOf(event.data.delta) ?? -1
-      if (projected && covered >= 0) {
-        child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
-        return
-      }
-      const next = (child.text.get(key) ?? "") + event.data.delta
-      child.text.set(key, next)
-      setFrame(child, key, {
+      const update = child.fragments.delta(
+        fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
+        event.data.delta,
+      )
+      if (!update) return
+      setFrame(child, update.key, {
         kind: "assistant",
         source: "assistant",
-        text: next,
+        text: update.text,
         phase: "progress",
         messageID: event.data.assistantMessageID,
-        partID: id,
+        partID: update.partID,
       })
       touch(child, event.created)
       notifyDetail(child)
       return
     }
     if (event.type === "session.text.ended") {
-      const id = `text:${event.data.ordinal}`
-      const key = fragmentKey(event.data.assistantMessageID, id)
-      child.text.set(key, event.data.text)
-      child.projectedText.delete(key)
-      setFrame(child, key, {
+      const update = child.fragments.end(
+        fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
+        event.data.text,
+      )
+      setFrame(child, update.key, {
         kind: "assistant",
         source: "assistant",
         text: event.data.text,
         phase: "progress",
         messageID: event.data.assistantMessageID,
-        partID: id,
+        partID: update.partID,
       })
       touch(child, event.created)
       notifyDetail(child)
@@ -721,41 +702,36 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       return
     }
     if (event.type === "session.reasoning.delta") {
-      const id = `reasoning:${event.data.ordinal}`
-      const key = fragmentKey(event.data.assistantMessageID, id)
-      const projected = child.projectedReasoning.get(key)
-      const covered = projected?.indexOf(event.data.delta) ?? -1
-      if (projected && covered >= 0) {
-        child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
-        return
-      }
-      const next = (child.reasoning.get(key) ?? "") + event.data.delta
-      child.reasoning.set(key, next)
+      const update = child.fragments.delta(
+        fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
+        event.data.delta,
+      )
+      if (!update) return
       if (!input.thinking) return
-      setFrame(child, key, {
+      setFrame(child, update.key, {
         kind: "reasoning",
         source: "reasoning",
-        text: `Thinking: ${next}`,
+        text: `Thinking: ${update.text}`,
         phase: "progress",
         messageID: event.data.assistantMessageID,
-        partID: id,
+        partID: update.partID,
       })
       notifyDetail(child)
       return
     }
     if (event.type === "session.reasoning.ended") {
-      const id = `reasoning:${event.data.ordinal}`
-      const key = fragmentKey(event.data.assistantMessageID, id)
-      child.reasoning.set(key, event.data.text)
-      child.projectedReasoning.delete(key)
+      const update = child.fragments.end(
+        fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
+        event.data.text,
+      )
       if (!input.thinking) return
-      setFrame(child, key, {
+      setFrame(child, update.key, {
         kind: "reasoning",
         source: "reasoning",
         text: `Thinking: ${event.data.text}`,
         phase: "progress",
         messageID: event.data.assistantMessageID,
-        partID: id,
+        partID: update.partID,
       })
       notifyDetail(child)
       return

+ 93 - 119
packages/tui/src/mini/stream-v2.transport.ts

@@ -11,6 +11,7 @@ import { Event } from "@opencode-ai/schema/event"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { blockerStatus, pickBlockerView } from "./session-data"
 import { writeSessionOutput } from "./stream"
+import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
 import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
 import { normalizeTool, toolOutputText } from "./tool"
 import type {
@@ -117,10 +118,7 @@ type State = {
   globalForms: MiniFormRequest[]
   view: FooterView
   messageIDs: Set<string>
-  text: Map<string, string>
-  projectedText: Map<string, string>
-  reasoning: Map<string, string>
-  projectedReasoning: Map<string, string>
+  fragments: FragmentReconciler
   tools: Map<string, ToolState>
   toolSources: Map<string, SessionMessageAssistantTool>
   finishedTools: Set<string>
@@ -202,12 +200,12 @@ function nextEvent(stream: AsyncIterator<RunV2Event>, signal: AbortSignal) {
   })
 }
 
-async function prepareFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
-  if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
+async function prepareInitialFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
+  if (file.mime !== "text/plain") return { type: "file" as const, file: { uri: file.url, name: file.filename } }
   const content = file.url.startsWith("data:")
     ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
     : await (readTextFile?.(file.url) ?? Promise.reject(new Error("Local text file acquisition is unavailable")))
-  return { text: `<file name="${file.filename}">\n${content}\n</file>` }
+  return { type: "text" as const, text: `<file name="${file.filename}">\n${content}\n</file>` }
 }
 
 function promptFileMention(part: PromptFilePart) {
@@ -233,6 +231,25 @@ function promptFiles(next: SessionTurnInput) {
   )
 }
 
+async function prepareAttachments(
+  next: SessionTurnInput,
+  mode: "command" | "prompt",
+  readTextFile?: StreamInput["readTextFile"],
+) {
+  const initial = next.includeFiles ? next.files : []
+  if (mode === "command") {
+    return {
+      text: [],
+      files: [...initial.map((file) => ({ uri: file.url, name: file.filename })), ...promptFiles(next)],
+    }
+  }
+  const prepared = await Promise.all(initial.map((file) => prepareInitialFile(file, readTextFile)))
+  return {
+    text: prepared.flatMap((file) => (file.type === "text" ? [file.text] : [])),
+    files: [...prepared.flatMap((file) => (file.type === "file" ? [file.file] : [])), ...promptFiles(next)],
+  }
+}
+
 function promptAgents(next: SessionTurnInput) {
   return next.prompt.parts.flatMap((part) =>
     part.type === "agent"
@@ -359,10 +376,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     globalForms: [],
     view: { type: "prompt" },
     messageIDs: new Set(),
-    text: new Map(),
-    projectedText: new Map(),
-    reasoning: new Map(),
-    projectedReasoning: new Map(),
+    fragments: createFragmentReconciler(),
     tools: new Map(),
     toolSources: new Map(),
     finishedTools: new Set(),
@@ -400,7 +414,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     emit: () => {
       if (state.closed || input.footer.isClosed) return
       const snapshot = subagents.snapshot()
-      writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits: [], footer: { subagent: snapshot } })
+      writeSessionOutput(
+        { footer: input.footer, trace: input.trace },
+        { commits: [], updates: [{ type: "stream.subagent", state: snapshot }] },
+      )
       syncBlockers()
     },
   })
@@ -414,14 +431,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
           input.onCommit?.(commit)
           return
         }
-        const key = streamPartKey(commit.messageID, commit.partID)
-        const text = commit.kind === "assistant" ? state.text.get(key) : state.reasoning.get(key)
+        const text = state.fragments.value({ messageID: commit.messageID, partID: commit.partID })
         input.onCommit?.({
           ...commit,
           text: commit.kind === "reasoning" && text ? `Thinking: ${text}` : (text ?? commit.text),
         })
       })
-    writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined })
+    writeSessionOutput(
+      { footer: input.footer, trace: input.trace },
+      { commits, updates: patch ? [{ type: "stream.patch", patch }] : undefined },
+    )
   }
 
   const syncBlockers = () => {
@@ -438,13 +457,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       { footer: input.footer, trace: input.trace },
       {
         commits: [],
-        footer: {
-          view: next,
-          patch:
-            next.type === "prompt"
-              ? { phase: state.rootActive ? "running" : "idle", status: blockerStatus(next) }
-              : { status: blockerStatus(next) },
-        },
+        updates: [
+          {
+            type: "stream.patch",
+            patch:
+              next.type === "prompt"
+                ? { phase: state.rootActive ? "running" : "idle", status: blockerStatus(next) }
+                : { status: blockerStatus(next) },
+          },
+          { type: "stream.view", view: next },
+        ],
       },
     )
   }
@@ -560,39 +582,34 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     let reasoningOrdinal = 0
     for (const item of message.content) {
       if (item.type === "text") {
-        const id = `text:${textOrdinal++}`
-        const key = streamPartKey(message.id, id)
-        const sent = state.text.get(key)?.length ?? 0
-        state.text.set(key, item.text)
-        if (render) state.projectedText.set(key, item.text)
-        if (render && item.text.length > sent)
+        const fragment = fragmentRef(message.id, "text", textOrdinal++)
+        const update = state.fragments.project(fragment, item.text, render)
+        if (render && item.text.length > update.previous.length)
           write([
             {
               kind: "assistant",
               source: "assistant",
-              text: item.text.slice(sent),
+              text: item.text.slice(update.previous.length),
               phase: "progress",
               messageID: message.id,
-              partID: id,
+              partID: fragment.partID,
             },
           ])
         continue
       }
       if (item.type === "reasoning") {
-        const id = `reasoning:${reasoningOrdinal++}`
-        const key = streamPartKey(message.id, id)
-        const sent = state.reasoning.get(key)?.length ?? 0
-        state.reasoning.set(key, item.text)
-        if (render) state.projectedReasoning.set(key, item.text)
-        if (render && input.thinking && item.text.length > sent)
+        const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
+        const update = state.fragments.project(fragment, item.text, render)
+        if (render && input.thinking && item.text.length > update.previous.length)
           write([
             {
               kind: "reasoning",
               source: "reasoning",
-              text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
+              text:
+                update.previous.length === 0 ? `Thinking: ${item.text}` : item.text.slice(update.previous.length),
               phase: "progress",
               messageID: message.id,
-              partID: id,
+              partID: fragment.partID,
             },
           ])
         continue
@@ -800,16 +817,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       return
     }
     if (event.type === "session.text.delta") {
-      const id = `text:${event.data.ordinal}`
-      const key = streamPartKey(event.data.assistantMessageID, id)
-      const projected = state.projectedText.get(key)
-      const covered = projected?.indexOf(event.data.delta) ?? -1
-      if (projected && covered >= 0) {
-        state.projectedText.set(key, projected.slice(covered + event.data.delta.length))
-        return
-      }
-      const previous = state.text.get(key) ?? ""
-      state.text.set(key, previous + event.data.delta)
+      const fragment = fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal)
+      if (!state.fragments.delta(fragment, event.data.delta)) return
       write([
         {
           kind: "assistant",
@@ -817,74 +826,67 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
           text: event.data.delta,
           phase: "progress",
           messageID: event.data.assistantMessageID,
-          partID: id,
+          partID: fragment.partID,
         },
       ])
       return
     }
     if (event.type === "session.text.ended") {
-      const id = `text:${event.data.ordinal}`
-      const key = streamPartKey(event.data.assistantMessageID, id)
-      const previous = state.text.get(key) ?? ""
-      state.text.set(key, event.data.text)
-      if (event.data.text.length > previous.length)
+      const update = state.fragments.end(
+        fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
+        event.data.text,
+      )
+      if (event.data.text.length > update.previous.length)
         write([
           {
             kind: "assistant",
             source: "assistant",
-            text: event.data.text.slice(previous.length),
+            text: event.data.text.slice(update.previous.length),
             phase: "progress",
             messageID: event.data.assistantMessageID,
-            partID: id,
+            partID: update.partID,
           },
         ])
-      state.projectedText.delete(key)
       return
     }
     if (event.type === "session.reasoning.started") {
       return
     }
     if (event.type === "session.reasoning.delta") {
-      const id = `reasoning:${event.data.ordinal}`
-      const key = streamPartKey(event.data.assistantMessageID, id)
-      const projected = state.projectedReasoning.get(key)
-      const covered = projected?.indexOf(event.data.delta) ?? -1
-      if (projected && covered >= 0) {
-        state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
-        return
-      }
-      const previous = state.reasoning.get(key) ?? ""
-      state.reasoning.set(key, previous + event.data.delta)
+      const update = state.fragments.delta(
+        fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
+        event.data.delta,
+      )
+      if (!update) return
       if (input.thinking)
         write([
           {
             kind: "reasoning",
             source: "reasoning",
-            text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
+            text: update.previous ? event.data.delta : `Thinking: ${event.data.delta}`,
             phase: "progress",
             messageID: event.data.assistantMessageID,
-            partID: id,
+            partID: update.partID,
           },
         ])
       return
     }
     if (event.type === "session.reasoning.ended") {
-      const id = `reasoning:${event.data.ordinal}`
-      const key = streamPartKey(event.data.assistantMessageID, id)
-      const previous = state.reasoning.get(key) ?? ""
-      state.reasoning.set(key, event.data.text)
-      if (input.thinking && event.data.text.length > previous.length)
+      const update = state.fragments.end(
+        fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
+        event.data.text,
+      )
+      if (input.thinking && event.data.text.length > update.previous.length)
         write([
           {
             kind: "reasoning",
             source: "reasoning",
-            text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
+            text: update.previous ? event.data.text.slice(update.previous.length) : `Thinking: ${event.data.text}`,
             phase: "progress",
             messageID: event.data.assistantMessageID,
-            partID: id,
+            partID: update.partID,
           },
         ])
-      state.projectedReasoning.delete(key)
       return
     }
     if (event.type === "session.tool.input.started") {
@@ -1299,10 +1301,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       if (!current(attempt)) return false
       reset = true
       state.messageIDs.clear()
-      state.text.clear()
-      state.projectedText.clear()
-      state.reasoning.clear()
-      state.projectedReasoning.clear()
+      state.fragments.clear()
       state.tools.clear()
       state.toolSources.clear()
       state.finishedTools.clear()
@@ -1326,34 +1325,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
             row.commit.partID &&
             (row.commit.kind === "assistant" || row.commit.kind === "reasoning")
           ) {
-            const key = streamPartKey(row.commit.messageID, row.commit.partID)
             const prefix = row.commit.kind === "reasoning" ? "Thinking: " : ""
             const text = row.commit.text.startsWith(prefix) ? row.commit.text.slice(prefix.length) : row.commit.text
-            const current = row.commit.kind === "assistant" ? state.text.get(key) : state.reasoning.get(key)
-            if (current === undefined) {
-              input.footer.append(row.commit)
-              if (row.commit.kind === "assistant") {
-                state.text.set(key, text)
-                state.projectedText.set(key, text)
-              } else {
-                state.reasoning.set(key, text)
-                state.projectedReasoning.set(key, text)
-              }
-              continue
-            }
-            if (text.startsWith(current)) {
-              const suffix = text.slice(current.length)
-              if (suffix) input.footer.append({ ...row.commit, text: suffix })
-              if (row.commit.kind === "assistant") {
-                state.text.set(key, text)
-                state.projectedText.set(key, text)
-              } else {
-                state.reasoning.set(key, text)
-                state.projectedReasoning.set(key, text)
-              }
+            const restored = state.fragments.restore(
+              { messageID: row.commit.messageID, partID: row.commit.partID },
+              text,
+            )
+            if (restored.type === "covered") continue
+            if (restored.type === "append") {
+              if (restored.suffix)
+                input.footer.append(restored.suffix === text ? row.commit : { ...row.commit, text: restored.suffix })
               continue
             }
-            if (current.startsWith(text)) continue
           }
           if (row.commit.kind === "error" && row.commit.messageID) {
             if (state.errors.has(row.commit.messageID)) continue
@@ -1431,10 +1414,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
         // Agent and model ride the command payload; the server switches only
         // when the command itself does not pin them.
-        const files = [
-          ...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
-          ...promptFiles(next),
-        ]
+        const attachments = await prepareAttachments(next, "command")
         const agents = promptAgents(next)
         input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
         await runTurnWait(next, messageID, {
@@ -1447,7 +1427,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
                 arguments: command.arguments,
                 agent: next.agent,
                 model: selected,
-                files: files.length ? files : undefined,
+                files: attachments.files.length ? attachments.files : undefined,
                 agents: agents.length ? agents : undefined,
                 delivery: "steer",
               },
@@ -1465,13 +1445,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       if (selected)
         await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
 
-      const prepared = await Promise.all(
-        (next.includeFiles ? next.files : []).map((file) => prepareFile(file, input.readTextFile)),
-      )
-      const attachments = [
-        ...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
-        ...promptFiles(next),
-      ]
+      const attachments = await prepareAttachments(next, "prompt", input.readTextFile)
       const agents = promptAgents(next)
       input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
       await runTurnWait(next, messageID, {
@@ -1480,8 +1454,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
             {
               sessionID: input.sessionID,
               id: messageID,
-              text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
-              files: attachments.length ? attachments : undefined,
+              text: [next.prompt.text, ...attachments.text].join("\n\n"),
+              files: attachments.files.length ? attachments.files : undefined,
               agents: agents.length ? agents : undefined,
               delivery: "steer",
             },

+ 19 - 32
packages/tui/src/mini/stream.ts

@@ -1,11 +1,11 @@
 // Thin bridge between transport output and the footer API.
 //
-// Transports produce StreamCommit[] and an optional FooterOutput (patch +
-// view + subagent state). This module forwards them to footer.append() and
-// footer.event() respectively, adding trace writes along the way. It also
+// Transports produce immutable StreamCommit[] rows and typed mutable-footer
+// updates. This module forwards both to the footer API, adding trace writes
+// along the way. It also
 // defaults status updates to phase "running" if the caller didn't set a
 // phase -- a convenience so transport code doesn't have to repeat that.
-import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
+import type { FooterApi, FooterEvent, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
 
 type Trace = {
   write(type: string, data?: unknown): void
@@ -18,7 +18,7 @@ type OutputInput = {
 
 type StreamOutput = {
   commits: StreamCommit[]
-  footer?: FooterOutput
+  updates?: Extract<FooterEvent, { type: "stream.patch" | "stream.view" | "stream.subagent" }>[]
 }
 
 // Default to "running" phase when a status string arrives without an explicit phase.
@@ -134,32 +134,19 @@ export function writeSessionOutput(input: OutputInput, out: StreamOutput): void
     input.footer.append(commit)
   }
 
-  if (out.footer?.patch) {
-    const next = patch(out.footer.patch)
-    input.trace?.write("ui.patch", next)
-    input.footer.event({
-      type: "stream.patch",
-      patch: next,
-    })
-  }
-
-  if (out.footer?.subagent) {
-    input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
-    input.footer.event({
-      type: "stream.subagent",
-      state: out.footer.subagent,
-    })
-  }
-
-  if (!out.footer?.view) {
-    return
+  for (const update of out.updates ?? []) {
+    if (update.type === "stream.patch") {
+      const next = { ...update, patch: patch(update.patch) }
+      input.trace?.write("ui.patch", next.patch)
+      input.footer.event(next)
+      continue
+    }
+    if (update.type === "stream.subagent") {
+      input.trace?.write("ui.subagent", traceSubagentState(update.state))
+      input.footer.event(update)
+      continue
+    }
+    input.trace?.write("ui.patch", { view: update.view })
+    input.footer.event(update)
   }
-
-  input.trace?.write("ui.patch", {
-    view: out.footer.view,
-  })
-  input.footer.event({
-    type: "stream.view",
-    view: out.footer.view,
-  })
 }

+ 1 - 8
packages/tui/src/mini/types.ts

@@ -7,7 +7,7 @@
 //
 // Data flow through the system:
 //
-//   V2 events / demo actions → StreamCommit[] + FooterOutput
+//   V2 events / demo actions → StreamCommit[] + FooterEvent[]
 //     → stream.ts bridges to footer API
 //       → footer.ts queues commits and patches the footer view
 //         → OpenTUI split-footer renderer writes to terminal
@@ -311,13 +311,6 @@ export type FooterSubagentState = {
   forms: MiniFormRequest[]
 }
 
-// The transport emits this alongside scrollback commits so the footer can update in the same frame.
-export type FooterOutput = {
-  patch?: FooterPatch
-  view?: FooterView
-  subagent?: FooterSubagentState
-}
-
 // Typed messages sent to RunFooter.event(). The prompt queue and stream
 // transport both emit these to update footer state without reaching into
 // internal signals directly.

+ 5 - 34
packages/tui/test/mini/catalog.shared.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpenCode } from "@opencode-ai/client/promise"
 import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
+import { catalogModel, catalogProvider } from "./fixture/catalog"
 
 afterEach(() => {
   mock.restore()
@@ -58,45 +59,15 @@ describe("run catalog shared", () => {
 
   test("merges current providers and models into the footer catalog shape", () => {
     const providers = runProviders(
+      [catalogProvider("openai", "OpenAI")],
       [
-        {
-          id: "openai",
-          name: "OpenAI",
-          package: "",
-        },
-      ],
-      [
-        {
+        catalogModel({
           id: "gpt-5",
           modelID: "openai",
           providerID: "openai",
           name: "Little Frank",
-          capabilities: {
-            tools: true,
-            input: ["text"],
-            output: ["text"],
-          },
-          variants: [{ id: "high" }],
-          time: {
-            released: 1,
-          },
-          cost: [
-            {
-              input: 0,
-              output: 0,
-              cache: {
-                read: 0,
-                write: 0,
-              },
-            },
-          ],
-          status: "active",
-          enabled: true,
-          limit: {
-            context: 128000,
-            output: 8192,
-          },
-        },
+          variants: ["high"],
+        }),
       ],
     )
 

+ 2 - 20
packages/tui/test/mini/entry.body.test.ts

@@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
 import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
 import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
 import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
+import { canonicalToolPart } from "./fixture/tool-part"
 
 function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
   return input
 }
 
-function toolPart(
-  name: string,
-  state: SessionMessageAssistantTool["state"],
-  id = `${name}-1`,
-): SessionMessageAssistantTool {
-  return {
-    type: "tool",
-    id,
-    name,
-    state,
-    time:
-      state.status === "streaming"
-        ? { created: 1 }
-        : state.status === "completed" || state.status === "error"
-          ? { created: 1, ran: 1, completed: 2 }
-          : { created: 1, ran: 1 },
-  }
-}
-
 function toolCommit(input: {
   tool: string
   state: SessionMessageAssistantTool["state"]
@@ -45,7 +27,7 @@ function toolCommit(input: {
       input.toolState ??
       (input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
     messageID: input.messageID,
-    part: toolPart(input.tool, input.state, input.id),
+    part: canonicalToolPart(input.tool, input.state, input.id),
   })
 }
 

+ 67 - 0
packages/tui/test/mini/fixture/catalog.ts

@@ -0,0 +1,67 @@
+import { spyOn } from "bun:test"
+import type {
+  LocationRef,
+  ModelListOutput,
+  OpenCodeClient,
+  ProviderListOutput,
+} from "@opencode-ai/client/promise"
+
+export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
+  return {
+    id,
+    name,
+    package: "",
+  }
+}
+
+export function catalogModel(input: {
+  id: string
+  modelID?: string
+  providerID: string
+  name?: string
+  context?: number
+  variants?: string[]
+}): ModelListOutput["data"][number] {
+  return {
+    id: input.id,
+    modelID: input.modelID ?? input.id,
+    providerID: input.providerID,
+    name: input.name ?? input.id,
+    capabilities: {
+      tools: true,
+      input: ["text"],
+      output: ["text"],
+    },
+    variants: (input.variants ?? []).map((id) => ({ id })),
+    time: { released: 1 },
+    cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
+    status: "active",
+    enabled: true,
+    limit: { context: input.context ?? 128_000, output: 8_192 },
+  }
+}
+
+export function stubCatalogLists(
+  sdk: OpenCodeClient,
+  input: {
+    location?: LocationRef
+    providers?: ProviderListOutput["data"]
+    models?: ModelListOutput["data"]
+  } = {},
+) {
+  const location = {
+    directory: input.location?.directory ?? "/tmp",
+    workspaceID: input.location?.workspaceID,
+    project: { id: "proj_1", directory: input.location?.directory ?? "/tmp" },
+  }
+  const empty = { location, data: [] }
+
+  return {
+    provider: spyOn(sdk.provider, "list").mockResolvedValue({ location, data: input.providers ?? [] } as never),
+    model: spyOn(sdk.model, "list").mockResolvedValue({ location, data: input.models ?? [] } as never),
+    agent: spyOn(sdk.agent, "list").mockResolvedValue(empty as never),
+    reference: spyOn(sdk.reference, "list").mockResolvedValue(empty as never),
+    command: spyOn(sdk.command, "list").mockResolvedValue(empty as never),
+    skill: spyOn(sdk.skill, "list").mockResolvedValue(empty as never),
+  }
+}

+ 67 - 0
packages/tui/test/mini/fixture/footer-api.ts

@@ -0,0 +1,67 @@
+import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../src/mini/types"
+
+export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
+  const prompts = new Set<(input: RunPrompt) => void>()
+  const queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
+  const closes = new Set<() => void>()
+  const events = input.events ?? []
+  const commits = input.commits ?? []
+  const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
+  let closed = false
+
+  const api: FooterApi = {
+    get isClosed() {
+      return closed
+    },
+    onPrompt(fn) {
+      prompts.add(fn)
+      return () => prompts.delete(fn)
+    },
+    onQueuedRemove(fn) {
+      queuedRemoves.add(fn)
+      return () => queuedRemoves.delete(fn)
+    },
+    onClose(fn) {
+      if (closed) {
+        fn()
+        return () => {}
+      }
+      closes.add(fn)
+      return () => closes.delete(fn)
+    },
+    event(next) {
+      events.push(next)
+      calls.push({ type: "event", value: next })
+    },
+    append(next) {
+      commits.push(next)
+      calls.push({ type: "commit", value: next })
+    },
+    idle: () => Promise.resolve(),
+    close() {
+      if (closed) return
+      closed = true
+      for (const fn of [...closes]) fn()
+    },
+    destroy() {
+      api.close()
+      prompts.clear()
+      queuedRemoves.clear()
+      closes.clear()
+    },
+  }
+
+  return {
+    api,
+    events,
+    commits,
+    calls,
+    submit(text: string, mode?: RunPrompt["mode"]) {
+      const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
+      for (const fn of [...prompts]) fn(prompt)
+    },
+    removeQueued(messageID: string) {
+      for (const fn of [...queuedRemoves]) void fn(messageID)
+    },
+  }
+}

+ 20 - 0
packages/tui/test/mini/fixture/tool-part.ts

@@ -0,0 +1,20 @@
+import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
+
+export function canonicalToolPart(
+  name: string,
+  state: SessionMessageAssistantTool["state"],
+  id = `${name}-1`,
+): SessionMessageAssistantTool {
+  return {
+    type: "tool",
+    id,
+    name,
+    state,
+    time:
+      state.status === "streaming"
+        ? { created: 1 }
+        : state.status === "completed" || state.status === "error"
+          ? { created: 1, ran: 1, completed: 2 }
+          : { created: 1, ran: 1 },
+  }
+}

+ 21 - 3
packages/tui/test/mini/footer.view.test.tsx

@@ -422,6 +422,7 @@ test("direct skill panel renders searchable skill list", async () => {
     command({ name: "internal", description: "Skill command", source: "skill" }),
     command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
   ])
+  const selected: string[] = []
 
   const app = await testRender(
     () => (
@@ -430,7 +431,9 @@ test("direct skill panel renders searchable skill list", async () => {
           theme={() => RUN_THEME_FALLBACK.footer}
           commands={commands}
           onClose={() => {}}
-          onSelect={() => {}}
+          onSelect={(name) => {
+            selected.push(name)
+          }}
         />
       </box>
     ),
@@ -451,6 +454,11 @@ test("direct skill panel renders searchable skill list", async () => {
     expect(frame).toContain("formatter")
     expect(frame).toContain("Apply formatter fixes")
     expect(frame).not.toContain("review")
+    await app.mockInput.typeText("format")
+    await app.renderOnce()
+    expect(app.captureCharFrame()).not.toContain("internal")
+    app.mockInput.pressEnter()
+    expect(selected).toEqual(["formatter"])
   } finally {
     app.renderer.destroy()
   }
@@ -674,6 +682,8 @@ test("direct subagent panel closes when moving up from the first item", async ()
 
 test("direct queued prompt panel renders pending prompt actions", async () => {
   const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
+  const edited: string[] = []
+  const deleted: string[] = []
 
   const app = await testRender(
     () => (
@@ -682,8 +692,12 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
           theme={() => RUN_THEME_FALLBACK.footer}
           prompts={prompts}
           onClose={() => {}}
-          onEdit={() => {}}
-          onDelete={() => {}}
+          onEdit={(prompt) => {
+            edited.push(prompt.messageID)
+          }}
+          onDelete={(prompt) => {
+            deleted.push(prompt.messageID)
+          }}
         />
       </box>
     ),
@@ -701,6 +715,10 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
     expect(frame).not.toContain("┌")
     expect(frame).not.toContain("┃")
     expectPaletteList(list, 0)
+    app.mockInput.pressKey("e", { ctrl: true })
+    app.mockInput.pressKey("DELETE")
+    expect(edited).toEqual(["m-1"])
+    expect(deleted).toEqual(["m-1"])
   } finally {
     app.renderer.destroy()
   }

+ 16 - 21
packages/tui/test/mini/permission.shared.test.ts

@@ -9,6 +9,7 @@ import {
   permissionRun,
 } from "../../src/mini/permission.shared"
 import type { MiniPermissionRequest } from "../../src/mini/types"
+import { canonicalToolPart } from "./fixture/tool-part"
 
 function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
   return {
@@ -89,18 +90,16 @@ describe("run permission shared", () => {
         req({
           action: "shell",
           source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
-          tool: {
-            type: "tool",
-            id: "call-shell",
-            name: "shell",
-            state: {
+          tool: canonicalToolPart(
+            "shell",
+            {
               status: "running",
               input: { command: "git status --short" },
               structured: {},
               content: [],
             },
-            time: { created: 1, ran: 1 },
-          },
+            "call-shell",
+          ),
         }),
       ),
     ).toMatchObject({
@@ -137,18 +136,16 @@ describe("run permission shared", () => {
           action: "websearch",
           metadata: { provider: "parallel" },
           source: { type: "tool", messageID: "msg-search", callID: "call-search" },
-          tool: {
-            type: "tool",
-            id: "call-search",
-            name: "websearch",
-            state: {
+          tool: canonicalToolPart(
+            "websearch",
+            {
               status: "running",
               input: { query: "current releases" },
               structured: { provider: "exa", retained: true },
               content: [],
             },
-            time: { created: 1, ran: 1 },
-          },
+            "call-search",
+          ),
         }),
       ),
     ).toMatchObject({
@@ -164,18 +161,16 @@ describe("run permission shared", () => {
           action: "edit",
           resources: ["src/index.ts"],
           source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
-          tool: {
-            type: "tool",
-            id: "call-edit",
-            name: "edit",
-            state: {
+          tool: canonicalToolPart(
+            "edit",
+            {
               status: "running",
               input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
               structured: {},
               content: [],
             },
-            time: { created: 1, ran: 1 },
-          },
+            "call-edit",
+          ),
         }),
       ),
     ).toMatchObject({

+ 10 - 63
packages/tui/test/mini/runtime.boot.test.ts

@@ -2,67 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpenCode } from "@opencode-ai/client/promise"
 import type { Resolved } from "../../src/config"
 import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
+import { catalogModel, catalogProvider } from "./fixture/catalog"
 import { createTuiResolvedConfig } from "./fixture/tui-runtime"
 
-function ok<T>(data: T) {
-  return Promise.resolve(data)
-}
-
-function provider(id: string, name: string) {
-  return {
-    id,
-    name,
-    api: { type: "native" as const, settings: {} },
-    request: { headers: {}, body: {} },
-  }
-}
-
-function model(id: string, providerID: string, context: number, variants: string[] = []) {
-  return {
-    id,
-    providerID,
-    api: {
-      id: providerID,
-      type: "native" as const,
-      settings: {},
-    },
-    name: id,
-    capabilities: {
-      tools: true,
-      input: ["text"],
-      output: ["text"],
-    },
-    request: {
-      headers: {},
-      body: {},
-    },
-    variants: variants.map((variant) => ({
-      id: variant,
-      headers: {},
-      body: {},
-    })),
-    time: {
-      released: 1,
-    },
-    cost: [
-      {
-        input: 0,
-        output: 0,
-        cache: {
-          read: 0,
-          write: 0,
-        },
-      },
-    ],
-    limit: {
-      context,
-      output: 8192,
-    },
-    status: "active" as const,
-    enabled: true,
-  }
-}
-
 function config(input?: {
   leader?: string
   leaderTimeout?: number
@@ -165,10 +107,15 @@ describe("run runtime boot", () => {
 
   test("loads v2 providers and models for model selector data", async () => {
     const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
-    const providers = [provider("openai", "OpenAI")]
-    const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
-    const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
-    spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
+    const location = { directory: "/workspace", project: { id: "proj_1", directory: "/workspace" } }
+    const providerList = spyOn(sdk.provider, "list").mockResolvedValue({
+      location,
+      data: [catalogProvider("openai", "OpenAI")],
+    } as never)
+    spyOn(sdk.model, "list").mockResolvedValue({
+      location,
+      data: [catalogModel({ id: "gpt-5", providerID: "openai", variants: ["high", "minimal"] })],
+    } as never)
 
     await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
       providers: [

+ 18 - 94
packages/tui/test/mini/runtime.queue.test.ts

@@ -1,87 +1,11 @@
 import { describe, expect, test } from "bun:test"
 import { runPromptQueue } from "../../src/mini/runtime.queue"
-import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../src/mini/types"
-
-function footer() {
-  const prompts = new Set<(input: RunPrompt) => void>()
-  const queuedRemoves = new Set<(messageID: string) => void>()
-  const closes = new Set<() => void>()
-  const events: FooterEvent[] = []
-  const commits: StreamCommit[] = []
-  let closed = false
-
-  const api: FooterApi = {
-    get isClosed() {
-      return closed
-    },
-    onPrompt(fn) {
-      prompts.add(fn)
-      return () => {
-        prompts.delete(fn)
-      }
-    },
-    onQueuedRemove(fn) {
-      queuedRemoves.add(fn)
-      return () => {
-        queuedRemoves.delete(fn)
-      }
-    },
-    onClose(fn) {
-      if (closed) {
-        fn()
-        return () => {}
-      }
-
-      closes.add(fn)
-      return () => {
-        closes.delete(fn)
-      }
-    },
-    event(next) {
-      events.push(next)
-    },
-    append(next) {
-      commits.push(next)
-    },
-    idle() {
-      return Promise.resolve()
-    },
-    close() {
-      if (closed) {
-        return
-      }
-
-      closed = true
-      for (const fn of [...closes]) {
-        fn()
-      }
-    },
-    destroy() {
-      api.close()
-      prompts.clear()
-      closes.clear()
-    },
-  }
-
-  return {
-    api,
-    events,
-    commits,
-    submit(text: string, mode?: RunPrompt["mode"]) {
-      const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
-      for (const fn of [...prompts]) {
-        fn(next)
-      }
-    },
-    removeQueued(messageID: string) {
-      for (const fn of [...queuedRemoves]) fn(messageID)
-    },
-  }
-}
+import type { RunPrompt } from "../../src/mini/types"
+import { createFooterApiFixture } from "./fixture/footer-api"
 
 describe("run runtime queue", () => {
   test("ignores empty prompts", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     let calls = 0
 
     const task = runPromptQueue({
@@ -99,7 +23,7 @@ describe("run runtime queue", () => {
   })
 
   test("treats /exit as a close command", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     let calls = 0
 
     const task = runPromptQueue({
@@ -116,7 +40,7 @@ describe("run runtime queue", () => {
   })
 
   test("treats /new as a local session command", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
     let created = 0
 
@@ -149,7 +73,7 @@ describe("run runtime queue", () => {
   })
 
   test("shell mode submits /exit as a shell command", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: RunPrompt[] = []
 
     const task = runPromptQueue({
@@ -168,7 +92,7 @@ describe("run runtime queue", () => {
   })
 
   test("shell mode submits /new instead of creating a session", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: RunPrompt[] = []
     let created = 0
 
@@ -192,7 +116,7 @@ describe("run runtime queue", () => {
   })
 
   test("shell mode does not append a synthetic user row", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
 
     const task = runPromptQueue({
       footer: ui.api,
@@ -207,7 +131,7 @@ describe("run runtime queue", () => {
   })
 
   test("shell mode does not emit a turn duration summary", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
 
     const task = runPromptQueue({
       footer: ui.api,
@@ -223,7 +147,7 @@ describe("run runtime queue", () => {
   })
 
   test("preserves whitespace for initial input", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
 
     await runPromptQueue({
@@ -248,7 +172,7 @@ describe("run runtime queue", () => {
   })
 
   test("passes prompts to onSend", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
 
     await runPromptQueue({
@@ -266,7 +190,7 @@ describe("run runtime queue", () => {
   })
 
   test("appends the user row before the turn starts", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
 
     await runPromptQueue({
       footer: ui.api,
@@ -287,7 +211,7 @@ describe("run runtime queue", () => {
   })
 
   test("runs queued prompts in order", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
     let wake: (() => void) | undefined
     const gate = new Promise<void>((resolve) => {
@@ -319,7 +243,7 @@ describe("run runtime queue", () => {
   })
 
   test("exposes ordinary in-flight prompts for removal before sending", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const turns: RunPrompt[] = []
     let wake: (() => void) | undefined
     const gate = new Promise<void>((resolve) => {
@@ -360,7 +284,7 @@ describe("run runtime queue", () => {
   })
 
   test("removing one managed queued prompt preserves the others", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const turns: string[] = []
     let wake: (() => void) | undefined
     const gate = new Promise<void>((resolve) => {
@@ -395,7 +319,7 @@ describe("run runtime queue", () => {
   })
 
   test("drains a prompt queued during an in-flight turn", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
     let wake: (() => void) | undefined
     const gate = new Promise<void>((resolve) => {
@@ -428,7 +352,7 @@ describe("run runtime queue", () => {
   })
 
   test("close aborts the active run and drops pending queued work", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
     const seen: string[] = []
     let hit = false
 
@@ -466,7 +390,7 @@ describe("run runtime queue", () => {
   })
 
   test("propagates run errors", async () => {
-    const ui = footer()
+    const ui = createFooterApiFixture()
 
     const task = runPromptQueue({
       footer: ui.api,

+ 28 - 114
packages/tui/test/mini/runtime.test.ts

@@ -2,7 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpenCode } from "@opencode-ai/client/promise"
 import { runInteractiveDeferredMode } from "../../src/mini/runtime"
 import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
-import type { FooterApi, FooterEvent, MiniHost } from "../../src/mini/types"
+import type { FooterEvent, MiniHost } from "../../src/mini/types"
+import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
+import { createFooterApiFixture } from "./fixture/footer-api"
 import { createTuiResolvedConfig } from "./fixture/tui-runtime"
 
 function defer<T>() {
@@ -38,55 +40,8 @@ function host(): MiniHost {
   }
 }
 
-function footer(events: FooterEvent[] = []): FooterApi {
-  let closed = false
-  const closes = new Set<() => void>()
-
-  const notify = () => {
-    for (const fn of closes) fn()
-  }
-
-  return {
-    get isClosed() {
-      return closed
-    },
-    onPrompt: () => () => {},
-    onQueuedRemove: () => () => {},
-    onClose(fn) {
-      if (closed) {
-        fn()
-        return () => {}
-      }
-
-      closes.add(fn)
-      return () => {
-        closes.delete(fn)
-      }
-    },
-    event(value) {
-      events.push(value)
-    },
-    append() {},
-    idle() {
-      return Promise.resolve()
-    },
-    close() {
-      if (closed) {
-        return
-      }
-
-      closed = true
-      notify()
-    },
-    destroy() {
-      if (closed) {
-        return
-      }
-
-      closed = true
-      notify()
-    },
-  }
+function footer(events: FooterEvent[] = []) {
+  return createFooterApiFixture({ events }).api
 }
 
 afterEach(() => {
@@ -100,12 +55,7 @@ describe("run interactive runtime", () => {
     const streamStarted = defer<void>()
     let lifecycle!: LifecycleInput
     const settled: Array<{ sessionID: string; formID: string }> = []
-    spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    stubCatalogLists(sdk)
     const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
 
     const task = runInteractiveDeferredMode(
@@ -195,12 +145,7 @@ describe("run interactive runtime", () => {
     const api = footer()
     let resolved = 0
     api.idle = () => painted.promise
-    spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    stubCatalogLists(sdk)
 
     const task = runInteractiveDeferredMode(
       {
@@ -279,38 +224,17 @@ describe("run interactive runtime", () => {
           cursor: {},
         }) as never,
     )
-    spyOn(sdk.provider, "list").mockImplementation(
-      () =>
-        ok({
-          location: { directory: "/tmp" },
-          data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
-        }) as never,
-    )
-    spyOn(sdk.model, "list").mockImplementation(
-      () =>
-        ok({
-          location: { directory: "/tmp" },
-          data: [
-            {
-              id: "gpt-5",
-              providerID: "openai",
-              name: "Little Frank",
-              capabilities: { tools: true, input: ["text"], output: ["text"] },
-              request: { headers: {}, body: {} },
-              variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
-              time: { released: 1 },
-              cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
-              status: "active",
-              enabled: true,
-              limit: { context: 128000, output: 8192 },
-            },
-          ],
-        }) as never,
-    )
-    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    stubCatalogLists(sdk, {
+      providers: [catalogProvider("openai", "OpenAI")],
+      models: [
+        catalogModel({
+          id: "gpt-5",
+          providerID: "openai",
+          name: "Little Frank",
+          variants: ["high"],
+        }),
+      ],
+    })
 
     const task = runInteractiveDeferredMode(
       {
@@ -391,13 +315,7 @@ describe("run interactive runtime", () => {
     const session = spyOn(sdk.session, "get").mockImplementation(
       (_request, options) => pending(options?.signal) as never,
     )
-    const response = { location: { directory: "/tmp" }, data: [] }
-    spyOn(sdk.provider, "list").mockResolvedValue(response as never)
-    spyOn(sdk.model, "list").mockResolvedValue(response as never)
-    spyOn(sdk.agent, "list").mockResolvedValue(response as never)
-    spyOn(sdk.reference, "list").mockResolvedValue(response as never)
-    spyOn(sdk.command, "list").mockResolvedValue(response as never)
-    spyOn(sdk.skill, "list").mockResolvedValue(response as never)
+    stubCatalogLists(sdk)
 
     const task = runInteractiveDeferredMode(
       {
@@ -457,13 +375,9 @@ describe("run interactive runtime", () => {
     let getDirectory: (() => string) | undefined
     let findFiles: ((query: string) => Promise<string[]>) | undefined
     let transportLocation: unknown
-    const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
-    const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
-    const modelList = spyOn(sdk.model, "list").mockResolvedValue(response as never)
-    const agentList = spyOn(sdk.agent, "list").mockResolvedValue(response as never)
-    const referenceList = spyOn(sdk.reference, "list").mockResolvedValue(response as never)
-    const commandList = spyOn(sdk.command, "list").mockResolvedValue(response as never)
-    const skillList = spyOn(sdk.skill, "list").mockResolvedValue(response as never)
+    const catalogs = stubCatalogLists(sdk, {
+      location: { directory: "/session", workspaceID: "work-1" },
+    })
     const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
       location: {
         directory: "/session",
@@ -538,12 +452,12 @@ describe("run interactive runtime", () => {
     const query = { location: { directory: "/session", workspace: "work-1" } }
     expect(getDirectory?.()).toBe("/session")
     expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
-    expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
-    expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
-    expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
-    expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
-    expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
-    expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
+    expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
     expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
   })
 })

+ 2 - 16
packages/tui/test/mini/scrollback.surface.test.ts

@@ -6,6 +6,7 @@ import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
 import { entryGroupKey } from "../../src/mini/scrollback.writer"
 import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
 import type { StreamCommit } from "../../src/mini/types"
+import { canonicalToolPart } from "./fixture/tool-part"
 
 type ClaimedCommit = {
   snapshot: {
@@ -220,21 +221,6 @@ function error(text: string): StreamCommit {
   }
 }
 
-function toolPart(name: string, state: SessionMessageAssistantTool["state"], id: string): SessionMessageAssistantTool {
-  return {
-    type: "tool",
-    id,
-    name,
-    state,
-    time:
-      state.status === "streaming"
-        ? { created: 1 }
-        : state.status === "completed" || state.status === "error"
-          ? { created: 1, ran: 1, completed: 2 }
-          : { created: 1, ran: 1 },
-  }
-}
-
 function toolCommit(input: {
   tool: string
   phase: StreamCommit["phase"]
@@ -256,7 +242,7 @@ function toolCommit(input: {
     messageID,
     tool: input.tool,
     ...(input.toolState ? { toolState: input.toolState } : {}),
-    ...(input.state ? { part: toolPart(input.tool, input.state, id) } : {}),
+    ...(input.state ? { part: canonicalToolPart(input.tool, input.state, id) } : {}),
   }
 }
 

+ 63 - 40
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -11,7 +11,9 @@ import {
   type PermissionV2Request,
 } from "@opencode-ai/client/promise"
 import { createSessionTransport } from "../../src/mini/stream-v2.transport"
-import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
+import type { StreamCommit } from "../../src/mini/types"
+import { createFooterApiFixture } from "./fixture/footer-api"
+import { canonicalToolPart } from "./fixture/tool-part"
 import { tmpdir } from "../fixture/fixture"
 
 type RunV2Event = EventSubscribeOutput
@@ -91,31 +93,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
 }
 
 function footer() {
-  const commits: StreamCommit[] = []
-  const events: FooterEvent[] = []
-  let closed = false
-  const api: FooterApi = {
-    get isClosed() {
-      return closed
-    },
-    onPrompt: () => () => {},
-    onQueuedRemove: () => () => {},
-    onClose: () => () => {},
-    event(value) {
-      events.push(value)
-    },
-    append(value) {
-      commits.push(value)
-    },
-    idle: () => Promise.resolve(),
-    close() {
-      closed = true
-    },
-    destroy() {
-      closed = true
-    },
-  }
-  return { api, commits, events }
+  return createFooterApiFixture()
 }
 
 type SessionMessages = MessageListOutput["data"]
@@ -268,18 +246,16 @@ describe("V2 mini transport", () => {
       agent: "build",
       model: { providerID: "test", id: "model" },
       content: [
-        {
-          type: "tool" as const,
-          id: "call_child_source",
-          name: "shell",
-          state: {
+        canonicalToolPart(
+          "shell",
+          {
             status: "running" as const,
             input: { command: "git status --short" },
             structured: {},
             content: [],
           },
-          time: { created: 1, ran: 1 },
-        },
+          "call_child_source",
+        ),
       ],
       time: { created: 1 },
     }
@@ -508,8 +484,10 @@ describe("V2 mini transport", () => {
   test("sends local file and directory mentions as structured prompt files", async () => {
     await using tmp = await tmpdir()
     const filePath = path.join(tmp.path, "note.ts")
+    const contextPath = path.join(tmp.path, "context.txt")
     const directoryPath = path.join(tmp.path, "docs")
     await Bun.write(filePath, "export const answer = 42\n")
+    await Bun.write(contextPath, "context body")
     await fs.mkdir(directoryPath)
     await Bun.write(path.join(directoryPath, "README.md"), "# hello\n")
 
@@ -573,12 +551,16 @@ describe("V2 mini transport", () => {
           },
         ],
       },
-      files: [],
+      files: [
+        { type: "file", url: pathToFileURL(contextPath).href, filename: "context.txt", mime: "text/plain" },
+        { type: "file", url: "file:///tmp/image.png", filename: "image.png", mime: "image/png" },
+      ],
       includeFiles: true,
     })
 
-    expect(request?.text).toBe("Review @note.ts and @docs")
+    expect(request?.text).toBe('Review @note.ts and @docs\n\n<file name="context.txt">\ncontext body\n</file>')
     expect(request?.files).toEqual([
+      { uri: "file:///tmp/image.png", name: "image.png" },
       {
         uri: pathToFileURL(filePath).href,
         name: "note.ts",
@@ -2393,10 +2375,19 @@ describe("V2 mini transport", () => {
       prompt: {
         messageID: "msg_cmd",
         text: "/deploy prod",
-        parts: [],
+        parts: [
+          {
+            type: "file",
+            url: "file:///tmp/mentioned.txt",
+            filename: "mentioned.txt",
+            source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
+          },
+        ],
         command: { name: "deploy", arguments: "prod" },
       },
-      files: [],
+      files: [
+        { type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" },
+      ],
       includeFiles: true,
     })
 
@@ -2407,6 +2398,14 @@ describe("V2 mini transport", () => {
       arguments: "prod",
       agent: "build",
       model: { providerID: "test", id: "model" },
+      files: [
+        { uri: "file:///tmp/context.txt", name: "context.txt" },
+        {
+          uri: "file:///tmp/mentioned.txt",
+          name: "mentioned.txt",
+          mention: { start: 8, end: 12, text: "prod" },
+        },
+      ],
       delivery: "steer",
     })
     // Selection rides the command payload; no separate client-side switch.
@@ -2845,6 +2844,14 @@ describe("V2 mini transport", () => {
             agents: [],
             time: { created: 1 },
           },
+          {
+            id: "msg_child_a",
+            type: "assistant" as const,
+            agent: "explore",
+            model: { providerID: "test", id: "model" },
+            content: [{ type: "text" as const, text: "child answer" }],
+            time: { created: 2 },
+          },
         ],
       },
     })
@@ -2890,18 +2897,34 @@ describe("V2 mini transport", () => {
       { sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" },
     ])
 
+    expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
+
     events.push({
-      id: "evt_child_text",
+      id: "evt_child_text_replayed",
       created: 0,
       type: "session.text.delta",
       data: {
         sessionID: "ses_child",
         assistantMessageID: "msg_child_a",
         ordinal: 0,
-        delta: "child answer",
+        delta: "answer",
+      },
+    })
+    await Bun.sleep(0)
+    expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
+
+    events.push({
+      id: "evt_child_text_suffix",
+      created: 0,
+      type: "session.text.delta",
+      data: {
+        sessionID: "ses_child",
+        assistantMessageID: "msg_child_a",
+        ordinal: 0,
+        delta: " suffix",
       },
     })
-    while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer")))
+    while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")))
       await Bun.sleep(0)
 
     events.push({

+ 27 - 30
packages/tui/test/mini/stream.test.ts

@@ -1,33 +1,10 @@
 import { describe, expect, test } from "bun:test"
 import { writeSessionOutput } from "../../src/mini/stream"
-import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
-
-function footer() {
-  const events: FooterEvent[] = []
-  const commits: StreamCommit[] = []
-
-  const api: FooterApi = {
-    isClosed: false,
-    onPrompt: () => () => {},
-    onQueuedRemove: () => () => {},
-    onClose: () => () => {},
-    event: (next) => {
-      events.push(next)
-    },
-    append: (next) => {
-      commits.push(next)
-    },
-    idle: () => Promise.resolve(),
-    close: () => {},
-    destroy: () => {},
-  }
-
-  return { api, events, commits }
-}
+import { createFooterApiFixture } from "./fixture/footer-api"
 
 describe("run stream bridge", () => {
   test("defaults status patches to running phase", () => {
-    const out = footer()
+    const out = createFooterApiFixture()
 
     writeSessionOutput(
       {
@@ -35,11 +12,7 @@ describe("run stream bridge", () => {
       },
       {
         commits: [],
-        footer: {
-          patch: {
-            status: "assistant responding",
-          },
-        },
+        updates: [{ type: "stream.patch", patch: { status: "assistant responding" } }],
       },
     )
 
@@ -53,4 +26,28 @@ describe("run stream bridge", () => {
       },
     ])
   })
+
+  test("delivers commits before ordered footer updates", () => {
+    const out = createFooterApiFixture()
+
+    writeSessionOutput(
+      { footer: out.api },
+      {
+        commits: [{ kind: "assistant", source: "assistant", text: "answer", phase: "progress" }],
+        updates: [
+          { type: "stream.patch", patch: { phase: "idle", status: "" } },
+          { type: "stream.subagent", state: { tabs: [], details: {}, permissions: [], forms: [] } },
+          { type: "stream.view", view: { type: "prompt" } },
+        ],
+      },
+    )
+
+    expect(out.calls.map((call) => (call.type === "commit" ? "commit" : call.value.type))).toEqual([
+      "commit",
+      "stream.patch",
+      "stream.subagent",
+      "stream.view",
+    ])
+  })
+
 })