diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index e44f7e0b2d..e82b650a06 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,7 +1,9 @@ -import { Effect, Option } from "effect" +import { Context, Effect, FileSystem, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" +import { Config } from "../../config" +import { resolve } from "@opencode-ai/tui/config" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { @@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) => yield* Effect.promise(async () => validateMiniTerminal()) const serverURL = Option.getOrUndefined(input.server) const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) + const config = yield* Config.Service + const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" }) + const fileSystem = yield* FileSystem.FileSystem + const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem)) + const service = server.service yield* Effect.promise(() => runMini({ - server, + server: { + endpoint: server.endpoint, + reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined, + }, continue: input.continue, session: Option.getOrUndefined(input.session), fork: input.fork, @@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) => replay: input.replay, replayLimit: Option.getOrUndefined(input.replayLimit), demo: input.demo, + tuiConfig: resolved, }), ) }), diff --git a/packages/cli/src/mini-host.ts b/packages/cli/src/mini-host.ts index d8bad6dac2..05f8b5edd3 100644 --- a/packages/cli/src/mini-host.ts +++ b/packages/cli/src/mini-host.ts @@ -47,7 +47,8 @@ function preferences(statePath: string): MiniHost["preferences"] { return { async resolveVariant(model) { if (!model) return - return (await read()).variant?.[variantKey(model)] + const variant = (await read()).variant?.[variantKey(model)] + return variant === "default" ? undefined : variant }, async saveVariant(model, variant) { if (!model) return @@ -78,7 +79,7 @@ function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] { function createTrace( logPath: string, - diagnostics: Pick, + diagnostics: { pid: number; cwd: string; argv: string[] }, ): MiniHost["diagnostics"]["trace"] { if (!process.env.OPENCODE_DIRECT_TRACE) return const stamp = new Date() @@ -162,7 +163,7 @@ export async function usingInteractiveStdin( export function createMiniHost(input: { terminal: InteractiveStdin directory: string - paths?: MiniHost["paths"] + paths?: { home: string; state: string; log: string } }): MiniHost { const paths = input.paths ?? { home: Global.Path.home, @@ -175,7 +176,7 @@ export function createMiniHost(input: { argv: process.argv.slice(2), } return { - terminal: input.terminal, + terminal: { stdin: input.terminal.stdin }, platform: process.platform, stdout: { write(value) { @@ -191,7 +192,7 @@ export function createMiniHost(input: { return openEditor(options) }, }, - paths, + paths: { home: paths.home }, signals: { sigint: signal("SIGINT"), sigusr2: signal("SIGUSR2"), @@ -201,7 +202,6 @@ export function createMiniHost(input: { now: () => performance.now(), }, diagnostics: { - ...diagnostics, trace: createTrace(paths.log, diagnostics), }, preferences: preferences(paths.state), diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index 8f1af75c61..035ac485a0 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -1,14 +1,17 @@ -import { Service } from "@opencode-ai/client/effect/service" -import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +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 { ServerConnection } from "./services/server-connection" 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" export type MiniCommandInput = { - server: ServerConnection.Resolved + server: { + endpoint: Endpoint + reconnect?: (signal: AbortSignal) => Promise + } continue?: boolean session?: string fork?: boolean @@ -21,7 +24,6 @@ export type MiniCommandInput = { tuiConfig?: MiniFrontendInput["tuiConfig"] } -type Session = Awaited> type Model = MiniFrontendInput["model"] class MiniInputError extends Error {} @@ -33,42 +35,86 @@ export async function runMini(input: MiniCommandInput) { const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt) const frontendTask = import("@opencode-ai/tui/mini") const directory = localDirectory() - const sdk = OpenCode.make({ - baseUrl: input.server.endpoint.url, - headers: Service.headers(input.server.endpoint), - }) - const model = parseModel(input.model) - let agentTask: Promise | undefined - const resolveAgent = () => { - agentTask ??= validateAgent(sdk, directory, input.agent) - return agentTask - } - const resolveSession = async () => { - const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)]) - const readyModel = - model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) - if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) - const session = selected ?? (await createSession(sdk, directory, agent, model)) - return { id: session.id, title: session.title, resume: selected !== undefined } + const connection = createMiniConnection(input.server) + const sdk = connection.sdk + const requested = parseModel(input.model) + const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined + const prepare = prepareTarget(input.agent) + const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => { + const resolved = await resolveMiniTarget({ + sdk: initial, + reconnect: connection.reconnect, + signal, + resolve: (client) => + resolveSessionTarget({ + client, + location: { directory }, + continue: input.continue, + session: input.session, + fork: input.fork, + model: requested, + agent: input.agent, + prepare, + signal, + }).catch((error) => { + if (error instanceof Error && error.message === "Session not found") + throw new MiniInputError(error.message) + throw error + }), + }) + const target = resolved.value + return { + sdk: resolved.sdk, + sessionID: target.session.id, + sessionTitle: target.session.title, + location: target.location, + model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined, + variant: target.model?.variant, + agent: target.agent, + resume: target.resume, + } } const create = ( - _sdk: OpenCodeClient, - next: { agent: string | undefined; model: Model; variant: string | undefined }, - ) => createSession(sdk, directory, next.agent, next.model, next.variant) + client: OpenCodeClient, + next: { + location: { directory: string; workspaceID?: string } + agent: string | undefined + model: Model + variant: string | undefined + }, + signal?: AbortSignal, + ) => + resolveSessionTarget({ + client, + location: { directory: next.location.directory, workspace: next.location.workspaceID }, + agent: next.agent, + model: next.model + ? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } + : undefined, + prepare, + signal, + }).then((target) => ({ + sessionID: target.session.id, + sessionTitle: target.session.title, + location: target.location, + model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined, + variant: target.model?.variant, + agent: target.agent, + resume: false, + })) const frontend = await frontendTask return frontend.runMiniFrontend({ host: createMiniHost({ terminal, directory }), sdk, directory, - resolveAgent, - session: resolveSession, + target: resolveTarget, + reconnect: connection.reconnect, createSession: create, agent: input.agent, model, - variant: undefined, + variant: requested?.variant, files: [], initialInput, - thinking: true, replay: input.replay ?? true, replayLimit: input.replayLimit, demo: input.demo, @@ -83,6 +129,51 @@ export async function runMini(input: MiniCommandInput) { } } +/** @internal Exported for CLI boundary tests. */ +export function createMiniConnection(input: MiniCommandInput["server"]) { + const make = (endpoint: Endpoint) => + OpenCode.make({ + baseUrl: endpoint.url, + headers: Service.headers(endpoint), + }) + const reconnect = input.reconnect + return { + sdk: make(input.endpoint), + reconnect: reconnect + ? async (signal: AbortSignal) => { + const endpoint = await reconnect(signal) + return make(endpoint) + } + : undefined, + } +} + +/** @internal Exported for reconnect lifecycle tests. */ +export async function resolveMiniTarget(input: { + sdk: OpenCodeClient + reconnect?: (signal: AbortSignal) => Promise + signal: AbortSignal + resolve: (sdk: OpenCodeClient) => Promise +}) { + let sdk = input.sdk + while (true) { + try { + return { sdk, value: await input.resolve(sdk) } + } catch (error) { + if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error + while (true) { + try { + sdk = await input.reconnect(input.signal) + break + } catch (resolveError) { + if (input.signal.aborted) throw resolveError + await setTimeout(250, undefined, { signal: input.signal }) + } + } + } + } +} + export function validateMiniTerminal() { if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") } @@ -112,28 +203,63 @@ function localDirectory(): string { } } -function parseModel(value?: string): Model { - if (!value) return - const [providerID, ...rest] = value.split("/") - const modelID = rest.join("/") - if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model") - return { providerID, modelID } +function parseModel(value?: string) { + try { + return parseSessionTargetModel(value) + } catch { + throw new MiniInputError("--model must use the format provider/model[#variant]") + } } -async function validateAgent(sdk: OpenCodeClient, directory: string, name?: 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) { - agents = await sdk.agent.list({ location: { directory } }).catch(() => 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) + await setTimeout(25, undefined, { signal }).catch(() => {}) } + if (signal?.aborted) return if (!agents) { warning("failed to list agents. Falling back to default agent") return @@ -141,37 +267,6 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri warning(`agent "${name}" not found. Falling back to default agent`) } -async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) { - const selected = - preselected ?? - (input.session - ? await sdk.session.get({ sessionID: input.session }).catch(() => undefined) - : input.continue - ? await sdk.session - .list({ directory, parentID: null, limit: 1, order: "desc" }) - .then((result) => result.data[0]) - : undefined) - if (input.session && !selected) throw new MiniInputError("Session not found") - if (!selected) return - if (!input.fork) return selected - return sdk.session.fork({ sessionID: selected.id }) -} - -async function createSession( - sdk: OpenCodeClient, - directory: string, - agent: string | undefined, - model: Model, - variant?: string, -): Promise { - if (model) await waitForCatalogReady({ sdk, directory, model }) - return sdk.session.create({ - agent, - model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, - location: { directory }, - }) -} - function warning(message: string) { process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) } diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index c7fb502e66..8bada9c8d7 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -1,4 +1,11 @@ -import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { + EventSubscribeOutput, + JsonValue, + LLMToolContent, + LocationRef, + OpenCodeClient, + SessionMessageAssistantTool, +} from "@opencode-ai/client/promise" import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" import { readFile } from "node:fs/promises" @@ -19,6 +26,7 @@ type File = { type Input = { client: OpenCodeClient sessionID: string + location: LocationRef message: string files: File[] agent?: string @@ -30,8 +38,8 @@ type Input = { /** True when the client is attached to a shared server rather than an exclusive in-process one. */ attached: boolean compatibility?: "v1" - renderTool: (part: MiniToolPart) => Promise - renderToolError: (part: MiniToolPart) => Promise + renderTool: (part: SessionMessageAssistantTool) => Promise + renderToolError: (part: SessionMessageAssistantTool) => Promise } type StartedPart = { @@ -42,9 +50,12 @@ type StartedPart = { type ToolState = StartedPart & { assistantMessageID: string tool: string - input: Record + input: Record raw?: string provider?: unknown + providerState?: SessionMessageAssistantTool["providerState"] + structured: Record + content: LLMToolContent[] } type V2Event = EventSubscribeOutput @@ -67,7 +78,6 @@ export async function runNonInteractivePrompt(input: Input) { let submitted = false let promoted = false let emittedError = false - let questionRejected = false let permissionRejected = false let formCancelled = false let interrupted = false @@ -126,14 +136,16 @@ export async function runNonInteractivePrompt(input: Input) { } } - const rejectQuestion = async (request: { id: string }) => { - questionRejected = true - await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) - } - const cancelForm = async (request: Pick) => { + try { + await input.client.form.cancel( + { sessionID: request.sessionID, formID: request.id }, + ...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined), + ) + } catch (error) { + if (!formAlreadySettled(error)) throw error + } formCancelled = true - await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {}) } const consume = async () => { @@ -152,15 +164,13 @@ export async function runNonInteractivePrompt(input: Input) { await replyPermission(event.data) continue } - if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) { - await rejectQuestion(event.data) - continue - } if ( event.type === "form.created" && submitted && (event.data.form.sessionID === input.sessionID || - (!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID)) + (!input.attached && + event.data.form.sessionID === GLOBAL_FORM_SESSION_ID && + sameLocation(event.location, input.location))) ) { await cancelForm(event.data.form) continue @@ -177,7 +187,7 @@ export async function runNonInteractivePrompt(input: Input) { if ( event.type === "session.execution.interrupted" && event.data.reason === "user" && - (interrupted || permissionRejected || questionRejected || formCancelled) + (interrupted || permissionRejected || formCancelled) ) { return } @@ -260,24 +270,32 @@ export async function runNonInteractivePrompt(input: Input) { if (event.type === "session.tool.input.started") { flushStep() - tools.set(event.data.callID, { + tools.set(toolKey(event.data.assistantMessageID, event.data.callID), { id: partID(event.id), timestamp: time, assistantMessageID: event.data.assistantMessageID, tool: event.data.name, input: {}, + structured: {}, + content: [], }) continue } if (event.type === "session.tool.input.ended") { - const current = tools.get(event.data.callID) + const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID)) if (current) current.raw = event.data.text continue } + if (event.type === "session.tool.input.delta") { + const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID)) + if (current) current.raw = (current.raw ?? "") + event.data.delta + continue + } if (event.type === "session.tool.called") { flushStep() - const current = tools.get(event.data.callID) - tools.set(event.data.callID, { + const key = toolKey(event.data.assistantMessageID, event.data.callID) + const current = tools.get(key) + tools.set(key, { id: current?.id ?? partID(event.id), timestamp: current?.timestamp ?? time, assistantMessageID: event.data.assistantMessageID, @@ -285,11 +303,39 @@ export async function runNonInteractivePrompt(input: Input) { input: event.data.input, raw: current?.raw, provider: { executed: event.data.executed, state: event.data.state }, + providerState: event.data.state, + structured: {}, + content: [], }) continue } + if (event.type === "session.tool.progress") { + const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID)) + if (current) { + current.structured = event.data.structured + current.content = event.data.content + } + continue + } if (event.type === "session.tool.success") { - const current = tools.get(event.data.callID) ?? fallbackTool(event) + const key = toolKey(event.data.assistantMessageID, event.data.callID) + const current = tools.get(key) ?? fallbackTool(event) + const tool: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: current.tool, + executed: event.data.executed, + providerState: current.providerState, + providerResultState: event.data.resultState, + state: { + status: "completed", + input: current.input, + structured: event.data.structured, + content: event.data.content, + result: event.data.result, + }, + time: { created: current.timestamp, ran: current.timestamp, completed: time }, + } const part: MiniToolPart = { id: current.id, sessionID: input.sessionID, @@ -313,13 +359,31 @@ export async function runNonInteractivePrompt(input: Input) { time: { start: current.timestamp, end: time }, }, } - tools.delete(event.data.callID) - if (!emit("tool_use", time, { part })) await input.renderTool(part) + tools.delete(key) + if (!emit("tool_use", time, { part })) await input.renderTool(tool) continue } if (event.type === "session.tool.failed") { - const current = tools.get(event.data.callID) ?? fallbackTool(event) + const key = toolKey(event.data.assistantMessageID, event.data.callID) + const current = tools.get(key) ?? fallbackTool(event) const error = event.data.error.message + const tool: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: current.tool, + executed: event.data.executed, + providerState: current.providerState, + providerResultState: event.data.resultState, + state: { + status: "error", + input: current.input, + structured: current.structured, + content: current.content, + error: event.data.error, + result: event.data.result, + }, + time: { created: current.timestamp, ran: current.timestamp, completed: time }, + } const part: MiniToolPart = { id: current.id, sessionID: input.sessionID, @@ -340,10 +404,21 @@ export async function runNonInteractivePrompt(input: Input) { time: { start: current.timestamp, end: time }, }, } - tools.delete(event.data.callID) - if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue + tools.delete(key) + if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue if (!emit("tool_use", time, { part })) { - await input.renderToolError(part) + if (toolOutputText(current.tool, current.content).trim()) + await input.renderTool({ + ...tool, + state: { + status: "completed", + input: current.input, + structured: current.structured, + content: current.content, + result: event.data.result, + }, + }) + await input.renderToolError(tool) UI.error(error) } continue @@ -373,7 +448,7 @@ export async function runNonInteractivePrompt(input: Input) { v1InvalidOutput = true continue } - if (interrupted || permissionRejected || questionRejected || formCancelled) continue + if (interrupted || permissionRejected || formCancelled) continue flushStep() emittedError = true process.exitCode = 1 @@ -381,13 +456,9 @@ export async function runNonInteractivePrompt(input: Input) { continue } if (event.type === "session.execution.failed") { - if ( - input.compatibility === "v1" && - (v1InvalidOutput || permissionRejected || questionRejected || formCancelled) - ) - return + if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return flushStep() - if (!emittedError && !questionRejected && !formCancelled) { + if (!emittedError && !formCancelled) { emittedError = true process.exitCode = 1 if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) @@ -395,7 +466,7 @@ export async function runNonInteractivePrompt(input: Input) { return } if (event.type === "session.execution.interrupted") { - if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return + if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return if (event.data.reason === "user" && interrupted) process.exitCode = 130 if (event.data.reason !== "user" && !emittedError) { emittedError = true @@ -470,19 +541,23 @@ export async function runNonInteractivePrompt(input: Input) { if (!response) return if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) - const [permissions, questions, forms] = await Promise.all([ + const [permissions, forms, globals] = await Promise.all([ input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined), - input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined), - Promise.all( - (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) => - input.client.form.list({ sessionID }).catch(() => undefined), - ), - ), + input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined), + input.attached + ? Promise.resolve(undefined) + : input.client.form.request + .list({ + location: { directory: input.location.directory, workspace: input.location.workspaceID }, + }) + .catch(() => undefined), ]) await Promise.all([ ...(permissions ?? []).map(replyPermission), - ...(questions ?? []).map(rejectQuestion), - ...forms.flatMap((response) => response ?? []).map(cancelForm), + ...(forms ?? []).map(cancelForm), + ...(globals && sameLocation(globals.location, input.location) + ? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm) + : []), ]) await completed } finally { @@ -492,10 +567,34 @@ export async function runNonInteractivePrompt(input: Input) { } } +function sameLocation(left: LocationRef | undefined, right: LocationRef) { + return !!left && left.directory === right.directory && left.workspaceID === right.workspaceID +} + +function formRequestOptions(location: LocationRef | undefined): [] | [{ headers: Record }] { + if (!location) return [] + return [ + { + headers: { + "x-opencode-directory": encodeURIComponent(location.directory), + ...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}), + }, + }, + ] +} + +function formAlreadySettled(error: unknown) { + return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError" +} + function partID(eventID: string) { return `prt_${eventID.replace(/^evt_/, "")}` } +function toolKey(messageID: string, callID: string) { + return `${messageID}\u0000${callID}` +} + function fallbackTool(event: { id: string created: number @@ -507,6 +606,8 @@ function fallbackTool(event: { assistantMessageID: event.data.assistantMessageID, tool: "tool", input: {}, + structured: {}, + content: [], } } diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 6f72fa0b20..6ebec1b3c4 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -1,13 +1,13 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" -import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Model } from "@opencode-ai/schema/model" 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 { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool" +import { parseSessionTargetModel, resolveSessionTarget } from "../session-target" +import { toolInlineInfo } from "@opencode-ai/tui/mini/tool" import { runNonInteractivePrompt } from "./noninteractive" import { UI } from "./ui" @@ -47,6 +47,15 @@ type ExecutionOptions = { compatibility?: "v1" } +class RunTargetError extends Error { + constructor( + message: string, + readonly sessionID?: string, + ) { + super(message) + } +} + const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 export function runNonInteractive(input: RunCommandInput) { @@ -72,50 +81,74 @@ async function run(input: RunCommandInput, options: ExecutionOptions) { async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) { const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const requestedDirectory = prepared.directory ?? (await client.location.get()).directory - if (!requestedDirectory) fail("Failed to resolve server directory") - const session = await selectSession(client, requestedDirectory, input) - const cwd = session?.location.directory ?? requestedDirectory - const workspace = session?.location.workspaceID const explicit = parseRunModel(input.model) - const explicitModel = explicit?.model - const variant = options.variant ?? explicit?.variant - const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined - const defaultModel = - !explicitModel && !sessionModel - ? await client.model - .default({ location: { directory: cwd, workspace } }) - .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) - : undefined - const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) - if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id) - if (model) { - await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) - const available = await client.model.list({ location: { directory: cwd, workspace } }) - if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID)) - return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) - } - const agent = await validateAgent(client, cwd, input.agent) - const selected = - session ?? - (await client.session.create({ - agent, - model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, - location: { directory: cwd }, - })) - if (!session && input.title !== undefined) { + const target = await resolveSessionTarget({ + client, + location: prepared.directory ? { directory: prepared.directory } : undefined, + continue: input.continue, + session: input.session, + fork: input.fork, + model: explicit + ? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant } + : undefined, + agent: input.agent, + prepare: async (next) => { + const selected = + next.model ?? + (await client.model + .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) + .then((result) => result.data)) + const model = selected + ? { + providerID: selected.providerID, + id: selected.id, + variant: options.variant ?? ("variant" in selected ? selected.variant : undefined), + } + : 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, + } + }, + }).catch((error) => { + if (!(error instanceof RunTargetError)) throw error + reportRunError(input, error.message, error.sessionID) + return undefined + }) + if (!target) return + const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined + const variant = target.model?.variant + if (!target.resume && input.title !== undefined) { await client.session.rename({ - sessionID: selected.id, + sessionID: target.session.id, title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), }) } await runNonInteractivePrompt({ client, - sessionID: selected.id, + sessionID: target.session.id, + location: target.location, message: prepared.message, files: prepared.files, - agent, + agent: target.agent, model, variant, thinking: input.thinking ?? false, @@ -123,9 +156,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End auto: input.auto ?? false, attached: options.attached ?? true, compatibility: options.compatibility, - renderTool, - renderToolError, - }).catch((error) => reportRunError(input, errorMessage(error), selected.id)) + renderTool: (part) => renderTool(part, target.location.directory), + renderToolError: (part) => renderToolError(part, target.location.directory), + }).catch((error) => reportRunError(input, errorMessage(error), target.session.id)) } export function mergeInput(message: string | undefined, piped: string | undefined) { @@ -134,17 +167,6 @@ export function mergeInput(message: string | undefined, piped: string | undefine return message + "\n" + piped } -export function pickRunModel( - explicit: { providerID: string; modelID: string } | undefined, - variant: string | undefined, - session: { providerID: string; modelID: string } | undefined, - fallback: { providerID: string; modelID: string } | undefined, -) { - if (explicit) return explicit - if (!variant) return - return session ?? fallback -} - function formatMessage(message: string[]) { const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ") return value || undefined @@ -160,18 +182,18 @@ function localDirectory(root: string) { } export function parseRunModel(value?: string) { - if (!value) return - const ref = Model.Ref.parse(value) + const ref = parseSessionTargetModel(value) + if (!ref) return return { model: { providerID: ref.providerID, modelID: ref.id }, variant: ref.variant, } } -async function validateAgent(client: OpenCodeClient, directory: string, name?: string) { +async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) { if (!name) return const agents = await client.agent - .list({ location: { directory } }) + .list({ location: { directory, workspace } }) .then((result) => result.data) .catch(() => undefined) if (!agents) { @@ -190,19 +212,6 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s return name } -async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) { - const selected = input.session - ? await client.session.get({ sessionID: input.session }).catch(() => undefined) - : input.continue - ? await client.session - .list({ directory, parentID: null, limit: 1, order: "desc" }) - .then((result) => result.data[0]) - : undefined - if (input.session && !selected) fail("Session not found") - if (!selected || !input.fork) return selected - return client.session.fork({ sessionID: selected.id }) -} - 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}`)) @@ -244,8 +253,8 @@ function isBinaryContent(bytes: Uint8Array) { return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 } -async function renderTool(part: MiniToolPart) { - const info = toolInlineInfo(part) +async function renderTool(part: SessionMessageAssistantTool, directory: string) { + const info = toolInlineInfo(part, directory) if (info.mode === "block") { UI.empty() UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title) @@ -260,8 +269,8 @@ async function renderTool(part: MiniToolPart) { ) } -async function renderToolError(part: MiniToolPart) { - const info = toolInlineInfo(part) +async function renderToolError(part: SessionMessageAssistantTool, directory: string) { + const info = toolInlineInfo(part, directory) UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) } diff --git a/packages/cli/src/services/catalog.ts b/packages/cli/src/services/catalog.ts index 71ab83e354..ddf8252c31 100644 --- a/packages/cli/src/services/catalog.ts +++ b/packages/cli/src/services/catalog.ts @@ -1,4 +1,4 @@ -import type { OpenCodeClient } from "@opencode-ai/client/promise" +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 @@ -9,14 +9,35 @@ export async function waitForCatalogReady(input: { workspace?: string model: { providerID: string; modelID: string } timeoutMs?: number + signal?: AbortSignal }) { const deadline = Date.now() + (input.timeoutMs ?? 5_000) - while (Date.now() < deadline) { + while (Date.now() < deadline && !input.signal?.aborted) { const models = await input.sdk.model - .list({ location: { directory: input.directory, workspace: input.workspace } }) + .list( + { location: { directory: input.directory, workspace: input.workspace } }, + { signal: input.signal }, + ) .then((result) => result.data) - .catch(() => undefined) + .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 new Promise((resolve) => setTimeout(resolve, 25)) + 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/src/session-target.ts b/packages/cli/src/session-target.ts new file mode 100644 index 0000000000..40412473ac --- /dev/null +++ b/packages/cli/src/session-target.ts @@ -0,0 +1,166 @@ +import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client/promise" +import { Model } from "@opencode-ai/schema/model" + +const SESSION_PAGE_LIMIT = 50 + +export type SessionTarget = { + session: SessionInfo + location: LocationGetOutput + model: ModelRef | undefined + agent: string | undefined + resume: boolean +} + +export type SessionTargetPreparation = (input: { + client: OpenCodeClient + location: LocationGetOutput + session: SessionInfo | undefined + model: ModelRef | undefined + agent: string | undefined + signal?: AbortSignal +}) => Promise<{ model: ModelRef | undefined; agent: string | undefined }> + +export class SessionTargetMutationError extends Error { + override readonly name = "SessionTargetMutationError" + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause }) + } +} + +export async function resolveSessionTarget(input: { + client: OpenCodeClient + location?: { directory?: string; workspace?: string } + continue?: boolean + session?: string + fork?: boolean + model?: ModelRef + agent?: string + prepare: SessionTargetPreparation + signal?: AbortSignal +}): Promise { + const selection = await selectSession(input) + const selected = selection.session + const location = + selection.location ?? + (await resolveLocation( + input.client, + selected ? { directory: selected.location.directory, workspace: selected.location.workspaceID } : input.location, + input.signal, + )) + const prepared = await input.prepare({ + client: input.client, + location, + session: selected, + model: input.model ?? selected?.model, + agent: input.agent ?? selected?.agent, + signal: input.signal, + }) + const session = + selected ?? + (await input.client.session + .create( + { + agent: prepared.agent, + model: prepared.model, + location: { directory: location.directory, workspaceID: location.workspaceID }, + }, + ...requestOptions(input.signal), + ) + .catch((error) => { + throw new SessionTargetMutationError(error) + })) + return { + session, + location, + model: prepared.model, + agent: prepared.agent, + resume: selected !== undefined, + } +} + +export function parseSessionTargetModel(value?: string): ModelRef | undefined { + if (!value) return + const model = Model.Ref.parse(value) + return { providerID: model.providerID, id: model.id, variant: model.variant } +} + +async function selectSession(input: { + client: OpenCodeClient + location?: { directory?: string; workspace?: string } + continue?: boolean + session?: string + fork?: boolean + signal?: AbortSignal +}) { + const explicit = input.session + ? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => { + if (error && typeof error === "object" && Reflect.get(error, "_tag") === "SessionNotFoundError") + return undefined + throw error + }) + : undefined + if (input.session && !explicit) throw new Error("Session not found") + if (explicit) + return { + session: input.fork + ? await input.client.session + .fork({ sessionID: explicit.id }, ...requestOptions(input.signal)) + .catch((error) => { + throw new SessionTargetMutationError(error) + }) + : explicit, + } + if (!input.continue) return { session: undefined } + + const location = await resolveLocation(input.client, input.location, input.signal) + const selected = await latestSession(input.client, location, undefined, input.signal) + if (!selected) return { session: undefined, location } + return { + session: input.fork + ? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => { + throw new SessionTargetMutationError(error) + }) + : selected, + } +} + +async function latestSession( + client: OpenCodeClient, + location: LocationGetOutput, + cursor?: string, + signal?: AbortSignal, +): Promise { + const page = await client.session.list( + { + directory: location.directory, + workspace: location.workspaceID, + parentID: null, + limit: SESSION_PAGE_LIMIT, + order: "desc", + ...(cursor ? { cursor } : {}), + }, + ...requestOptions(signal), + ) + const selected = page.data.find( + (session) => + session.location.directory === location.directory && session.location.workspaceID === location.workspaceID, + ) + if (selected) return selected + if (!page.cursor.next || page.data.length === 0) return + return latestSession(client, location, page.cursor.next, signal) +} + +function resolveLocation( + client: OpenCodeClient, + location?: { directory?: string; workspace?: string }, + signal?: AbortSignal, +) { + if (!location && !signal) return client.location.get() + if (!location) return client.location.get(undefined, { signal }) + return client.location.get({ location }, ...requestOptions(signal)) +} + +function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] { + return signal ? [{ signal }] : [] +} diff --git a/packages/cli/test/drive/mini-interactive.drive.mjs b/packages/cli/test/drive/mini-interactive.drive.mjs index c231f77539..18f28b3115 100644 --- a/packages/cli/test/drive/mini-interactive.drive.mjs +++ b/packages/cli/test/drive/mini-interactive.drive.mjs @@ -91,6 +91,31 @@ export default defineScript({ 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, + id: "mini-question", + name: "question", + input: { + questions: [ + { + header: "Drive form", + question: "Choose the Mini Form answer", + options: [{ label: "Accepted", description: "Continue the run" }], + multiple: false, + }, + ], + }, + }), + llm.finish("tool-calls"), + ) + llm.queue(llm.text("drive mini form complete")) + await tmux(["send-keys", "-t", session, "-l", "exercise the form"]) + await tmux(["send-keys", "-H", "-t", session, "0d"]) + await waitForPane(session, "Choose the Mini Form answer", 20_000) + await tmux(["send-keys", "-H", "-t", session, "0d"]) + await waitForPane(session, "drive mini form complete", 20_000) + llm.queue( llm.toolCall({ index: 0, diff --git a/packages/cli/test/mini-host.test.ts b/packages/cli/test/mini-host.test.ts index fd890e2623..b1bcc53117 100644 --- a/packages/cli/test/mini-host.test.ts +++ b/packages/cli/test/mini-host.test.ts @@ -145,11 +145,11 @@ describe("Mini CLI host", () => { expect(process.listenerCount("SIGUSR2")).toBe(sigusr2) }) - test("passes paths, platform, timing, and diagnostic context", async () => { + test("passes frontend host capabilities", async () => { const directory = await root() const input = host({ stdin: stream(true), cleanup() {} }, directory) - expect(input.paths).toEqual({ home: directory, state: directory, log: directory }) + expect(input.paths).toEqual({ home: directory }) expect(input.platform).toBe(process.platform) expect(typeof input.files.readText).toBe("function") const file = path.join(directory, "attachment.txt") @@ -157,7 +157,6 @@ describe("Mini CLI host", () => { expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents") expect(typeof input.startup.showTiming).toBe("boolean") expect(typeof input.startup.now()).toBe("number") - expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory }) }) test("merges, clears, and repairs persisted model variants", async () => { @@ -186,6 +185,9 @@ describe("Mini CLI host", () => { variant: { "openai/gpt-4.1": "low" }, }) + await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } })) + expect(await input.preferences.resolveVariant(model)).toBeUndefined() + await Bun.write(file, "{") await input.preferences.saveVariant(model, "high") expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } }) diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 59fd6ae174..357506d6c2 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test" +import { ClientError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "node:path" -import { mergeInput as mergeInteractiveInput } from "../src/mini" -import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run" +import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini" +import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run" +import { parseSessionTargetModel } from "../src/session-target" async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { @@ -24,26 +26,91 @@ describe("mini command", () => { expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag") }) + test("constructs a fresh authenticated client for a replacement endpoint", async () => { + const authorization: Array = [] + const initial = Bun.serve({ + port: 0, + fetch() { + return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + }, + }) + const replacement = Bun.serve({ + port: 0, + fetch(request) { + authorization.push(request.headers.get("authorization")) + return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + }, + }) + const controller = new AbortController() + let signal: AbortSignal | undefined + + try { + const connection = createMiniConnection({ + endpoint: { url: initial.url.toString() }, + reconnect: async (next) => { + signal = next + return { + url: replacement.url.toString(), + auth: { type: "basic", username: "replacement", password: "secret" }, + } + }, + }) + const client = await connection.reconnect?.(controller.signal) + if (!client) throw new Error("Expected a replacement client") + await client.health.get() + + expect(client).not.toBe(connection.sdk) + expect(signal).toBe(controller.signal) + expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`]) + expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined() + } finally { + initial.stop(true) + replacement.stop(true) + } + }) + + test("re-resolves a managed target when the endpoint moves before transport construction", async () => { + const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" }) + const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" }) + const controller = new AbortController() + const seen: (typeof initial)[] = [] + let reconnects = 0 + + const result = await resolveMiniTarget({ + sdk: initial, + reconnect: async (signal) => { + expect(signal).toBe(controller.signal) + reconnects++ + if (reconnects === 1) throw new Error("service still moving") + return replacement + }, + signal: controller.signal, + resolve: async (sdk) => { + seen.push(sdk) + if (sdk === initial) throw new ClientError("Transport") + return "ses-replacement" + }, + }) + + expect(seen).toEqual([initial, replacement]) + expect(reconnects).toBe(2) + expect(result).toEqual({ sdk: replacement, value: "ses-replacement" }) + }) + test("merges non-interactive argument and stdin input", () => { expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin") expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin") }) - test("applies a variant to a resumed session's model", () => { - expect( - pickRunModel( - undefined, - "high", - { providerID: "session-provider", modelID: "session-model" }, - { providerID: "default-provider", modelID: "default-model" }, - ), - ).toEqual({ providerID: "session-provider", modelID: "session-model" }) - }) - test("parses model variants from the model reference", () => { expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe( JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }), ) + expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({ + providerID: "openrouter", + id: "openai/gpt-5", + variant: "high", + }) }) test("is registered in the preview CLI", async () => { diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index 49dafabe8c..f77aa7a679 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise" +import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/client/promise" import { runNonInteractivePrompt } from "../../src/run/noninteractive" type V2Event = EventSubscribeOutput type FormInfo = Extract["data"]["form"] +const location = { directory: "/work tree", workspaceID: "wrk_1" } function ok(data: T) { return Promise.resolve(data) @@ -18,8 +19,8 @@ function form(id: string, sessionID: string): FormInfo { } } -function formCreated(info: FormInfo): V2Event { - return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } } +function formCreated(info: FormInfo, eventLocation = location): V2Event { + return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } } } function prompted(inputID: string): V2Event { @@ -92,6 +93,64 @@ function executionFailed(message: string): V2Event { } } +function failedTool(inputID: string): V2Event[] { + return [ + prompted(inputID), + { + id: "evt_failed_tool_input", + created: 1, + type: "session.tool.input.started", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_tool", + callID: "call_failed_tool", + name: "shell", + }, + }, + { + id: "evt_failed_tool_called", + created: 2, + type: "session.tool.called", + durable: { aggregateID: "ses_1", seq: 2, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_tool", + callID: "call_failed_tool", + input: { command: "printf partial && false" }, + executed: true, + }, + }, + { + id: "evt_failed_tool_progress", + created: 3, + type: "session.tool.progress", + durable: { aggregateID: "ses_1", seq: 3, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_tool", + callID: "call_failed_tool", + structured: { checkpoint: 1 }, + content: [{ type: "text", text: "partial output" }], + }, + }, + { + id: "evt_failed_tool_terminal", + created: 4, + type: "session.tool.failed", + durable: { aggregateID: "ses_1", seq: 4, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_failed_tool", + callID: "call_failed_tool", + error: { type: "unknown", message: "tool failed" }, + executed: true, + }, + }, + settled(), + ] +} + // Runs one non-interactive prompt against a mocked SDK. `turn` produces the // live events the prompt admission triggers, keyed by the generated message ID. async function run(input: { @@ -100,6 +159,9 @@ async function run(input: { attached?: boolean format?: "default" | "json" compatibility?: "v1" + cancel?: (input: { sessionID: string; formID: string }) => Promise + renderTool?: (part: SessionMessageAssistantTool) => Promise + renderToolError?: (part: SessionMessageAssistantTool) => Promise }) { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] @@ -119,10 +181,18 @@ async function run(input: { spyOn(sdk.event, "subscribe").mockImplementation(() => stream) spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) + spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never) spyOn(sdk.form, "list").mockImplementation( (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, ) - spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) + spyOn(sdk.form.request, "list").mockImplementation( + () => + ok({ + location: { ...location, project: { id: "proj_1", directory: location.directory } }, + data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [], + }) as never, + ) + spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never) spyOn(sdk.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" values.push(...input.turn(messageID)) @@ -133,6 +203,7 @@ async function run(input: { await runNonInteractivePrompt({ client: sdk, sessionID: "ses_1", + location, message: "hello", files: [], thinking: false, @@ -140,8 +211,8 @@ async function run(input: { auto: false, attached: input.attached ?? false, compatibility: input.compatibility, - renderTool: () => Promise.resolve(), - renderToolError: () => Promise.resolve(), + renderTool: input.renderTool ?? (() => Promise.resolve()), + renderToolError: input.renderToolError ?? (() => Promise.resolve()), }) return sdk } @@ -180,9 +251,20 @@ describe("runNonInteractivePrompt", () => { // which must not leave the consume loop waiting forever. turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")], }) - expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) + const globalOptions = { + headers: { + "x-opencode-directory": "%2Fwork%20tree", + "x-opencode-workspace": "wrk_1", + }, + } + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) - expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions) + expect(sdk.form.request.list).toHaveBeenCalledWith({ + location: { directory: "/work tree", workspace: "wrk_1" }, + }) + expect(sdk.question.list).not.toHaveBeenCalled() + expect(sdk.question.reject).not.toHaveBeenCalled() }) test("attach mode cancels only session-owned forms", async () => { @@ -192,9 +274,12 @@ describe("runNonInteractivePrompt", () => { turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()], }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) - expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) - expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) - expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + expect(sdk.form.request.list).not.toHaveBeenCalled() + expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything()) + expect(sdk.form.cancel).not.toHaveBeenCalledWith( + { sessionID: "global", formID: "frm_pending_global" }, + expect.anything(), + ) }) test("V1 JSON output flushes step_start before an unrelated step failure", async () => { @@ -250,4 +335,69 @@ describe("runNonInteractivePrompt", () => { expect(output).toEqual({ stdout: "", stderr: "" }) }) + + test("renders native failed tool output before the terminal error", async () => { + const rendered: SessionMessageAssistantTool[] = [] + const failed: SessionMessageAssistantTool[] = [] + await capture({ + turn: failedTool, + renderTool: (part) => { + rendered.push(part) + return Promise.resolve() + }, + renderToolError: (part) => { + failed.push(part) + return Promise.resolve() + }, + }) + + expect(rendered).toMatchObject([ + { + id: "call_failed_tool", + state: { + status: "completed", + structured: { checkpoint: 1 }, + content: [{ type: "text", text: "partial output" }], + }, + }, + ]) + expect(failed).toMatchObject([ + { + id: "call_failed_tool", + state: { + status: "error", + structured: { checkpoint: 1 }, + content: [{ type: "text", text: "partial output" }], + error: { message: "tool failed" }, + }, + }, + ]) + }) + + test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => { + const output = await capture({ compatibility: "v1", format: "json", turn: failedTool }) + const events = output.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: "tool_use", + part: { + type: "tool", + callID: "call_failed_tool", + tool: "shell", + state: { + status: "error", + input: { command: "printf partial && false" }, + error: "tool failed", + }, + }, + }) + expect(events[0].part.state.output).toBeUndefined() + expect(events[0].part.state.metadata.structured).toBeUndefined() + expect(events[0].part.state.metadata.content).toBeUndefined() + expect(output.stderr).toBe("") + }) }) diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts new file mode 100644 index 0000000000..fef8faf72a --- /dev/null +++ b/packages/cli/test/session-target.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise" +import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target" + +function location(directory: string, workspaceID?: string): LocationGetOutput { + return { directory, workspaceID, project: { id: "project", directory } } +} + +function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo { + return { + id, + projectID: "project", + title: id, + location: { directory, workspaceID }, + model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } +} + +const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({ + model: input.model, + agent: input.agent, +}) + +afterEach(() => mock.restore()) + +describe("session target resolver", () => { + test("adopts an explicit Session location and model", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" }) + spyOn(client.session, "get").mockResolvedValue(selected) + spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1")) + + const target = await resolveSessionTarget({ client, session: selected.id, prepare }) + expect(target).toMatchObject({ + session: { id: "ses_resume" }, + location: { directory: "/session", workspaceID: "work_1" }, + model: { providerID: "openai", id: "gpt-5" }, + resume: true, + }) + }) + + test("paginates to continue the exact implicit workspace", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.location, "get").mockResolvedValue(location("/project")) + const explicit = Array.from({ length: 50 }, (_, index) => session(`ses_${index}`, "/project", `work_${index}`)) + const list = spyOn(client.session, "list") + .mockResolvedValueOnce({ data: explicit, cursor: { next: "page_2" } }) + .mockResolvedValueOnce({ data: [session("ses_implicit", "/project")], cursor: {} }) + + const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare }) + expect(list).toHaveBeenCalledTimes(2) + expect(target.session.id).toBe("ses_implicit") + }) + + test("prepares a fresh Session at the server Location before creation", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const order: string[] = [] + spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) + const create = spyOn(client.session, "create").mockImplementation(async (input) => { + order.push("create") + expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } }) + return session("ses_fresh", "/server", "work_1") + }) + + await resolveSessionTarget({ + client, + agent: "requested", + prepare: async (input) => { + order.push("prepare") + expect(input.location.workspaceID).toBe("work_1") + return { model: input.model, agent: "prepared" } + }, + }) + expect(create).toHaveBeenCalledTimes(1) + expect(order).toEqual(["prepare", "create"]) + }) + + test("does not retry an ambiguous Session creation", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.location, "get").mockResolvedValue(location("/project")) + spyOn(client.session, "create").mockRejectedValue(new Error("connection closed after create")) + await expect(resolveSessionTarget({ client, prepare })).rejects.toBeInstanceOf(SessionTargetMutationError) + }) +}) diff --git a/packages/tui/package.json b/packages/tui/package.json index e86a9b461b..6f82926512 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -23,6 +23,7 @@ "./context/runtime": "./src/context/runtime.tsx", "./context/client": "./src/context/client.tsx", "./context/theme": "./src/context/theme.tsx", + "./theme/discovery": "./src/theme/discovery.ts", "./context/editor": "./src/context/editor.ts", "./context/clipboard": "./src/context/clipboard.tsx", "./attention": "./src/attention.ts", diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 836277261c..c9f5f01a6e 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -17,6 +17,7 @@ import { type ThemeJson, } from "../theme" import { generateSystem, terminalMode } from "../theme/system" +import { discoverThemes, themeDirectories } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" import { resolveThemeFile } from "../theme/v2/resolve" import { migrateV1 } from "../theme/v2/v1-migrate" @@ -25,9 +26,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useConfig } from "../config" import { Global } from "@opencode-ai/core/global" -import { Glob } from "@opencode-ai/core/util/glob" -import { readFile } from "node:fs/promises" -import path from "node:path" import { DevTools } from "../devtools" const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) @@ -39,12 +37,7 @@ export type ThemeSource = Readonly<{ const themeSource: ThemeSource = { async discover() { - const directories = [Global.Path.config] - for (let current = process.cwd(); ; current = path.dirname(current)) { - directories.push(path.join(current, ".opencode")) - if (path.dirname(current) === current) break - } - return discoverThemes(directories) + return discoverThemes(themeDirectories(Global.Path.config, process.cwd())) }, subscribeRefresh(refresh) { process.on("SIGUSR2", refresh) @@ -52,16 +45,7 @@ const themeSource: ThemeSource = { }, } -export async function discoverThemes(directories: string[]) { - const result: Record = {} - for (const directory of directories) { - const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true }) - for (const file of files) { - result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown - } - } - return result -} +export { discoverThemes } from "../theme/discovery" export { DEFAULT_THEMES, diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index f5b18252c1..d9eb4cb874 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -1,6 +1,7 @@ import type { AgentListOutput, CommandListOutput, + LocationRef, ModelListOutput, OpenCodeClient, ProviderListOutput, @@ -14,45 +15,38 @@ type CurrentSkill = SkillListOutput["data"][number] type CurrentProvider = ProviderListOutput["data"][number] type CurrentModel = ModelListOutput["data"][number] -function location(directory: string, workspace?: string) { +function location(ref: LocationRef) { return { location: { - directory, - workspace, + directory: ref.directory, + workspace: ref.workspaceID, }, } } function defaultCost(model: CurrentModel) { const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0] - if (!picked) { - return undefined - } - - return { - ...picked, - input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input, - } + if (!picked) return + return model.cost.every((cost) => cost.input === 0) ? 0 : picked.input } -export function runAgent(input: CurrentAgent): RunAgent { +function runAgent(input: CurrentAgent): RunAgent { return { id: input.id, name: input.name, - description: input.description, mode: input.mode, hidden: input.hidden, } } -export function runCommand(input: CurrentCommand): RunCommand { +function runCommand(input: CurrentCommand): RunCommand { return { name: input.name, description: input.description, } } -export function runSkill(input: CurrentSkill): RunCommand { +function runSkill(input: CurrentSkill): RunCommand { return { name: input.id, description: input.description, @@ -77,13 +71,10 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ name: model.providerID, models: {}, } + const cost = defaultCost(model) provider.models[model.id] = { - id: model.id, - providerID: model.providerID, name: model.name, - capabilities: model.capabilities, - cost: defaultCost(model), - limit: model.limit, + cost: cost === undefined ? undefined : { input: cost }, status: model.status, variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])), } @@ -95,43 +86,103 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ export async function waitForDefaultModel(input: { sdk: OpenCodeClient - directory: string + 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.active?.() ?? true)) { - const model = await input.sdk.model - .default(location(input.directory)) - .then((result) => result.data) - .catch(() => undefined) + 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 new Promise((resolve) => setTimeout(resolve, 25)) + await wait(25, input.signal) } } -export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise { - const result = await sdk.agent.list(location(directory)) +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) } -export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise { +export async function loadRunCommands( + sdk: OpenCodeClient, + ref: LocationRef, + signal?: AbortSignal, +): Promise { const [commands, skills] = await Promise.all([ - sdk.command.list(location(directory)), - sdk.skill.list(location(directory)), + sdk.command.list(location(ref), ...requestOptions(signal)), + sdk.skill.list(location(ref), ...requestOptions(signal)), ]) return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)] } -export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise { - const result = await sdk.reference.list(location(directory)) +export async function loadRunReferences( + sdk: OpenCodeClient, + ref: LocationRef, + signal?: AbortSignal, +): Promise { + const result = await sdk.reference.list(location(ref), ...requestOptions(signal)) return result.data.filter((reference) => !reference.hidden) } -export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise { +export async function loadRunProviders( + sdk: OpenCodeClient, + ref: LocationRef, + signal?: AbortSignal, +): Promise { const [providers, models] = await Promise.all([ - sdk.provider.list(location(directory)), - sdk.model.list(location(directory)), + sdk.provider.list(location(ref), ...requestOptions(signal)), + sdk.model.list(location(ref), ...requestOptions(signal)), ]) return runProviders([...providers.data], [...models.data]) } + +function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] { + return signal ? [{ signal }] : [] +} diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index d9bcd9a6bf..f3dc094ce1 100644 --- a/packages/tui/src/mini/demo.ts +++ b/packages/tui/src/mini/demo.ts @@ -2,28 +2,30 @@ // // Enabled with `--demo`. Intercepts prompt submissions and drives the same // presentation commits and footer actions as the live transport. This -// lets you test scrollback formatting, permission UI, question UI, and tool +// lets you test scrollback formatting, permission UI, canonical Form UI, and tool // snapshots without making actual model calls. Pass a demo slash command as // the initial interactive message to trigger a preview immediately. // // Slash commands: // /permission [kind] → triggers a permission request variant -// /question [kind] → triggers a question request variant +// /form [kind] → triggers a canonical Form request variant // /fmt → emits a specific tool/text type (text, reasoning, shell, -// write, edit, patch, task, question, error, mix) +// write, edit, patch, subagent, question, error, mix) // -// Demo mode also handles permission and question replies locally, completing -// or failing the synthetic tool parts as appropriate. +// Demo mode handles permission and Form replies locally, completing or failing +// the synthetic tool parts through the same callbacks used by the live footer. import path from "path" -import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise" +import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise" import { writeSessionOutput } from "./stream" import { toolCommit } from "./stream-v2.subagent" import type { FooterApi, - MiniToolPart, + FooterView, + FormCancel, + FormReply, + MiniFormRequest, + MiniPermissionRequest, PermissionReply, - QuestionReject, - QuestionReply, RunPrompt, StreamCommit, } from "./types" @@ -37,25 +39,25 @@ const KINDS = [ "write", "edit", "patch", - "task", + "subagent", "question", "error", "mix", ] -const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const -const QUESTIONS = ["multi", "single", "checklist", "custom"] as const +const PERMISSIONS = ["edit", "shell", "read", "subagent", "external", "doom"] as const +const FORMS = ["question", "external"] as const type PermissionKind = (typeof PERMISSIONS)[number] -type QuestionKind = (typeof QUESTIONS)[number] +type FormKind = (typeof FORMS)[number] function permissionKind(value: string | undefined): PermissionKind | undefined { const next = (value || "edit").toLowerCase() return PERMISSIONS.find((item) => item === next) } -function questionKind(value: string | undefined): QuestionKind | undefined { - const next = (value || "multi").toLowerCase() - return QUESTIONS.find((item) => item === next) +function formKind(value: string | undefined): FormKind | undefined { + const next = (value || "question").toLowerCase() + return FORMS.find((item) => item === next) } const SAMPLE_MARKDOWN = [ @@ -111,12 +113,14 @@ type Ref = { part: string call: string tool: string - input: Record + input: Record start: number } -type Ask = { +type FormRequest = { ref: Ref + kind: FormKind + request: MiniFormRequest } type Perm = { @@ -124,7 +128,7 @@ type Perm = { done: { title: string output: string - metadata?: Record + metadata?: Record } } @@ -132,7 +136,7 @@ type Permit = { ref: Ref permission: string patterns: string[] - metadata?: PermissionV2Request["metadata"] + metadata?: MiniPermissionRequest["metadata"] always: string[] done: Perm["done"] } @@ -145,9 +149,9 @@ type State = { part: number call: number perm: number - ask: number + form: number perms: Map - asks: Map + forms: Map started: Set } @@ -173,7 +177,7 @@ function clearSubagent(footer: FooterApi): void { tabs: [], details: {}, permissions: [], - questions: [], + forms: [], }, }) } @@ -182,13 +186,10 @@ function showSubagent( state: State, input: { sessionID: string - partID: string - callID: string label: string description: string status: "running" | "completed" | "cancelled" | "error" title?: string - toolCalls?: number commits: StreamCommit[] }, ) { @@ -198,24 +199,20 @@ function showSubagent( tabs: [ { sessionID: input.sessionID, - partID: input.partID, - callID: input.callID, label: input.label, description: input.description, status: input.status, title: input.title, - toolCalls: input.toolCalls, lastUpdatedAt: Date.now(), }, ], details: { [input.sessionID]: { - sessionID: input.sessionID, commits: input.commits, }, }, permissions: [], - questions: [], + forms: [], }, }) } @@ -256,20 +253,20 @@ function split(text: string): string[] { return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] } -function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { +function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string { state[key] += 1 return `demo_${prefix}_${state[key]}` } -function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void { +function present(state: State, commits: StreamCommit[], view?: FooterView): void { writeSessionOutput( { footer: state.footer }, { commits, footer: view ? { - view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view }, - patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" }, + view, + patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" }, } : undefined, }, @@ -295,7 +292,9 @@ async function emitText(state: State, body: string, signal?: AbortSignal): Promi return } - present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }]) + present(state, [ + { kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }, + ]) await wait(45, signal) } } @@ -326,7 +325,7 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal): } } -function make(state: State, tool: string, input: Record): Ref { +function make(state: State, tool: string, input: Record): Ref { return { msg: open(state), part: take(state, "part", "part"), @@ -337,28 +336,21 @@ function make(state: State, tool: string, input: Record): Ref { } } -function startTool(state: State, ref: Ref, metadata: Record = {}): void { +function startTool(state: State, ref: Ref, structured: Record = {}): SessionMessageAssistantTool { state.started.add(ref.part) - present( - state, - [ - toolCommit( - { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - callID: ref.call, - tool: ref.tool, - state: { status: "running", input: ref.input, metadata, time: { start: ref.start } }, - }, - "start", - ), - ], - ) + const part = { + type: "tool" as const, + id: ref.call, + name: ref.tool, + state: { status: "running" as const, input: ref.input, structured, content: [] }, + time: { created: ref.start, ran: ref.start }, + } + present(state, [toolCommit(part, ref.msg, "start")]) + return part } function askPermission(state: State, item: Permit): void { - startTool(state, item.ref) + const tool = startTool(state, item.ref) const id = take(state, "perm", "perm") state.perms.set(id, { @@ -367,13 +359,17 @@ function askPermission(state: State, item: Permit): void { }) present(state, [], { - id, - sessionID: state.id, - action: item.permission, - resources: item.patterns, - metadata: item.metadata ?? {}, - save: item.always, - source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call }, + type: "permission", + request: { + id, + sessionID: state.id, + action: item.permission, + resources: item.patterns, + metadata: item.metadata ?? {}, + save: item.always, + source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call }, + tool, + }, }) } @@ -383,52 +379,46 @@ function doneTool( output: { title: string output: string - metadata?: Record + metadata?: Record }, ): void { if (!state.started.has(ref.part)) startTool(state, ref) - const part: MiniToolPart = { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - callID: ref.call, - tool: ref.tool, + const part: SessionMessageAssistantTool = { + type: "tool", + id: ref.call, + name: ref.tool, state: { status: "completed", input: ref.input, - output: output.output, - title: output.title, - metadata: output.metadata ?? {}, - time: { start: ref.start, end: Date.now() }, + content: output.output ? [{ type: "text", text: output.output }] : [], + structured: output.metadata ?? {}, }, + time: { created: ref.start, ran: ref.start, completed: Date.now() }, } - present(state, [toolCommit(part, output.output ? "progress" : "final")]) + present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")]) } function failTool(state: State, ref: Ref, error: string): void { if (!state.started.has(ref.part)) startTool(state, ref) - present( - state, - [ - toolCommit( - { - id: ref.part, - sessionID: state.id, - messageID: ref.msg, - callID: ref.call, - tool: ref.tool, - state: { - status: "error", - input: ref.input, - error, - metadata: {}, - time: { start: ref.start, end: Date.now() }, - }, + present(state, [ + toolCommit( + { + type: "tool", + id: ref.call, + name: ref.tool, + state: { + status: "error", + input: ref.input, + error: { type: "unknown", message: error }, + structured: {}, + content: [], }, - "final", - ), - ], - ) + time: { created: ref.start, ran: ref.start, completed: Date.now() }, + }, + ref.msg, + "final", + ), + ]) } function emitError(state: State, text: string): void { @@ -447,7 +437,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise { title: "git status", output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, metadata: { - exitCode: 0, + exit: 0, }, }) } @@ -455,7 +445,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise { function emitWrite(state: State): void { const file = path.join(process.cwd(), "src", "demo-format.ts") const ref = make(state, "write", { - filePath: file, + path: file, content: "export const demo = 42\n", }) doneTool(state, ref, { @@ -468,13 +458,19 @@ function emitWrite(state: State): void { function emitEdit(state: State): void { const file = path.join(process.cwd(), "src", "demo-format.ts") const ref = make(state, "edit", { - filePath: file, + path: file, }) doneTool(state, ref, { title: "edit", output: "", metadata: { - diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + files: [ + { + file, + status: "modified", + patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + ], }, }) } @@ -490,17 +486,15 @@ function emitPatch(state: State): void { metadata: { files: [ { - type: "update", - filePath: file, - relativePath: "src/demo-format.ts", - diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + status: "modified", + file, + patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", deletions: 1, }, { - type: "add", - filePath: path.join(process.cwd(), "README-demo.md"), - relativePath: "README-demo.md", - diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + status: "added", + file: path.join(process.cwd(), "README-demo.md"), + patch: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", deletions: 0, }, ], @@ -509,46 +503,41 @@ function emitPatch(state: State): void { } function emitTask(state: State): void { - const ref = make(state, "task", { + const ref = make(state, "subagent", { description: "Scan run/* for reducer touchpoints", - subagent_type: "explore", + agent: "explore", }) doneTool(state, ref, { title: "Reducer touchpoints found", output: "", metadata: { - toolcalls: 4, - sessionId: "sub_demo_1", + sessionID: "sub_demo_1", + status: "completed", + output: "", }, }) const part = { - id: "sub_demo_tool_1", type: "tool", - sessionID: "sub_demo_1", - messageID: "sub_demo_msg_tool", - callID: "sub_demo_call_1", - tool: "read", + id: "sub_demo_call_1", + name: "read", state: { status: "running", input: { - filePath: "packages/tui/src/mini/stream.ts", + path: "packages/tui/src/mini/stream.ts", offset: 1, limit: 200, }, - time: { - start: Date.now(), - }, + structured: {}, + content: [], }, - } satisfies MiniToolPart + time: { created: Date.now(), ran: Date.now() }, + } satisfies SessionMessageAssistantTool showSubagent(state, { sessionID: "sub_demo_1", - partID: ref.part, - callID: ref.call, label: "Explore", description: "Scan run/* for reducer touchpoints", status: "completed", title: "Reducer touchpoints found", - toolCalls: 4, commits: [ { kind: "user", @@ -597,6 +586,7 @@ function emitQuestionTool(state: State): void { { label: "Code", description: "Show code block" }, ], multiple: false, + custom: false, }, { header: "Extras", @@ -639,7 +629,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { title: "git status --short", output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, metadata: { - exitCode: 0, + exit: 0, }, }, }) @@ -649,7 +639,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { if (kind === "read") { const target = path.join(root, "package.json") const ref = make(state, "read", { - filePath: target, + path: target, offset: 1, limit: 80, }) @@ -667,22 +657,23 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { return } - if (kind === "task") { - const ref = make(state, "task", { + if (kind === "subagent") { + const ref = make(state, "subagent", { description: "Inspect footer spacing across direct-mode prompts", - subagent_type: "explore", + agent: "explore", }) askPermission(state, { ref, - permission: "task", + permission: "subagent", patterns: ["explore"], always: ["*"], done: { title: "Footer spacing checked", output: "", metadata: { - toolcalls: 3, - sessionId: "sub_demo_perm_1", + sessionID: "sub_demo_perm_1", + status: "completed", + output: "", }, }, }) @@ -693,7 +684,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { const dir = path.join(path.dirname(root), "demo-shared") const target = path.join(dir, "README.md") const ref = make(state, "read", { - filePath: target, + path: target, offset: 1, limit: 40, }) @@ -716,9 +707,9 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { } if (kind === "doom") { - const ref = make(state, "task", { + const ref = make(state, "subagent", { description: "Retry the formatter after repeated failures", - subagent_type: "general", + agent: "general", }) askPermission(state, { ref, @@ -736,9 +727,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" const ref = make(state, "edit", { - filePath: file, - filepath: file, - diff, + path: file, }) askPermission(state, { ref, @@ -749,96 +738,98 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void { title: "edit", output: "", metadata: { - diff, + files: [{ file, status: "modified", patch: diff }], }, }, }) } -function emitQuestion(state: State, kind: QuestionKind = "multi"): void { - const questions = (() => { - if (kind === "single") { - return [ - { - header: "Mode", - question: "Which footer should be the reference for spacing checks?", - options: [ - { label: "Permission", description: "Inspect the permission footer" }, - { label: "Question", description: "Keep this question footer open" }, - { label: "Prompt", description: "Return to the normal composer" }, - ], - multiple: false, - custom: false, - }, - ] - } - - if (kind === "checklist") { - return [ - { - header: "Checks", - question: "Select the direct-mode cases you want to inspect next", - options: [ - { label: "Diff", description: "Show an edit diff in the footer" }, - { label: "Task", description: "Show a structured task summary" }, - { label: "Error", description: "Show an error transcript row" }, - ], - multiple: true, - custom: false, - }, - ] - } - - if (kind === "custom") { - return [ - { - header: "Reply", - question: "What custom answer should appear in the footer preview?", - options: [ - { label: "Short note", description: "Keep the answer to one line" }, - { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, - ], - multiple: false, - custom: true, - }, - ] - } - - return [ +function demoForm(kind: FormKind): { title: string; fields: MiniFormRequest["fields"]; questions?: JsonValue[] } { + if (kind === "question") { + const questions: JsonValue[] = [ { header: "Layout", - question: "Which footer view should stay active while testing?", + question: "Which footer view should be the reference for spacing checks?", options: [ - { label: "Prompt", description: "Return to prompt" }, - { label: "Question", description: "Keep question open" }, + { label: "Form", description: "Inspect the canonical Form footer" }, + { label: "Prompt", description: "Return to the normal composer" }, ], multiple: false, + custom: true, }, { - header: "Rows", + header: "Checks", question: "Pick formatting previews", options: [ - { label: "Diff", description: "Emit edit diff" }, - { label: "Task", description: "Emit task card" }, + { label: "Diff", description: "Emit an edit diff" }, + { label: "Subagent", description: "Emit a subagent card" }, ], multiple: true, custom: true, }, ] - })() + return { + title: "Questions", + questions, + fields: [ + { + key: "q0", + title: "Layout", + description: "Which footer view should be the reference for spacing checks?", + type: "string", + options: [ + { value: "Form", label: "Form", description: "Inspect the canonical Form footer" }, + { value: "Prompt", label: "Prompt", description: "Return to the normal composer" }, + ], + custom: true, + }, + { + key: "q1", + title: "Checks", + description: "Pick formatting previews", + type: "multiselect", + options: [ + { value: "Diff", label: "Diff", description: "Emit an edit diff" }, + { value: "Subagent", label: "Subagent", description: "Emit a subagent card" }, + ], + custom: true, + }, + ], + } + } + return { + title: "MCP authorization", + fields: [ + { + key: "authorization", + type: "external", + url: "https://example.com/opencode-demo", + title: "Authorize demo MCP server", + description: "Complete authorization in your browser", + }, + ], + } +} - const ref = make(state, "question", { questions }) - startTool(state, ref) - - const id = take(state, "ask", "ask") - state.asks.set(id, { ref }) - - present(state, [], { - id, - sessionID: state.id, - questions, - tool: { messageID: ref.msg, callID: ref.call }, +function emitForm(state: State, kind: FormKind = "question"): void { + const form = demoForm(kind) + const ref = make(state, kind === "question" ? "question" : "mcp_demo", { + ...(form.questions ? { questions: form.questions } : { form: kind }), }) + startTool(state, ref) + state.form++ + const request: MiniFormRequest = { + id: `frm_demo_${state.form}`, + sessionID: state.id, + title: form.title, + metadata: + kind === "question" + ? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } } + : { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` }, + fields: form.fields, + } + state.forms.set(request.id, { ref, kind, request }) + present(state, [], { type: "form", request }) } async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { @@ -882,7 +873,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS return true } - if (kind === "task") { + if (kind === "subagent") { emitTask(state) return true } @@ -921,11 +912,12 @@ function intro(state: State): void { [ "Demo slash commands enabled for interactive mode.", `- /permission [kind] (${PERMISSIONS.join(", ")})`, - `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /form [kind] (${FORMS.join(", ")})`, `- /fmt (${KINDS.join(", ")})`, "Examples:", "- /permission shell", - "- /question custom", + "- /form question", + "- /form external", "- /fmt markdown", "- /fmt table", "- /fmt text your custom text", @@ -942,9 +934,9 @@ export function createRunDemo(input: Input) { part: 0, call: 0, perm: 0, - ask: 0, + form: 0, perms: new Map(), - asks: new Map(), + forms: new Map(), started: new Set(), } @@ -975,14 +967,14 @@ export function createRunDemo(input: Input) { return true } - if (cmd === "/question") { - const kind = questionKind(list[1]) + if (cmd === "/form") { + const kind = formKind(list[1]) if (!kind) { - note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`) return true } - emitQuestion(state, kind) + emitForm(state, kind) return true } @@ -1024,33 +1016,41 @@ export function createRunDemo(input: Input) { return true } - const questionReply = (input: QuestionReply): boolean => { - const ask = state.asks.get(input.requestID) - if (!ask || !input.answers) { - return false - } - - state.asks.delete(input.requestID) + const formReply = (input: FormReply): boolean => { + const form = state.forms.get(input.formID) + if (!form || input.sessionID !== form.request.sessionID) return false + state.forms.delete(input.formID) clearBlocker(state) - doneTool(state, ask.ref, { - title: "question", - output: "", - metadata: { - answers: input.answers, - }, + if (form.kind === "question") { + doneTool(state, form.ref, { + title: "question", + output: "", + metadata: { + answers: form.request.fields.map((field) => { + const value = input.answer[field.key] + if (value === undefined) return [] + return Array.isArray(value) ? [...value] : [String(value)] + }), + }, + }) + 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`, + metadata: { answer: input.answer }, }) return true } - const questionReject = (input: QuestionReject): boolean => { - const ask = state.asks.get(input.requestID) - if (!ask) { - return false - } - - state.asks.delete(input.requestID) + const formCancel = (input: FormCancel): boolean => { + const form = state.forms.get(input.formID) + if (!form || input.sessionID !== form.request.sessionID) return false + state.forms.delete(input.formID) clearBlocker(state) - failTool(state, ask.ref, "question rejected") + failTool(state, form.ref, "form cancelled") return true } @@ -1058,7 +1058,7 @@ export function createRunDemo(input: Input) { start, prompt, permission, - questionReply, - questionReject, + formReply, + formCancel, } } diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 205e14ce25..683a2bf5d1 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -6,11 +6,11 @@ export type EntryFlags = { trailingNewline: boolean } -export const RUN_ENTRY_NONE: RunEntryBody = { +const RUN_ENTRY_NONE: RunEntryBody = { type: "none", } -export function cleanRunText(text: string): string { +function cleanRunText(text: string): string { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") } diff --git a/packages/tui/src/mini/footer.form.tsx b/packages/tui/src/mini/footer.form.tsx new file mode 100644 index 0000000000..1a0c6780ba --- /dev/null +++ b/packages/tui/src/mini/footer.form.tsx @@ -0,0 +1,426 @@ +/** @jsxImportSource @opentui/solid */ +import type { TextareaRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { + createFormBodyState, + formAcknowledge, + formCommitInput, + formConfirm, + formCurrent, + formCustom, + formDisplay, + formErrorMessage, + formInput, + formLabel, + formMove, + formPick, + formPlaceholder, + formReply, + formRows, + formSetError, + formSetExternalReady, + formSetDraft, + formSetField, + formSetSelected, + formSetSubmitting, + formSingle, + formSync, + formTextual, + formUnsupported, + formValidate, + formValidateValue, +} from "./form.shared" +import type { FormBodyState } from "./form.shared" +import type { RunFooterTheme } from "./theme" +import type { FormCancel, FormReply, MiniFormRequest } from "./types" + +export function RunFormBody(props: { + request: MiniFormRequest + theme: RunFooterTheme + onReply: (input: FormReply) => void | Promise + onCancel: (input: FormCancel) => void | Promise + openExternal?: (url: string) => Promise + state?: FormBodyState + onState?: (state: FormBodyState) => void +}) { + const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request)) + const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => { + const value = typeof next === "function" ? next(state()) : next + setLocalState(value) + props.onState?.(value) + } + const unsupported = createMemo(() => formUnsupported(props.request)) + const current = createMemo(() => formCurrent(props.request, state())) + const answerField = createMemo(() => { + const field = current() + return field?.type === "external" ? undefined : field + }) + const externalField = createMemo(() => { + const field = current() + return field?.type === "external" ? field : undefined + }) + const confirm = createMemo(() => formConfirm(props.request, state())) + const rows = createMemo(() => formRows(current())) + const custom = createMemo(() => formCustom(current())) + const textual = createMemo(() => formTextual(current())) + const multiple = createMemo(() => current()?.type === "multiselect") + const message = createMemo(() => { + const value = props.request.metadata?.message + return typeof value === "string" ? value : undefined + }) + let area: TextareaRenderable | undefined + + createEffect(() => { + setState((previous) => formSync(previous, props.request)) + }) + + onCleanup(() => { + const currentArea = area + if (!currentArea || currentArea.isDestroyed) return + setState((previous) => formSetDraft(previous, current(), currentArea.plainText)) + }) + + createEffect(() => { + if (!state().editing || !area || area.isDestroyed) return + const value = formInput(state(), current()) + if (area.plainText !== value) { + area.setText(value) + area.cursorOffset = value.length + } + queueMicrotask(() => { + if (!area || area.isDestroyed || !state().editing) return + area.focus() + area.cursorOffset = area.plainText.length + }) + }) + + const beginReply = async (input: FormReply) => { + const formID = props.request.id + setState((previous) => formSetSubmitting(previous, true)) + try { + await props.onReply(input) + } catch (error) { + setState((previous) => (previous.formID === formID ? formSetError(previous, formErrorMessage(error)) : previous)) + } + } + + const submit = (next = state()) => { + const invalid = formValidate(props.request, next) + if (invalid) { + setState((previous) => formSetError(previous, invalid)) + return + } + const reply = formReply(props.request, next) + if (reply) void beginReply(reply) + } + + const cancel = async () => { + const formID = props.request.id + setState((previous) => formSetSubmitting(previous, true)) + try { + await props.onCancel({ + sessionID: props.request.sessionID, + formID: props.request.id, + location: props.request.location, + }) + } catch (error) { + setState((previous) => (previous.formID === formID ? formSetError(previous, formErrorMessage(error)) : previous)) + } + } + + const commitInput = () => { + const next = formCommitInput(state(), props.request, area?.plainText ?? formInput(state(), current())) + setState(next) + if (next.error) return + if (formSingle(props.request)) { + submit(next) + return + } + setState(formSetField(next, props.request, next.field + 1)) + } + + const choose = (selected = state().selected) => { + const base = formSetSelected(state(), selected) + const next = formPick(base, props.request) + setState(next) + if (next.editing || multiple()) return + if (formSingle(props.request)) submit(next) + } + + const moveField = (direction: -1 | 1) => { + const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1) + if (direction < 0 || confirm()) { + setState((previous) => formSetField(previous, props.request, next)) + return + } + const field = current() + if (field?.type === "external") { + if (state().answers[field.key] !== true) { + setState((previous) => formSetError(previous, `Acknowledge ${formLabel(field)}`)) + return + } + } else if (field) { + const invalid = formValidateValue(field, state().answers[field.key]) + if (invalid) { + setState((previous) => formSetError(previous, invalid)) + return + } + } + setState((previous) => formSetField(previous, props.request, next)) + } + + const external = async () => { + const field = current() + if (field?.type !== "external") return + if (state().answers[field.key] === true) { + if (formSingle(props.request)) submit() + else moveField(1) + return + } + if (state().externalReady[field.key]) { + const next = formAcknowledge(state(), props.request) + setState(next) + if (formSingle(props.request)) submit(next) + return + } + try { + if (props.openExternal) await props.openExternal(field.url) + else { + const { default: open } = await import("open") + await open(field.url) + } + setState((previous) => formSetExternalReady(previous, field.key)) + } catch { + setState((previous) => formSetExternalReady(previous, field.key)) + setState((previous) => formSetError(previous, "Could not open the URL. Open it manually, then press enter.")) + } + } + + useKeyboard((event) => { + if (state().submitting) { + event.preventDefault() + return + } + if (event.name === "escape") { + void cancel() + event.preventDefault() + return + } + if (unsupported()) return + if (state().editing) return + if ( + event.name === "tab" || + event.name === "left" || + event.name === "right" || + event.name === "h" || + event.name === "l" + ) { + const direction = event.shift || event.name === "left" || event.name === "h" ? -1 : 1 + moveField(direction) + event.preventDefault() + return + } + if (confirm() && event.name === "return") { + submit() + event.preventDefault() + return + } + if (current()?.type === "external" && event.name === "return") { + void external() + event.preventDefault() + return + } + const total = rows().length + (custom() ? 1 : 0) + const digit = Number(event.name) + if (!Number.isNaN(digit) && digit >= 1 && digit <= Math.min(total, 9)) { + choose(digit - 1) + event.preventDefault() + return + } + if (event.name === "up" || event.name === "k") { + setState((previous) => formMove(previous, props.request, -1)) + event.preventDefault() + return + } + if (event.name === "down" || event.name === "j") { + setState((previous) => formMove(previous, props.request, 1)) + event.preventDefault() + return + } + if (event.name === "return") { + choose() + event.preventDefault() + } + }) + + return ( + + + + + {props.request.title} + + + {confirm() + ? "Review" + : `${Math.min(state().field + 1, props.request.fields.length)}/${props.request.fields.length}`} + + + + {(value) => {value()}} + + {(value) => ( + + + {value()} + + This request remains pending until you dismiss it. + + )} + + + {(field) => ( + + {field().description ?? formLabel(field())} + + {field().url} + + + {state().answers[field().key] === true + ? "Acknowledged" + : state().externalReady[field().key] + ? "Press enter to acknowledge completion" + : "Press enter to open the URL"} + + + )} + + + + + {answerField()!.description ?? formLabel(answerField()!)} + {answerField()!.required ? " (required)" : ""} + {multiple() ? " (select all that apply)" : ""} + + +