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:
parent
f9ba23ab62
commit
7ccb7889af
12 changed files with 570 additions and 64 deletions
|
|
@ -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(() => {})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue