opencode(run): add queued prompt management (#30103)

Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.
This commit is contained in:
Simon Klee 2026-06-01 09:26:09 +02:00 committed by GitHub
commit 7ccb7889af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 570 additions and 64 deletions

View file

@ -1,17 +1,17 @@
// Serial prompt queue for direct interactive mode.
//
// Prompts arrive from the footer (user types and hits enter) and queue up
// here. The queue drains one turn at a time: it appends the user row to
// scrollback, calls input.run() to execute the turn through the stream
// transport, and waits for completion before starting the next prompt.
// here. The queue drains one turn at a time; ordinary prompts waiting behind
// an active ordinary turn are exposed for edit/removal until they begin.
//
// The queue also handles /exit, /quit, and /new 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 * as Locale from "@/util/locale"
import { MessageID, PartID } from "@/session/schema"
import { isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
type Trace = {
write(type: string, data?: unknown): void
@ -34,6 +34,8 @@ export type QueueInput = {
type State = {
queue: RunPrompt[]
queued: FooterQueuedPrompt[]
active?: RunPrompt
ctrl?: AbortController
closed: boolean
}
@ -51,15 +53,15 @@ function defer<T = void>(): Deferred<T> {
// Runs the prompt queue until the footer closes.
//
// Subscribes to footer prompt events, queues them, and drains one at a
// time through input.run(). If the user submits multiple prompts while
// a turn is running, they queue up and execute in order. The footer shows
// the queue depth so the user knows how many are pending.
// Subscribes to footer prompt events and drains operations through input.run().
// Ordinary prompts submitted during an ordinary active turn remain local and
// are exposed by the footer for edit/removal until their turn begins.
export async function runPromptQueue(input: QueueInput): Promise<void> {
const stop = defer<{ type: "closed" }>()
const done = defer()
const state: State = {
queue: [],
queued: [],
closed: input.footer.isClosed,
}
let draining: Promise<void> | undefined
@ -69,6 +71,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.footer.event(next)
}
const syncQueue = () => {
const queue = state.queue.length
emit({ type: "queue", queue }, { queue })
emit(
{
type: "queued.prompts",
prompts: [...state.queued],
},
{ queued: state.queued.length },
)
}
const removeLocalQueued = (queued: FooterQueuedPrompt) => {
if (!state.queued.includes(queued)) return
state.queued = state.queued.filter((item) => item !== queued)
syncQueue()
}
const finish = () => {
if (!state.closed || draining) {
return
@ -84,6 +104,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
state.closed = true
state.queue.length = 0
state.queued.length = 0
state.ctrl?.abort()
stop.resolve({ type: "closed" })
finish()
@ -102,16 +123,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
continue
}
const queued = state.queued.find((item) => item.prompt === prompt)
if (queued) removeLocalQueued(queued)
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
emit(
{
type: "queue",
queue: state.queue.length,
},
{
queue: state.queue.length,
},
)
syncQueue()
if (!input.onNewSession) {
emit(
{
@ -146,6 +162,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
continue
}
state.active = prompt
emit(
{
type: "turn.send",
@ -192,6 +210,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
if (next.type === "error") {
throw next.error
}
} finally {
if (state.ctrl === ctrl) {
state.ctrl = undefined
@ -207,6 +226,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
duration,
},
)
state.active = undefined
}
}
} catch (error) {
@ -241,16 +261,28 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
return
}
const active = state.active
if (
active &&
active.mode !== "shell" &&
!active.command &&
prompt.mode !== "shell" &&
!prompt.command &&
!isNewCommand(prompt.text)
) {
const queued: FooterQueuedPrompt = {
messageID: MessageID.ascending(),
partID: PartID.ascending(),
prompt,
}
state.queued = [...state.queued, queued]
state.queue.push(prompt)
syncQueue()
return
}
state.queue.push(prompt)
emit(
{
type: "queue",
queue: state.queue.length,
},
{
queue: state.queue.length,
},
)
syncQueue()
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
drain()
return
@ -274,6 +306,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const offClose = input.footer.onClose(() => {
close()
})
const offRemoveQueued = input.footer.onQueuedRemove((messageID) => {
const queued = state.queued.find((item) => item.messageID === messageID)
if (!queued) return false
state.queue = state.queue.filter((prompt) => prompt !== queued.prompt)
removeLocalQueued(queued)
return true
})
try {
if (state.closed) {
@ -289,6 +328,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
} finally {
offPrompt()
offClose()
offRemoveQueued()
close()
await draining?.catch(() => {})
}