tui/mini: consolidate stream and panel internals (#37903)
This commit is contained in:
parent
0eb71d0fc7
commit
75c7ac6a2c
21 changed files with 960 additions and 1140 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
86
packages/tui/src/mini/stream-v2.fragment.ts
Normal file
86
packages/tui/src/mini/stream-v2.fragment.ts
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
|
@ -58,45 +59,15 @@ describe("run catalog shared", () => {
|
|||
|
||||
test("merges current providers and models into the footer catalog shape", () => {
|
||||
const providers = runProviders(
|
||||
[catalogProvider("openai", "OpenAI")],
|
||||
[
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
package: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
catalogModel({
|
||||
id: "gpt-5",
|
||||
modelID: "openai",
|
||||
providerID: "openai",
|
||||
name: "Little Frank",
|
||||
capabilities: {
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: [{ id: "high" }],
|
||||
time: {
|
||||
released: 1,
|
||||
},
|
||||
cost: [
|
||||
{
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 8192,
|
||||
},
|
||||
},
|
||||
variants: ["high"],
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
|
|||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
||||
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
||||
return input
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
name: string,
|
||||
state: SessionMessageAssistantTool["state"],
|
||||
id = `${name}-1`,
|
||||
): SessionMessageAssistantTool {
|
||||
return {
|
||||
type: "tool",
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
time:
|
||||
state.status === "streaming"
|
||||
? { created: 1 }
|
||||
: state.status === "completed" || state.status === "error"
|
||||
? { created: 1, ran: 1, completed: 2 }
|
||||
: { created: 1, ran: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolCommit(input: {
|
||||
tool: string
|
||||
state: SessionMessageAssistantTool["state"]
|
||||
|
|
@ -45,7 +27,7 @@ function toolCommit(input: {
|
|||
input.toolState ??
|
||||
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
|
||||
messageID: input.messageID,
|
||||
part: toolPart(input.tool, input.state, input.id),
|
||||
part: canonicalToolPart(input.tool, input.state, input.id),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
67
packages/tui/test/mini/fixture/catalog.ts
Normal file
67
packages/tui/test/mini/fixture/catalog.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
67
packages/tui/test/mini/fixture/footer-api.ts
Normal file
67
packages/tui/test/mini/fixture/footer-api.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/tui/test/mini/fixture/tool-part.ts
Normal file
20
packages/tui/test/mini/fixture/tool-part.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue