From cb5b092090ef0a43f59c10e45fc5a25dfb5970d0 Mon Sep 17 00:00:00 2001 From: Eli Eliyahu Date: Wed, 24 Jun 2026 16:39:33 +0300 Subject: [PATCH] feat(tui): add queued follow-up messages (chain feature) Adds /queue, /queue-com, and /queue-new prompt commands that chain follow-up messages to fire only after the current turn fully completes, plus /queue-edit-N and /queue-remove-N for managing the queue. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 36 +++ packages/tui/src/app.tsx | 3 + .../tui/src/component/prompt/autocomplete.tsx | 42 ++++ packages/tui/src/component/prompt/index.tsx | 178 ++++++++++++++- packages/tui/src/context/chain.tsx | 214 ++++++++++++++++++ 5 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/context/chain.tsx diff --git a/AGENTS.md b/AGENTS.md index 4c6be738db56..b475f5034143 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,3 +156,39 @@ const table = sqliteTable("session", { - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. + +## Custom Features + +These are project-specific features added on top of upstream opencode. Document any new one here so future agents can extend it without re-deriving the design. + +### Queued follow-ups (the "chain" feature) + +**What it does.** Lets the user queue follow-up messages from the prompt that fire **only after the agent has completely finished the current turn** — not mid-turn. The default TUI behavior of typing while busy uses `steer` delivery, which can inject between provider steps; the chain feature instead waits for true idle. Messages chain: each waits its turn, runs to full completion, then the next runs. Mixed permutations of the three variants chain correctly. + +**Three variants (slash commands typed in the prompt):** + +- `/queue ` — kind `"followup"`. Plain follow-up in the **same** session, sent after the current turn fully completes. +- `/queue-com ` — kind `"compact"`. Compacts the **same** session first (a compaction checkpoint slices model context server-side and renders as the visible boundary), then answers in place. No new chat. +- `/queue-new ` — kind `"fresh"`. Opens a **brand-new standalone** session and answers there. Must NOT set `parentID` — a `parentID` makes opencode render the session as a sub-agent and hides the prompt input (see `packages/tui/src/routes/session/index.tsx` `visible` memo, ~line 235). + +**Queue management commands:** + +- `/queue-edit-N` — load queued message N (1-based) into the prompt for editing. Enter applies (`chain.update`), empty input leaves it unchanged, Esc cancels. If the edited job is consumed mid-edit, the edit is discarded and the draft restored (a `createEffect` watches `chain.has(id)`). +- `/queue-remove-N` — remove queued message N (1-based) from the queue. Cancels the edit first if that job is being edited. + +**Architecture (TUI-only; ephemeral — jobs live in memory, lost on TUI restart, like the upstream queue).** + +- `packages/tui/src/context/chain.tsx` — the orchestrator context (`ChainProvider` / `useChain`). Holds a `createStore` of `{ jobs: ChainJob[], running: boolean }`. Exposes `jobs`, `running`, `enqueue`, `update`, `remove`, `has`, `clear`. + - `pump()` is a single-flight async loop (guarded by `store.running`) that processes jobs FIFO. It threads a `head` session forward: a `fresh` job advances the head to its new session; `followup`/`compact` keep it. `head ?? job.sessionID` seeds the first job from where the command was typed. + - `dispatch(job, sourceID)` waits for the source turn to fully finish (`waitForIdle`), then per kind: `followup` → `prompt` same session; `compact` → `summarize` (which awaits compaction server-side before resolving, so no extra status polling) then `prompt`; `fresh` → `create` (no `parentID`) + `navigate` + `prompt`. Returns the session the message landed in (the next head). + - `waitForIdle`/`waitForBusy` poll `sync.session.status(sessionID)` (returns `"idle"`/`"working"`/`"compacting"`, see `packages/tui/src/context/sync.tsx` ~line 567). After each dispatch the pump calls `waitForBusy(ranIn)` so the next job doesn't read a stale `"idle"` from the just-finished turn. +- `packages/tui/src/app.tsx` — `` is mounted just inside ``, **above** the session route, so a `/queue-new` navigation doesn't tear down the queue. +- `packages/tui/src/component/prompt/index.tsx` — in `submitInner`, intercepts (before any server call) `/queue`, `/queue-com`, `/queue-new` (longest-name-first regex so prefixes don't collide), `/queue-edit-N`, and `/queue-remove-N`. Also: edit-mode state (`editingJobID` signal, `beginEditJob`/`endEditJob`), the mid-edit cancellation effect, the Esc-to-cancel binding, the edit-mode placeholder/highlight, and the per-job rectangles rendered above the input (`` with serial number, kind tag, one-line truncated text). +- `packages/tui/src/component/prompt/autocomplete.tsx` — registers `/queue`, `/queue-com`, `/queue-new` static entries plus dynamic `/queue-edit-N` and `/queue-remove-N` entries (one pair per queued job, with a text preview). All gated on an active `sessionID`. + +**Key invariants for future edits:** + +- `parentID` must stay omitted for `fresh` sessions (else the prompt is hidden). +- `session.create` takes `model: { id, providerID, variant }`; `session.prompt` takes `model: { providerID, modelID }`. Don't mix these. +- `summarize` resolves only after compaction is written server-side — rely on the awaited promise, don't add status polling after it (a no-op summarize would otherwise stall on a `waitForBusy` timeout). +- Reactivity: reads of `chain.jobs` / `chain.has(...)` inside a `createMemo`/`createEffect`/`` are tracked by SolidJS dynamically; no need to wrap them. diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 39aa35993f76..c083e40f3342 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -37,6 +37,7 @@ import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" +import { ChainProvider } from "./context/chain" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" @@ -298,6 +299,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + @@ -316,6 +318,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83eb7..6821be903092 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -9,6 +9,7 @@ import { useEditorContext } from "../../context/editor" import { useProject } from "../../context/project" import { useSDK } from "../../context/sdk" import { useSync } from "../../context/sync" +import { useChain } from "../../context/chain" import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" @@ -87,6 +88,7 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() + const chain = useChain() const data = useData() const project = useProject() const slashes = useCommandSlashes() @@ -447,6 +449,46 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] + if (props.sessionID) { + const insertCommand = (name: string) => { + const newText = "/" + name + " " + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + } + results.push( + { + display: "/queue", + description: "Run a follow-up in this chat after the agent fully finishes answering", + onSelect: () => insertCommand("queue"), + }, + { + display: "/queue-com", + description: "Compact this session, then run a follow-up with only the summary as context", + onSelect: () => insertCommand("queue-com"), + }, + { + display: "/queue-new", + description: "Run a follow-up in a brand-new conversation after this turn finishes", + onSelect: () => insertCommand("queue-new"), + }, + ) + chain.jobs.forEach((job, index) => { + const preview = job.text.replace(/\s+/g, " ").slice(0, 40) + results.push({ + display: `/queue-edit-${index + 1}`, + description: `Edit queued message #${index + 1}: ${preview}`, + onSelect: () => insertCommand(`queue-edit-${index + 1}`), + }) + results.push({ + display: `/queue-remove-${index + 1}`, + description: `Remove queued message #${index + 1}: ${preview}`, + onSelect: () => insertCommand(`queue-remove-${index + 1}`), + }) + }) + } + for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue const label = serverCommand.source === "mcp" ? ":mcp" : "" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aa002080b1dd..0e16432cd3bb 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -9,7 +9,7 @@ import { type Renderable, } from "@opentui/core" import type { CommandContext } from "@opentui/keymap" -import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" +import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js" import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" @@ -24,6 +24,7 @@ import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useProject } from "../../context/project" import { useSync } from "../../context/sync" +import { useChain } from "../../context/chain" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" @@ -153,6 +154,7 @@ export function Prompt(props: PromptProps) { const route = useRoute() const project = useProject() const sync = useSync() + const chain = useChain() const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() @@ -208,6 +210,8 @@ export function Prompt(props: PromptProps) { const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + // Id of the queued chain job currently being edited in the prompt, if any. + const [editingJobID, setEditingJobID] = createSignal() function promptModelWarning() { toast.show({ @@ -224,6 +228,43 @@ export function Prompt(props: PromptProps) { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } + + // Load a queued chain job's text into the prompt for editing. Stashes any + // in-progress draft so it can be restored when the edit finishes/cancels. + let editStash: { input: string; parts: PromptInfo["parts"] } | undefined + function beginEditJob(jobID: string) { + if (editingJobID()) return + const job = chain.jobs.find((j) => j.id === jobID) + if (!job) return + editStash = { input: store.prompt.input, parts: unwrap(store.prompt.parts) } + setEditingJobID(jobID) + input.setText(job.text) + input.extmarks.clear() + setStore("prompt", { input: job.text, parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.gotoBufferEnd() + } + function endEditJob() { + setEditingJobID(undefined) + const restore = editStash + editStash = undefined + input.extmarks.clear() + input.setText(restore?.input ?? "") + setStore("prompt", { input: restore?.input ?? "", parts: restore?.parts ?? [] }) + setStore("extmarkToPartIndex", new Map()) + restoreExtmarksFromParts(restore?.parts ?? []) + input.gotoBufferEnd() + } + + // If the job being edited gets consumed (its turn starts) mid-edit, discard + // the edit, restore the previous draft, and tell the user it already ran. + createEffect(() => { + const id = editingJobID() + if (!id) return + if (chain.has(id)) return + endEditJob() + toast.show({ message: "Queued message already started; edit discarded", variant: "warning" }) + }) const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! @@ -619,7 +660,11 @@ export function Prompt(props: PromptProps) { }) onCleanup(() => { - if (store.prompt.input) { + // Mid-edit unmount: the input holds the queued message's text, not the + // user's draft. Stash the original draft (saved in editStash) instead. + if (editingJobID() && editStash) { + if (editStash.input) stashed = { prompt: { input: editStash.input, parts: editStash.parts }, cursor: 0 } + } else if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } setInputTarget(undefined) @@ -842,6 +887,14 @@ export function Prompt(props: PromptProps) { } }) + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && editingJobID() !== undefined, + bindings: [{ key: "escape", desc: "Cancel edit", group: "Prompt", cmd: () => endEditJob() }], + } + }) + useBindings(() => { return { target: inputTarget, @@ -948,6 +1001,57 @@ export function Prompt(props: PromptProps) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } + + // Editing a queued chain message: Enter applies the new text (or cancels if + // emptied) and returns to the normal prompt — never sends to the session. + const editingID = editingJobID() + if (editingID) { + if (auto()?.visible) return false + const next = store.prompt.input.trim() + if (next) chain.update(editingID, next) + else toast.show({ message: "Empty edit ignored; queued message unchanged", variant: "info" }) + endEditJob() + return true + } + + // /queue-edit-N: open queued message N (1-based) for editing. Handled before + // session creation since it only manipulates the existing queue. + const editMatch = /^\/queue-edit-(\d+)\s*$/.exec(store.prompt.input.trim()) + if (editMatch) { + const index = Number(editMatch[1]) - 1 + const job = chain.jobs[index] + if (!job) { + toast.show({ message: `No queued message #${editMatch[1]}`, variant: "warning" }) + return false + } + input.clear() + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + beginEditJob(job.id) + return true + } + + // /queue-remove-X: drop queued message X (1-based) from the queue. If it is + // the one currently being edited, the edit is cancelled via endEditJob. + const removeMatch = /^\/queue-remove-(\d+)\s*$/.exec(store.prompt.input.trim()) + if (removeMatch) { + const index = Number(removeMatch[1]) - 1 + const job = chain.jobs[index] + if (!job) { + toast.show({ message: `No queued message #${removeMatch[1]}`, variant: "warning" }) + return false + } + if (editingJobID() === job.id) endEditJob() + chain.remove(job.id) + input.clear() + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + toast.show({ message: `Removed queued message #${removeMatch[1]}`, variant: "info" }) + return true + } + if (props.disabled) return false if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false @@ -1030,6 +1134,35 @@ export function Prompt(props: PromptProps) { // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + // Intercept chained queue commands before any server call so the argument + // text is captured and nothing is sent into the current turn. Longest names + // first so /queue-com and /queue-new win over the /queue prefix. + const chainMatch = /^\/(queue-com|queue-new|queue)(?:\s+([\s\S]*))?$/.exec(inputText.trim()) + if (chainMatch) { + if (!props.sessionID) { + toast.show({ message: "Chained messages need an active session", variant: "warning" }) + return false + } + const text = (chainMatch[2] ?? "").trim() + if (!text) { + toast.show({ message: `Usage: /${chainMatch[1]} `, variant: "warning" }) + return false + } + chain.enqueue({ + kind: chainMatch[1] === "queue-com" ? "compact" : chainMatch[1] === "queue-new" ? "fresh" : "followup", + text, + parts: nonTextParts.filter((part) => part.type === "file"), + sessionID: props.sessionID, + }) + history.append({ ...store.prompt, mode: store.mode }) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + props.onSubmit?.() + return true + } + // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() @@ -1281,6 +1414,7 @@ export function Prompt(props: PromptProps) { const highlight = createMemo(() => { if (leader()) return theme.border + if (editingJobID()) return theme.primary if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1304,6 +1438,11 @@ export function Prompt(props: PromptProps) { const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined + if (editingJobID()) { + const index = chain.jobs.findIndex((j) => j.id === editingJobID()) + if (index === -1) return `Editing queued message — Enter to apply, Esc to cancel` + return `Editing queued message #${index + 1} — Enter to apply, Esc to cancel` + } if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] @@ -1351,6 +1490,41 @@ export function Prompt(props: PromptProps) { bottomLeft: "╹", }} > + 0}> + + + {(job, index) => { + const being = createMemo(() => editingJobID() === job.id) + const kindTag = job.kind === "compact" ? "com" : job.kind === "fresh" ? "new" : "queue" + const oneLine = createMemo(() => + Locale.truncate( + job.text.replace(/\s+/g, " ").trim(), + Math.max(10, Math.floor(dimensions().width) - 16), + ), + ) + return ( + + + {`${index() + 1})`} + {` [${kindTag}] `} + {oneLine()} + + — editing + + + + ) + }} + + + ["session"]["status"]> + +const POLL_MS = 150 +const WAIT_IDLE_TIMEOUT_MS = 10 * 60 * 1000 +const WAIT_WORKING_TIMEOUT_MS = 15 * 1000 + +export const { use: useChain, provider: ChainProvider } = createSimpleContext({ + name: "Chain", + init: () => { + const sdk = useSDK() + const sync = useSync() + const route = useRoute() + const local = useLocal() + const project = useProject() + const toast = useToast() + + const [store, setStore] = createStore<{ jobs: ChainJob[]; running: boolean }>({ + jobs: [], + running: false, + }) + + function status(sessionID: string): SessionStatus { + return sync.session.status(sessionID) + } + + function waitFor(sessionID: string, predicate: (status: SessionStatus) => boolean, timeoutMs: number) { + return new Promise((resolve) => { + if (predicate(status(sessionID))) return resolve(true) + const started = Date.now() + const timer = setInterval(() => { + if (predicate(status(sessionID))) { + clearInterval(timer) + resolve(true) + return + } + if (Date.now() - started > timeoutMs) { + clearInterval(timer) + resolve(false) + } + }, POLL_MS) + }) + } + + const waitForIdle = (sessionID: string) => waitFor(sessionID, (s) => s === "idle", WAIT_IDLE_TIMEOUT_MS) + const waitForBusy = (sessionID: string) => waitFor(sessionID, (s) => s !== "idle", WAIT_WORKING_TIMEOUT_MS) + + function model() { + const selected = local.model.current() + if (!selected) return undefined + return { + providerID: selected.providerID, + id: selected.modelID, + variant: local.model.variant.current(), + } + } + + function prompt(sessionID: string, agentName: string, providerID: string, modelID: string, variant: string | undefined, job: ChainJob) { + return sdk.client.session.prompt( + { + sessionID, + model: { providerID, modelID }, + agent: agentName, + variant, + parts: [{ type: "text", text: job.text }, ...job.parts], + }, + { throwOnError: true }, + ) + } + + // `sourceID` is the chain's current head — the session the previous job + // ended in, NOT the session captured when the command was typed. This keeps + // mixed permutations chaining correctly: each job waits on the live head. + // Returns the session the job's message landed in (the next head). + async function dispatch(job: ChainJob, sourceID: string): Promise { + const agent = local.agent.current() + const selectedModel = model() + if (!agent || !selectedModel) { + toast.show({ title: "Chained message skipped", message: "No model or agent selected", variant: "error" }) + return undefined + } + + // Make sure the source turn is fully finished before we touch the session. + await waitForIdle(sourceID) + + if (job.kind === "followup") { + // Plain follow-up in the same chat — like typing a normal message, but + // only after the agent has completely finished (not mid-turn steering). + await prompt(sourceID, agent.name, selectedModel.providerID, selectedModel.id, selectedModel.variant, job) + return sourceID + } + + if (job.kind === "compact") { + // Stay in the same session. Compaction inserts a checkpoint that both + // slices model context server-side and renders as the visible boundary, + // then we answer the queued message right here — no jump to a new chat. + // The summarize endpoint awaits compaction server-side before resolving + // (see opencode httpapi handler: compactSvc.create + promptSvc.loop), so + // the checkpoint is already written once this await returns — no extra + // status polling needed (and a no-op summarize won't stall us). + await sdk.client.session.summarize({ + sessionID: sourceID, + providerID: selectedModel.providerID, + modelID: selectedModel.id, + }) + await prompt(sourceID, agent.name, selectedModel.providerID, selectedModel.id, selectedModel.variant, job) + return sourceID + } + + // fresh: open a truly standalone session (no parentID — a parentID would + // make opencode render it as a sub-agent and hide the prompt). + const created = await sdk.client.session.create({ + directory: project.instance.directory(), + workspace: project.workspace.current() ?? undefined, + agent: agent.name, + model: { providerID: selectedModel.providerID, id: selectedModel.id, variant: selectedModel.variant }, + }) + if (created.error || !created.data) { + toast.show({ title: "Chained message failed", message: errorMessage(created.error ?? "no response"), variant: "error" }) + return undefined + } + const newID = created.data.id + route.navigate({ type: "session", sessionID: newID }) + await prompt(newID, agent.name, selectedModel.providerID, selectedModel.id, selectedModel.variant, job) + return newID + } + + async function pump() { + if (store.running) return + setStore("running", true) + // The chain head is the session each job's message lands in. A fresh job + // moves the head to its new session; a compact job keeps it in place. The + // next job waits on / summarizes that head. The first job seeds the head + // from where the command was typed. + let head: string | undefined + try { + while (store.jobs.length > 0) { + const job = store.jobs[0] + const target = head ?? job.sessionID + try { + const ranIn = await dispatch(job, target) + // Confirm the new turn actually started so the next job's waitForIdle + // doesn't read a stale "idle" from the just-finished turn. + if (ranIn) { + head = ranIn + await waitForBusy(ranIn) + } + } catch (error) { + toast.show({ title: "Chained message failed", message: errorMessage(error), variant: "error" }) + } + setStore("jobs", (jobs) => jobs.slice(1)) + } + } finally { + setStore("running", false) + } + } + + return { + get jobs() { + return store.jobs + }, + get running() { + return store.running + }, + enqueue(input: { kind: ChainKind; text: string; parts?: FilePartInput[]; sessionID: string }) { + const job: ChainJob = { + id: `chain_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + kind: input.kind, + text: input.text, + parts: input.parts ?? [], + sessionID: input.sessionID, + } + setStore("jobs", produce((jobs) => jobs.push(job))) + void pump() + }, + remove(id: string) { + setStore("jobs", (jobs) => jobs.filter((job) => job.id !== id)) + }, + update(id: string, text: string) { + const index = store.jobs.findIndex((job) => job.id === id) + if (index === -1) return false + setStore("jobs", index, "text", text) + return true + }, + has(id: string) { + return store.jobs.some((job) => job.id === id) + }, + clear() { + setStore("jobs", []) + }, + } + }, +})