feat(tui): queue prompts with option enter
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ export type QueueInput = {
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
onCompact?: () => void | Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
@@ -1643,14 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
return {
|
||||
async queuePromptTurn(next) {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1688,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(next, messageID, client, () => 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<Sessio
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
await runTurnWait(next, messageID, client, () => 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;
|
||||
|
||||
@@ -75,6 +75,7 @@ export type RunPrompt = {
|
||||
messageID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
delivery?: "steer" | "queue"
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, signal) => {
|
||||
admit: async (_prompt, _delivery, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user