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

This commit is contained in:
Simon Klee 2026-07-20 12:45:48 +02:00 committed by GitHub
commit 75c7ac6a2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 960 additions and 1140 deletions

View file

@ -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 }))
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 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")
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)
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)
llm.queue(
llm.toolCall({
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"),
)
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)
llm.queue(
llm.toolCall({
index: 0,
id: "mini-slow-shell",
id: "mini-shell",
name: "shell",
input: { command: "sleep 10" },
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
llm.finish("tool-calls"),
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.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
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)
}
},
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 = 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")
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))
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))
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))
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))
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 */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.

View file

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

View file

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

View file

@ -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),
}
}

View file

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

View file

@ -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 },
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) } : {}),
}
}

View file

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

View file

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