Browse Source

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.
Simon Klee 3 months ago
parent
commit
7ccb7889af

+ 120 - 1
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<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[]>

+ 18 - 15
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -63,7 +63,6 @@ type PromptInput = {
   directory: string
   findFiles: (query: string) => Promise<string[]>
   agents: Accessor<RunAgent[]>
-  subagents: Accessor<number>
   resources: Accessor<RunResource[]>
   commands: Accessor<RunCommand[] | undefined>
   tuiConfig: RunTuiConfig
@@ -79,7 +78,6 @@ type PromptInput = {
   onInputClear: () => void
   onExitRequest?: () => boolean
   onExit: () => void
-  onSubagentMenu?: () => void
   onRows: (rows: number) => void
   onStatus: (text: string) => void
 }
@@ -98,6 +96,7 @@ export type PromptState = {
   onKeyDown: (event: KeyEvent) => void
   onContentChange: () => void
   replaceDraft: (text: string) => void
+  replacePrompt: (prompt: RunPrompt) => void
   bind: (area?: TextareaRenderable) => void
 }
 
@@ -791,12 +790,20 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     if (next.kind === "slash") {
-      const text = `/${next.name} `
       const cursor = area.cursorOffset
+      const head = slashHead(area.plainText)
+      const local = !shell() && (next.name === "new" || next.name === "exit")
+      const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
+      const text = `/${next.name}${separator}`
 
       area.cursorOffset = 0
       const start = area.logicalCursor
-      area.cursorOffset = cursor
+      area.cursorOffset =
+        shell() || !head
+          ? cursor
+          : local
+            ? Bun.stringWidth(area.plainText)
+            : Bun.stringWidth(area.plainText.slice(0, head.end))
       const end = area.logicalCursor
 
       area.deleteRange(start.row, start.col, end.row, end.col)
@@ -804,6 +811,11 @@ export function createPromptState(input: PromptInput): PromptState {
       area.cursorOffset = Bun.stringWidth(text)
       hide()
       syncDraft()
+      if (!shell()) {
+        submitPrompt(clonePrompt(draft))
+        return
+      }
+
       scheduleRows()
       area.focus()
       return
@@ -888,6 +900,7 @@ export function createPromptState(input: PromptInput): PromptState {
     if (current === "command") return false
     if (current === "model") return false
     if (current === "variant") return false
+    if (current === "queued-menu") return false
     if (current === "subagent-menu") return false
     return true
   }
@@ -957,17 +970,6 @@ export function createPromptState(input: PromptInput): PromptState {
     mode: OPENCODE_BASE_MODE,
     enabled: input.prompt() && !visible(),
     bindings: [
-      {
-        key: "down",
-        desc: "View subagents",
-        group: "Prompt",
-        cmd() {
-          if (!area || area.isDestroyed) return false
-          if (area.plainText.length !== 0) return false
-          if (input.subagents() === 0) return false
-          input.onSubagentMenu?.()
-        },
-      },
       {
         key: "!",
         desc: "Shell mode",
@@ -1199,6 +1201,7 @@ export function createPromptState(input: PromptInput): PromptState {
       scheduleRows()
     },
     replaceDraft,
+    replacePrompt: restore,
     bind,
   }
 }

+ 37 - 4
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<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()
   }

+ 102 - 7
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<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}>

+ 66 - 26
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<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(() => {})
   }

+ 2 - 2
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

+ 14 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -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

+ 2 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -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",

+ 121 - 9
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<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()

+ 86 - 0
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<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[] = []

+ 1 - 0
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)

+ 1 - 0
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)