From d4686f247bdaa262ca12c79d43678c63edb13b2e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 6 Aug 2026 20:26:05 +0000 Subject: [PATCH] feat(tui): queue prompts with option enter --- packages/tui/src/config/v1/keybind.ts | 4 ++- packages/tui/src/mini/footer.prompt.tsx | 25 +++++++++++++--- packages/tui/src/mini/footer.view.tsx | 4 +++ packages/tui/src/mini/runtime.queue.ts | 9 +++--- packages/tui/src/mini/runtime.ts | 25 +++++++++------- packages/tui/src/mini/stream-v2.transport.ts | 10 +++---- packages/tui/src/mini/types.ts | 1 + packages/tui/test/mini/fixture/footer-api.ts | 4 +-- packages/tui/test/mini/footer.view.test.tsx | 21 +++++++++----- packages/tui/test/mini/runtime.boot.test.ts | 3 +- packages/tui/test/mini/runtime.queue.test.ts | 29 ++++++++++++++++++- packages/tui/test/mini/runtime.test.ts | 6 ++-- .../tui/test/mini/stream-v2.transport.test.ts | 8 ++--- 13 files changed, 106 insertions(+), 43 deletions(-) diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 0b4ecd94a0..ac048ff666 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -161,6 +161,7 @@ export const Definitions = { display_thinking: keybind("none", "Toggle thinking blocks visibility"), prompt_submit: keybind("none", "Submit prompt"), + prompt_queue: keybind("alt+return", "Queue prompt"), prompt_editor_context_clear: keybind("none", "Clear editor context"), prompt_skills: keybind("none", "Open skill selector"), prompt_stash: keybind("none", "Stash prompt"), @@ -170,7 +171,7 @@ export const Definitions = { input_clear: keybind("ctrl+c", "Clear input field"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), input_submit: keybind("return", "Submit input"), - input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"), 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"), @@ -359,6 +360,7 @@ export const CommandMap = { messages_redo: "session.redo", display_thinking: "session.toggle.thinking", prompt_submit: "prompt.submit", + prompt_queue: "prompt.queue", prompt_editor_context_clear: "prompt.editor_context.clear", prompt_skills: "prompt.skills", prompt_stash: "prompt.stash", diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 8b8acf9848..71510dd6ff 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -19,6 +19,7 @@ import { displayCharAt, displaySlice, isExitCommand, + isCompactCommand, mentionTriggerIndex, isNewCommand, movePromptHistory, @@ -982,6 +983,15 @@ export function createPromptState(input: PromptInput): PromptState { Keymap.createLayer(() => ({ enabled: input.prompt() && !visible(), commands: [ + { + id: "prompt.queue", + title: "Queue prompt", + group: "Prompt", + run() { + syncDraft() + submitPrompt(promptCopy(draft), "queue") + }, + }, { id: "prompt.editor", title: "Open editor", @@ -1116,7 +1126,7 @@ export function createPromptState(input: PromptInput): PromptState { } } - const submitPrompt = (next: RunPrompt) => { + const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => { if (!area || area.isDestroyed) { draft = promptCopy(next) } @@ -1136,6 +1146,13 @@ export function createPromptState(input: PromptInput): PromptState { } const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) + if ( + delivery === "queue" && + (next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text)) + ) { + input.onStatus("this prompt cannot be queued") + return + } if (!command && next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return @@ -1157,10 +1174,10 @@ export function createPromptState(input: PromptInput): PromptState { } const submit = command - ? { ...next, command } + ? { ...next, command, delivery } : parsed?.type === "command" - ? { ...next, command: parsed.command } - : next + ? { ...next, command: parsed.command, delivery } + : { ...next, delivery } const shellMode = next.mode === "shell" resetDraft() diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 0656a83eac..e47907ba76 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -185,6 +185,7 @@ export function RunFooterView(props: RunFooterViewProps) { const command = () => shortcut("command.palette.show") const subagentShortcut = () => shortcut("session.child.first") const queuedShortcut = () => shortcut("session.queued_prompts") + const queueShortcut = () => shortcut("prompt.queue") const backgroundShortcut = () => shortcut("session.background") const subagentInterruptShortcut = () => shortcut("subagent.interrupt") const interrupt = () => shortcut("session.interrupt") @@ -457,6 +458,9 @@ export function RunFooterView(props: RunFooterViewProps) { if (activeTabs().length > 0 && subagentShortcut()) { items.push({ key: subagentShortcut(), label: "subagents" }) } + if (busy() && queueShortcut()) { + items.push({ key: queueShortcut(), label: "queue" }) + } return items }) diff --git a/packages/tui/src/mini/runtime.queue.ts b/packages/tui/src/mini/runtime.queue.ts index 75894237e3..0b93752c8e 100644 --- a/packages/tui/src/mini/runtime.queue.ts +++ b/packages/tui/src/mini/runtime.queue.ts @@ -25,7 +25,7 @@ export type QueueInput = { onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise onNewSession?: () => void | Promise onCompact?: () => void | Promise - admit: (prompt: RunPrompt, signal: AbortSignal) => Promise + admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise settle: () => Promise run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise } @@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise { input.trace?.write("ui.commit", commit) input.footer.append(commit) } - input.onSend?.(sent, "steer") + input.onSend?.(sent, sent.delivery ?? "steer") if (state.closed) { break @@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise { const sent = { ...prompt, messageID: SessionMessage.ID.create() } const admission = state.admission admissionVersion += 1 - input.onSend?.(sent, "queue") + const delivery = prompt.delivery ?? "queue" + input.onSend?.(sent, delivery) admissions = admissions .then(() => admission) - .then(() => input.admit(sent, admissionController.signal)) + .then(() => input.admit(sent, delivery, admissionController.signal)) .catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error))) return } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index 041d3bc28c..dc5f425947 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -892,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep trace: log, onSend: (prompt, delivery) => { state.shown = true - state.history.push(prompt) + state.history.push({ ...prompt, delivery: undefined }) if (prompt.mode !== "shell" && delivery === "steer") { rememberLocal({ kind: "user", @@ -903,18 +903,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) } }, - admit: async (prompt, signal) => { + admit: async (prompt, delivery, signal) => { await state.switching?.catch(() => {}) const next = await ensureStream() - await next.handle.queuePromptTurn({ - agent: state.agent, - model: state.model, - variant: state.activeVariant, - prompt, - files: input.files, - includeFiles: false, - signal, - }) + await next.handle.admitPromptTurn( + { + agent: state.agent, + model: state.model, + variant: state.activeVariant, + prompt, + files: input.files, + includeFiles: false, + signal, + }, + delivery, + ) }, onAdmissionError: renderPromptError, onCompact: async () => { diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index ad249c6eb5..a77ac64b1c 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -71,7 +71,7 @@ export type SessionResizeReplayInput = { export type SessionTransport = { runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise - queuePromptTurn(input: SessionTurnInput): Promise + admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise waitForIdle(): Promise interruptActiveTurn(): Promise selectSubagent(sessionID: string | undefined): void @@ -1643,14 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise admitPrompt(next, client, "steer"), admitted) + await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) return } @@ -1700,7 +1700,7 @@ export async function createSessionTransport(input: StreamInput): Promise admitPrompt(next, client, "steer"), admitted) + await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) }, async interruptActiveTurn() { // A running shell holds no drain, so session.interrupt cannot reach it; diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 001ec92605..b676cdcf75 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -75,6 +75,7 @@ export type RunPrompt = { messageID?: string text: string parts: RunPromptPart[] + delivery?: "steer" | "queue" mode?: "shell" command?: { name: string diff --git a/packages/tui/test/mini/fixture/footer-api.ts b/packages/tui/test/mini/fixture/footer-api.ts index 83c06ca50f..61e3ca9604 100644 --- a/packages/tui/test/mini/fixture/footer-api.ts +++ b/packages/tui/test/mini/fixture/footer-api.ts @@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits? commits, calls, promptReady, - submit(text: string, mode?: RunPrompt["mode"]) { + submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) { if (prompts.size === 0) return false - const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] } + const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) } for (const fn of [...prompts]) fn(prompt) return true }, diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 50e81d168d..2dc583d539 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -1068,11 +1068,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh await app.renderOnce() expect(submits).toEqual([ - { text: "/review ", parts: [], command: { name: "review", arguments: "" } }, - { text: "/review ", parts: [], command: { name: "review", arguments: "" } }, - { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } }, - { text: "/new ", parts: [] }, - { text: "/new ", parts: [] }, + { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" }, + { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" }, + { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" }, + { text: "/new ", parts: [], delivery: "steer" }, + { text: "/new ", parts: [], delivery: "steer" }, ]) expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ") } finally { @@ -1100,7 +1100,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () => app.mockInput.pressEnter() await app.renderOnce() - expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }]) + expect(submits).toEqual([ + { text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" }, + ]) expect(app.captureCharFrame()).not.toContain("Apply formatter fixes") } finally { app.cleanup() @@ -1158,7 +1160,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy await app.renderOnce() expect(submits).toEqual([ - { text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } }, + { + text: "/formatter src", + parts: [], + command: { name: "formatter", arguments: "src", source: "skill" }, + delivery: "steer", + }, ]) } finally { app.cleanup() diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 734d2a4923..067a4d7011 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -82,7 +82,8 @@ describe("run runtime boot", () => { expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down") expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c") expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return") - expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j") + expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j") + expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return") }) test("preserves disabled leader from resolved tui config", async () => { diff --git a/packages/tui/test/mini/runtime.queue.test.ts b/packages/tui/test/mini/runtime.queue.test.ts index 1d024e4337..c9a73e5adc 100644 --- a/packages/tui/test/mini/runtime.queue.test.ts +++ b/packages/tui/test/mini/runtime.queue.test.ts @@ -265,6 +265,33 @@ describe("run runtime queue", () => { await task }) + test("preserves explicit steer and queue delivery for in-flight prompts", async () => { + const ui = createFooterApiFixture() + const admitted: string[] = [] + const gate = Promise.withResolvers() + + const task = runPromptQueue({ + footer: ui.api, + run: async (_input, _signal, onAdmitted) => { + onAdmitted() + await gate.promise + }, + admit: async (input, delivery) => { + admitted.push(`${input.text}:${delivery}`) + }, + settle: async () => ui.api.close(), + }) + + ui.submit("one") + ui.submit("two", undefined, "steer") + ui.submit("three", undefined, "queue") + while (admitted.length < 2) await Bun.sleep(0) + expect(admitted).toEqual(["two:steer", "three:queue"]) + + gate.resolve() + await task + }) + test("continues durable admission after one fails", async () => { const ui = createFooterApiFixture() const admitted: string[] = [] @@ -308,7 +335,7 @@ describe("run runtime queue", () => { admitted() await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })) }, - admit: async (_prompt, signal) => { + admit: async (_prompt, _delivery, signal) => { admissionStarted.resolve() await new Promise((resolve) => { if (signal.aborted) { diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index d912e60008..ff661d6667 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -126,7 +126,7 @@ describe("run interactive runtime", () => { turnStarted.resolve() api.close() }, - queuePromptTurn: async () => {}, + admitPromptTurn: async () => {}, waitForIdle: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, @@ -209,7 +209,7 @@ describe("run interactive runtime", () => { streamStarted.resolve() return { runPromptTurn: async () => {}, - queuePromptTurn: async () => {}, + admitPromptTurn: async () => {}, waitForIdle: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, @@ -556,7 +556,7 @@ describe("run interactive runtime", () => { setTimeout(() => input.footer.close(), 0) return { runPromptTurn: async () => {}, - queuePromptTurn: async () => {}, + admitPromptTurn: async () => {}, waitForIdle: async () => {}, interruptActiveTurn: async () => {}, selectSubagent: () => {}, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 7dcb2772f2..c5b997adef 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -701,14 +701,14 @@ describe("V2 mini transport", () => { const prompt = spyOn(client.session, "prompt").mockImplementation( (request) => ok(promptAdmission(request)) as never, ) - await transport.queuePromptTurn({ + await transport.admitPromptTurn({ agent: "review", model: undefined, variant: undefined, prompt: { messageID: "msg_next", text: "another", parts: [] }, files: [], includeFiles: false, - }) + }, "queue") expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) events.push({ @@ -813,14 +813,14 @@ describe("V2 mini transport", () => { durable: durable("ses_1", 2), data: { sessionID: "ses_1", inputID: "msg_prompt" }, }) - await transport.queuePromptTurn({ + await transport.admitPromptTurn({ agent: undefined, model: undefined, variant: undefined, prompt: { messageID: "msg_queued", text: "follow up", parts: [] }, files: [], includeFiles: false, - }) + }, "queue") events.push({ id: "evt_queued_promoted", created: 3,