diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ace585e6a6c..acf8ec75dd4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,7 +11,6 @@ "bin" ], "exports": { - "./daemon": "./src/daemon.ts", "./run": "./src/run/index.ts", "./server-process": "./src/server-process.ts" }, diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a287c3c9ecb8..c0b914fc62b8 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDescription("Session ID to continue"), Flag.optional, ), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ Spec.make("acp", { description: "Start an Agent Client Protocol server" }), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 66f7469aa57e..f83a0ef7cb8f 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) => } : undefined, }, - args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + args: { + continue: input.continue, + sessionID: Option.getOrUndefined(input.session), + prompt: Option.getOrUndefined(input.prompt), + }, config: { path: config.path, get: () => runPromise(config.get()), diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index ff578099d754..049f08a24ea7 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import { setTimeout } from "node:timers/promises" -import { waitForCatalogReady } from "./services/catalog" import { readStdin } from "./util/io" import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host" import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target" @@ -214,63 +213,7 @@ function parseModel(value?: string) { } function prepareTarget(requestedAgent?: string): SessionTargetPreparation { - return async (input) => { - if (input.model) - await waitForCatalogReady({ - sdk: input.client, - directory: input.location.directory, - workspace: input.location.workspaceID, - model: { providerID: input.model.providerID, modelID: input.model.id }, - signal: input.signal, - }) - return { - model: input.model, - agent: requestedAgent - ? await validateAgent( - input.client, - input.location.directory, - input.location.workspaceID, - requestedAgent, - input.signal, - ) - : input.agent, - } - } -} - -async function validateAgent( - sdk: OpenCodeClient, - directory: string, - workspace: string | undefined, - name?: string, - signal?: AbortSignal, -) { - if (!name) return - const deadline = Date.now() + 5_000 - let agents: Awaited> | undefined - while (Date.now() < deadline && !signal?.aborted) { - agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => { - if (signal && error instanceof ClientError && error.reason === "Transport") throw error - return undefined - }) - const agent = agents?.data.find((item) => item.id === name) - if (agent?.mode === "subagent") { - warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) - return - } - if (agent) return name - await setTimeout(25, undefined, { signal }).catch(() => {}) - } - if (signal?.aborted) return - if (!agents) { - warning("failed to list agents. Falling back to default agent") - return - } - warning(`agent "${name}" not found. Falling back to default agent`) -} - -function warning(message: string) { - process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) + return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent }) } function fail(message: string): never { diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index 102294f64b0b..5ea239ca1b6c 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -211,10 +211,17 @@ export async function runNonInteractivePrompt(input: Input) { } if (!promoted && event.type === "session.execution.failed") { prePromotionError = event.data.error + if (finalizing) return continue } + if ( + !promoted && + finalizing && + (event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted") + ) + return if (!promoted) continue - if (finalizing) continue + if (finalizing && !event.type.startsWith("session.execution.")) continue if (event.type === "session.step.started") { const part = { @@ -618,7 +625,10 @@ export async function runNonInteractivePrompt(input: Input) { if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message) } } - return projected.found + return { + found: projected.found, + responded: projected.messages.some((message) => message.type === "assistant"), + } } const interrupt = () => { @@ -708,9 +718,18 @@ export async function runNonInteractivePrompt(input: Input) { const waiting = input.client.session.wait({ sessionID: input.sessionID }) await Promise.race([waiting, completed.then(() => waiting)]) finalizing = true - controller.abort() - const found = await reconcile() - if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) { + const projected = await reconcile() + if ( + !projected.responded && + !interrupted && + !permissionRejected && + !formCancelled && + !emittedError && + !prePromotionError + ) { + await completed + } + if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) { const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" } emittedError = true process.exitCode = 1 diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 496bdfa86d0d..4342bb329f09 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -5,7 +5,6 @@ import { open } from "node:fs/promises" import path from "node:path" import { readStdin } from "../util/io" import { ServerConnection } from "../services/server-connection" -import { waitForCatalogReady } from "../services/catalog" import { parseSessionTargetModel, resolveSessionTarget } from "../session-target" import { toolInlineInfo } from "@opencode-ai/tui/mini/tool" import { runNonInteractivePrompt } from "./noninteractive" @@ -95,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End prepare: async (next) => { const selected = next.model ?? - (await client.model - .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) - .then((result) => result.data)) + (options.variant + ? await client.model + .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) + .then((result) => result.data) + : undefined) const model = selected ? { providerID: selected.providerID, @@ -107,25 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End : undefined if ((options.variant ?? explicit?.variant) && !model) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) - if (model) { - await waitForCatalogReady({ - sdk: client, - directory: next.location.directory, - workspace: next.location.workspaceID, - model: { providerID: model.providerID, modelID: model.id }, - }) - const available = await client.model.list({ - location: { directory: next.location.directory, workspace: next.location.workspaceID }, - }) - if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id)) - throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id) - } - return { - model, - agent: input.agent - ? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent) - : next.agent, - } + return { model, agent: next.agent } }, }).catch((error) => { if (!(error instanceof RunTargetError)) throw error @@ -190,28 +173,6 @@ export function parseRunModel(value?: string) { } } -async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) { - if (!name) return - const agents = await client.agent - .list({ location: { directory, workspace } }) - .then((result) => result.data) - .catch(() => undefined) - if (!agents) { - warning("failed to list agents. Falling back to default agent") - return - } - const agent = agents.find((item) => item.id === name) - if (!agent) { - warning(`agent "${name}" not found. Falling back to default agent`) - return - } - if (agent.mode === "subagent") { - warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) - return - } - return name -} - async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) diff --git a/packages/cli/src/services/catalog.ts b/packages/cli/src/services/catalog.ts deleted file mode 100644 index ddf8252c31df..000000000000 --- a/packages/cli/src/services/catalog.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise" - -// Location plugins initialize asynchronously, so explicit model selection must -// wait for that exact model before prompt admission. The execution path owns -// the authoritative error if readiness times out. -export async function waitForCatalogReady(input: { - sdk: OpenCodeClient - directory: string - workspace?: string - model: { providerID: string; modelID: string } - timeoutMs?: number - signal?: AbortSignal -}) { - const deadline = Date.now() + (input.timeoutMs ?? 5_000) - while (Date.now() < deadline && !input.signal?.aborted) { - const models = await input.sdk.model - .list( - { location: { directory: input.directory, workspace: input.workspace } }, - { signal: input.signal }, - ) - .then((result) => result.data) - .catch((error) => { - if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error - return undefined - }) - if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return - await wait(25, input.signal) - } -} - -function wait(delay: number, signal?: AbortSignal) { - if (!signal) return new Promise((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal?.removeEventListener("abort", done) - resolve() - } - }) -} diff --git a/packages/cli/test/drive/mini-interactive.drive.mjs b/packages/cli/test/drive/mini-interactive.drive.mjs index e745c481b417..02d0772ef664 100644 --- a/packages/cli/test/drive/mini-interactive.drive.mjs +++ b/packages/cli/test/drive/mini-interactive.drive.mjs @@ -16,7 +16,30 @@ export default defineScript({ const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli")) const session = `mini-stage2-${process.pid}` const snapshots = path.join(artifacts, "mini-stage2") - yield* Effect.promise(() => mkdir(snapshots, { recursive: true })) + const explicitDirectory = path.join(artifacts, "explicit-model") + yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true })))) + /** @param {string} directory @param {string | undefined} model */ + const mini = (directory, model) => [ + "env", + `PWD=${directory}`, + `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 ? ["--model", model] : []), + ] yield* llm.queue( Llm.toolCall({ @@ -42,26 +65,7 @@ export default defineScript({ "-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", + ...mini(path.join(artifacts, "files"), undefined), ]), ), ) @@ -72,7 +76,16 @@ export default defineScript({ 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(() => waitForPane(session, "Default model", 15_000)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Commands")) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Switch model")) + yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Select model")) + yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"])) + yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything...")) 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"])) @@ -134,7 +147,7 @@ export default defineScript({ 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")) + const armed = yield* Effect.promise(() => waitForPane(session, "esc again")) 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)) @@ -144,7 +157,7 @@ export default defineScript({ 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(() => waitForPane(session, "EXIT Press ctrl+")) yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) yield* Effect.promise(() => waitForDeadPane(session)) const status = yield* Effect.promise(() => paneDeadStatus(session)) @@ -153,6 +166,75 @@ export default defineScript({ 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* Effect.promise(() => tmux(["clear-history", "-t", session])) + yield* Effect.promise(() => + tmux([ + "respawn-pane", + "-k", + "-t", + session, + "--", + ...mini(explicitDirectory, "simulation/gpt-sim-model"), + ]), + ) + const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000)) + yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel)) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) + yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+")) + yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"])) + yield* Effect.promise(() => waitForDeadPane(session)) + if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0) + throw new Error("Explicit-model Mini did not exit cleanly") + + yield* Effect.promise(async () => { + for (const failure of [ + { + args: ["--model", "simulation/definitely-missing"], + capture: "08-unavailable-model.txt", + expected: "Model unavailable: simulation/definitely-missing", + }, + { + args: ["--agent", "definitely-missing"], + capture: "09-unavailable-agent.txt", + expected: 'Agent not found: "definitely-missing"', + }, + ]) { + const child = Bun.spawn( + [ + process.execPath, + path.join(root, "packages/cli/src/index.ts"), + "run", + "--server", + registration.url, + ...failure.args, + "optimistic selection check", + ], + { + cwd: path.join(root, "packages/cli"), + env: { + ...process.env, + PWD: path.join(artifacts, "files"), + OPENCODE_PASSWORD: registration.password, + OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"), + OPENCODE_DISABLE_AUTOUPDATE: "1", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + await Bun.write(path.join(snapshots, failure.capture), stdout + stderr) + if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`) + if (!stderr.includes(failure.expected)) + throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`) + } + }) }) yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true)))) @@ -188,6 +270,19 @@ function captureVisiblePane(session) { return tmux(["capture-pane", "-p", "-t", session]) } +/** @param {string} session @param {string} text @param {number} [timeout] */ +async function waitForVisiblePane(session, text, timeout = 5_000) { + const deadline = Date.now() + timeout + let last = "" + while (Date.now() < deadline) { + last = await captureVisiblePane(session) + if (last.includes(text)) return last + if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`) + await Bun.sleep(50) + } + throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`) +} + /** @param {string} session */ async function paneAlive(session) { return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0" diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts index f0eef3cbd8bf..8b189ce2c032 100644 --- a/packages/cli/test/import-boundaries.test.ts +++ b/packages/cli/test/import-boundaries.test.ts @@ -18,10 +18,12 @@ describe("CLI frontend import boundaries", () => { test("exposes only the intentional package entrypoints", async () => { const run = await import("@opencode-ai/cli/run") const mini = await import("@opencode-ai/tui/mini") + const tool = await import("@opencode-ai/tui/mini/tool") const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json() expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"]) expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"]) + expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"]) expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([]) }) diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 3eb418598117..ba2c51ab0450 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -138,32 +138,47 @@ describe("mini command", () => { expect(result.stderr).not.toContain("You must provide a message") }) - test("preserves a run failure exit code", async () => { - let modelRequests = 0 + test("passes explicit selections to session creation without catalog preflight", async () => { + const requests: string[] = [] + let session: unknown const server = Bun.serve({ port: 0, - fetch(request) { + async fetch(request) { const url = new URL(request.url) + requests.push(url.pathname) if (url.pathname === "/api/health") return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) if (url.pathname === "/api/location") return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) - if (url.pathname === "/api/model") { - modelRequests++ - return Response.json({ - location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } }, - data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [], - }) + if (url.pathname === "/api/session") { + session = await request.json() + return new Response("boom", { status: 500 }) } return new Response(undefined, { status: 404 }) }, }) try { - const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"]) + const result = await cli([ + "run", + "--server", + server.url.toString(), + "--model", + "definitely/missing", + "--agent", + "definitely-missing", + "hi", + ]) expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("Model unavailable: definitely/missing") + expect(result.stderr).toContain("UnexpectedStatus") + expect(session).toMatchObject({ + agent: "definitely-missing", + model: { providerID: "definitely", id: "missing" }, + }) + expect(requests).not.toContain("/api/model") + expect(requests).not.toContain("/api/agent") + expect(requests).not.toContain("/api/location/wait") } finally { server.stop(true) } diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index f27869a991d5..58dea74a93aa 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -169,6 +169,7 @@ async function run(input: { renderToolError?: (part: SessionMessageAssistantTool) => Promise messages?: (inputID: string) => SessionMessageInfo[] wait?: () => Promise + terminalDelay?: number }) { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] @@ -183,7 +184,10 @@ async function run(input: { }) continue } - if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0) + if (value.type.startsWith("session.execution.")) { + if (input.terminalDelay) await Bun.sleep(input.terminalDelay) + setTimeout(wait.resolve, 0) + } yield value } })() @@ -251,7 +255,7 @@ async function capture(input: Parameters[0]) { }) try { await run(input) - return { stdout: stdout.join(""), stderr: stderr.join("") } + return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode } } finally { process.exitCode = exitCode ?? 0 stdoutWrite.mockRestore() @@ -319,6 +323,26 @@ describe("runNonInteractivePrompt", () => { error: { type: "provider.transport", message: "instructions unavailable" }, }), ]) + expect(output.exitCode).toBe(1) + }) + + test("waits for a terminal failure when idle wins before projection", async () => { + for (const promotedBeforeFailure of [true, false]) { + const output = await capture({ + format: "json", + turn: (messageID) => [ + ...(promotedBeforeFailure ? [prompted(messageID)] : []), + executionFailed("selection unavailable"), + ], + messages: (messageID) => + promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [], + wait: () => Promise.resolve(), + terminalDelay: 10, + }) + + expect(output.exitCode).toBe(1) + expect(output.stdout).toContain("selection unavailable") + } }) test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => { @@ -413,7 +437,7 @@ describe("runNonInteractivePrompt", () => { ], }) - expect(output).toEqual({ stdout: "", stderr: "" }) + expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 }) }) test("renders native failed tool output before the terminal error", async () => { diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index a5970192bb46..c3f6828770e7 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -17,7 +17,8 @@ export const ModelGroup = HttpApiGroup.make("server.model") OpenApi.annotations({ identifier: "v2.model.list", summary: "List models", - description: "Retrieve available models ordered by release date.", + description: + "Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.", }), ), ) diff --git a/packages/tui/package.json b/packages/tui/package.json index 8ced93ff1fcb..0fb182f8d355 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -30,7 +30,7 @@ "./editor": "./src/editor.ts", "./editor-zed": "./src/editor-zed.ts", "./mini": "./src/mini/index.ts", - "./mini/tool": "./src/mini/tool.ts", + "./mini/tool": "./src/mini/tool.public.ts", "./model-preference": "./src/model-preference.ts", "./runtime": "./src/runtime.tsx", "./terminal-win32": "./src/terminal-win32.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b4e6fb815ec8..704332977955 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { }) const args = useArgs() + const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { route.navigate({ type: "session", sessionID: args.sessionID, + prompt: startupPrompt, }) } }) @@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const match = response.data[0]?.id if (!match) return if (!args.fork) { - route.navigate({ type: "session", sessionID: match }) + route.navigate({ type: "session", sessionID: match, prompt: startupPrompt }) return } void client.api.session .fork({ sessionID: match }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) .catch(toast.error) @@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { forked = true void client.api.session .fork({ sessionID: args.sessionID }) - .then((result) => route.navigate({ type: "session", sessionID: result.id })) + .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index b152a4926f39..941e44d9c529 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -85,67 +85,6 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ return [...grouped.values()] } -export async function waitForDefaultModel(input: { - sdk: OpenCodeClient - location: LocationRef - timeoutMs?: number - requestTimeoutMs?: number - active?: () => boolean - signal?: AbortSignal -}): Promise<{ providerID: string; modelID: string } | undefined> { - const deadline = Date.now() + (input.timeoutMs ?? 5_000) - while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) { - const controller = new AbortController() - const timeout = setTimeout( - () => controller.abort(), - Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())), - ) - const abort = () => controller.abort() - input.signal?.addEventListener("abort", abort, { once: true }) - const model = await abortable( - input.sdk.model - .default(location(input.location), { signal: controller.signal }) - .then((result) => result.data) - .catch(() => undefined), - controller.signal, - ).finally(() => { - clearTimeout(timeout) - input.signal?.removeEventListener("abort", abort) - }) - if (model) return { providerID: model.providerID, modelID: model.id } - await wait(25, input.signal) - } -} - -function abortable(task: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(undefined) - return new Promise((resolve) => { - const abort = () => { - signal.removeEventListener("abort", abort) - resolve(undefined) - } - signal.addEventListener("abort", abort, { once: true }) - void task.then((value) => { - signal.removeEventListener("abort", abort) - resolve(value) - }) - }) -} - -function wait(delay: number, signal?: AbortSignal) { - if (!signal) return new Promise((resolve) => setTimeout(resolve, delay)) - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal?.removeEventListener("abort", done) - resolve() - } - }) -} - export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise { const result = await sdk.agent.list(location(ref), ...requestOptions(signal)) return result.data.map(runAgent) diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index e4c11c9813af..a16ae991ac28 100644 --- a/packages/tui/src/mini/demo.ts +++ b/packages/tui/src/mini/demo.ts @@ -16,8 +16,9 @@ // the synthetic tool parts through the same callbacks used by the live footer. import path from "path" import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { parseSlashHead } from "../prompt/parse" import { writeSessionOutput } from "./stream" -import { toolCommit } from "./stream-v2.subagent" +import { toolCommit, toolFinalPhase } from "./stream-v2.subagent" import type { FooterApi, FooterView, @@ -110,7 +111,6 @@ const SAMPLE_TABLE = [ type Ref = { msg: string - part: string call: string tool: string input: Record @@ -126,7 +126,6 @@ type FormRequest = { type Perm = { ref: Ref done: { - title: string output: string metadata?: Record } @@ -203,7 +202,6 @@ function showSubagent( description: input.description, status: input.status, title: input.title, - lastUpdatedAt: Date.now(), }, ], details: { @@ -337,7 +335,6 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal): function make(state: State, tool: string, input: Record): Ref { return { msg: open(state), - part: take(state, "part", "part"), call: take(state, "call", "call"), tool, input, @@ -346,7 +343,7 @@ function make(state: State, tool: string, input: Record): Ref } function startTool(state: State, ref: Ref, structured: Record = {}): SessionMessageAssistantTool { - state.started.add(ref.part) + state.started.add(ref.call) const part = { type: "tool" as const, id: ref.call, @@ -386,12 +383,11 @@ function doneTool( state: State, ref: Ref, output: { - title: string output: string metadata?: Record }, ): void { - if (!state.started.has(ref.part)) startTool(state, ref) + if (!state.started.has(ref.call)) startTool(state, ref) const part: SessionMessageAssistantTool = { type: "tool", id: ref.call, @@ -404,11 +400,11 @@ function doneTool( }, time: { created: ref.start, ran: ref.start, completed: Date.now() }, } - present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")]) + present(state, [toolCommit(part, ref.msg, toolFinalPhase(part))]) } function failTool(state: State, ref: Ref, error: string): void { - if (!state.started.has(ref.part)) startTool(state, ref) + if (!state.started.has(ref.call)) startTool(state, ref) present(state, [ toolCommit( { @@ -443,7 +439,6 @@ async function emitBash(state: State, signal?: AbortSignal): Promise { startTool(state, ref) await wait(70, signal) doneTool(state, ref, { - title: "git status", output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, metadata: { exit: 0, @@ -458,7 +453,6 @@ function emitWrite(state: State): void { content: "export const demo = 42\n", }) doneTool(state, ref, { - title: "write", output: "", metadata: {}, }) @@ -470,7 +464,6 @@ function emitEdit(state: State): void { path: file, }) doneTool(state, ref, { - title: "edit", output: "", metadata: { files: [ @@ -490,7 +483,6 @@ function emitPatch(state: State): void { patchText: "*** Begin Patch\n*** End Patch", }) doneTool(state, ref, { - title: "patch", output: "", metadata: { files: [ @@ -517,10 +509,9 @@ function emitTask(state: State): void { agent: "explore", }) doneTool(state, ref, { - title: "Reducer touchpoints found", output: "", metadata: { - sessionID: "sub_demo_1", + sessionID: "ses_demo_child", status: "completed", output: "", }, @@ -542,7 +533,7 @@ function emitTask(state: State): void { time: { created: Date.now(), ran: Date.now() }, } satisfies SessionMessageAssistantTool showSubagent(state, { - sessionID: "sub_demo_1", + sessionID: "ses_demo_child", label: "Explore", description: "Scan run/* for reducer touchpoints", status: "completed", @@ -562,16 +553,7 @@ function emitTask(state: State): void { messageID: "sub_demo_msg_reasoning", partID: "sub_demo_reasoning_1", }, - { - kind: "tool", - text: "running read", - phase: "start", - source: "tool", - messageID: "sub_demo_msg_tool", - partID: "sub_demo_tool_1", - tool: "read", - part, - }, + toolCommit(part, "sub_demo_msg_tool", "start"), { kind: "assistant", text: "Footer updates flow through stream.ts into RunFooter", @@ -610,7 +592,6 @@ function emitQuestionTool(state: State): void { ], }) doneTool(state, ref, { - title: "question", output: "", metadata: { answers: [["Diff"], ["Usage", "custom-note"]], @@ -635,7 +616,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [command], always: ["*"], done: { - title: "git status --short", output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, metadata: { exit: 0, @@ -658,7 +638,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [target], always: [target], done: { - title: "read", output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), metadata: {}, }, @@ -677,7 +656,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: ["explore"], always: ["*"], done: { - title: "Footer spacing checked", output: "", metadata: { sessionID: "sub_demo_perm_1", @@ -707,7 +685,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { }, always: [`${dir}/**`], done: { - title: "read", output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, metadata: {}, }, @@ -726,7 +703,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: ["*"], always: ["*"], done: { - title: "Retry allowed", output: "Continuing after repeated failures.\n", metadata: {}, }, @@ -744,7 +720,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { patterns: [file], always: [file], done: { - title: "edit", output: "", metadata: { files: [{ file, status: "modified", patch: diff }], @@ -955,8 +930,9 @@ export function createRunDemo(input: Input) { const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { const text = line.text.trim() - const list = text.split(/\s+/) - const cmd = list[0] || "" + const head = parseSlashHead(text) + const list = head?.arguments.split(/\s+/).filter(Boolean) ?? [] + const cmd = head ? `/${head.name}` : "" clearSubagent(state.footer) @@ -966,7 +942,7 @@ export function createRunDemo(input: Input) { } if (cmd === "/permission") { - const kind = permissionKind(list[1]) + const kind = permissionKind(list[0]) if (!kind) { note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) return true @@ -977,7 +953,7 @@ export function createRunDemo(input: Input) { } if (cmd === "/form") { - const kind = formKind(list[1]) + const kind = formKind(list[0]) if (!kind) { note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`) return true @@ -988,8 +964,8 @@ export function createRunDemo(input: Input) { } if (cmd === "/fmt") { - const kind = (list[1] || "").toLowerCase() - const body = list.slice(2).join(" ") + const kind = (list[0] || "").toLowerCase() + const body = list.slice(1).join(" ") if (!kind) { note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) return true @@ -1032,7 +1008,6 @@ export function createRunDemo(input: Input) { clearBlocker(state) if (form.kind === "question") { doneTool(state, form.ref, { - title: "question", output: "", metadata: { answers: form.request.fields.map((field) => { @@ -1045,7 +1020,6 @@ export function createRunDemo(input: Input) { return true } doneTool(state, form.ref, { - title: form.request.title, output: `Form submitted: ${Object.entries(input.answer) .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`) .join("; ")}\n`, diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 4c7e5aef13cc..4dff4f8112e5 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -54,10 +54,6 @@ type SubagentEntry = PanelEntry & { current: boolean } -type QueuedEntry = PanelEntry & { - prompt: FooterQueuedPrompt -} - type SettingEntry = PanelEntry & { key: keyof MiniSettings } @@ -92,18 +88,6 @@ function countLabel(count: number, total: number, query: string) { return `${count}/${total}` } -function categoryRank(category: string) { - if (category === "Project Commands") { - return 0 - } - - if (category === "MCP Commands") { - return 1 - } - - return 2 -} - function subagentStatusLabel(status: FooterSubagentTab["status"]) { if (status === "completed") { return "done" @@ -374,7 +358,6 @@ export function RunCommandMenuBody(props: { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["compact", "editor", "new", "settings"] const session: CommandEntry[] = [ { action: "editor", @@ -473,29 +456,10 @@ export function RunCommandMenuBody(props: { ] : []), ] - const commands = (props.commands() ?? []) - .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) - .map( - (item) => - ({ - action: "slash", - category: item.source === "mcp" ? "MCP Commands" : "Project Commands", - name: item.name, - display: item.name, - footer: `/${item.name}`, - keywords: - item.source === "mcp" - ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` - : `/${item.name} ${item.name} ${item.description ?? ""}`, - }) satisfies CommandEntry, - ) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)) - return [ ...session, ...prompt, ...agent, - ...commands, { action: "settings", category: "System", @@ -779,13 +743,12 @@ export function RunQueuedPromptSelectBody(props: { onRows?: (rows: number) => void mono?: boolean }) { - const entries = createMemo(() => + const entries = createMemo(() => props.prompts().map((prompt) => ({ category: "", display: prompt.prompt.text.replaceAll("\n", " "), footer: prompt.delivery, keywords: prompt.prompt.text, - prompt, })), ) const controller = createSearchablePanelController({ diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 544d76007d20..8b8acf98484a 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -31,13 +31,13 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit import { monoTruncateMiddle } from "./mono" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types" +import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 export const TEXTAREA_MIN_ROWS = 1 -export const TEXTAREA_MAX_ROWS = 6 +const TEXTAREA_MAX_ROWS = 6 export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS type Mention = Extract @@ -65,7 +65,6 @@ type PromptInput = { agents: Accessor references: Accessor commands: Accessor - tuiConfig: RunTuiConfig state: Accessor view: Accessor prompt: Accessor @@ -142,7 +141,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { } } -export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) { +export function selectedCommand(text: string, command: RunPrompt["command"]) { if (!command) { return } @@ -152,14 +151,10 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com return } - // Bound drafts (e.g. the skill picker) may predate or omit the catalog - // source; resolve it at submit time so routing never degrades to a plain - // command for a skill entry. - const source = command.source ?? commands?.find((item) => item.name === command.name)?.source return { name: command.name, arguments: head.arguments, - ...(source ? { source } : {}), + ...(command.source ? { source: command.source } : {}), } } @@ -1140,7 +1135,7 @@ export function createPromptState(input: PromptInput): PromptState { return } - const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands()) + const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) if (!command && next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index b21c249612a7..2673cc5e6f45 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -74,7 +74,6 @@ type RunFooterOptions = { agents: RunAgent[] references: RunReference[] wrote?: boolean - sessionID: () => string | undefined agentLabel: string modelLabel: string model: RunInput["model"] @@ -312,7 +311,6 @@ export class RunFooter implements FooterApi { currentVariant: footer.currentVariant, theme: footer.theme, mono: options.mono, - tuiConfig: options.tuiConfig, miniSettings: footer.miniSettings, history: footer.history, onSubmit: footer.handlePrompt, diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index c1cf4cc89533..dfc326f4b456 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -50,7 +50,6 @@ import type { RunPrompt, RunProvider, RunReference, - RunTuiConfig, } from "./types" import type { RunTheme } from "./theme" @@ -88,7 +87,6 @@ type RunFooterViewProps = { queuedPrompts?: () => FooterQueuedPrompt[] theme: () => RunTheme mono: boolean - tuiConfig: RunTuiConfig miniSettings: () => MiniSettings history?: () => RunPrompt[] onSubmit: (input: RunPrompt) => boolean @@ -359,7 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) { agents: props.agents, references: props.references, commands: props.commands, - tuiConfig: props.tuiConfig, state: props.state, view: promptView, prompt, @@ -425,7 +422,7 @@ export function RunFooterView(props: RunFooterViewProps) { return props.mono ? usage().replaceAll(" · ", " - ") : usage() }) const modelStatus = createMemo(() => { - const current = model() + const current = model() ?? props.state().model.trim() if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return return { model: current, diff --git a/packages/tui/src/mini/form.shared.ts b/packages/tui/src/mini/form.shared.ts index 7fdcd7fe51c4..e571dabbdcf7 100644 --- a/packages/tui/src/mini/form.shared.ts +++ b/packages/tui/src/mini/form.shared.ts @@ -14,7 +14,7 @@ import { import type { FormAnswerField } from "../util/form" import type { FormReply, MiniFormRequest } from "./types" -export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue } +export { formCustom, formLabel, formRows, formTextual, formValidateValue } export type FormBodyState = { formID: string @@ -97,7 +97,7 @@ export function formSetSelected(state: FormBodyState, selected: number): FormBod return { ...state, selected, error: "" } } -export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState { +function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState { return { ...state, editing, error: "" } } diff --git a/packages/tui/src/mini/permission.shared.ts b/packages/tui/src/mini/permission.shared.ts index fa4610efcbf4..e293d3ef17b7 100644 --- a/packages/tui/src/mini/permission.shared.ts +++ b/packages/tui/src/mini/permission.shared.ts @@ -76,7 +76,7 @@ export function permissionLabel(option: PermissionOption): string { export { permissionAlwaysLines } -export function permissionReply( +function permissionReply( sessionID: string, requestID: string, reply: PermissionReply["reply"], diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 280cb2851785..9c43d7fd6f3a 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -82,6 +82,7 @@ export type Lifecycle = { footer: FooterApi onResize(fn: () => void): () => void refreshTheme(): void + setTitle(title?: string): void resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise } @@ -126,7 +127,7 @@ function footerLabels(input: Pick): Foo const agentLabel = Locale.titlecase(input.agent ?? "build") return { agentLabel, - modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "", + modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model", } } @@ -184,6 +185,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { + if (input.host.platform !== "linux") return + if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode") + renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`) + } + setTitle(input.sessionTitle) const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono) renderer.setBackgroundColor(theme.background) const state: SplashState = { @@ -225,7 +232,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise input.sessionID), ...labels, model: input.model, variant: input.variant, @@ -338,6 +344,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() + if (input.host.platform === "linux") renderer.setTerminalTitle("") shutdown(renderer) if (!wroteExit) { input.host.stdout.write("\n") @@ -350,6 +357,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise currentModelLoad(generation, sdk), - signal, - }) - if (!currentModelLoad(generation, sdk)) return - const [fallbackSavedVariant, info] = await Promise.all([ - input.host.preferences.resolveVariant(model), - abortable(resolveModelInfo(sdk, state.location, signal), signal), - ]) - if (!info || !currentModelLoad(generation, sdk)) return - if (model && !state.model) state.model = model - const boot = !!model && state.model?.providerID === model.providerID && state.model.modelID === model.modelID applyModelInfo( info, - boot ? session.variant : state.activeVariant, + selected ? session.variant : state.activeVariant, { sdk, generation, signal }, - boot, - fallbackSavedVariant, + !!selected, + savedVariant, ) } finally { if (modelAttempt === controller) modelAttempt = undefined @@ -760,6 +742,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep replayLimit: input.replayLimit, footer, onCommit: rememberLocal, + onSessionTitle: (title) => { + state.sessionTitle = title + shell.setTitle(title) + }, trace: log, onCatalogRefresh: requestCatalogRefresh, contextLimit: (model) => @@ -906,6 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.shown = false state.sessionID = created.sessionID state.sessionTitle = created.sessionTitle + shell.setTitle(state.sessionTitle) state.agent = created.agent ?? state.agent state.location = created.location state.model = created.model diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index 436397248a70..6b141e28e832 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -30,7 +30,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm return Boolean(current && next && current === next) } -export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout { +function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout { if (commit.kind === "tool") { if (body.type === "structured" || body.type === "markdown") { return "block" diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index 164aa9a1798d..7ae8865cad4c 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -194,7 +194,6 @@ function tab(child: ChildState): FooterSubagentTab { status: child.status, background: child.background ? true : undefined, title: child.title, - lastUpdatedAt: child.lastUpdatedAt, } } @@ -1084,11 +1083,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input.emit() }, snapshot() { - const tabs = [...children.values()].map(tab).toSorted((a, b) => { + const tabs = [...children.values()].toSorted((a, b) => { const active = Number(b.status === "running") - Number(a.status === "running") if (active !== 0) return active return b.lastUpdatedAt - a.lastUpdatedAt - }) + }).map(tab) const child = selected ? children.get(selected) : undefined const details: Record = child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {} diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index b90f5e682d70..b8aab5ee2491 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -46,6 +46,7 @@ type StreamInput = { replayLimit?: number footer: FooterApi onCommit?: (commit: StreamCommit) => void + onSessionTitle?: (title: string) => void trace?: Trace signal?: AbortSignal onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise @@ -828,6 +829,10 @@ export async function createSessionTransport(input: StreamInput): Promise +type ToolDict = Record type PatchFile = { status?: string @@ -78,7 +77,7 @@ type ToolMetadata = ToolDict & { exit?: number } -export type ToolFrame = { +type ToolFrame = { directory?: string raw: string name: string @@ -94,7 +93,7 @@ export type ToolFrame = { } } -export type ToolInline = { +type ToolInline = { icon: string title: string description?: string @@ -102,7 +101,7 @@ export type ToolInline = { body?: string } -export type ToolProps = { +type ToolProps = { input: ToolInput metadata: ToolMetadata frame: ToolFrame @@ -166,7 +165,7 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri function normalizeInput(name: string, value: unknown) { const input = dict(value) - const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath) + const path = typeof input.path === "string" ? input.path : text(input.filePath) const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type) return { ...input, @@ -191,7 +190,7 @@ function normalizeFile(value: unknown): PatchFile | undefined { : legacy === "move" ? "moved" : legacy) - const patch = typeof file.patch === "string" ? file.patch : text(file.diff) || undefined + const patch = typeof file.patch === "string" ? file.patch : undefined const deletions = finiteNumber(file.deletions) return { ...file, @@ -214,11 +213,6 @@ function normalizeStructured(name: string, value: unknown) { ...structured, ...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}), ...(name === "subagent" && sessionID ? { sessionID } : {}), - ...(name === "shell" && - finiteNumber(structured.exit) === undefined && - finiteNumber(structured.exitCode) !== undefined - ? { exit: finiteNumber(structured.exitCode) } - : {}), } } @@ -674,7 +668,7 @@ function scrollShellFinal(p: ToolProps): string { return fail(p.frame) } - const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code) + const code = p.metadata.exit const time = span(p.frame) if (code === undefined) { if (!time) { @@ -1113,7 +1107,7 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame } } -export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { +function toolFrame(commit: StreamCommit, raw: string): ToolFrame { const current = commit.part ? frame(commit.part, commit.directory) : undefined return { directory: commit.directory, @@ -1198,7 +1192,7 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string { return fallbackFinal(ctx) } -export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined { +function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined { const ctx = toolFrame(commit, raw) const draw = rule(ctx.name)?.snap if (!draw) { diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 232f74c1e5bd..3d7920f93e3a 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -53,7 +53,7 @@ export type RunCommand = { source?: string } -export type RunProviderModel = { +type RunProviderModel = { name?: string cost?: { input: number @@ -159,7 +159,7 @@ export type MiniHost = { export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error" // Whether the assistant is actively processing a turn. -export type FooterPhase = "idle" | "running" +type FooterPhase = "idle" | "running" // Full snapshot of footer status bar state. Every update replaces the whole // object in the SolidJS signal so the view re-renders atomically. @@ -188,14 +188,14 @@ export type ScrollbackOptions = { mono?: boolean } -export type ToolCodeSnapshot = { +type ToolCodeSnapshot = { kind: "code" title: string content: string file?: string } -export type ToolDiffSnapshot = { +type ToolDiffSnapshot = { kind: "diff" items: Array<{ title: string @@ -205,14 +205,14 @@ export type ToolDiffSnapshot = { }> } -export type ToolTaskSnapshot = { +type ToolTaskSnapshot = { kind: "task" title: string rows: string[] tail: string } -export type ToolQuestionSnapshot = { +type ToolQuestionSnapshot = { kind: "question" items: Array<{ question: string @@ -223,15 +223,7 @@ export type ToolQuestionSnapshot = { export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot -export type MiniToolState = - | { status: "pending"; input: Record; raw?: string } - | { - status: "running" - input: Record - title?: string - metadata?: Record - time: { start: number } - } +type MiniToolState = | { status: "completed" input: Record @@ -304,7 +296,6 @@ export type FooterSubagentTab = { status: "running" | "completed" | "cancelled" | "error" background?: boolean title?: string - lastUpdatedAt: number } export type FooterSubagentDetail = { @@ -392,7 +383,7 @@ export type FormCancel = { location?: LocationRef } -export type RunTuiConfig = Pick +export type RunTuiConfig = Pick export type MiniSettings = { thinking: "show" | "hide" @@ -408,11 +399,11 @@ export type MiniSettingChange = { // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. -export type StreamPhase = "start" | "progress" | "final" +type StreamPhase = "start" | "progress" | "final" -export type StreamSource = "assistant" | "reasoning" | "tool" | "system" +type StreamSource = "assistant" | "reasoning" | "tool" | "system" -export type StreamToolState = "running" | "completed" | "error" +type StreamToolState = "running" | "completed" | "error" // A single append-only commit to scrollback. The transport produces these from // V2 events, and RunFooter.append() queues them for the next diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index acf38c353c3a..5a4fd04e828c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" +import { useArgs } from "../../context/args" addDefaultParsers(parsers.parsers) @@ -120,6 +121,7 @@ export function Session() { const { navigate } = useRoute() const data = useData() const local = useLocal() + const args = useArgs() const paths = useTuiPaths() const configState = useConfig() const config = configState.data @@ -216,6 +218,7 @@ export function Session() { const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) + const [synced, setSynced] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -242,6 +245,7 @@ export function Session() { createEffect(() => { if (client.connection.status() !== "connected") return + setSynced(false) const sessionID = route.sessionID void (async () => { await Promise.all([ @@ -261,6 +265,7 @@ export function Session() { } editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -273,15 +278,25 @@ export function Session() { }) let seeded = false + let sent = false let scroll: ScrollBoxRenderable - let prompt: PromptRef | undefined + const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { - prompt = r + setPrompt(r) promptRef.set(r) if (seeded || !route.prompt || !r) return seeded = true r.set(route.prompt) } + + createEffect(() => { + const current = prompt() + if (sent || !current || !synced() || !local.model.ready) return + if (!local.agent.current() || !local.model.current()) return + if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return + sent = true + current.submit() + }) const dialog = useDialog() const renderer = useRenderer() const unavailable = (feature: string) => { @@ -526,7 +541,7 @@ export function Session() { void client.api.session.revert .stage({ sessionID: route.sessionID, messageID: message.id }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) - prompt?.set({ + prompt()?.set({ ...projectedPromptInput(message), pasted: [], }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 81a61684baf9..a07e3e6b12b2 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after setTitle(title) } const events = createEventStream() + let promptRequests = 0 const calls = createFetch((url) => { const session = { id: "dummy", @@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/prompt") { + promptRequests++ + return json({ data: {} }) + } }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) @@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode2 -s dummy") + expect(promptRequests).toBe(0) } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after mock.restore() } }) + +test("session startup prompt is submitted exactly once", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const bodies: unknown[] = [] + const promptSubmitted = Promise.withResolvers() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + if (url.pathname === "/api/model") + return json({ + location, + data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + }) + if (url.pathname === "/api/session/dummy/prompt") { + bodies.push(await request.json()) + promptSubmitted.resolve() + return json({ data: {} }) + } + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: { sessionID: "dummy", prompt: "RESUME_READY" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.race([ + promptSubmitted.promise, + Bun.sleep(2000).then(() => { + throw new Error("startup prompt was not submitted") + }), + ]) + await Bun.sleep(20) + setup.renderer.destroy() + await task + + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +}) diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 334454c3aa4d..fbfdf40089fc 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/packages/tui/test/mini/catalog.shared.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" -import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared" +import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared" import { catalogModel, catalogProvider } from "./fixture/catalog" afterEach(() => { @@ -8,26 +8,6 @@ afterEach(() => { }) describe("run catalog shared", () => { - test("resolves the catalog-selected model for the footer", async () => { - const client = OpenCode.make({ baseUrl: "https://opencode.test" }) - const selected = spyOn(client.model, "default").mockImplementation( - () => - Promise.resolve({ - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: { id: "gpt-5", providerID: "openai" }, - }) as never, - ) - - await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({ - providerID: "openai", - modelID: "gpt-5", - }) - expect(selected).toHaveBeenCalledWith( - { location: { directory: "/tmp", workspace: undefined } }, - { signal: expect.any(AbortSignal) }, - ) - }) - test("loads visible project references from the current reference catalog", async () => { const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const list = spyOn(client.reference, "list").mockImplementation( diff --git a/packages/tui/test/mini/fixture/tui-runtime.ts b/packages/tui/test/mini/fixture/tui-runtime.ts deleted file mode 100644 index 0334ea5abe83..000000000000 --- a/packages/tui/test/mini/fixture/tui-runtime.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { resolve, type Info, type Resolved } from "../../../src/config" -import { TuiKeybind } from "../../../src/config/keybind" - -type ResolvedInput = Omit & { - attention?: Partial - keybinds?: Partial - leader_timeout?: number -} - -export function createTuiResolvedConfig(input: ResolvedInput = {}) { - const { leader_timeout, ...current } = input - return resolve( - { - ...current, - leader: leader_timeout === undefined ? undefined : { timeout: leader_timeout }, - }, - { terminalSuspend: process.platform !== "win32" }, - ) -} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 15ddbaacbfae..da2513dba4b4 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -26,7 +26,6 @@ test("down opens subagents from an empty prompt", async () => { label: "Explore", description: "Inspect the keymap", status: "running", - lastUpdatedAt: 1, }, ], details: {}, @@ -54,7 +53,6 @@ test("down opens subagents from an empty prompt", async () => { view={view} subagent={subagents} theme={() => RUN_THEME_FALLBACK} - tuiConfig={config} miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })} mono={false} onSubmit={() => true} diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index a4a5936dba46..38eabf85ae9c 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -35,7 +35,7 @@ import type { } from "../../src/mini/types" import { selectedCommand } from "../../src/mini/footer.prompt" import { RejectField } from "../../src/mini/footer.permission" -import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" const tuiConfig = createTuiResolvedConfig() @@ -87,7 +87,6 @@ function subagent(input: { label: input.label, description: input.description, status: input.status ?? "running", - lastUpdatedAt: 1, } satisfies FooterSubagentTab } @@ -152,7 +151,6 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} mono={input.mono ?? false} - tuiConfig={config} miniSettings={miniSettings} onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -195,6 +193,16 @@ async function renderFooter( } } +test("direct footer shows the generic default model before resolution", async () => { + const app = await renderFooter({ state: { model: "Default model" } }) + try { + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Default model") + } finally { + app.cleanup() + } +}) + test("direct footer preserves a partial multi-field form draft across permission preemption", async () => { const request: FormInfo = { id: "frm_preempted", @@ -355,7 +363,7 @@ test("run entry content updates when live commit text changes", async () => { } }) -test("direct command panel renders grouped command palette", async () => { +test("direct command panel renders grouped actions without catalog commands", async () => { const [commands] = createSignal([ command({ name: "review", description: "Review code" }), command({ name: "deploy", description: "Deploy prompt", source: "mcp" }), @@ -423,6 +431,16 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).not.toContain("Review code") expect(frame).not.toContain("Commands 8") + await app.mockInput.typeText("review") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) + await app.mockInput.typeText("deploy") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("No results found") + + app.mockInput.pressKey("u", { ctrl: true }) await app.mockInput.typeText("status") await app.renderOnce() expect(app.captureCharFrame()).toContain("Show status") @@ -976,27 +994,17 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async ( } }) -test("selectedCommand backfills the catalog source for bound drafts", () => { - const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })] - - // The skill picker binds `/name ` drafts; older drafts may lack source. - expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({ - name: "opencode-ts", - arguments: "fix it", - source: "skill", - }) - // An explicit source wins without a catalog lookup. +test("selectedCommand validates the bound command and refreshes its arguments", () => { expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({ name: "opencode-ts", arguments: "", source: "skill", }) - // Plain commands stay untagged. - expect( - selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [ - command({ name: "deploy", description: "Deploy" }), - ]), - ).toEqual({ name: "deploy", arguments: "prod" }) + expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({ + name: "deploy", + arguments: "prod", + }) + expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined() }) test("direct footer tags skill slash submissions with their catalog source", async () => { @@ -1142,7 +1150,6 @@ test("direct footer shows authoritative pending work while running", async () => }, ]} theme={() => RUN_THEME_FALLBACK} - tuiConfig={tuiConfig} miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })} mono={false} onSubmit={() => true} diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 8d85cfa1f0fe..431b5bdc0198 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -3,7 +3,7 @@ import { OpenCode } from "@opencode-ai/client/promise" import type { Resolved } from "../../src/config" import { resolveMiniSettings, 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 config(input?: { leader?: string @@ -21,7 +21,7 @@ function config(input?: { }): Resolved { const bind = input?.bindings return createTuiResolvedConfig({ - leader_timeout: input?.leaderTimeout, + leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, keybinds: { ...(input?.leader && { leader: input.leader }), ...(bind?.commandList && { command_list: bind.commandList }), @@ -95,14 +95,12 @@ describe("run runtime boot", () => { const result = await resolveRunTuiConfig( createTuiResolvedConfig({ theme: { mode: "light" }, - leader_timeout: 450, - session: { thinking: "show" }, + leader: { timeout: 450 }, }), ) expect(result.theme).toEqual({ mode: "light" }) expect(result.leader.timeout).toBe(450) - expect(result.session?.thinking).toBe("show") expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index 2185aec127a3..6aff69274d04 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -5,7 +5,6 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle" import type { FooterEvent, MiniHost } from "../../src/mini/types" import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog" import { createFooterApiFixture } from "./fixture/footer-api" -import { createTuiResolvedConfig } from "./fixture/tui-runtime" function defer() { let resolve!: (value: T | PromiseLike) => void @@ -84,6 +83,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -180,6 +180,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -199,6 +200,7 @@ describe("run interactive runtime", () => { const lifecycleStarted = defer() const painted = defer() const events: FooterEvent[] = [] + const titles: Array = [] const api = footer(events) api.idle = () => painted.promise const event = api.event @@ -265,6 +267,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: (title) => titles.push(title), resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -282,6 +285,7 @@ describe("run interactive runtime", () => { history: [{ text: "previous prompt", parts: [] }], }) expect(events).toContainEqual({ type: "agent", agent: "review" }) + expect(titles).toEqual(["Resume"]) expect(events).toContainEqual({ type: "model", model: "Little Frank · OpenAI · high", @@ -344,6 +348,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: async (input) => { closedTitle = input.sessionTitle @@ -423,6 +428,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 371611972ece..18bbc0b08c75 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -200,6 +200,31 @@ afterEach(() => { }) describe("V2 mini transport", () => { + test("reports session title changes", async () => { + const events = feed() + events.push(connected()) + const titles: string[] = [] + const transport = await createSessionTransport({ + sdk: sdk({ streams: [events] }), + sessionID: "ses_1", + thinking: false, + footer: footer().api, + onSessionTitle: (title) => titles.push(title), + }) + + events.push({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", title: "Greeting" }, + }) + + while (titles.length === 0) await Bun.sleep(0) + expect(titles).toEqual(["Greeting"]) + await transport.close() + }) + test("formats footer usage with compact tokens and context percentage", async () => { const events = feed() events.push(connected()) diff --git a/packages/tui/test/mini/tool.test.ts b/packages/tui/test/mini/tool.test.ts index 4ebd16c8168a..86c40d1492a2 100644 --- a/packages/tui/test/mini/tool.test.ts +++ b/packages/tui/test/mini/tool.test.ts @@ -33,7 +33,7 @@ describe("Mini tool presentation", () => { type: "update", filePath: "/tmp/project/src/a.ts", relativePath: "src/a.ts", - diff: "@@ -1 +1 @@\n-old\n+new", + patch: "@@ -1 +1 @@\n-old\n+new", }, ], },