From ede3f0d4edd5c8051b09d49c951a719c4161e792 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 20 Jul 2026 12:25:31 +0200 Subject: [PATCH] tui/mini: consolidate stream and panel internals Share text/reasoning fragment reconciliation across root and child owners so projected content is not replayed twice. Emit ordered footer updates, unify searchable panel control, and prepare attachments once so prompt and command paths stay aligned. --- .../cli/test/drive/mini-interactive.drive.mjs | 286 ++++----- packages/tui/src/mini/demo.ts | 21 +- packages/tui/src/mini/footer.command.tsx | 550 +++++++----------- packages/tui/src/mini/stream-v2.fragment.ts | 86 +++ packages/tui/src/mini/stream-v2.subagent.ts | 104 ++-- packages/tui/src/mini/stream-v2.transport.ts | 212 +++---- packages/tui/src/mini/stream.ts | 51 +- packages/tui/src/mini/types.ts | 9 +- packages/tui/test/mini/catalog.shared.test.ts | 39 +- packages/tui/test/mini/entry.body.test.ts | 22 +- packages/tui/test/mini/fixture/catalog.ts | 67 +++ packages/tui/test/mini/fixture/footer-api.ts | 67 +++ packages/tui/test/mini/fixture/tool-part.ts | 20 + packages/tui/test/mini/footer.view.test.tsx | 24 +- .../tui/test/mini/permission.shared.test.ts | 37 +- packages/tui/test/mini/runtime.boot.test.ts | 73 +-- packages/tui/test/mini/runtime.queue.test.ts | 112 +--- packages/tui/test/mini/runtime.test.ts | 142 +---- .../tui/test/mini/scrollback.surface.test.ts | 18 +- .../tui/test/mini/stream-v2.transport.test.ts | 103 ++-- packages/tui/test/mini/stream.test.ts | 57 +- 21 files changed, 960 insertions(+), 1140 deletions(-) create mode 100644 packages/tui/src/mini/stream-v2.fragment.ts create mode 100644 packages/tui/test/mini/fixture/catalog.ts create mode 100644 packages/tui/test/mini/fixture/footer-api.ts create mode 100644 packages/tui/test/mini/fixture/tool-part.ts diff --git a/packages/cli/test/drive/mini-interactive.drive.mjs b/packages/cli/test/drive/mini-interactive.drive.mjs index 426cc25e10d4..e745c481b417 100644 --- a/packages/cli/test/drive/mini-interactive.drive.mjs +++ b/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 */ diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index f3dc094ce1cb..e4c11c9813af 100644 --- a/packages/tui/src/mini/demo.ts +++ b/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" } }, + ], + }, ) } diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 3176c1724f00..9906286ad48a 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -48,8 +48,6 @@ type QueuedEntry = PanelEntry & { prompt: FooterQueuedPrompt } -type MenuState = ReturnType - 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(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(input: { + entries: Accessor + 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(() => 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(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(() => { @@ -466,8 +518,6 @@ export function RunCommandMenuBody(props: { { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, ] }) - const items = createMemo(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > 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(() => props.tabs().map((item) => { const title = item.description || item.title || item.label @@ -597,72 +628,35 @@ export function RunSubagentSelectBody(props: { } }), ) - const items = createMemo(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > void | Promise onRows?: (rows: number) => void }) { - let field: InputRenderable | undefined - const [query, setQuery] = createSignal("") const entries = createMemo(() => props.prompts().map((prompt) => ({ category: "", @@ -694,72 +686,49 @@ export function RunQueuedPromptSelectBody(props: { prompt, })), ) - const items = createMemo(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > void onSelect: (name: string) => void }) { - let field: InputRenderable | undefined - const [query, setQuery] = createSignal("") const entries = createMemo(() => (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(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > 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(() => [ { category: "", @@ -876,64 +822,32 @@ export function RunVariantSelectBody(props: { current: props.current() === variant, })), ]) - const items = createMemo(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > PANEL_LIST_ROWS} limit={PANEL_LIST_ROWS} empty="No results found" @@ -954,8 +868,6 @@ export function RunModelSelectBody(props: { onClose: () => void onSelect: (model: NonNullable) => void }) { - let field: InputRenderable | undefined - const [query, setQuery] = createSignal("") const entries = createMemo(() => (props.providers() ?? []) .flatMap((provider) => @@ -997,71 +909,39 @@ export function RunModelSelectBody(props: { return a.display.localeCompare(b.display) }), ) - const items = createMemo(() => 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 ( { - field = input - }} - onQuery={setQuery} + inputRef={controller.inputRef} + onQuery={controller.setQuery} dark chrome="minimal" > 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} /> diff --git a/packages/tui/src/mini/stream-v2.fragment.ts b/packages/tui/src/mini/stream-v2.fragment.ts new file mode 100644 index 000000000000..cca8132fc4a6 --- /dev/null +++ b/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() + 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 diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index cef2c75ab669..164aa9a1798d 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/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 - projectedText: Map - reasoning: Map - projectedReasoning: Map + fragments: FragmentReconciler tools: Map toolSources: Map finishedTools: Set @@ -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 diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 7c7947e63195..5e4b6bbcfdbf 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/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 - text: Map - projectedText: Map - reasoning: Map - projectedReasoning: Map + fragments: FragmentReconciler tools: Map toolSources: Map finishedTools: Set @@ -202,12 +200,12 @@ function nextEvent(stream: AsyncIterator, 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: `\n${content}\n` } + return { type: "text" as const, text: `\n${content}\n` } } 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 { 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 { @@ -438,13 +457,16 @@ export async function createSessionTransport(input: StreamInput): Promise 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= 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 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 ({ 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 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 (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", }, diff --git a/packages/tui/src/mini/stream.ts b/packages/tui/src/mini/stream.ts index 80defae68fe8..c22131faaac1 100644 --- a/packages/tui/src/mini/stream.ts +++ b/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[] } // 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, - }) } diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 4ee57978d6ce..296fc225a9d5 100644 --- a/packages/tui/src/mini/types.ts +++ b/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. diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 5db2e2a13fc1..39163e62b097 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/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"], + }), ], ) diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index cc25804a93e3..75647ab5b744 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/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 & Pick): 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), }) } diff --git a/packages/tui/test/mini/fixture/catalog.ts b/packages/tui/test/mini/fixture/catalog.ts new file mode 100644 index 000000000000..1c3ef547bf5a --- /dev/null +++ b/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), + } +} diff --git a/packages/tui/test/mini/fixture/footer-api.ts b/packages/tui/test/mini/fixture/footer-api.ts new file mode 100644 index 000000000000..223a5145b188 --- /dev/null +++ b/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>() + 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) + }, + } +} diff --git a/packages/tui/test/mini/fixture/tool-part.ts b/packages/tui/test/mini/fixture/tool-part.ts new file mode 100644 index 000000000000..4c74f9336e51 --- /dev/null +++ b/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 }, + } +} diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index d0eeb2416b08..7fce03d87977 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/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) + }} /> ), @@ -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) + }} /> ), @@ -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() } diff --git a/packages/tui/test/mini/permission.shared.test.ts b/packages/tui/test/mini/permission.shared.test.ts index 06f68a871937..b8e30644d0bc 100644 --- a/packages/tui/test/mini/permission.shared.test.ts +++ b/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 { 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({ diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index a2e842183732..9587299ca1ce 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/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(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: [ diff --git a/packages/tui/test/mini/runtime.queue.test.ts b/packages/tui/test/mini/runtime.queue.test.ts index d2aa09032fd5..165fd1e8338c 100644 --- a/packages/tui/test/mini/runtime.queue.test.ts +++ b/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((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((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((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((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, diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index 2a198e905743..3c6e629ea88d 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/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() { @@ -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() 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) | 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 }) }) }) diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index 0dfe9c9486dd..98182c7edaee 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/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) } : {}), } } diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 7b05b4a126a9..32f7a507e0eb 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/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[ } 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\ncontext body\n') 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({ diff --git a/packages/tui/test/mini/stream.test.ts b/packages/tui/test/mini/stream.test.ts index d0bb66614efe..170d7cd11dc7 100644 --- a/packages/tui/test/mini/stream.test.ts +++ b/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", + ]) + }) + })