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
parent f9ba23ab62
commit 7ccb7889af
12 changed files with 570 additions and 64 deletions
@@ -5,7 +5,7 @@ import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
type PanelEntry = RunFooterMenuItem & {
category: string
@@ -14,6 +14,7 @@ type PanelEntry = RunFooterMenuItem & {
type CommandEntry =
| (PanelEntry & { action: "model" })
| (PanelEntry & { action: "queued" })
| (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "variant.cycle" })
| (PanelEntry & { action: "variant.list" })
@@ -37,6 +38,10 @@ type SubagentEntry = PanelEntry & {
current: boolean
}
type QueuedEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type MenuState = ReturnType<typeof createFooterMenuState>
const PANEL_PAD = 2
@@ -294,11 +299,13 @@ export function RunCommandMenuBody(props: {
theme: Accessor<RunFooterTheme>
commands: Accessor<RunCommand[] | undefined>
subagents: Accessor<FooterSubagentTab[]>
queued: Accessor<FooterQueuedPrompt[]>
variants: Accessor<string[]>
variantCycle: string
onClose: () => void
onModel: () => void
onSubagent: () => void
onQueued: () => void
onVariant: () => void
onVariantCycle: () => void
onCommand: (name: string) => void
@@ -315,6 +322,17 @@ export function RunCommandMenuBody(props: {
category: "Suggested",
display: "Switch model",
},
...(props.queued().length > 0
? [
{
action: "queued" as const,
category: "Suggested",
display: "Manage queued prompts",
footer: `${props.queued().length} queued`,
keywords: props.queued().map((item) => item.prompt.text).join(" "),
},
]
: []),
...(props.subagents().length > 0
? [
{
@@ -387,6 +405,11 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "queued") {
props.onQueued()
return
}
if (item.action === "variant.cycle") {
props.onVariantCycle()
return
@@ -559,6 +582,102 @@ export function RunSubagentSelectBody(props: {
)
}
export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
onRows?: (rows: number) => void
}) {
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const entries = createMemo<QueuedEntry[]>(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: "queued · ctrl+e edit · ctrl+d remove",
keywords: prompt.prompt.text,
prompt,
})),
)
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
const selected = () => items()[menu.selected()]
createEffect(() => {
query()
menu.reset()
})
createEffect(() => {
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
})
useKeyboard((event) => {
if (event.defaultPrevented) {
return
}
const item = selected()
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
event.preventDefault()
props.onDelete(item.prompt)
return
}
if (item && ctrl && event.name === "e") {
event.preventDefault()
props.onEdit(item.prompt)
return
}
handleKey({
event,
menu,
field: () => field,
setQuery,
select: () => {
const item = selected()
if (item) props.onEdit(item.prompt)
},
close: props.onClose,
})
})
return (
<PanelShell
id="run-direct-footer-queued-panel"
title="Queued prompts"
query={query()}
count={items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={(input) => {
field = input
}}
onQuery={setQuery}
>
<RunFooterMenu
id="run-direct-footer-queued-list"
theme={props.theme}
items={items}
selected={menu.selected}
offset={menu.offset}
rows={menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No queued prompts"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
/>
</PanelShell>
)
}
export function RunVariantSelectBody(props: {
theme: Accessor<RunFooterTheme>
variants: Accessor<string[]>
@@ -63,7 +63,6 @@ type PromptInput = {
directory: string
findFiles: (query: string) => Promise<string[]>
agents: Accessor<RunAgent[]>
subagents: Accessor<number>
resources: Accessor<RunResource[]>
commands: Accessor<RunCommand[] | undefined>
tuiConfig: RunTuiConfig
@@ -79,7 +78,6 @@ type PromptInput = {
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onSubagentMenu?: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
}
@@ -98,6 +96,7 @@ export type PromptState = {
onKeyDown: (event: KeyEvent) => void
onContentChange: () => void
replaceDraft: (text: string) => void
replacePrompt: (prompt: RunPrompt) => void
bind: (area?: TextareaRenderable) => void
}
@@ -791,12 +790,20 @@ export function createPromptState(input: PromptInput): PromptState {
}
if (next.kind === "slash") {
const text = `/${next.name} `
const cursor = area.cursorOffset
const head = slashHead(area.plainText)
const local = !shell() && (next.name === "new" || next.name === "exit")
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
const text = `/${next.name}${separator}`
area.cursorOffset = 0
const start = area.logicalCursor
area.cursorOffset = cursor
area.cursorOffset =
shell() || !head
? cursor
: local
? Bun.stringWidth(area.plainText)
: Bun.stringWidth(area.plainText.slice(0, head.end))
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
@@ -804,6 +811,11 @@ export function createPromptState(input: PromptInput): PromptState {
area.cursorOffset = Bun.stringWidth(text)
hide()
syncDraft()
if (!shell()) {
submitPrompt(clonePrompt(draft))
return
}
scheduleRows()
area.focus()
return
@@ -888,6 +900,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (current === "command") return false
if (current === "model") return false
if (current === "variant") return false
if (current === "queued-menu") return false
if (current === "subagent-menu") return false
return true
}
@@ -957,17 +970,6 @@ export function createPromptState(input: PromptInput): PromptState {
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
bindings: [
{
key: "down",
desc: "View subagents",
group: "Prompt",
cmd() {
if (!area || area.isDestroyed) return false
if (area.plainText.length !== 0) return false
if (input.subagents() === 0) return false
input.onSubagentMenu?.()
},
},
{
key: "!",
desc: "Shell mode",
@@ -1199,6 +1201,7 @@ export function createPromptState(input: PromptInput): PromptState {
scheduleRows()
},
replaceDraft,
replacePrompt: restore,
bind,
}
}
+37 -4
View File
@@ -42,6 +42,7 @@ import type {
FooterEvent,
FooterPatch,
FooterPromptRoute,
FooterQueuedPrompt,
FooterState,
FooterSubagentState,
FooterView,
@@ -164,6 +165,7 @@ export class RunFooter implements FooterApi {
private closed = false
private destroyed = false
private prompts = new Set<(input: RunPrompt) => void>()
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
private closes = new Set<() => void>()
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
private queue: StreamCommit[] = []
@@ -192,6 +194,8 @@ export class RunFooter implements FooterApi {
private setView: Setter<FooterView>
private subagent: Accessor<FooterSubagentState>
private setSubagent: (next: FooterSubagentState) => void
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS
private autocomplete = false
@@ -249,6 +253,9 @@ export class RunFooter implements FooterApi {
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
setSubagent("questions", reconcile(next.questions, { key: "id" }))
}
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
this.queuedPrompts = queuedPrompts
this.setQueuedPrompts = setQueuedPrompts
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
diffStyle: options.diffStyle,
@@ -270,6 +277,7 @@ export class RunFooter implements FooterApi {
state: footer.state,
view: footer.view,
subagent: footer.subagent,
queuedPrompts: footer.queuedPrompts,
findFiles: options.findFiles,
agents: footer.agents,
resources: footer.resources,
@@ -299,6 +307,7 @@ export class RunFooter implements FooterApi {
onLayout: footer.syncLayout,
onStatus: footer.setStatus,
onSubagentSelect: options.onSubagentSelect,
onQueuedRemove: footer.handleQueuedRemove,
})
},
}),
@@ -325,6 +334,13 @@ export class RunFooter implements FooterApi {
}
}
public onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void {
this.queuedRemoves.add(fn)
return () => {
this.queuedRemoves.delete(fn)
}
}
public onClose(fn: () => void): () => void {
if (this.isClosed) {
fn()
@@ -370,6 +386,15 @@ export class RunFooter implements FooterApi {
return
}
if (next.type === "queued.prompts") {
if (this.isGone) {
return
}
this.setQueuedPrompts(next.prompts)
return
}
const patch = eventPatch(next)
if (patch) {
this.patch(patch)
@@ -546,6 +571,11 @@ export class RunFooter implements FooterApi {
this.requestExitHandler = fn
}
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
const fn = [...this.queuedRemoves][0]
return fn ? await fn(messageID) : false
}
private handleInputClear = (): void => {
this.clearInterruptTimer()
this.clearExitTimer()
@@ -573,11 +603,13 @@ export class RunFooter implements FooterApi {
? 1 + MODEL_ROWS
: this.promptRoute.type === "variant"
? 1 + VARIANT_ROWS
: this.promptRoute.type === "subagent-menu"
: this.promptRoute.type === "queued-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
: this.promptRoute.type === "subagent-menu"
? 1 + this.subagentMenuRows
: this.promptRoute.type === "subagent"
? this.base + SUBAGENT_INSPECTOR_ROWS
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
if (height !== this.renderer.footerHeight) {
this.renderer.footerHeight = height
@@ -872,6 +904,7 @@ export class RunFooter implements FooterApi {
this.clearExitTimer()
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
this.prompts.clear()
this.queuedRemoves.clear()
this.closes.clear()
this.scrollback.destroy()
}
@@ -18,6 +18,7 @@ import {
RUN_SUBAGENT_PANEL_ROWS,
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "./footer.command"
@@ -35,6 +36,7 @@ import {
} from "@/cli/cmd/tui/keymap"
import type {
FooterPromptRoute,
FooterQueuedPrompt,
FooterState,
FooterSubagentState,
FooterView,
@@ -79,6 +81,7 @@ type RunFooterViewProps = {
state: () => FooterState
view?: () => FooterView
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme?: RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
@@ -100,6 +103,7 @@ type RunFooterViewProps = {
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
onStatus: (text: string) => void
onSubagentSelect?: (sessionID: string | undefined) => void
onQueuedRemove: (messageID: string) => Promise<boolean>
}
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
@@ -119,13 +123,15 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
const panel = createMemo(() => selectingSubagent() || commanding() || modeling() || varianting())
const panel = createMemo(() => selectingQueued() || selectingSubagent() || commanding() || modeling() || varianting())
const selected = createMemo(() => {
const current = route()
return current.type === "subagent" ? current.sessionID : undefined
@@ -151,6 +157,11 @@ export function RunFooterView(props: RunFooterViewProps) {
label: count === 1 ? "agent" : "agents",
}
})
const queuedIndicator = createMemo(() => {
const count = queuedPrompts().length
if (count === 0) return
return { count, label: count === 1 ? "prompt" : "prompts" }
})
const detail = createMemo(() => {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
@@ -180,11 +191,28 @@ export function RunFooterView(props: RunFooterViewProps) {
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts"),
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }).get("session.child.first"),
props.tuiConfig,
) ?? "",
)
const hints = createMemo(() => hintFlags(term().width))
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 additionalQueue = createMemo(() => Math.max(0, queue() - queuedPrompts().length))
const duration = createMemo(() => props.state().duration)
const usage = createMemo(() => props.state().usage)
const interruptKey = createMemo(() => interrupt() || "/exit")
@@ -248,6 +276,12 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onSubagentSelect?.(undefined)
}
const openQueuedMenu = () => {
if (queuedPrompts().length === 0) return
setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined)
}
const closePanel = () => {
setRoute({ type: "composer" })
}
@@ -282,7 +316,6 @@ export function RunFooterView(props: RunFooterViewProps) {
directory: props.directory,
findFiles: props.findFiles,
agents: props.agents,
subagents: () => tabs().length,
resources: props.resources,
commands: props.commands,
tuiConfig: props.tuiConfig,
@@ -298,7 +331,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onInputClear: props.onInputClear,
onExitRequest: props.onExitRequest,
onExit: props.onExit,
onSubagentMenu: openSubagentMenu,
onRows: props.onRows,
onStatus: props.onStatus,
})
@@ -336,6 +368,34 @@ export function RunFooterView(props: RunFooterViewProps) {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [
{
name: "session.child.first",
title: "View subagents",
category: "Session",
run: openSubagentMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
name: "session.queued_prompts",
title: "Manage queued prompts",
category: "Session",
run: openQueuedMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
createEffect(() => {
const current = route()
if (current.type !== "subagent") {
@@ -361,6 +421,11 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
})
createEffect(() => {
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
closePanel()
})
createEffect(() => {
if (active().type === "prompt") {
return
@@ -371,6 +436,7 @@ export function RunFooterView(props: RunFooterViewProps) {
current.type !== "command" &&
current.type !== "model" &&
current.type !== "variant" &&
current.type !== "queued-menu" &&
current.type !== "subagent-menu"
) {
return
@@ -449,16 +515,32 @@ export function RunFooterView(props: RunFooterViewProps) {
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queuedPrompts}
onClose={closePanel}
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
onEdit={async (item) => {
if (!(await props.onQueuedRemove(item.messageID))) return
closePanel()
queueMicrotask(() => composer.replacePrompt(item.prompt))
}}
onRows={setSubagentMenuRows}
/>
</Match>
<Match when={commanding()}>
<RunCommandMenuBody
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queuedPrompts}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
onModel={openModel}
onSubagent={openSubagentMenu}
onQueued={openQueuedMenu}
onVariant={openVariant}
onVariantCycle={() => {
props.onCycle()
@@ -615,7 +697,7 @@ export function RunFooterView(props: RunFooterViewProps) {
gap={1}
flexShrink={0}
>
<Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
<Show when={busy() || exiting() || duration().length > 0 || queuedIndicator() || subagentIndicator()}>
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
<Show when={exiting()}>
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
@@ -665,11 +747,24 @@ export function RunFooterView(props: RunFooterViewProps) {
</Show>
{info().count} <span style={{ fg: theme().muted }}>{info().label}</span>
<span style={{ fg: theme().muted }}> · </span>
<span style={{ fg: theme().highlight }}></span>
<span style={{ fg: theme().highlight }}>{subagentShortcut() || "leader+down"}</span>
<span style={{ fg: theme().muted }}> to view</span>
</text>
)}
</Show>
<Show when={queuedIndicator()}>
{(info) => (
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>
<Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
<span style={{ fg: theme().muted }}>· </span>
</Show>
{info().count} <span style={{ fg: theme().muted }}>queued {info().label}</span>
<span style={{ fg: theme().muted }}> · </span>
<span style={{ fg: theme().highlight }}>{queuedShortcut() || "leader+q"}</span>
<span style={{ fg: theme().muted }}> to edit/remove</span>
</text>
)}
</Show>
</box>
</Show>
@@ -686,9 +781,9 @@ export function RunFooterView(props: RunFooterViewProps) {
when={shell()}
fallback={
<>
<Show when={queue() > 0}>
<Show when={additionalQueue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
{additionalQueue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
@@ -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(() => {})
}
@@ -5,8 +5,8 @@
// produce scrollback commits and footer patches, which get forwarded to the
// footer through stream.ts.
//
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
// SDK, arms a deferred Wait, and resolves when the session becomes idle.
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt, arms a
// deferred Wait, and resolves when the session becomes idle.
// Prefer session.status idle events, but also poll session.status because some
// transports can miss status events while still delivering message events. If
// the turn is aborted (user interrupt), it flushes any in-progress parts as
@@ -31,6 +31,8 @@ export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"
export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
export type RunPrompt = {
messageID?: string
partID?: string
text: string
parts: RunPromptPart[]
mode?: "shell"
@@ -40,6 +42,12 @@ export type RunPrompt = {
}
}
export type FooterQueuedPrompt = {
messageID: string
partID: string
prompt: RunPrompt
}
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
@@ -162,6 +170,7 @@ export type FooterView =
export type FooterPromptRoute =
| { type: "composer" }
| { type: "queued-menu" }
| { type: "subagent-menu" }
| { type: "subagent"; sessionID: string }
| { type: "command" }
@@ -222,6 +231,10 @@ export type FooterEvent =
type: "queue"
queue: number
}
| {
type: "queued.prompts"
prompts: FooterQueuedPrompt[]
}
| {
type: "first"
first: boolean
@@ -302,6 +315,7 @@ export type StreamCommit = {
export type FooterApi = {
readonly isClosed: boolean
onPrompt(fn: (input: RunPrompt) => void): () => void
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
onClose(fn: () => void): () => void
event(next: FooterEvent): void
append(commit: StreamCommit): void
@@ -95,6 +95,7 @@ export const Definitions = {
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
@@ -292,6 +293,7 @@ export const CommandMap = {
session_compact: "session.compact",
session_toggle_timestamps: "session.toggle.timestamps",
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
session_queued_prompts: "session.queued_prompts",
session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
@@ -10,6 +10,7 @@ import {
RUN_SUBAGENT_PANEL_ROWS,
RunCommandMenuBody,
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "@/cli/cmd/run/footer.command"
@@ -23,6 +24,7 @@ import type {
FooterView,
RunCommand,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
@@ -147,7 +149,14 @@ function footerState(input: Partial<FooterState> = {}) {
})[0]
}
async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) {
async function renderFooter(
input: {
tuiConfig?: RunTuiConfig
commands?: RunCommand[]
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
} = {},
) {
const [view] = createSignal<FooterView>({ type: "prompt" })
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
const state = footerState()
@@ -166,7 +175,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
findFiles={async () => []}
agents={() => []}
resources={() => []}
commands={() => []}
commands={() => input.commands ?? []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
@@ -177,7 +186,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
theme={RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
@@ -189,7 +198,8 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
)
@@ -276,11 +286,13 @@ test("direct command panel renders grouped command palette", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
subagents={subagents}
queued={() => []}
variants={variants}
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
@@ -334,11 +346,13 @@ test("direct command panel shows subagent entry when available", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
subagents={subagents}
queued={() => []}
variants={variants}
variantCycle="ctrl+t"
onClose={() => {}}
onModel={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
onVariantCycle={() => {}}
onCommand={() => {}}
@@ -407,6 +421,36 @@ test("direct subagent panel renders active subagents", async () => {
}
})
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
])
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onEdit={() => {}}
onDelete={() => {}}
/>
</box>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Queued prompts")
expect(app.captureCharFrame()).toContain("fix the auth test")
expect(app.captureCharFrame()).toContain("queued")
} finally {
app.renderer.destroy()
}
})
// OpenTUI currently segfaults when the full footer view suite creates several
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
test.skip("direct footer opens command panel through keymap binding", async () => {
@@ -462,11 +506,73 @@ test("direct footer keeps leader variant binding inactive when leader is disable
}
})
test("direct footer shows subagent indicator while prompt is running", async () => {
test("direct footer submits slash autocomplete selections without dispatching shell completions", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "review", description: "Review code" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressKey("TAB")
await app.renderOnce()
"/re branch".split("").forEach((key) => app.mockInput.pressKey(key))
Array.from({ length: 7 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
app.mockInput.pressKey("v")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/nx".split("").forEach((key) => app.mockInput.pressKey(key))
app.mockInput.pressKey("ARROW_LEFT")
app.mockInput.pressKey("e")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"/n scratch".split("").forEach((key) => app.mockInput.pressKey(key))
Array.from({ length: 8 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
app.mockInput.pressKey("e")
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
app.mockInput.pressKey("!")
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
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: [] },
])
expect(app.captureCharFrame()).toContain("/review")
} finally {
app.cleanup()
}
})
test("direct footer shows editable prompts and additional queued work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
queue: 0,
queue: 3,
model: "gpt-5",
duration: "",
usage: "",
@@ -502,6 +608,9 @@ test("direct footer shows subagent indicator while prompt is running", async ()
state={state}
view={view}
subagent={subagents}
queuedPrompts={() => [
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
]}
theme={RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
agent="opencode"
@@ -518,6 +627,7 @@ test("direct footer shows subagent indicator while prompt is running", async ()
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
)
@@ -525,19 +635,21 @@ test("direct footer shows subagent indicator while prompt is running", async ()
const app = await testRender(
() => (
<box width={100} height={8}>
<box width={160} height={8}>
<Harness />
</box>
),
{
width: 100,
width: 160,
height: 8,
},
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ctrl+x down to view · 1 queued prompt · ctrl+x q")
expect(app.captureCharFrame()).toContain("2 queued")
expect(app.captureCharFrame()).not.toContain("agent · ·")
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
@@ -4,6 +4,7 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
@@ -19,6 +20,12 @@ function footer() {
prompts.delete(fn)
}
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => {
queuedRemoves.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
@@ -66,6 +73,9 @@ function footer() {
fn(next)
}
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) fn(messageID)
},
}
}
@@ -289,6 +299,82 @@ describe("run runtime queue", () => {
expect(seen).toEqual(["one", "two"])
})
test("exposes ordinary in-flight prompts for removal before sending", async () => {
const ui = footer()
const turns: RunPrompt[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input)
await gate
},
})
ui.submit("one")
ui.submit("two")
await Promise.resolve()
await Promise.resolve()
expect(turns.map((item) => item.text)).toEqual(["one"])
expect(turns[0]?.messageID).toBeUndefined()
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
const first = ui.events.find((item) => item.type === "queued.prompts")
const event = ui.events.findLast((item) => item.type === "queued.prompts")
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
expect(first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true).toBe(
false,
)
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
await Promise.resolve()
wake?.()
ui.api.close()
await task
expect(turns.map((item) => item.text)).toEqual(["one"])
})
test("removing one managed queued prompt preserves the others", async () => {
const ui = footer()
const turns: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
wake = resolve
})
const task = runPromptQueue({
footer: ui.api,
run: async (input) => {
turns.push(input.text)
if (input.text === "active") await gate
if (input.text === "queued three") ui.api.close()
},
})
ui.submit("active")
ui.submit("queued one")
ui.submit("queued two")
ui.submit("queued three")
await Promise.resolve()
await Promise.resolve()
const event = ui.events.findLast((item) => item.type === "queued.prompts")
if (event?.type === "queued.prompts") {
const second = event.prompts.find((item) => item.prompt.text === "queued two")
if (second) ui.removeQueued(second.messageID)
}
wake?.()
await task
expect(turns).toEqual(["active", "queued one", "queued three"])
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const seen: string[] = []
@@ -9,6 +9,7 @@ function footer() {
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
@@ -358,6 +358,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event(next) {
events.push(next)