Skip to content

Commit 7ccb788

Browse files
authored
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.
1 parent f9ba23a commit 7ccb788

12 files changed

Lines changed: 570 additions & 64 deletions

File tree

packages/opencode/src/cli/cmd/run/footer.command.tsx

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import fuzzysort from "fuzzysort"
55
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
66
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
77
import type { RunFooterTheme } from "./theme"
8-
import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
8+
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
99

1010
type PanelEntry = RunFooterMenuItem & {
1111
category: string
@@ -14,6 +14,7 @@ type PanelEntry = RunFooterMenuItem & {
1414

1515
type CommandEntry =
1616
| (PanelEntry & { action: "model" })
17+
| (PanelEntry & { action: "queued" })
1718
| (PanelEntry & { action: "subagent" })
1819
| (PanelEntry & { action: "variant.cycle" })
1920
| (PanelEntry & { action: "variant.list" })
@@ -37,6 +38,10 @@ type SubagentEntry = PanelEntry & {
3738
current: boolean
3839
}
3940

41+
type QueuedEntry = PanelEntry & {
42+
prompt: FooterQueuedPrompt
43+
}
44+
4045
type MenuState = ReturnType<typeof createFooterMenuState>
4146

4247
const PANEL_PAD = 2
@@ -294,11 +299,13 @@ export function RunCommandMenuBody(props: {
294299
theme: Accessor<RunFooterTheme>
295300
commands: Accessor<RunCommand[] | undefined>
296301
subagents: Accessor<FooterSubagentTab[]>
302+
queued: Accessor<FooterQueuedPrompt[]>
297303
variants: Accessor<string[]>
298304
variantCycle: string
299305
onClose: () => void
300306
onModel: () => void
301307
onSubagent: () => void
308+
onQueued: () => void
302309
onVariant: () => void
303310
onVariantCycle: () => void
304311
onCommand: (name: string) => void
@@ -315,6 +322,17 @@ export function RunCommandMenuBody(props: {
315322
category: "Suggested",
316323
display: "Switch model",
317324
},
325+
...(props.queued().length > 0
326+
? [
327+
{
328+
action: "queued" as const,
329+
category: "Suggested",
330+
display: "Manage queued prompts",
331+
footer: `${props.queued().length} queued`,
332+
keywords: props.queued().map((item) => item.prompt.text).join(" "),
333+
},
334+
]
335+
: []),
318336
...(props.subagents().length > 0
319337
? [
320338
{
@@ -387,6 +405,11 @@ export function RunCommandMenuBody(props: {
387405
return
388406
}
389407

408+
if (item.action === "queued") {
409+
props.onQueued()
410+
return
411+
}
412+
390413
if (item.action === "variant.cycle") {
391414
props.onVariantCycle()
392415
return
@@ -559,6 +582,102 @@ export function RunSubagentSelectBody(props: {
559582
)
560583
}
561584

585+
export function RunQueuedPromptSelectBody(props: {
586+
theme: Accessor<RunFooterTheme>
587+
prompts: Accessor<FooterQueuedPrompt[]>
588+
onClose: () => void
589+
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
590+
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
591+
onRows?: (rows: number) => void
592+
}) {
593+
let field: InputRenderable | undefined
594+
const [query, setQuery] = createSignal("")
595+
const entries = createMemo<QueuedEntry[]>(() =>
596+
props.prompts().map((prompt) => ({
597+
category: "",
598+
display: prompt.prompt.text.replaceAll("\n", " "),
599+
footer: "queued · ctrl+e edit · ctrl+d remove",
600+
keywords: prompt.prompt.text,
601+
prompt,
602+
})),
603+
)
604+
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
605+
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
606+
const selected = () => items()[menu.selected()]
607+
608+
createEffect(() => {
609+
query()
610+
menu.reset()
611+
})
612+
613+
createEffect(() => {
614+
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
615+
})
616+
617+
useKeyboard((event) => {
618+
if (event.defaultPrevented) {
619+
return
620+
}
621+
622+
const item = selected()
623+
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
624+
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
625+
event.preventDefault()
626+
props.onDelete(item.prompt)
627+
return
628+
}
629+
630+
if (item && ctrl && event.name === "e") {
631+
event.preventDefault()
632+
props.onEdit(item.prompt)
633+
return
634+
}
635+
636+
handleKey({
637+
event,
638+
menu,
639+
field: () => field,
640+
setQuery,
641+
select: () => {
642+
const item = selected()
643+
if (item) props.onEdit(item.prompt)
644+
},
645+
close: props.onClose,
646+
})
647+
})
648+
649+
return (
650+
<PanelShell
651+
id="run-direct-footer-queued-panel"
652+
title="Queued prompts"
653+
query={query()}
654+
count={items().length}
655+
total={entries().length}
656+
placeholder="Search"
657+
theme={props.theme}
658+
inputRef={(input) => {
659+
field = input
660+
}}
661+
onQuery={setQuery}
662+
>
663+
<RunFooterMenu
664+
id="run-direct-footer-queued-list"
665+
theme={props.theme}
666+
items={items}
667+
selected={menu.selected}
668+
offset={menu.offset}
669+
rows={menu.rows}
670+
limit={SUBAGENT_LIST_ROWS}
671+
empty="No queued prompts"
672+
border={false}
673+
paddingLeft={PANEL_PAD}
674+
paddingRight={PANEL_PAD}
675+
grouped={false}
676+
/>
677+
</PanelShell>
678+
)
679+
}
680+
562681
export function RunVariantSelectBody(props: {
563682
theme: Accessor<RunFooterTheme>
564683
variants: Accessor<string[]>

packages/opencode/src/cli/cmd/run/footer.prompt.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ type PromptInput = {
6363
directory: string
6464
findFiles: (query: string) => Promise<string[]>
6565
agents: Accessor<RunAgent[]>
66-
subagents: Accessor<number>
6766
resources: Accessor<RunResource[]>
6867
commands: Accessor<RunCommand[] | undefined>
6968
tuiConfig: RunTuiConfig
@@ -79,7 +78,6 @@ type PromptInput = {
7978
onInputClear: () => void
8079
onExitRequest?: () => boolean
8180
onExit: () => void
82-
onSubagentMenu?: () => void
8381
onRows: (rows: number) => void
8482
onStatus: (text: string) => void
8583
}
@@ -98,6 +96,7 @@ export type PromptState = {
9896
onKeyDown: (event: KeyEvent) => void
9997
onContentChange: () => void
10098
replaceDraft: (text: string) => void
99+
replacePrompt: (prompt: RunPrompt) => void
101100
bind: (area?: TextareaRenderable) => void
102101
}
103102

@@ -791,19 +790,32 @@ export function createPromptState(input: PromptInput): PromptState {
791790
}
792791

793792
if (next.kind === "slash") {
794-
const text = `/${next.name} `
795793
const cursor = area.cursorOffset
794+
const head = slashHead(area.plainText)
795+
const local = !shell() && (next.name === "new" || next.name === "exit")
796+
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
797+
const text = `/${next.name}${separator}`
796798

797799
area.cursorOffset = 0
798800
const start = area.logicalCursor
799-
area.cursorOffset = cursor
801+
area.cursorOffset =
802+
shell() || !head
803+
? cursor
804+
: local
805+
? Bun.stringWidth(area.plainText)
806+
: Bun.stringWidth(area.plainText.slice(0, head.end))
800807
const end = area.logicalCursor
801808

802809
area.deleteRange(start.row, start.col, end.row, end.col)
803810
area.insertText(text)
804811
area.cursorOffset = Bun.stringWidth(text)
805812
hide()
806813
syncDraft()
814+
if (!shell()) {
815+
submitPrompt(clonePrompt(draft))
816+
return
817+
}
818+
807819
scheduleRows()
808820
area.focus()
809821
return
@@ -888,6 +900,7 @@ export function createPromptState(input: PromptInput): PromptState {
888900
if (current === "command") return false
889901
if (current === "model") return false
890902
if (current === "variant") return false
903+
if (current === "queued-menu") return false
891904
if (current === "subagent-menu") return false
892905
return true
893906
}
@@ -957,17 +970,6 @@ export function createPromptState(input: PromptInput): PromptState {
957970
mode: OPENCODE_BASE_MODE,
958971
enabled: input.prompt() && !visible(),
959972
bindings: [
960-
{
961-
key: "down",
962-
desc: "View subagents",
963-
group: "Prompt",
964-
cmd() {
965-
if (!area || area.isDestroyed) return false
966-
if (area.plainText.length !== 0) return false
967-
if (input.subagents() === 0) return false
968-
input.onSubagentMenu?.()
969-
},
970-
},
971973
{
972974
key: "!",
973975
desc: "Shell mode",
@@ -1199,6 +1201,7 @@ export function createPromptState(input: PromptInput): PromptState {
11991201
scheduleRows()
12001202
},
12011203
replaceDraft,
1204+
replacePrompt: restore,
12021205
bind,
12031206
}
12041207
}

packages/opencode/src/cli/cmd/run/footer.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type {
4242
FooterEvent,
4343
FooterPatch,
4444
FooterPromptRoute,
45+
FooterQueuedPrompt,
4546
FooterState,
4647
FooterSubagentState,
4748
FooterView,
@@ -164,6 +165,7 @@ export class RunFooter implements FooterApi {
164165
private closed = false
165166
private destroyed = false
166167
private prompts = new Set<(input: RunPrompt) => void>()
168+
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
167169
private closes = new Set<() => void>()
168170
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
169171
private queue: StreamCommit[] = []
@@ -192,6 +194,8 @@ export class RunFooter implements FooterApi {
192194
private setView: Setter<FooterView>
193195
private subagent: Accessor<FooterSubagentState>
194196
private setSubagent: (next: FooterSubagentState) => void
197+
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
198+
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
195199
private promptRoute: FooterPromptRoute = { type: "composer" }
196200
private subagentMenuRows = SUBAGENT_ROWS
197201
private autocomplete = false
@@ -249,6 +253,9 @@ export class RunFooter implements FooterApi {
249253
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
250254
setSubagent("questions", reconcile(next.questions, { key: "id" }))
251255
}
256+
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
257+
this.queuedPrompts = queuedPrompts
258+
this.setQueuedPrompts = setQueuedPrompts
252259
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
253260
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
254261
diffStyle: options.diffStyle,
@@ -270,6 +277,7 @@ export class RunFooter implements FooterApi {
270277
state: footer.state,
271278
view: footer.view,
272279
subagent: footer.subagent,
280+
queuedPrompts: footer.queuedPrompts,
273281
findFiles: options.findFiles,
274282
agents: footer.agents,
275283
resources: footer.resources,
@@ -299,6 +307,7 @@ export class RunFooter implements FooterApi {
299307
onLayout: footer.syncLayout,
300308
onStatus: footer.setStatus,
301309
onSubagentSelect: options.onSubagentSelect,
310+
onQueuedRemove: footer.handleQueuedRemove,
302311
})
303312
},
304313
}),
@@ -325,6 +334,13 @@ export class RunFooter implements FooterApi {
325334
}
326335
}
327336

337+
public onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void {
338+
this.queuedRemoves.add(fn)
339+
return () => {
340+
this.queuedRemoves.delete(fn)
341+
}
342+
}
343+
328344
public onClose(fn: () => void): () => void {
329345
if (this.isClosed) {
330346
fn()
@@ -370,6 +386,15 @@ export class RunFooter implements FooterApi {
370386
return
371387
}
372388

389+
if (next.type === "queued.prompts") {
390+
if (this.isGone) {
391+
return
392+
}
393+
394+
this.setQueuedPrompts(next.prompts)
395+
return
396+
}
397+
373398
const patch = eventPatch(next)
374399
if (patch) {
375400
this.patch(patch)
@@ -546,6 +571,11 @@ export class RunFooter implements FooterApi {
546571
this.requestExitHandler = fn
547572
}
548573

574+
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
575+
const fn = [...this.queuedRemoves][0]
576+
return fn ? await fn(messageID) : false
577+
}
578+
549579
private handleInputClear = (): void => {
550580
this.clearInterruptTimer()
551581
this.clearExitTimer()
@@ -573,11 +603,13 @@ export class RunFooter implements FooterApi {
573603
? 1 + MODEL_ROWS
574604
: this.promptRoute.type === "variant"
575605
? 1 + VARIANT_ROWS
576-
: this.promptRoute.type === "subagent-menu"
606+
: this.promptRoute.type === "queued-menu"
577607
? 1 + this.subagentMenuRows
578-
: this.promptRoute.type === "subagent"
579-
? this.base + SUBAGENT_INSPECTOR_ROWS
580-
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
608+
: this.promptRoute.type === "subagent-menu"
609+
? 1 + this.subagentMenuRows
610+
: this.promptRoute.type === "subagent"
611+
? this.base + SUBAGENT_INSPECTOR_ROWS
612+
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
581613

582614
if (height !== this.renderer.footerHeight) {
583615
this.renderer.footerHeight = height
@@ -872,6 +904,7 @@ export class RunFooter implements FooterApi {
872904
this.clearExitTimer()
873905
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
874906
this.prompts.clear()
907+
this.queuedRemoves.clear()
875908
this.closes.clear()
876909
this.scrollback.destroy()
877910
}

0 commit comments

Comments
 (0)