// Serial prompt queue for direct interactive mode. // // Prompts arrive from the footer (user types and hits enter) and local // operations drain one at a time. Ordinary prompts submitted during an active // ordinary turn are admitted immediately to the server's durable queue. // // The queue also handles local session commands, empty-prompt rejection, // and tracks per-turn wall-clock duration for the footer status line. // // Resolves when the footer closes and all in-flight work finishes. import { SessionMessage } from "@opencode-ai/schema/session-message" import { Locale } from "../util/locale" import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import type { FooterApi, FooterEvent, RunPrompt } from "./types" type Trace = { write(type: string, data?: unknown): void } export type QueueInput = { footer: FooterApi initialInput?: string trace?: Trace onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise onNewSession?: () => void | Promise onCompact?: () => void | Promise admit: (prompt: RunPrompt, signal: AbortSignal) => Promise settle: () => Promise run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise } type State = { queue: RunPrompt[] active?: RunPrompt admission?: Promise ctrl?: AbortController closed: boolean } // Runs the prompt queue until the footer closes. // // Subscribes to footer prompt events and drains operations through input.run(). // Ordinary prompts submitted during an ordinary active turn are admitted as // durable queued work instead of remaining editable process-local state. export async function runPromptQueue(input: QueueInput): Promise { const stop = Promise.withResolvers<{ type: "closed" }>() const done = Promise.withResolvers() const state: State = { queue: [], closed: input.footer.isClosed, } let draining: Promise | undefined let admissions = Promise.resolve() let admissionVersion = 0 const admissionController = new AbortController() const emit = (next: FooterEvent, row: Record) => { input.trace?.write("ui.patch", row) input.footer.event(next) } const finish = () => { if (!state.closed || draining) { return } done.resolve() } const close = () => { if (state.closed) { return } state.closed = true state.queue.length = 0 // Ordinary turn signals map to session.interrupt; exiting should only detach the TUI. if (state.active?.mode === "shell") state.ctrl?.abort() admissionController.abort() stop.resolve({ type: "closed" }) finish() } const drain = () => { if (draining || state.closed || state.queue.length === 0) { return } draining = (async () => { try { while (!state.closed && state.queue.length > 0) { const prompt = state.queue.shift() if (!prompt) { continue } if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { if (!input.onNewSession) { emit( { type: "stream.patch", patch: { status: "new sessions unavailable", }, }, { status: "new sessions unavailable", }, ) continue } emit( { type: "stream.patch", patch: { phase: "running", status: "starting new session", }, }, { phase: "running", status: "starting new session", }, ) await input.onNewSession() continue } if (prompt.mode !== "shell" && isCompactCommand(prompt.text)) { emit( { type: "stream.patch", patch: { phase: "running", status: "compacting session", }, }, { phase: "running", status: "compacting session", }, ) await input.onCompact?.() continue } const sent = prompt.mode === "shell" ? prompt : { ...prompt, messageID: prompt.messageID ?? SessionMessage.ID.create(), } state.active = sent emit( { type: "turn.send" }, { phase: "running", status: "sending prompt", }, ) const start = Date.now() const ctrl = new AbortController() const admission = Promise.withResolvers() const version = admissionVersion state.ctrl = ctrl state.admission = admission.promise try { await input.footer.idle() if (state.closed) { break } if (sent.mode !== "shell") { const commit = { kind: "user", text: sent.text, phase: "start", source: "system", messageID: sent.messageID, } as const input.trace?.write("ui.commit", commit) input.footer.append(commit) } input.onSend?.(sent, "steer") if (state.closed) { break } const task = input.run(sent, ctrl.signal, admission.resolve).then( () => ({ type: "done" as const }), (error) => ({ type: "error" as const, error }), ) const next = await Promise.race([task, stop.promise]) if (next.type === "closed") { break } if (next.type === "error") { throw next.error } if (sent.mode !== "shell" && admissionVersion !== version) { do { const current = admissionVersion await admissions if (state.closed) break await input.settle() if (current === admissionVersion) break } while (!state.closed) } } finally { admission.resolve() if (state.ctrl === ctrl) { state.ctrl = undefined } if (state.admission === admission.promise) state.admission = undefined if (sent.mode !== "shell") { const duration = Locale.duration(Math.max(0, Date.now() - start)) emit( { type: "turn.duration", duration, }, { duration, }, ) } state.active = undefined } } } catch (error) { done.reject(error) return } finally { draining = undefined emit( { type: "turn.idle" }, { phase: "idle", status: "", }, ) } finish() })() } const submit = (prompt: RunPrompt) => { if (!prompt.text.trim() || state.closed) { return } if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { input.footer.close() return } const active = state.active if ( active && active.mode !== "shell" && prompt.mode !== "shell" && prompt.command?.source !== "skill" && !isNewCommand(prompt.text) && !isCompactCommand(prompt.text) && !state.queue.some( (item) => item.mode !== "shell" && (isNewCommand(item.text) || isCompactCommand(item.text)), ) ) { const sent = { ...prompt, messageID: SessionMessage.ID.create() } const admission = state.admission admissionVersion += 1 input.onSend?.(sent, "queue") admissions = admissions .then(() => admission) .then(() => input.admit(sent, admissionController.signal)) .catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error))) return } state.queue.push(prompt) if (prompt.mode !== "shell" && (isNewCommand(prompt.text) || isCompactCommand(prompt.text))) { drain() return } emit( { type: "first", first: false, }, { first: false, }, ) drain() } const offPrompt = input.footer.onPrompt((prompt) => { submit(prompt) }) const offClose = input.footer.onClose(() => { close() }) try { if (state.closed) { return } submit({ text: input.initialInput ?? "", parts: [], }) finish() await done.promise } finally { offPrompt() offClose() close() await draining?.catch(() => {}) await admissions } }