From 892990c0c91a4bb9880eb041de79d4c6d6cfba39 Mon Sep 17 00:00:00 2001 From: henry701 Date: Mon, 1 Jun 2026 01:35:15 -0300 Subject: [PATCH 01/20] feat(tui): wire input.queue deferred prompt in main TUI Register input.queue on the session prompt textarea, send delivery=deferred via session.prompt, and share submit guards with a common promptInFlight lock. --- .../cli/cmd/tui/component/prompt/index.tsx | 281 +++++++++++++----- .../src/cli/cmd/tui/config/keybind.ts | 2 +- .../test/cli/tui/prompt-queue.test.ts | 110 +++++++ packages/plugin/src/tui.ts | 1 + 4 files changed, 314 insertions(+), 80 deletions(-) create mode 100644 packages/opencode/test/cli/tui/prompt-queue.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 966ba932d41a..12d1aebaff77 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -86,6 +86,7 @@ export type PromptRef = { blur(): void focus(): void submit(): void + queue(): void } const money = new Intl.NumberFormat("en-US", { @@ -657,6 +658,30 @@ export function Prompt(props: PromptProps) { ]), })) + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined + })(), + commands: [ + { + name: "input.queue", + title: "Queue prompt", + category: "Prompt", + hidden: true, + run: async () => { + if (!input.focused) return + const handled = await queue() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue"), + })) + const ref: PromptRef = { get focused() { return input.focused @@ -688,6 +713,9 @@ export function Prompt(props: PromptProps) { submit() { void submit() }, + queue() { + void queue() + }, } onMount(() => { @@ -1005,7 +1033,7 @@ export function Prompt(props: PromptProps) { } }) - let submitting = false + let promptInFlight = false async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, @@ -1013,25 +1041,186 @@ export function Prompt(props: PromptProps) { // clears `store.prompt.input`, then awaits its own `session.create` and // ultimately reads the now-empty store — sending a phantom empty prompt // to a freshly created session. - if (submitting) return false - submitting = true + if (promptInFlight) return false + promptInFlight = true try { return await submitInner() } finally { - submitting = false + promptInFlight = false } } - async function submitInner() { - setWarpNotice(undefined) + async function queue() { + if (promptInFlight) return false + promptInFlight = true + try { + return await queueInner() + } finally { + promptInFlight = false + } + } - // IME: double-defer may fire before onContentChange flushes the last - // composed character (e.g. Korean hangul) to the store, so read - // plainText directly and sync before any downstream reads. + function syncPromptInputFromTextarea() { if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } + } + + function expandedInputText() { + let inputText = store.prompt.input + const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) + const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) + + for (const extmark of sortedExtmarks) { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex !== undefined) { + const part = store.prompt.parts[partIndex] + if (part?.type === "text" && part.text) { + const before = inputText.slice(0, extmark.start) + const after = inputText.slice(extmark.end) + inputText = before + part.text + after + } + } + } + + return inputText + } + + function editorContextParts() { + const editorSelection = editorContext() + if (!editorSelection || editor.labelState() !== "pending") return [] + + return [ + { + id: PartID.ascending(), + type: "text" as const, + text: formatEditorContext(editorSelection), + synthetic: true, + metadata: { + kind: "editor_context", + source: editorSelection.source ?? "editor", + filePath: editorSelection.filePath, + ranges: editorSelection.ranges, + }, + }, + ] + } + + function isRegisteredSlashCommand(inputText: string) { + if (!inputText.startsWith("/")) return false + const firstLine = inputText.split("\n")[0] + const command = firstLine.split(" ")[0].slice(1) + return sync.data.command.some((x) => x.name === command) + } + + function resetPromptAfterSend(currentMode: typeof store.mode, sessionID: string, editorParts: ReturnType) { + history.append({ + ...store.prompt, + mode: currentMode, + }) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + + if (!props.sessionID) { + if (editorParts.length > 0) editor.preserveSelectionFromNewSession() + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + } + input.clear() + } + + async function queueInner() { + setWarpNotice(undefined) + syncPromptInputFromTextarea() + if (props.disabled) return false + if (workspaceCreating()) return false + if (auto()?.visible) return false + if (!store.prompt.input.trim()) return false + if (store.mode === "shell") return false + if (isRegisteredSlashCommand(store.prompt.input)) return false + if (!props.sessionID) return submitInner() + + const agent = local.agent.current() + if (!agent) return false + const trimmed = store.prompt.input.trim() + if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") return false + const selectedModel = local.model.current() + if (!selectedModel) { + void promptModelWarning() + return false + } + + const workspaceSession = sync.session.get(props.sessionID) + const workspaceID = workspaceSession?.workspaceID + const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined + if (workspaceID && workspaceStatus !== "connected") { + dialog.replace(() => ( + { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + project, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false + }} + /> + )) + return false + } + + const sessionID = props.sessionID + const variant = local.model.variant.current() + const messageID = MessageID.ascending() + const inputText = expandedInputText() + const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + const currentMode = store.mode + const editorParts = editorContextParts() + + void sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: agent.name, + model: selectedModel, + variant, + delivery: "deferred", + parts: [ + ...editorParts, + { + id: PartID.ascending(), + type: "text", + text: inputText, + }, + ...nonTextParts.map(assign), + ], + }) + .catch(() => {}) + if (editorParts.length > 0) editor.markSelectionSent() + resetPromptAfterSend(currentMode, sessionID, editorParts) + return true + } + + async function submitInner() { + setWarpNotice(undefined) + + syncPromptInputFromTextarea() if (props.disabled) return false if (workspaceCreating()) return false if (auto()?.visible) return false @@ -1109,47 +1298,10 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } - - // Filter out text parts (pasted content) since they're now expanded inline + const inputText = expandedInputText() const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") - - // Capture mode before it gets reset const currentMode = store.mode - const editorSelection = editorContext() - const editorParts = - editorSelection && editor.labelState() === "pending" - ? [ - { - id: PartID.ascending(), - type: "text" as const, - text: formatEditorContext(editorSelection), - synthetic: true, - metadata: { - kind: "editor_context", - source: editorSelection.source ?? "editor", - filePath: editorSelection.filePath, - ranges: editorSelection.ranges, - }, - }, - ] - : [] + const editorParts = editorContextParts() if (store.mode === "shell") { void sdk.client.session.shell({ @@ -1162,14 +1314,7 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const firstLine = inputText.split("\n")[0] - const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) - }) - ) { + } else if (isRegisteredSlashCommand(inputText)) { // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) @@ -1214,29 +1359,7 @@ export function Prompt(props: PromptProps) { .catch(() => {}) if (editorParts.length > 0) editor.markSelectionSent() } - history.append({ - ...store.prompt, - mode: currentMode, - }) - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - props.onSubmit?.() - - // temporary hack to make sure the message is sent - if (!props.sessionID) { - if (editorParts.length > 0) editor.preserveSelectionFromNewSession() - setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) - }, 50) - } - input.clear() + resetPromptAfterSend(currentMode, sessionID, editorParts) return true } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index e261e673fa23..c8f1cadc1e1a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -157,7 +157,7 @@ export const Definitions = { input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), input_submit: keybind("return", "Submit input"), input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"), - input_queue: keybind("ctrl+shift+return", "Queue prompt for next turn (override via tui keybinds.input_queue)"), + input_queue: keybind("ctrl+shift+return", "Queue prompt for next turn in main TUI and run --interactive (keybinds.input_queue)"), input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), input_move_up: keybind("up", "Move cursor up in input"), diff --git a/packages/opencode/test/cli/tui/prompt-queue.test.ts b/packages/opencode/test/cli/tui/prompt-queue.test.ts new file mode 100644 index 000000000000..df5337d3b9b7 --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-queue.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" + +// Mirrors queue guard logic in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx + +function canQueue(input: { + disabled: boolean + workspaceCreating: boolean + autocompleteVisible: boolean + text: string + mode: "normal" | "shell" + slashCommand: boolean +}) { + if (input.disabled) return false + if (input.workspaceCreating) return false + if (input.autocompleteVisible) return false + if (!input.text.trim()) return false + if (input.mode === "shell") return false + if (input.slashCommand) return false + return true +} + +describe("main TUI prompt queue guards", () => { + test("allows normal mode with text", () => { + expect( + canQueue({ + disabled: false, + workspaceCreating: false, + autocompleteVisible: false, + text: "follow up", + mode: "normal", + slashCommand: false, + }), + ).toBe(true) + }) + + test("blocks shell mode", () => { + expect( + canQueue({ + disabled: false, + workspaceCreating: false, + autocompleteVisible: false, + text: "ls", + mode: "shell", + slashCommand: false, + }), + ).toBe(false) + }) + + test("blocks empty input", () => { + expect( + canQueue({ + disabled: false, + workspaceCreating: false, + autocompleteVisible: false, + text: " ", + mode: "normal", + slashCommand: false, + }), + ).toBe(false) + }) + + test("blocks slash commands", () => { + expect( + canQueue({ + disabled: false, + workspaceCreating: false, + autocompleteVisible: false, + text: "/compact", + mode: "normal", + slashCommand: true, + }), + ).toBe(false) + }) + + test("blocks disabled prompt", () => { + expect( + canQueue({ + disabled: true, + workspaceCreating: false, + autocompleteVisible: false, + text: "hello", + mode: "normal", + slashCommand: false, + }), + ).toBe(false) + }) +}) + +describe("Prompt queue in-flight guard", () => { + test("concurrent queue and submit only run once", async () => { + let inFlight = false + const calls: string[] = [] + + async function send(label: string) { + if (inFlight) return false + inFlight = true + try { + await Bun.sleep(5) + calls.push(label) + return true + } finally { + inFlight = false + } + } + + await Promise.all([send("queue"), send("submit")]) + + expect(calls).toHaveLength(1) + }) +}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index e36d91381d5d..f50d4d039fe1 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -206,6 +206,7 @@ export type TuiPromptRef = { blur(): void focus(): void submit(): void + queue(): void } export type TuiPromptProps = { From 2d49b1b9ec49bb9ae1f6786a664a015a084349c4 Mon Sep 17 00:00:00 2001 From: henry701 Date: Mon, 1 Jun 2026 12:15:59 -0300 Subject: [PATCH 02/20] fix(session): fifo deferred queue drain and TUI smoke harness Process deferred prompts one at a time after the open turn completes, persist delivery on user messages, and exclude sibling queued rows from model context. Add dock/queue helpers, run TUI parity, and serve+script smoke tests with a mocked LLM endpoint. --- .../src/cli/cmd/run/footer.prompt.tsx | 11 + .../src/cli/cmd/run/footer.queue-dock.tsx | 89 +++++++ packages/opencode/src/cli/cmd/run/footer.ts | 11 +- .../opencode/src/cli/cmd/run/footer.view.tsx | 29 ++- .../opencode/src/cli/cmd/run/prompt.shared.ts | 2 + .../opencode/src/cli/cmd/run/runtime.boot.ts | 2 + .../opencode/src/cli/cmd/run/runtime.queue.ts | 98 ++++--- packages/opencode/src/cli/cmd/run/types.ts | 15 ++ .../cli/cmd/tui/component/prompt/index.tsx | 86 ++++++ .../cmd/tui/component/prompt/queue-dock.tsx | 80 ++++++ .../src/cli/cmd/tui/component/prompt/queue.ts | 55 ++++ .../src/cli/cmd/tui/config/keybind.ts | 2 + .../src/cli/cmd/tui/routes/session/index.tsx | 23 +- packages/opencode/src/queue/preview.ts | 34 +++ packages/opencode/src/session/message-v2.ts | 71 +++++ packages/opencode/src/session/prompt.ts | 152 ++++++++--- packages/opencode/src/session/run-state.ts | 11 + .../test/cli/run/footer.view.test.tsx | 2 + .../test/cli/run/prompt.shared.test.ts | 1 + .../test/cli/run/runtime.queue.test.ts | 1 + packages/opencode/test/cli/run/stream.test.ts | 1 + .../test/cli/run/stream.transport.test.ts | 1 + .../test/cli/tui/prompt-queue-dock.test.ts | 65 +++++ .../test/cli/tui/prompt-queue-smoke.test.tsx | 95 +++++++ .../tui/prompt-queue-tui-smoke.cli.test.ts | 122 +++++++++ .../cli/tui/scripts/prompt-queue-tui-smoke.sh | 98 +++++++ packages/opencode/test/session/prompt.test.ts | 245 ++++++++++++++++++ .../opencode/test/session/run-state.test.ts | 11 +- packages/plugin/src/tui.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 155 +++++------ 30 files changed, 1407 insertions(+), 162 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/queue-dock.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/queue.ts create mode 100644 packages/opencode/src/queue/preview.ts create mode 100644 packages/opencode/test/cli/tui/prompt-queue-dock.test.ts create mode 100644 packages/opencode/test/cli/tui/prompt-queue-smoke.test.tsx create mode 100644 packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts create mode 100755 packages/opencode/test/cli/tui/scripts/prompt-queue-tui-smoke.sh diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 67ed6130165c..f5646b23b082 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -78,6 +78,7 @@ type PromptInput = { history?: RunPrompt[] onSubmit: (input: RunPrompt) => boolean | Promise onQueue: (input: RunPrompt) => void + onEditQueue: () => void onCycle: () => void onInterrupt: () => boolean onInputClear: () => void @@ -103,6 +104,7 @@ export type PromptState = { onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void + restorePrompt: (prompt: RunPrompt) => void bind: (area?: TextareaRenderable) => void } @@ -115,6 +117,8 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { text: prompt.text, parts: structuredClone(prompt.parts), ...(prompt.mode ? { mode: prompt.mode } : {}), + ...(prompt.delivery ? { delivery: prompt.delivery } : {}), + ...(prompt.queueID ? { queueID: prompt.queueID } : {}), } } @@ -969,6 +973,12 @@ export function createPromptState(input: PromptInput): PromptState { } } + if (!shell() && promptHit(keys().editQueues, key)) { + event.preventDefault() + input.onEditQueue() + return + } + if (!shell() && promptHit(keys().queues, key)) { event.preventDefault() onQueue() @@ -1242,6 +1252,7 @@ export function createPromptState(input: PromptInput): PromptState { scheduleRows() }, replaceDraft, + restorePrompt: restore, bind, } } diff --git a/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx b/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx new file mode 100644 index 000000000000..14ed0b28adeb --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx @@ -0,0 +1,89 @@ +/** @jsxImportSource @opentui/solid */ +import { For, Show, createSignal } from "solid-js" +import type { QueuedItem } from "@/queue/preview" +import type { RunFooterTheme } from "./theme" + +export function RunQueueDock(props: { + items: () => QueuedItem[] + theme: () => RunFooterTheme + disabled?: boolean + onEdit: (id: string) => void + onSendNow: (id: string) => void +}) { + const [collapsed, setCollapsed] = createSignal(false) + const total = () => props.items().length + const label = () => (total() === 1 ? "1 message queued" : `${total()} messages queued`) + const preview = () => props.items()[0]?.text ?? "" + + return ( + 0}> + + setCollapsed((value) => !value)} + > + + {label()} + + + + {preview()} + + + + {collapsed() ? "▾" : "▴"} + + + + + + {(item) => ( + + + {item.text} + + { + if (props.disabled) return + props.onSendNow(item.id) + }} + > + + [ + send + ] + + + { + if (props.disabled) return + props.onEdit(item.id) + }} + > + + [ + edit + ] + + + + )} + + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 4bbf4a558274..d01c3979e59d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -46,6 +46,7 @@ import type { FooterSubagentState, FooterView, PermissionReply, + QueueControl, QuestionReject, QuestionReply, RunAgent, @@ -113,7 +114,7 @@ function createEmptySubagentState(): FooterSubagentState { function eventPatch(next: FooterEvent): FooterPatch | undefined { if (next.type === "queue") { - return { queue: next.queue } + return { queue: next.queue, queued: next.queued } } if (next.type === "first") { @@ -197,6 +198,7 @@ export class RunFooter implements FooterApi { private exitTimeout: NodeJS.Timeout | undefined private interruptHint: string private requestExitHandler: (() => boolean) | undefined + private queueControl: QueueControl | undefined private scrollback: RunScrollbackStream constructor( @@ -207,6 +209,7 @@ export class RunFooter implements FooterApi { phase: "idle", status: "", queue: 0, + queued: [], model: options.modelLabel, duration: "", usage: "", @@ -281,6 +284,7 @@ export class RunFooter implements FooterApi { agent: options.agentLabel, onSubmit: this.handlePrompt, onQueue: this.handleQueue, + queueControl: () => this.queueControl, onPermissionReply: this.handlePermissionReply, onQuestionReply: this.handleQuestionReply, onQuestionReject: this.handleQuestionReject, @@ -332,6 +336,10 @@ export class RunFooter implements FooterApi { } } + public setQueueControl(control: QueueControl | undefined): void { + this.queueControl = control + } + public event(next: FooterEvent): void { if (next.type === "catalog") { if (this.isGone) { @@ -396,6 +404,7 @@ export class RunFooter implements FooterApi { phase: next.phase ?? prev.phase, status: typeof next.status === "string" ? next.status : prev.status, queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue, + queued: next.queued ?? prev.queued, model: typeof next.model === "string" ? next.model : prev.model, duration: typeof next.duration === "string" ? next.duration : prev.duration, usage: typeof next.usage === "string" ? next.usage : prev.usage, diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 8a342cdcf223..f5c844ed7c50 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -24,6 +24,8 @@ import { import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu" import { RunFooterSubagentBody } from "./footer.subagent" import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt" +import { RunQueueDock } from "./footer.queue-dock" +import type { QueueControl } from "./types" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared" @@ -80,6 +82,7 @@ type RunFooterViewProps = { agent: string onSubmit: (input: RunPrompt) => boolean onQueue: (input: RunPrompt) => void + queueControl?: () => QueueControl | undefined onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -157,7 +160,7 @@ export function RunFooterView(props: RunFooterViewProps) { const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) - const queue = createMemo(() => props.state().queue) + const queued = createMemo(() => props.state().queued) const duration = createMemo(() => props.state().duration) const usage = createMemo(() => props.state().usage) const interruptKey = createMemo(() => interrupt() || "/exit") @@ -267,6 +270,14 @@ export function RunFooterView(props: RunFooterViewProps) { history: props.history, onSubmit: props.onSubmit, onQueue: props.onQueue, + onEditQueue: () => { + const items = queued() + const id = items.at(-1)?.id + if (!id) return + const removed = props.queueControl?.()?.remove(id) + if (!removed) return + composer.restorePrompt(removed) + }, onCycle: props.onCycle, onInterrupt: props.onInterrupt, onInputClear: props.onInputClear, @@ -406,6 +417,17 @@ export function RunFooterView(props: RunFooterViewProps) { + { + const removed = props.queueControl?.()?.remove(id) + if (!removed) return + composer.restorePrompt(removed) + }} + onSendNow={(id) => props.queueControl?.()?.sendNow(id)} + /> - 0}> - - {queue()} queued - - 0}> {usage()} diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index a3fc38c70eeb..1d603676503e 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -45,6 +45,7 @@ export type PromptKeys = { next: PromptInfo[] clear: PromptInfo[] queues: PromptInfo[] + editQueues: PromptInfo[] bindings: KeyBinding[] } @@ -162,6 +163,7 @@ export function promptKeys(keybinds: FooterKeybinds): PromptKeys { // "submit"/"newline" actions, so the queue key is dispatched manually in the // footer's onKeyDown handler (see Gap 17 in PLAN.md). queues: promptBindings(keybinds.inputQueue, keybinds.leader), + editQueues: promptBindings(keybinds.inputEditQueue, keybinds.leader), bindings: textareaBindings(keybinds), } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index e4659258daeb..f3eff649f4b1 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -27,6 +27,7 @@ const DEFAULT_KEYBINDS: FooterKeybinds = { inputSubmit: [{ key: "return" }], inputNewline: [{ key: "shift+return,ctrl+return,ctrl+j" }], inputQueue: [{ key: "ctrl+shift+return" }], + inputEditQueue: [{ key: "alt+up" }], } export type ModelInfo = { @@ -104,6 +105,7 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds { inputSubmit: config.keybinds.get("input.submit"), inputNewline: config.keybinds.get("input.newline"), inputQueue: config.keybinds.get("input.queue"), + inputEditQueue: config.keybinds.get("input.queue.edit"), } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 20b7feb21144..ea718dccf284 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -10,8 +10,9 @@ // // Resolves when the footer closes and all in-flight work finishes. import * as Locale from "@/util/locale" +import { runPromptPreview } from "@/queue/preview" import { isExitCommand, isNewCommand } from "./prompt.shared" -import type { FooterApi, FooterEvent, RunPrompt } from "./types" +import type { FooterApi, FooterEvent, QueueControl, QueuedPromptPreview, RunPrompt } from "./types" type Trace = { write(type: string, data?: unknown): void @@ -36,6 +37,20 @@ type State = { queue: RunPrompt[] ctrl?: AbortController closed: boolean + nextQueueID: number +} + +function withQueueID(prompt: RunPrompt, state: State): RunPrompt { + if (prompt.queueID) return prompt + state.nextQueueID += 1 + return { ...prompt, queueID: `queue-${state.nextQueueID}` } +} + +function queueSnapshot(state: State): QueuedPromptPreview[] { + return state.queue.map((prompt) => ({ + id: prompt.queueID ?? prompt.text, + text: runPromptPreview(prompt) || prompt.text.trim().slice(0, 80) || "[queued]", + })) } function defer(): Deferred { @@ -61,6 +76,7 @@ export async function runPromptQueue(input: QueueInput): Promise { const state: State = { queue: [], closed: input.footer.isClosed, + nextQueueID: 0, } let draining: Promise | undefined @@ -69,6 +85,40 @@ export async function runPromptQueue(input: QueueInput): Promise { input.footer.event(next) } + const emitQueue = () => { + const queued = queueSnapshot(state) + emit( + { + type: "queue", + queue: state.queue.length, + queued, + }, + { + queue: state.queue.length, + queued, + }, + ) + } + + const removeQueued = (id: string) => { + const index = state.queue.findIndex((prompt) => prompt.queueID === id) + if (index === -1) return undefined + const [removed] = state.queue.splice(index, 1) + emitQueue() + return removed + } + + const queueControl: QueueControl = { + remove: removeQueued, + sendNow: (id: string) => { + const prompt = removeQueued(id) + if (!prompt) return + submitPrompt({ ...prompt, delivery: "immediate" }) + }, + } + + let submitPrompt = (_prompt: RunPrompt) => {} + const finish = () => { if (!state.closed || draining) { return @@ -85,6 +135,8 @@ export async function runPromptQueue(input: QueueInput): Promise { state.closed = true state.queue.length = 0 state.ctrl?.abort() + input.footer.setQueueControl?.(undefined) + emitQueue() stop.resolve({ type: "closed" }) finish() } @@ -102,16 +154,9 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + emitQueue() + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) if (!input.onNewSession) { emit( { @@ -231,7 +276,7 @@ export async function runPromptQueue(input: QueueInput): Promise { })() } - const submit = (prompt: RunPrompt) => { + submitPrompt = (prompt: RunPrompt) => { if (!prompt.text.trim() || state.closed) { return } @@ -241,16 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - state.queue.push(prompt) - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) + state.queue.push(withQueueID(prompt, state)) + emitQueue() if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { drain() return @@ -268,6 +305,8 @@ export async function runPromptQueue(input: QueueInput): Promise { drain() } + input.footer.setQueueControl?.(queueControl) + // Ctrl+Shift+Enter queues a prompt with delivery="deferred". Unlike a normal submit, // it never kicks off draining on its own: if a turn is already running, the // in-flight drain loop picks it up after the current turn; if the session is @@ -282,16 +321,8 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - state.queue.push(prompt) - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) + state.queue.push(withQueueID(prompt, state)) + emitQueue() emit( { type: "first", @@ -308,7 +339,7 @@ export async function runPromptQueue(input: QueueInput): Promise { enqueue(prompt) return } - submit(prompt) + submitPrompt(prompt) }) const offClose = input.footer.onClose(() => { close() @@ -319,7 +350,7 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - submit({ + submitPrompt({ text: input.initialInput ?? "", parts: [], }) @@ -329,6 +360,7 @@ export async function runPromptQueue(input: QueueInput): Promise { offPrompt() offClose() close() + input.footer.setQueueControl?.(undefined) await draining?.catch(() => {}) } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index ab5c23ba2026..99263880bd92 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -40,6 +40,17 @@ export type RunPrompt = { arguments: string } delivery?: "immediate" | "deferred" + queueID?: string +} + +export type QueuedPromptPreview = { + id: string + text: string +} + +export type QueueControl = { + remove: (id: string) => RunPrompt | undefined + sendNow: (id: string) => void } export type RunAgent = NonNullable>["data"]>[number] @@ -77,6 +88,7 @@ export type FooterState = { phase: FooterPhase status: string queue: number + queued: QueuedPromptPreview[] model: string duration: string usage: string @@ -223,6 +235,7 @@ export type FooterEvent = | { type: "queue" queue: number + queued: QueuedPromptPreview[] } | { type: "first" @@ -280,6 +293,7 @@ export type FooterKeybinds = { inputSubmit: readonly FooterBinding[] inputNewline: readonly FooterBinding[] inputQueue: readonly FooterBinding[] + inputEditQueue: readonly FooterBinding[] } // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" @@ -319,6 +333,7 @@ export type FooterApi = { readonly isClosed: boolean onPrompt(fn: (input: RunPrompt) => void): () => void onClose(fn: () => void): () => void + setQueueControl?(control: QueueControl | undefined): void event(next: FooterEvent): void append(commit: StreamCommit): void idle(): Promise diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 12d1aebaff77..c85f6178132c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -30,6 +30,8 @@ import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign, expandPastedTextPlaceholders } from "./part" import { usePromptStash } from "./stash" +import { listDeferredQueued, partsToPromptInfo } from "./queue" +import { PromptQueueDock } from "./queue-dock" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" @@ -87,6 +89,7 @@ export type PromptRef = { focus(): void submit(): void queue(): void + editQueue?(): void } const money = new Intl.NumberFormat("en-US", { @@ -658,6 +661,21 @@ export function Prompt(props: PromptProps) { ]), })) + const pendingAssistant = createMemo(() => { + if (!props.sessionID) return + const messages = sync.data.message[props.sessionID] ?? [] + return messages.findLast((message) => message.role === "assistant" && !message.time.completed)?.id + }) + + const queueItems = createMemo(() => { + if (!props.sessionID) return [] + return listDeferredQueued({ + messages: sync.data.message[props.sessionID] ?? [], + parts: sync.data.part, + pendingAssistantID: pendingAssistant(), + }) + }) + useBindings(() => ({ target: inputTarget, enabled: (() => { @@ -682,6 +700,36 @@ export function Prompt(props: PromptProps) { bindings: tuiConfig.keybinds.get("input.queue"), })) + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + queueItems().length > 0 + ) + })(), + commands: [ + { + name: "input.queue.edit", + title: "Edit queued prompt", + category: "Prompt", + hidden: true, + run: async () => { + if (!input.focused) return + const handled = await editQueue() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue.edit"), + })) + const ref: PromptRef = { get focused() { return input.focused @@ -716,6 +764,9 @@ export function Prompt(props: PromptProps) { queue() { void queue() }, + editQueue() { + void editQueue() + }, } onMount(() => { @@ -1139,6 +1190,31 @@ export function Prompt(props: PromptProps) { input.clear() } + async function editQueuedMessage(messageID: string, opts?: { submit?: boolean }) { + if (!props.sessionID || props.disabled) return false + const sessionID = props.sessionID + const parts = sync.data.part[messageID] ?? [] + const promptInfo = partsToPromptInfo(parts) + if (!promptInfo.input.trim() && promptInfo.parts.length === 0) return false + + await sdk.client.session.revert({ sessionID, messageID }).catch(() => {}) + input.setText(promptInfo.input) + setStore("prompt", promptInfo) + setStore("extmarkToPartIndex", new Map()) + restoreExtmarksFromParts(promptInfo.parts) + input.gotoBufferEnd() + input.focus() + + if (!opts?.submit) return true + return submitInner() + } + + async function editQueue() { + const id = queueItems().at(0)?.id + if (!id) return false + return editQueuedMessage(id) + } + async function queueInner() { setWarpNotice(undefined) syncPromptInputFromTextarea() @@ -1625,6 +1701,16 @@ export function Prompt(props: PromptProps) { flexGrow={1} width="100%" > + { + void editQueuedMessage(id) + }} + onSendNow={(id: string) => { + void editQueuedMessage(id, { submit: true }) + }} + />