diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index bc2434f3e4..d007707e8b 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -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 const PANEL_PAD = 2 @@ -294,11 +299,13 @@ export function RunCommandMenuBody(props: { theme: Accessor commands: Accessor subagents: Accessor + queued: Accessor variants: Accessor 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 + prompts: Accessor + onClose: () => void + onEdit: (prompt: FooterQueuedPrompt) => void | Promise + onDelete: (prompt: FooterQueuedPrompt) => void | Promise + onRows?: (rows: number) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + 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(() => 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 ( + { + field = input + }} + onQuery={setQuery} + > + + + ) +} + export function RunVariantSelectBody(props: { theme: Accessor variants: Accessor diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index fd4e9072f0..7f4a9ca5b2 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -63,7 +63,6 @@ type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor - subagents: Accessor resources: Accessor commands: Accessor 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, } } diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 4ac8b40d88..16c6b24207 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -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>() 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 private subagent: Accessor private setSubagent: (next: FooterSubagentState) => void + private queuedPrompts: Accessor + private setQueuedPrompts: Setter 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([]) + 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): () => 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 => { + 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() } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index bd37c1b211..839765d0af 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -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 } export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" @@ -119,13 +123,15 @@ export function RunFooterView(props: RunFooterViewProps) { }) const [route, setRoute] = createSignal({ 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} /> + + void props.onQueuedRemove(item.messageID)} + onEdit={async (item) => { + if (!(await props.onQueuedRemove(item.messageID))) return + closePanel() + queueMicrotask(() => composer.replacePrompt(item.prompt)) + }} + onRows={setSubagentMenuRows} + /> + { props.onCycle() @@ -615,7 +697,7 @@ export function RunFooterView(props: RunFooterViewProps) { gap={1} flexShrink={0} > - 0 || subagentIndicator()}> + 0 || queuedIndicator() || subagentIndicator()}> @@ -665,11 +747,24 @@ export function RunFooterView(props: RunFooterViewProps) { {info().count} {info().label} · - + {subagentShortcut() || "leader+down"} to view )} + + {(info) => ( + + 0 || subagentIndicator()}> + · + + {info().count} queued {info().label} + · + {queuedShortcut() || "leader+q"} + to edit/remove + + )} + @@ -686,9 +781,9 @@ export function RunFooterView(props: RunFooterViewProps) { when={shell()} fallback={ <> - 0}> + 0}> - {queue()} queued + {additionalQueue()} queued 0}> diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 79be71cadf..63ce618beb 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -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(): Deferred { // 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 { const stop = defer<{ type: "closed" }>() const done = defer() const state: State = { queue: [], + queued: [], closed: input.footer.isClosed, } let draining: Promise | undefined @@ -69,6 +71,24 @@ export async function runPromptQueue(input: QueueInput): Promise { 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 { 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 { 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 { continue } + state.active = prompt + emit( { type: "turn.send", @@ -192,6 +210,7 @@ export async function runPromptQueue(input: QueueInput): Promise { 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 { duration, }, ) + state.active = undefined } } } catch (error) { @@ -241,16 +261,28 @@ export async function runPromptQueue(input: QueueInput): Promise { 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 { 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 { } finally { offPrompt() offClose() + offRemoveQueued() close() await draining?.catch(() => {}) } diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 41a083c702..ca6a55d1d2 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 556b1f8623..8a88bb7ad9 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -31,6 +31,8 @@ export type RunCommand = NonNullable>["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>["data"]>[number] type RunResourceMap = NonNullable>["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): () => void onClose(fn: () => void): () => void event(next: FooterEvent): void append(commit: StreamCommit): void diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index c03123aed1..f69cb68fd8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -95,6 +95,7 @@ export const Definitions = { session_compact: keybind("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("q", "Manage queued prompts"), session_child_first: keybind("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", diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 0f7ddb532e..a4f4c296e6 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -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 = {}) { })[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({ type: "prompt" }) const [subagents] = createSignal({ 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} /> ) @@ -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( + () => ( + + RUN_THEME_FALLBACK.footer} + prompts={prompts} + onClose={() => {}} + onEdit={() => {}} + onDelete={() => {}} + /> + + ), + { 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({ 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} /> ) @@ -525,19 +635,21 @@ test("direct footer shows subagent indicator while prompt is running", async () const app = await testRender( () => ( - + ), { - 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() diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 5515787caf..ead73af890 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -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((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((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[] = [] diff --git a/packages/opencode/test/cli/run/stream.test.ts b/packages/opencode/test/cli/run/stream.test.ts index 9fb6e7b614..e6b40dd925 100644 --- a/packages/opencode/test/cli/run/stream.test.ts +++ b/packages/opencode/test/cli/run/stream.test.ts @@ -9,6 +9,7 @@ function footer() { const api: FooterApi = { isClosed: false, onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, onClose: () => () => {}, event: (next) => { events.push(next) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index d1b145db24..74fb7ec028 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -358,6 +358,7 @@ function footer(fn?: (commit: StreamCommit) => void) { return closed }, onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, onClose: () => () => {}, event(next) { events.push(next)