mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user