tui/mini: consolidate stream and panel internals (#37903)
This commit is contained in:
@@ -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 { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
||||||
export default defineScript({
|
export default defineScript({
|
||||||
launch: "manual",
|
launch: "manual",
|
||||||
setup({ config }) {
|
config: { autoupdate: false },
|
||||||
config.autoupdate = false
|
run: ({ artifacts, llm, server }) =>
|
||||||
},
|
Effect.gen(function* () {
|
||||||
async run({ artifacts, llm, server, signal }) {
|
yield* Effect.promise(() => configureServicePort(artifacts))
|
||||||
await configureServicePort(artifacts)
|
yield* server.launch()
|
||||||
await server.launch()
|
|
||||||
|
|
||||||
const registration = await serviceRegistration(artifacts)
|
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
|
||||||
const root = path.resolve(import.meta.dir, "../../../..")
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
||||||
const session = `mini-stage2-${process.pid}`
|
const session = `mini-stage2-${process.pid}`
|
||||||
const snapshots = path.join(artifacts, "mini-stage2")
|
const snapshots = path.join(artifacts, "mini-stage2")
|
||||||
await mkdir(snapshots, { recursive: true })
|
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
|
||||||
|
|
||||||
llm.queue(
|
yield* llm.queue(
|
||||||
llm.toolCall({
|
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({
|
|
||||||
index: 0,
|
index: 0,
|
||||||
id: "mini-question",
|
id: "mini-shell",
|
||||||
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",
|
|
||||||
name: "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"])
|
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
|
||||||
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")
|
|
||||||
|
|
||||||
await tmux(["send-keys", "-t", session, "C-c"])
|
const journey = Effect.gen(function* () {
|
||||||
await waitForPane(session, "Press ctrl+c again to exit")
|
yield* Effect.uninterruptible(
|
||||||
await tmux(["send-keys", "-t", session, "C-c"])
|
Effect.promise(() =>
|
||||||
await waitForDeadPane(session)
|
tmux([
|
||||||
const status = await paneDeadStatus(session)
|
"new-session",
|
||||||
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
|
"-d",
|
||||||
const exited = await capturePane(session)
|
"-s",
|
||||||
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
session,
|
||||||
throw new Error("Mini exit splash was not rendered before teardown")
|
"-x",
|
||||||
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
|
"140",
|
||||||
} finally {
|
"-y",
|
||||||
signal.removeEventListener("abort", abort)
|
"30",
|
||||||
await tmux(["kill-session", "-t", session], true)
|
"--",
|
||||||
}
|
"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 */
|
/** @param {string[]} args */
|
||||||
|
|||||||
@@ -263,11 +263,14 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
|
|||||||
{ footer: state.footer },
|
{ footer: state.footer },
|
||||||
{
|
{
|
||||||
commits,
|
commits,
|
||||||
footer: view
|
updates: view
|
||||||
? {
|
? [
|
||||||
view,
|
{
|
||||||
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
type: "stream.patch" as const,
|
||||||
}
|
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
||||||
|
},
|
||||||
|
{ type: "stream.view" as const, view },
|
||||||
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -276,7 +279,13 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
|
|||||||
function clearBlocker(state: State): void {
|
function clearBlocker(state: State): void {
|
||||||
writeSessionOutput(
|
writeSessionOutput(
|
||||||
{ footer: state.footer },
|
{ 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
|
prompt: FooterQueuedPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
|
||||||
|
|
||||||
const PANEL_PAD = 2
|
const PANEL_PAD = 2
|
||||||
const PANEL_LIST_ROWS = 10
|
const PANEL_LIST_ROWS = 10
|
||||||
const PANEL_FRAME_ROWS = 6
|
const PANEL_FRAME_ROWS = 6
|
||||||
@@ -124,72 +122,6 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
|||||||
return "running"
|
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[]) {
|
function match<T extends PanelEntry>(query: string, entries: T[]) {
|
||||||
const text = query.trim()
|
const text = query.trim()
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -201,6 +133,128 @@ function match<T extends PanelEntry>(query: string, entries: T[]) {
|
|||||||
.map((item) => item.obj)
|
.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: {
|
function PanelShell(props: {
|
||||||
title: string
|
title: string
|
||||||
countVisible?: boolean
|
countVisible?: boolean
|
||||||
@@ -350,8 +404,6 @@ export function RunCommandMenuBody(props: {
|
|||||||
onNew: () => void
|
onNew: () => void
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||||
const entries = createMemo<CommandEntry[]>(() => {
|
const entries = createMemo<CommandEntry[]>(() => {
|
||||||
@@ -466,8 +518,6 @@ export function RunCommandMenuBody(props: {
|
|||||||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
{ 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) => {
|
const pick = (item: CommandEntry) => {
|
||||||
if (item.action === "model") {
|
if (item.action === "model") {
|
||||||
props.onModel()
|
props.onModel()
|
||||||
@@ -516,56 +566,39 @@ export function RunCommandMenuBody(props: {
|
|||||||
|
|
||||||
props.onCommand(item.name)
|
props.onCommand(item.name)
|
||||||
}
|
}
|
||||||
const select = () => {
|
const controller = createSearchablePanelController({
|
||||||
const item = items()[menu.selected()]
|
entries,
|
||||||
if (!item) {
|
limit: PANEL_LIST_ROWS,
|
||||||
return
|
onClose: props.onClose,
|
||||||
}
|
onSelect: pick,
|
||||||
|
|
||||||
pick(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
query()
|
|
||||||
menu.reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
useKeyboard((event) => {
|
|
||||||
if (event.defaultPrevented) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Commands"
|
title="Commands"
|
||||||
countVisible={false}
|
countVisible={false}
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={() => PANEL_LIST_ROWS}
|
rows={() => PANEL_LIST_ROWS}
|
||||||
limit={PANEL_LIST_ROWS}
|
limit={PANEL_LIST_ROWS}
|
||||||
empty="No results found"
|
empty="No results found"
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={!query().trim()}
|
grouped={!controller.query().trim()}
|
||||||
background
|
background
|
||||||
headerColor={props.theme().muted}
|
headerColor={props.theme().muted}
|
||||||
/>
|
/>
|
||||||
@@ -581,8 +614,6 @@ export function RunSubagentSelectBody(props: {
|
|||||||
onSelect: (sessionID: string) => void
|
onSelect: (sessionID: string) => void
|
||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const entries = createMemo<SubagentEntry[]>(() =>
|
const entries = createMemo<SubagentEntry[]>(() =>
|
||||||
props.tabs().map((item) => {
|
props.tabs().map((item) => {
|
||||||
const title = item.description || item.title || item.label
|
const title = item.description || item.title || item.label
|
||||||
@@ -597,72 +628,35 @@ export function RunSubagentSelectBody(props: {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
|
const controller = createSearchablePanelController({
|
||||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
entries,
|
||||||
const select = () => {
|
limit: SUBAGENT_LIST_ROWS,
|
||||||
const item = items()[menu.selected()]
|
onClose: props.onClose,
|
||||||
if (!item) {
|
onSelect: (item) => props.onSelect(item.sessionID),
|
||||||
return
|
isCurrent: (item) => item.current,
|
||||||
}
|
closeOnFirstUp: true,
|
||||||
|
onRows: props.onRows,
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Select subagent"
|
title="Select subagent"
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={menu.rows}
|
rows={controller.menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No subagents found"
|
empty="No subagents found"
|
||||||
border={false}
|
border={false}
|
||||||
@@ -683,8 +677,6 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const entries = createMemo<QueuedEntry[]>(() =>
|
const entries = createMemo<QueuedEntry[]>(() =>
|
||||||
props.prompts().map((prompt) => ({
|
props.prompts().map((prompt) => ({
|
||||||
category: "",
|
category: "",
|
||||||
@@ -694,72 +686,49 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
prompt,
|
prompt,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
|
const controller = createSearchablePanelController({
|
||||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
entries,
|
||||||
const selected = () => items()[menu.selected()]
|
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(() => {
|
if (item && ctrl && event.name === "e") {
|
||||||
query()
|
event.preventDefault()
|
||||||
menu.reset()
|
props.onEdit(item.prompt)
|
||||||
})
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
return false
|
||||||
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 (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Queued prompts"
|
title="Queued prompts"
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={menu.rows}
|
rows={controller.menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No queued prompts"
|
empty="No queued prompts"
|
||||||
border={false}
|
border={false}
|
||||||
@@ -778,8 +747,6 @@ export function RunSkillSelectBody(props: {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelect: (name: string) => void
|
onSelect: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const entries = createMemo<SkillEntry[]>(() =>
|
const entries = createMemo<SkillEntry[]>(() =>
|
||||||
(props.commands() ?? [])
|
(props.commands() ?? [])
|
||||||
.filter((item) => item.source === "skill")
|
.filter((item) => item.source === "skill")
|
||||||
@@ -792,50 +759,31 @@ export function RunSkillSelectBody(props: {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a.display.localeCompare(b.display)),
|
.sort((a, b) => a.display.localeCompare(b.display)),
|
||||||
)
|
)
|
||||||
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
|
const controller = createSearchablePanelController({
|
||||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
entries,
|
||||||
const select = () => {
|
limit: PANEL_LIST_ROWS,
|
||||||
const item = items()[menu.selected()]
|
onClose: props.onClose,
|
||||||
if (!item) {
|
onSelect: (item) => props.onSelect(item.name),
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Skills"
|
title="Skills"
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={() => PANEL_LIST_ROWS}
|
rows={() => PANEL_LIST_ROWS}
|
||||||
limit={PANEL_LIST_ROWS}
|
limit={PANEL_LIST_ROWS}
|
||||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||||
@@ -856,8 +804,6 @@ export function RunVariantSelectBody(props: {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelect: (variant: string | undefined) => void
|
onSelect: (variant: string | undefined) => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const entries = createMemo<VariantEntry[]>(() => [
|
const entries = createMemo<VariantEntry[]>(() => [
|
||||||
{
|
{
|
||||||
category: "",
|
category: "",
|
||||||
@@ -876,64 +822,32 @@ export function RunVariantSelectBody(props: {
|
|||||||
current: props.current() === variant,
|
current: props.current() === variant,
|
||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
|
const controller = createSearchablePanelController({
|
||||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
entries,
|
||||||
const pick = (item: VariantEntry) => {
|
limit: PANEL_LIST_ROWS,
|
||||||
props.onSelect(item.variant)
|
onClose: props.onClose,
|
||||||
}
|
onSelect: (item) => props.onSelect(item.variant),
|
||||||
const select = () => {
|
isCurrent: (item) => item.current,
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Select variant"
|
title="Select variant"
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={() => PANEL_LIST_ROWS}
|
rows={() => PANEL_LIST_ROWS}
|
||||||
limit={PANEL_LIST_ROWS}
|
limit={PANEL_LIST_ROWS}
|
||||||
empty="No results found"
|
empty="No results found"
|
||||||
@@ -954,8 +868,6 @@ export function RunModelSelectBody(props: {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||||
}) {
|
}) {
|
||||||
let field: InputRenderable | undefined
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const entries = createMemo<ModelEntry[]>(() =>
|
const entries = createMemo<ModelEntry[]>(() =>
|
||||||
(props.providers() ?? [])
|
(props.providers() ?? [])
|
||||||
.flatMap((provider) =>
|
.flatMap((provider) =>
|
||||||
@@ -997,71 +909,39 @@ export function RunModelSelectBody(props: {
|
|||||||
return a.display.localeCompare(b.display)
|
return a.display.localeCompare(b.display)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
|
const controller = createSearchablePanelController({
|
||||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
entries,
|
||||||
const pick = (item: ModelEntry) => {
|
limit: PANEL_LIST_ROWS,
|
||||||
props.onSelect({ providerID: item.providerID, modelID: item.modelID })
|
onClose: props.onClose,
|
||||||
}
|
onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
|
||||||
const select = () => {
|
isCurrent: (item) => item.current,
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Select model"
|
title="Select model"
|
||||||
query={query()}
|
query={controller.query()}
|
||||||
count={items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={(input) => {
|
inputRef={controller.inputRef}
|
||||||
field = input
|
onQuery={controller.setQuery}
|
||||||
}}
|
|
||||||
onQuery={setQuery}
|
|
||||||
dark
|
dark
|
||||||
chrome="minimal"
|
chrome="minimal"
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
items={items}
|
items={controller.items}
|
||||||
selected={menu.selected}
|
selected={controller.menu.selected}
|
||||||
offset={menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={() => PANEL_LIST_ROWS}
|
rows={() => PANEL_LIST_ROWS}
|
||||||
limit={PANEL_LIST_ROWS}
|
limit={PANEL_LIST_ROWS}
|
||||||
empty={props.providers() ? "No results found" : "Models loading"}
|
empty={props.providers() ? "No results found" : "Models loading"}
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={PANEL_PAD}
|
paddingLeft={PANEL_PAD}
|
||||||
paddingRight={PANEL_PAD}
|
paddingRight={PANEL_PAD}
|
||||||
grouped={!query().trim()}
|
grouped={!controller.query().trim()}
|
||||||
background
|
background
|
||||||
headerColor={props.theme().muted}
|
headerColor={props.theme().muted}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
SessionMessageInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
|
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||||
import type {
|
import type {
|
||||||
FooterSubagentDetail,
|
FooterSubagentDetail,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
@@ -105,10 +106,7 @@ type ChildState = {
|
|||||||
title?: string
|
title?: string
|
||||||
lastUpdatedAt: number
|
lastUpdatedAt: number
|
||||||
frames: Frame[]
|
frames: Frame[]
|
||||||
text: Map<string, string>
|
fragments: FragmentReconciler
|
||||||
projectedText: Map<string, string>
|
|
||||||
reasoning: Map<string, string>
|
|
||||||
projectedReasoning: Map<string, string>
|
|
||||||
tools: Map<string, ToolTrack>
|
tools: Map<string, ToolTrack>
|
||||||
toolSources: Map<string, SessionMessageAssistantTool>
|
toolSources: Map<string, SessionMessageAssistantTool>
|
||||||
finishedTools: Set<string>
|
finishedTools: Set<string>
|
||||||
@@ -225,8 +223,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
let blockerEpoch = 0
|
let blockerEpoch = 0
|
||||||
let closed = false
|
let closed = false
|
||||||
const active = (signal = input.signal) => !closed && !input.signal.aborted && !signal.aborted
|
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 admitChild = (sessionID: string): ChildState | undefined => {
|
||||||
const existing = children.get(sessionID)
|
const existing = children.get(sessionID)
|
||||||
if (!existing && children.size >= FAMILY_LIST_LIMIT) return
|
if (!existing && children.size >= FAMILY_LIST_LIMIT) return
|
||||||
@@ -238,10 +234,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
background: false,
|
background: false,
|
||||||
lastUpdatedAt: 0,
|
lastUpdatedAt: 0,
|
||||||
frames: [],
|
frames: [],
|
||||||
text: new Map(),
|
fragments: createFragmentReconciler(),
|
||||||
projectedText: new Map(),
|
|
||||||
reasoning: new Map(),
|
|
||||||
projectedReasoning: new Map(),
|
|
||||||
tools: new Map(),
|
tools: new Map(),
|
||||||
toolSources: new Map(),
|
toolSources: new Map(),
|
||||||
finishedTools: new Set(),
|
finishedTools: new Set(),
|
||||||
@@ -337,10 +330,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
|
|
||||||
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
||||||
child.frames = []
|
child.frames = []
|
||||||
child.text.clear()
|
child.fragments.clear()
|
||||||
child.projectedText.clear()
|
|
||||||
child.reasoning.clear()
|
|
||||||
child.projectedReasoning.clear()
|
|
||||||
child.finishedTools.clear()
|
child.finishedTools.clear()
|
||||||
child.toolSources.clear()
|
child.toolSources.clear()
|
||||||
child.messageIDs.clear()
|
child.messageIDs.clear()
|
||||||
@@ -356,33 +346,29 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
let reasoningOrdinal = 0
|
let reasoningOrdinal = 0
|
||||||
for (const item of message.content) {
|
for (const item of message.content) {
|
||||||
if (item.type === "text") {
|
if (item.type === "text") {
|
||||||
const id = `text:${textOrdinal++}`
|
const fragment = fragmentRef(message.id, "text", textOrdinal++)
|
||||||
const key = fragmentKey(message.id, id)
|
const update = child.fragments.project(fragment, item.text, true)
|
||||||
child.text.set(key, item.text)
|
setFrame(child, update.key, {
|
||||||
child.projectedText.set(key, item.text)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: item.text,
|
text: item.text,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: id,
|
partID: fragment.partID,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.type === "reasoning") {
|
if (item.type === "reasoning") {
|
||||||
const id = `reasoning:${reasoningOrdinal++}`
|
const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
|
||||||
const key = fragmentKey(message.id, id)
|
const update = child.fragments.project(fragment, item.text, true)
|
||||||
child.reasoning.set(key, item.text)
|
|
||||||
child.projectedReasoning.set(key, item.text)
|
|
||||||
if (input.thinking)
|
if (input.thinking)
|
||||||
setFrame(child, key, {
|
setFrame(child, update.key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${item.text}`,
|
text: `Thinking: ${item.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: id,
|
partID: fragment.partID,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -678,40 +664,35 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
const id = `text:${event.data.ordinal}`
|
const update = child.fragments.delta(
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||||
const projected = child.projectedText.get(key)
|
event.data.delta,
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
)
|
||||||
if (projected && covered >= 0) {
|
if (!update) return
|
||||||
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
setFrame(child, update.key, {
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = (child.text.get(key) ?? "") + event.data.delta
|
|
||||||
child.text.set(key, next)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: next,
|
text: update.text,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
})
|
})
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const id = `text:${event.data.ordinal}`
|
const update = child.fragments.end(
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||||
child.text.set(key, event.data.text)
|
event.data.text,
|
||||||
child.projectedText.delete(key)
|
)
|
||||||
setFrame(child, key, {
|
setFrame(child, update.key, {
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
})
|
})
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
@@ -721,41 +702,36 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
const update = child.fragments.delta(
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||||
const projected = child.projectedReasoning.get(key)
|
event.data.delta,
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
)
|
||||||
if (projected && covered >= 0) {
|
if (!update) return
|
||||||
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)
|
|
||||||
if (!input.thinking) return
|
if (!input.thinking) return
|
||||||
setFrame(child, key, {
|
setFrame(child, update.key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${next}`,
|
text: `Thinking: ${update.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
})
|
})
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended") {
|
if (event.type === "session.reasoning.ended") {
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
const update = child.fragments.end(
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||||
child.reasoning.set(key, event.data.text)
|
event.data.text,
|
||||||
child.projectedReasoning.delete(key)
|
)
|
||||||
if (!input.thinking) return
|
if (!input.thinking) return
|
||||||
setFrame(child, key, {
|
setFrame(child, update.key, {
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: `Thinking: ${event.data.text}`,
|
text: `Thinking: ${event.data.text}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
})
|
})
|
||||||
notifyDetail(child)
|
notifyDetail(child)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Event } from "@opencode-ai/schema/event"
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||||
import { writeSessionOutput } from "./stream"
|
import { writeSessionOutput } from "./stream"
|
||||||
|
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
|
||||||
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||||
import { normalizeTool, toolOutputText } from "./tool"
|
import { normalizeTool, toolOutputText } from "./tool"
|
||||||
import type {
|
import type {
|
||||||
@@ -117,10 +118,7 @@ type State = {
|
|||||||
globalForms: MiniFormRequest[]
|
globalForms: MiniFormRequest[]
|
||||||
view: FooterView
|
view: FooterView
|
||||||
messageIDs: Set<string>
|
messageIDs: Set<string>
|
||||||
text: Map<string, string>
|
fragments: FragmentReconciler
|
||||||
projectedText: Map<string, string>
|
|
||||||
reasoning: Map<string, string>
|
|
||||||
projectedReasoning: Map<string, string>
|
|
||||||
tools: Map<string, ToolState>
|
tools: Map<string, ToolState>
|
||||||
toolSources: Map<string, SessionMessageAssistantTool>
|
toolSources: Map<string, SessionMessageAssistantTool>
|
||||||
finishedTools: Set<string>
|
finishedTools: Set<string>
|
||||||
@@ -202,12 +200,12 @@ function nextEvent(stream: AsyncIterator<RunV2Event>, signal: AbortSignal) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
|
async function prepareInitialFile(file: RunFilePart, readTextFile?: StreamInput["readTextFile"]) {
|
||||||
if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
|
if (file.mime !== "text/plain") return { type: "file" as const, file: { uri: file.url, name: file.filename } }
|
||||||
const content = file.url.startsWith("data:")
|
const content = file.url.startsWith("data:")
|
||||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
? 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")))
|
: 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) {
|
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) {
|
function promptAgents(next: SessionTurnInput) {
|
||||||
return next.prompt.parts.flatMap((part) =>
|
return next.prompt.parts.flatMap((part) =>
|
||||||
part.type === "agent"
|
part.type === "agent"
|
||||||
@@ -359,10 +376,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
globalForms: [],
|
globalForms: [],
|
||||||
view: { type: "prompt" },
|
view: { type: "prompt" },
|
||||||
messageIDs: new Set(),
|
messageIDs: new Set(),
|
||||||
text: new Map(),
|
fragments: createFragmentReconciler(),
|
||||||
projectedText: new Map(),
|
|
||||||
reasoning: new Map(),
|
|
||||||
projectedReasoning: new Map(),
|
|
||||||
tools: new Map(),
|
tools: new Map(),
|
||||||
toolSources: new Map(),
|
toolSources: new Map(),
|
||||||
finishedTools: new Set(),
|
finishedTools: new Set(),
|
||||||
@@ -400,7 +414,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
emit: () => {
|
emit: () => {
|
||||||
if (state.closed || input.footer.isClosed) return
|
if (state.closed || input.footer.isClosed) return
|
||||||
const snapshot = subagents.snapshot()
|
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()
|
syncBlockers()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -414,14 +431,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
input.onCommit?.(commit)
|
input.onCommit?.(commit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const key = streamPartKey(commit.messageID, commit.partID)
|
const text = state.fragments.value({ messageID: commit.messageID, partID: commit.partID })
|
||||||
const text = commit.kind === "assistant" ? state.text.get(key) : state.reasoning.get(key)
|
|
||||||
input.onCommit?.({
|
input.onCommit?.({
|
||||||
...commit,
|
...commit,
|
||||||
text: commit.kind === "reasoning" && text ? `Thinking: ${text}` : (text ?? commit.text),
|
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 = () => {
|
const syncBlockers = () => {
|
||||||
@@ -438,13 +457,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
{ footer: input.footer, trace: input.trace },
|
{ footer: input.footer, trace: input.trace },
|
||||||
{
|
{
|
||||||
commits: [],
|
commits: [],
|
||||||
footer: {
|
updates: [
|
||||||
view: next,
|
{
|
||||||
patch:
|
type: "stream.patch",
|
||||||
next.type === "prompt"
|
patch:
|
||||||
? { phase: state.rootActive ? "running" : "idle", status: blockerStatus(next) }
|
next.type === "prompt"
|
||||||
: { status: blockerStatus(next) },
|
? { 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
|
let reasoningOrdinal = 0
|
||||||
for (const item of message.content) {
|
for (const item of message.content) {
|
||||||
if (item.type === "text") {
|
if (item.type === "text") {
|
||||||
const id = `text:${textOrdinal++}`
|
const fragment = fragmentRef(message.id, "text", textOrdinal++)
|
||||||
const key = streamPartKey(message.id, id)
|
const update = state.fragments.project(fragment, item.text, render)
|
||||||
const sent = state.text.get(key)?.length ?? 0
|
if (render && item.text.length > update.previous.length)
|
||||||
state.text.set(key, item.text)
|
|
||||||
if (render) state.projectedText.set(key, item.text)
|
|
||||||
if (render && item.text.length > sent)
|
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: item.text.slice(sent),
|
text: item.text.slice(update.previous.length),
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: id,
|
partID: fragment.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.type === "reasoning") {
|
if (item.type === "reasoning") {
|
||||||
const id = `reasoning:${reasoningOrdinal++}`
|
const fragment = fragmentRef(message.id, "reasoning", reasoningOrdinal++)
|
||||||
const key = streamPartKey(message.id, id)
|
const update = state.fragments.project(fragment, item.text, render)
|
||||||
const sent = state.reasoning.get(key)?.length ?? 0
|
if (render && input.thinking && item.text.length > update.previous.length)
|
||||||
state.reasoning.set(key, item.text)
|
|
||||||
if (render) state.projectedReasoning.set(key, item.text)
|
|
||||||
if (render && input.thinking && item.text.length > sent)
|
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "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",
|
phase: "progress",
|
||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
partID: id,
|
partID: fragment.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
continue
|
continue
|
||||||
@@ -800,16 +817,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
const id = `text:${event.data.ordinal}`
|
const fragment = fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal)
|
||||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
if (!state.fragments.delta(fragment, event.data.delta)) return
|
||||||
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)
|
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
@@ -817,74 +826,67 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
text: event.data.delta,
|
text: event.data.delta,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: fragment.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.ended") {
|
if (event.type === "session.text.ended") {
|
||||||
const id = `text:${event.data.ordinal}`
|
const update = state.fragments.end(
|
||||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
|
||||||
const previous = state.text.get(key) ?? ""
|
event.data.text,
|
||||||
state.text.set(key, event.data.text)
|
)
|
||||||
if (event.data.text.length > previous.length)
|
if (event.data.text.length > update.previous.length)
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "assistant",
|
kind: "assistant",
|
||||||
source: "assistant",
|
source: "assistant",
|
||||||
text: event.data.text.slice(previous.length),
|
text: event.data.text.slice(update.previous.length),
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
state.projectedText.delete(key)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
const update = state.fragments.delta(
|
||||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||||
const projected = state.projectedReasoning.get(key)
|
event.data.delta,
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
)
|
||||||
if (projected && covered >= 0) {
|
if (!update) return
|
||||||
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)
|
|
||||||
if (input.thinking)
|
if (input.thinking)
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "reasoning",
|
source: "reasoning",
|
||||||
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
text: update.previous ? event.data.delta : `Thinking: ${event.data.delta}`,
|
||||||
phase: "progress",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.ended") {
|
if (event.type === "session.reasoning.ended") {
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
const update = state.fragments.end(
|
||||||
const key = streamPartKey(event.data.assistantMessageID, id)
|
fragmentRef(event.data.assistantMessageID, "reasoning", event.data.ordinal),
|
||||||
const previous = state.reasoning.get(key) ?? ""
|
event.data.text,
|
||||||
state.reasoning.set(key, event.data.text)
|
)
|
||||||
if (input.thinking && event.data.text.length > previous.length)
|
if (input.thinking && event.data.text.length > update.previous.length)
|
||||||
write([
|
write([
|
||||||
{
|
{
|
||||||
kind: "reasoning",
|
kind: "reasoning",
|
||||||
source: "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",
|
phase: "progress",
|
||||||
messageID: event.data.assistantMessageID,
|
messageID: event.data.assistantMessageID,
|
||||||
partID: id,
|
partID: update.partID,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
state.projectedReasoning.delete(key)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
@@ -1299,10 +1301,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
if (!current(attempt)) return false
|
if (!current(attempt)) return false
|
||||||
reset = true
|
reset = true
|
||||||
state.messageIDs.clear()
|
state.messageIDs.clear()
|
||||||
state.text.clear()
|
state.fragments.clear()
|
||||||
state.projectedText.clear()
|
|
||||||
state.reasoning.clear()
|
|
||||||
state.projectedReasoning.clear()
|
|
||||||
state.tools.clear()
|
state.tools.clear()
|
||||||
state.toolSources.clear()
|
state.toolSources.clear()
|
||||||
state.finishedTools.clear()
|
state.finishedTools.clear()
|
||||||
@@ -1326,34 +1325,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
row.commit.partID &&
|
row.commit.partID &&
|
||||||
(row.commit.kind === "assistant" || row.commit.kind === "reasoning")
|
(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 prefix = row.commit.kind === "reasoning" ? "Thinking: " : ""
|
||||||
const text = row.commit.text.startsWith(prefix) ? row.commit.text.slice(prefix.length) : row.commit.text
|
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)
|
const restored = state.fragments.restore(
|
||||||
if (current === undefined) {
|
{ messageID: row.commit.messageID, partID: row.commit.partID },
|
||||||
input.footer.append(row.commit)
|
text,
|
||||||
if (row.commit.kind === "assistant") {
|
)
|
||||||
state.text.set(key, text)
|
if (restored.type === "covered") continue
|
||||||
state.projectedText.set(key, text)
|
if (restored.type === "append") {
|
||||||
} else {
|
if (restored.suffix)
|
||||||
state.reasoning.set(key, text)
|
input.footer.append(restored.suffix === text ? row.commit : { ...row.commit, text: restored.suffix })
|
||||||
state.projectedReasoning.set(key, text)
|
|
||||||
}
|
|
||||||
continue
|
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 (row.commit.kind === "error" && row.commit.messageID) {
|
||||||
if (state.errors.has(row.commit.messageID)) continue
|
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")
|
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
|
// Agent and model ride the command payload; the server switches only
|
||||||
// when the command itself does not pin them.
|
// when the command itself does not pin them.
|
||||||
const files = [
|
const attachments = await prepareAttachments(next, "command")
|
||||||
...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
|
|
||||||
...promptFiles(next),
|
|
||||||
]
|
|
||||||
const agents = promptAgents(next)
|
const agents = promptAgents(next)
|
||||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
|
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
|
||||||
await runTurnWait(next, messageID, {
|
await runTurnWait(next, messageID, {
|
||||||
@@ -1447,7 +1427,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
arguments: command.arguments,
|
arguments: command.arguments,
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: selected,
|
model: selected,
|
||||||
files: files.length ? files : undefined,
|
files: attachments.files.length ? attachments.files : undefined,
|
||||||
agents: agents.length ? agents : undefined,
|
agents: agents.length ? agents : undefined,
|
||||||
delivery: "steer",
|
delivery: "steer",
|
||||||
},
|
},
|
||||||
@@ -1465,13 +1445,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
if (selected)
|
if (selected)
|
||||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
|
||||||
const prepared = await Promise.all(
|
const attachments = await prepareAttachments(next, "prompt", input.readTextFile)
|
||||||
(next.includeFiles ? next.files : []).map((file) => prepareFile(file, input.readTextFile)),
|
|
||||||
)
|
|
||||||
const attachments = [
|
|
||||||
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
|
||||||
...promptFiles(next),
|
|
||||||
]
|
|
||||||
const agents = promptAgents(next)
|
const agents = promptAgents(next)
|
||||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
||||||
await runTurnWait(next, messageID, {
|
await runTurnWait(next, messageID, {
|
||||||
@@ -1480,8 +1454,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
text: [next.prompt.text, ...attachments.text].join("\n\n"),
|
||||||
files: attachments.length ? attachments : undefined,
|
files: attachments.files.length ? attachments.files : undefined,
|
||||||
agents: agents.length ? agents : undefined,
|
agents: agents.length ? agents : undefined,
|
||||||
delivery: "steer",
|
delivery: "steer",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Thin bridge between transport output and the footer API.
|
// Thin bridge between transport output and the footer API.
|
||||||
//
|
//
|
||||||
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
|
// Transports produce immutable StreamCommit[] rows and typed mutable-footer
|
||||||
// view + subagent state). This module forwards them to footer.append() and
|
// updates. This module forwards both to the footer API, adding trace writes
|
||||||
// footer.event() respectively, adding trace writes along the way. It also
|
// along the way. It also
|
||||||
// defaults status updates to phase "running" if the caller didn't set a
|
// 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.
|
// 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 = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
@@ -18,7 +18,7 @@ type OutputInput = {
|
|||||||
|
|
||||||
type StreamOutput = {
|
type StreamOutput = {
|
||||||
commits: StreamCommit[]
|
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.
|
// 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)
|
input.footer.append(commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (out.footer?.patch) {
|
for (const update of out.updates ?? []) {
|
||||||
const next = patch(out.footer.patch)
|
if (update.type === "stream.patch") {
|
||||||
input.trace?.write("ui.patch", next)
|
const next = { ...update, patch: patch(update.patch) }
|
||||||
input.footer.event({
|
input.trace?.write("ui.patch", next.patch)
|
||||||
type: "stream.patch",
|
input.footer.event(next)
|
||||||
patch: 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:
|
// Data flow through the system:
|
||||||
//
|
//
|
||||||
// V2 events / demo actions → StreamCommit[] + FooterOutput
|
// V2 events / demo actions → StreamCommit[] + FooterEvent[]
|
||||||
// → stream.ts bridges to footer API
|
// → stream.ts bridges to footer API
|
||||||
// → footer.ts queues commits and patches the footer view
|
// → footer.ts queues commits and patches the footer view
|
||||||
// → OpenTUI split-footer renderer writes to terminal
|
// → OpenTUI split-footer renderer writes to terminal
|
||||||
@@ -311,13 +311,6 @@ export type FooterSubagentState = {
|
|||||||
forms: MiniFormRequest[]
|
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
|
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||||
// transport both emit these to update footer state without reaching into
|
// transport both emit these to update footer state without reaching into
|
||||||
// internal signals directly.
|
// internal signals directly.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
|
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
|
||||||
|
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
@@ -58,45 +59,15 @@ describe("run catalog shared", () => {
|
|||||||
|
|
||||||
test("merges current providers and models into the footer catalog shape", () => {
|
test("merges current providers and models into the footer catalog shape", () => {
|
||||||
const providers = runProviders(
|
const providers = runProviders(
|
||||||
|
[catalogProvider("openai", "OpenAI")],
|
||||||
[
|
[
|
||||||
{
|
catalogModel({
|
||||||
id: "openai",
|
|
||||||
name: "OpenAI",
|
|
||||||
package: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: "gpt-5",
|
id: "gpt-5",
|
||||||
modelID: "openai",
|
modelID: "openai",
|
||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
name: "Little Frank",
|
name: "Little Frank",
|
||||||
capabilities: {
|
variants: ["high"],
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||||
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
||||||
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
|
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 {
|
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
||||||
return input
|
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: {
|
function toolCommit(input: {
|
||||||
tool: string
|
tool: string
|
||||||
state: SessionMessageAssistantTool["state"]
|
state: SessionMessageAssistantTool["state"]
|
||||||
@@ -45,7 +27,7 @@ function toolCommit(input: {
|
|||||||
input.toolState ??
|
input.toolState ??
|
||||||
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
|
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
|
||||||
messageID: input.messageID,
|
messageID: input.messageID,
|
||||||
part: toolPart(input.tool, input.state, input.id),
|
part: canonicalToolPart(input.tool, input.state, input.id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "internal", description: "Skill command", source: "skill" }),
|
||||||
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
|
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
|
||||||
])
|
])
|
||||||
|
const selected: string[] = []
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
@@ -430,7 +431,9 @@ test("direct skill panel renders searchable skill list", async () => {
|
|||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
commands={commands}
|
commands={commands}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onSelect={() => {}}
|
onSelect={(name) => {
|
||||||
|
selected.push(name)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
@@ -451,6 +454,11 @@ test("direct skill panel renders searchable skill list", async () => {
|
|||||||
expect(frame).toContain("formatter")
|
expect(frame).toContain("formatter")
|
||||||
expect(frame).toContain("Apply formatter fixes")
|
expect(frame).toContain("Apply formatter fixes")
|
||||||
expect(frame).not.toContain("review")
|
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 {
|
} finally {
|
||||||
app.renderer.destroy()
|
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 () => {
|
test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||||
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
|
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
|
||||||
|
const edited: string[] = []
|
||||||
|
const deleted: string[] = []
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
@@ -682,8 +692,12 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
|||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onEdit={() => {}}
|
onEdit={(prompt) => {
|
||||||
onDelete={() => {}}
|
edited.push(prompt.messageID)
|
||||||
|
}}
|
||||||
|
onDelete={(prompt) => {
|
||||||
|
deleted.push(prompt.messageID)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
),
|
),
|
||||||
@@ -701,6 +715,10 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
|||||||
expect(frame).not.toContain("┌")
|
expect(frame).not.toContain("┌")
|
||||||
expect(frame).not.toContain("┃")
|
expect(frame).not.toContain("┃")
|
||||||
expectPaletteList(list, 0)
|
expectPaletteList(list, 0)
|
||||||
|
app.mockInput.pressKey("e", { ctrl: true })
|
||||||
|
app.mockInput.pressKey("DELETE")
|
||||||
|
expect(edited).toEqual(["m-1"])
|
||||||
|
expect(deleted).toEqual(["m-1"])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
permissionRun,
|
permissionRun,
|
||||||
} from "../../src/mini/permission.shared"
|
} from "../../src/mini/permission.shared"
|
||||||
import type { MiniPermissionRequest } from "../../src/mini/types"
|
import type { MiniPermissionRequest } from "../../src/mini/types"
|
||||||
|
import { canonicalToolPart } from "./fixture/tool-part"
|
||||||
|
|
||||||
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
|
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
|
||||||
return {
|
return {
|
||||||
@@ -89,18 +90,16 @@ describe("run permission shared", () => {
|
|||||||
req({
|
req({
|
||||||
action: "shell",
|
action: "shell",
|
||||||
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
|
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
|
||||||
tool: {
|
tool: canonicalToolPart(
|
||||||
type: "tool",
|
"shell",
|
||||||
id: "call-shell",
|
{
|
||||||
name: "shell",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
status: "running",
|
||||||
input: { command: "git status --short" },
|
input: { command: "git status --short" },
|
||||||
structured: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
},
|
},
|
||||||
time: { created: 1, ran: 1 },
|
"call-shell",
|
||||||
},
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -137,18 +136,16 @@ describe("run permission shared", () => {
|
|||||||
action: "websearch",
|
action: "websearch",
|
||||||
metadata: { provider: "parallel" },
|
metadata: { provider: "parallel" },
|
||||||
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
|
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
|
||||||
tool: {
|
tool: canonicalToolPart(
|
||||||
type: "tool",
|
"websearch",
|
||||||
id: "call-search",
|
{
|
||||||
name: "websearch",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
status: "running",
|
||||||
input: { query: "current releases" },
|
input: { query: "current releases" },
|
||||||
structured: { provider: "exa", retained: true },
|
structured: { provider: "exa", retained: true },
|
||||||
content: [],
|
content: [],
|
||||||
},
|
},
|
||||||
time: { created: 1, ran: 1 },
|
"call-search",
|
||||||
},
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
@@ -164,18 +161,16 @@ describe("run permission shared", () => {
|
|||||||
action: "edit",
|
action: "edit",
|
||||||
resources: ["src/index.ts"],
|
resources: ["src/index.ts"],
|
||||||
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
||||||
tool: {
|
tool: canonicalToolPart(
|
||||||
type: "tool",
|
"edit",
|
||||||
id: "call-edit",
|
{
|
||||||
name: "edit",
|
|
||||||
state: {
|
|
||||||
status: "running",
|
status: "running",
|
||||||
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
|
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
|
||||||
structured: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
},
|
},
|
||||||
time: { created: 1, ran: 1 },
|
"call-edit",
|
||||||
},
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
|
|||||||
@@ -2,67 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
|||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import type { Resolved } from "../../src/config"
|
import type { Resolved } from "../../src/config"
|
||||||
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||||
|
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
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?: {
|
function config(input?: {
|
||||||
leader?: string
|
leader?: string
|
||||||
leaderTimeout?: number
|
leaderTimeout?: number
|
||||||
@@ -165,10 +107,15 @@ describe("run runtime boot", () => {
|
|||||||
|
|
||||||
test("loads v2 providers and models for model selector data", async () => {
|
test("loads v2 providers and models for model selector data", async () => {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const providers = [provider("openai", "OpenAI")]
|
const location = { directory: "/workspace", project: { id: "proj_1", directory: "/workspace" } }
|
||||||
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
|
const providerList = spyOn(sdk.provider, "list").mockResolvedValue({
|
||||||
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
|
location,
|
||||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
|
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({
|
await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,87 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { runPromptQueue } from "../../src/mini/runtime.queue"
|
import { runPromptQueue } from "../../src/mini/runtime.queue"
|
||||||
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../src/mini/types"
|
import type { RunPrompt } from "../../src/mini/types"
|
||||||
|
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("run runtime queue", () => {
|
describe("run runtime queue", () => {
|
||||||
test("ignores empty prompts", async () => {
|
test("ignores empty prompts", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
@@ -99,7 +23,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("treats /exit as a close command", async () => {
|
test("treats /exit as a close command", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
@@ -116,7 +40,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("treats /new as a local session command", async () => {
|
test("treats /new as a local session command", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
let created = 0
|
let created = 0
|
||||||
|
|
||||||
@@ -149,7 +73,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("shell mode submits /exit as a shell command", async () => {
|
test("shell mode submits /exit as a shell command", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: RunPrompt[] = []
|
const seen: RunPrompt[] = []
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
@@ -168,7 +92,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("shell mode submits /new instead of creating a session", async () => {
|
test("shell mode submits /new instead of creating a session", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: RunPrompt[] = []
|
const seen: RunPrompt[] = []
|
||||||
let created = 0
|
let created = 0
|
||||||
|
|
||||||
@@ -192,7 +116,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("shell mode does not append a synthetic user row", async () => {
|
test("shell mode does not append a synthetic user row", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
@@ -207,7 +131,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("shell mode does not emit a turn duration summary", async () => {
|
test("shell mode does not emit a turn duration summary", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
@@ -223,7 +147,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("preserves whitespace for initial input", async () => {
|
test("preserves whitespace for initial input", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
|
|
||||||
await runPromptQueue({
|
await runPromptQueue({
|
||||||
@@ -248,7 +172,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("passes prompts to onSend", async () => {
|
test("passes prompts to onSend", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
|
|
||||||
await runPromptQueue({
|
await runPromptQueue({
|
||||||
@@ -266,7 +190,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("appends the user row before the turn starts", async () => {
|
test("appends the user row before the turn starts", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
|
|
||||||
await runPromptQueue({
|
await runPromptQueue({
|
||||||
footer: ui.api,
|
footer: ui.api,
|
||||||
@@ -287,7 +211,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("runs queued prompts in order", async () => {
|
test("runs queued prompts in order", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
const gate = new Promise<void>((resolve) => {
|
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 () => {
|
test("exposes ordinary in-flight prompts for removal before sending", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const turns: RunPrompt[] = []
|
const turns: RunPrompt[] = []
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
const gate = new Promise<void>((resolve) => {
|
const gate = new Promise<void>((resolve) => {
|
||||||
@@ -360,7 +284,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("removing one managed queued prompt preserves the others", async () => {
|
test("removing one managed queued prompt preserves the others", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const turns: string[] = []
|
const turns: string[] = []
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
const gate = new Promise<void>((resolve) => {
|
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 () => {
|
test("drains a prompt queued during an in-flight turn", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
const gate = new Promise<void>((resolve) => {
|
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 () => {
|
test("close aborts the active run and drops pending queued work", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
const seen: string[] = []
|
const seen: string[] = []
|
||||||
let hit = false
|
let hit = false
|
||||||
|
|
||||||
@@ -466,7 +390,7 @@ describe("run runtime queue", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("propagates run errors", async () => {
|
test("propagates run errors", async () => {
|
||||||
const ui = footer()
|
const ui = createFooterApiFixture()
|
||||||
|
|
||||||
const task = runPromptQueue({
|
const task = runPromptQueue({
|
||||||
footer: ui.api,
|
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 { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
|
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
|
||||||
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
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"
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
|
|
||||||
function defer<T>() {
|
function defer<T>() {
|
||||||
@@ -38,55 +40,8 @@ function host(): MiniHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function footer(events: FooterEvent[] = []): FooterApi {
|
function footer(events: FooterEvent[] = []) {
|
||||||
let closed = false
|
return createFooterApiFixture({ events }).api
|
||||||
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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -100,12 +55,7 @@ describe("run interactive runtime", () => {
|
|||||||
const streamStarted = defer<void>()
|
const streamStarted = defer<void>()
|
||||||
let lifecycle!: LifecycleInput
|
let lifecycle!: LifecycleInput
|
||||||
const settled: Array<{ sessionID: string; formID: string }> = []
|
const settled: Array<{ sessionID: string; formID: string }> = []
|
||||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
stubCatalogLists(sdk)
|
||||||
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)
|
|
||||||
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
|
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
|
||||||
|
|
||||||
const task = runInteractiveDeferredMode(
|
const task = runInteractiveDeferredMode(
|
||||||
@@ -195,12 +145,7 @@ describe("run interactive runtime", () => {
|
|||||||
const api = footer()
|
const api = footer()
|
||||||
let resolved = 0
|
let resolved = 0
|
||||||
api.idle = () => painted.promise
|
api.idle = () => painted.promise
|
||||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
stubCatalogLists(sdk)
|
||||||
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)
|
|
||||||
|
|
||||||
const task = runInteractiveDeferredMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
@@ -279,38 +224,17 @@ describe("run interactive runtime", () => {
|
|||||||
cursor: {},
|
cursor: {},
|
||||||
}) as never,
|
}) as never,
|
||||||
)
|
)
|
||||||
spyOn(sdk.provider, "list").mockImplementation(
|
stubCatalogLists(sdk, {
|
||||||
() =>
|
providers: [catalogProvider("openai", "OpenAI")],
|
||||||
ok({
|
models: [
|
||||||
location: { directory: "/tmp" },
|
catalogModel({
|
||||||
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
|
id: "gpt-5",
|
||||||
}) as never,
|
providerID: "openai",
|
||||||
)
|
name: "Little Frank",
|
||||||
spyOn(sdk.model, "list").mockImplementation(
|
variants: ["high"],
|
||||||
() =>
|
}),
|
||||||
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)
|
|
||||||
|
|
||||||
const task = runInteractiveDeferredMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
@@ -391,13 +315,7 @@ describe("run interactive runtime", () => {
|
|||||||
const session = spyOn(sdk.session, "get").mockImplementation(
|
const session = spyOn(sdk.session, "get").mockImplementation(
|
||||||
(_request, options) => pending(options?.signal) as never,
|
(_request, options) => pending(options?.signal) as never,
|
||||||
)
|
)
|
||||||
const response = { location: { directory: "/tmp" }, data: [] }
|
stubCatalogLists(sdk)
|
||||||
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)
|
|
||||||
|
|
||||||
const task = runInteractiveDeferredMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
@@ -457,13 +375,9 @@ describe("run interactive runtime", () => {
|
|||||||
let getDirectory: (() => string) | undefined
|
let getDirectory: (() => string) | undefined
|
||||||
let findFiles: ((query: string) => Promise<string[]>) | undefined
|
let findFiles: ((query: string) => Promise<string[]>) | undefined
|
||||||
let transportLocation: unknown
|
let transportLocation: unknown
|
||||||
const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
|
const catalogs = stubCatalogLists(sdk, {
|
||||||
const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
|
location: { directory: "/session", workspaceID: "work-1" },
|
||||||
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 fileFind = spyOn(sdk.file, "find").mockResolvedValue({
|
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
|
||||||
location: {
|
location: {
|
||||||
directory: "/session",
|
directory: "/session",
|
||||||
@@ -538,12 +452,12 @@ describe("run interactive runtime", () => {
|
|||||||
const query = { location: { directory: "/session", workspace: "work-1" } }
|
const query = { location: { directory: "/session", workspace: "work-1" } }
|
||||||
expect(getDirectory?.()).toBe("/session")
|
expect(getDirectory?.()).toBe("/session")
|
||||||
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
||||||
expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||||
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
|
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 { entryGroupKey } from "../../src/mini/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||||
import type { StreamCommit } from "../../src/mini/types"
|
import type { StreamCommit } from "../../src/mini/types"
|
||||||
|
import { canonicalToolPart } from "./fixture/tool-part"
|
||||||
|
|
||||||
type ClaimedCommit = {
|
type ClaimedCommit = {
|
||||||
snapshot: {
|
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: {
|
function toolCommit(input: {
|
||||||
tool: string
|
tool: string
|
||||||
phase: StreamCommit["phase"]
|
phase: StreamCommit["phase"]
|
||||||
@@ -256,7 +242,7 @@ function toolCommit(input: {
|
|||||||
messageID,
|
messageID,
|
||||||
tool: input.tool,
|
tool: input.tool,
|
||||||
...(input.toolState ? { toolState: input.toolState } : {}),
|
...(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,
|
type PermissionV2Request,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
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"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
type RunV2Event = EventSubscribeOutput
|
type RunV2Event = EventSubscribeOutput
|
||||||
@@ -91,31 +93,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
|
|||||||
}
|
}
|
||||||
|
|
||||||
function footer() {
|
function footer() {
|
||||||
const commits: StreamCommit[] = []
|
return createFooterApiFixture()
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionMessages = MessageListOutput["data"]
|
type SessionMessages = MessageListOutput["data"]
|
||||||
@@ -268,18 +246,16 @@ describe("V2 mini transport", () => {
|
|||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "test", id: "model" },
|
model: { providerID: "test", id: "model" },
|
||||||
content: [
|
content: [
|
||||||
{
|
canonicalToolPart(
|
||||||
type: "tool" as const,
|
"shell",
|
||||||
id: "call_child_source",
|
{
|
||||||
name: "shell",
|
|
||||||
state: {
|
|
||||||
status: "running" as const,
|
status: "running" as const,
|
||||||
input: { command: "git status --short" },
|
input: { command: "git status --short" },
|
||||||
structured: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
},
|
},
|
||||||
time: { created: 1, ran: 1 },
|
"call_child_source",
|
||||||
},
|
),
|
||||||
],
|
],
|
||||||
time: { created: 1 },
|
time: { created: 1 },
|
||||||
}
|
}
|
||||||
@@ -508,8 +484,10 @@ describe("V2 mini transport", () => {
|
|||||||
test("sends local file and directory mentions as structured prompt files", async () => {
|
test("sends local file and directory mentions as structured prompt files", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filePath = path.join(tmp.path, "note.ts")
|
const filePath = path.join(tmp.path, "note.ts")
|
||||||
|
const contextPath = path.join(tmp.path, "context.txt")
|
||||||
const directoryPath = path.join(tmp.path, "docs")
|
const directoryPath = path.join(tmp.path, "docs")
|
||||||
await Bun.write(filePath, "export const answer = 42\n")
|
await Bun.write(filePath, "export const answer = 42\n")
|
||||||
|
await Bun.write(contextPath, "context body")
|
||||||
await fs.mkdir(directoryPath)
|
await fs.mkdir(directoryPath)
|
||||||
await Bun.write(path.join(directoryPath, "README.md"), "# hello\n")
|
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,
|
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([
|
expect(request?.files).toEqual([
|
||||||
|
{ uri: "file:///tmp/image.png", name: "image.png" },
|
||||||
{
|
{
|
||||||
uri: pathToFileURL(filePath).href,
|
uri: pathToFileURL(filePath).href,
|
||||||
name: "note.ts",
|
name: "note.ts",
|
||||||
@@ -2393,10 +2375,19 @@ describe("V2 mini transport", () => {
|
|||||||
prompt: {
|
prompt: {
|
||||||
messageID: "msg_cmd",
|
messageID: "msg_cmd",
|
||||||
text: "/deploy prod",
|
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" },
|
command: { name: "deploy", arguments: "prod" },
|
||||||
},
|
},
|
||||||
files: [],
|
files: [
|
||||||
|
{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" },
|
||||||
|
],
|
||||||
includeFiles: true,
|
includeFiles: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2407,6 +2398,14 @@ describe("V2 mini transport", () => {
|
|||||||
arguments: "prod",
|
arguments: "prod",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "test", id: "model" },
|
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",
|
delivery: "steer",
|
||||||
})
|
})
|
||||||
// Selection rides the command payload; no separate client-side switch.
|
// Selection rides the command payload; no separate client-side switch.
|
||||||
@@ -2845,6 +2844,14 @@ describe("V2 mini transport", () => {
|
|||||||
agents: [],
|
agents: [],
|
||||||
time: { created: 1 },
|
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" },
|
{ 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({
|
events.push({
|
||||||
id: "evt_child_text",
|
id: "evt_child_text_replayed",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.text.delta",
|
type: "session.text.delta",
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_child",
|
sessionID: "ses_child",
|
||||||
assistantMessageID: "msg_child_a",
|
assistantMessageID: "msg_child_a",
|
||||||
ordinal: 0,
|
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)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -1,33 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { writeSessionOutput } from "../../src/mini/stream"
|
import { writeSessionOutput } from "../../src/mini/stream"
|
||||||
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
|
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("run stream bridge", () => {
|
describe("run stream bridge", () => {
|
||||||
test("defaults status patches to running phase", () => {
|
test("defaults status patches to running phase", () => {
|
||||||
const out = footer()
|
const out = createFooterApiFixture()
|
||||||
|
|
||||||
writeSessionOutput(
|
writeSessionOutput(
|
||||||
{
|
{
|
||||||
@@ -35,11 +12,7 @@ describe("run stream bridge", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
commits: [],
|
commits: [],
|
||||||
footer: {
|
updates: [{ type: "stream.patch", patch: { status: "assistant responding" } }],
|
||||||
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",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user