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", + ]) + }) + })