diff --git a/PLAN.md b/PLAN.md index f61f2cab6075..b73e61200290 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,7 @@ # Alt+Enter Queueing + Web Queue Button — Implementation Plan +> **Superseded** by [PLAN_UNIFIED_PROMPT_QUEUE.md](./PLAN_UNIFIED_PROMPT_QUEUE.md) (SQLite `prompt_queue`, dormant `delivery: "deferred"` on prompt). This file is kept for historical context only. + Based on thorough analysis of the codebase, existing scaffolding, and relevant PRs. ## Current State Summary diff --git a/PLAN_MAIN_TUI_QUEUE.md b/PLAN_MAIN_TUI_QUEUE.md new file mode 100644 index 000000000000..7d2e1914755b --- /dev/null +++ b/PLAN_MAIN_TUI_QUEUE.md @@ -0,0 +1,403 @@ +# Main TUI Prompt Queue — Implementation Plan + +> **Superseded** by [PLAN_UNIFIED_PROMPT_QUEUE.md](./PLAN_UNIFIED_PROMPT_QUEUE.md). Queue UX is implemented via `session.queue.*` and `prompt_queue` sync, not `delivery: "deferred"`. + +**Location:** repo root — `PLAN_MAIN_TUI_QUEUE.md` (not deleted; keep this file until the queue PR merges to `production`, then remove it). + +Branch target: `production` +Status: **living plan** — tracks remaining UX (queue dock + edit) in addition to main-TUI wiring. + +## Problem + +Deferred prompt queueing (`delivery: "deferred"`) ships today on: + +| Surface | Status | +|---------|--------| +| Backend (`session/prompt.ts`, `run-state.ts`) | ✅ Merged | +| Web app (`packages/app`) | ✅ Merged | +| Direct run TUI (`opencode run --interactive`, `cli/cmd/run/*`) | ✅ Merged — `ctrl+shift+return` default | +| **Main TUI** (`opencode`, `cli/cmd/tui/component/prompt`) | ✅ Phase A + B in PR | +| **Queue list + edit (both TUIs)** | ✅ Phase B — dock + edit + **Alt+Up** in main and run TUI | + +Users expect the queue hotkey in the main TUI because: + +1. `input_queue` already exists in `tui/config/keybind.ts` with default `ctrl+shift+return`. +2. `CommandMap` maps `input_queue` → `"input.queue"`. +3. Nothing registers or handles the `input.queue` command. +4. `registerManagedTextareaLayer` only binds `inputCommands` in `keymap.tsx` — **`input.queue` is intentionally excluded** (same Gap 17 as run footer: queue must not be a textarea submit/newline action). + +The main TUI **does** already render a `QUEUED` badge on user messages when they arrive after an in-flight assistant turn (`routes/session/index.tsx` `UserMessage`, driven by the `pending` assistant id memo). That badge is **not** a substitute for the web **followup dock** — users still need a list of pending messages with **Edit** / **Send now**, not only a transcript label. + +**Run TUI gap:** `footer.view.tsx` shows only `{queue()} queued` beside usage hints. No per-message preview, no edit affordance, no keyboard path to edit — insufficient vs web. + +--- + +## Goals + +### Phase A — Main TUI queue submit (MVP) + +1. **Ctrl+Shift+Enter** (configurable via `keybinds.input_queue`) queues the current prompt in the main TUI. +2. Queue uses **`delivery: "deferred"`** on the existing session prompt API — same backend path as web and run TUI. +3. Guards match run TUI / web semantics: + - Normal mode only (no queue in shell mode). + - Ignore empty/whitespace-only input. + - Slash commands submit immediately (not queued). + - Disabled prompt (permissions/questions open) → no-op. +4. Keybind shows in help / which-key via existing config description. + +### Phase B — Queue dock + edit (both TUIs) — **required before calling queue “done”** + +Match web `SessionFollowupDock` (`packages/app/src/pages/session/composer/session-followup-dock.tsx`): + +| Web behavior | TUI requirement | +|--------------|-----------------| +| Collapsible tray above composer | Dock panel above prompt (main TUI + run footer stack) | +| Summary: “N messages queued” + first-line preview when collapsed | Same copy/i18n keys where possible | +| Expanded list: truncated text per item | One row per queued message | +| **Edit** button per item | Select/focus row + **Edit** action (or primary click) | +| **Send now** per item | Optional phase-B.1; web has it — include if low cost | +| `editFollowup(id)` → remove from queue, load draft into prompt | TUI: load text/parts into prompt; clear input history noise | + +**Keyboard (Codex parity):** + +- Default: **`Alt+Up`** (`alt+up` in `keybinds` — same chord as Codex for “edit queued message”). +- New keybind id: `input_edit_queue` (name TBD: `input.queue.edit`). +- Command: `input.queue.edit` — when queue non-empty, edit the **most recently queued** item (or focused row if dock has selection); no-op when queue empty or prompt disabled. +- Register via `useBindings` on prompt target — **not** in `inputCommands` / textarea layer (same rule as `input.queue`). +- Document in `keybind.ts` description + which-key; overridable via `keybinds.input_edit_queue` in `tui.json`. + +**Two queue sources (implement both paths):** + +1. **Client-side queue (pre-submit)** — run `runtime.queue.ts` `state.queue[]`, web `followup.items` via `onQueue`: + - Edit = splice item from queue, hydrate prompt (no server round-trip). + - Dock reads this list directly. + +2. **Server-persisted deferred** (`delivery: "deferred"` on `session.prompt` — main TUI Ctrl+Shift+Enter, run `onQueue` with deferred): + - Messages already in DB with `QUEUED` badge. + - Edit = **`session.revert`** (or delete message API if available) for that user message, then load parts into prompt — mirror web `editFollowup` semantics. + - Dock lists deferred user messages from sync (`delivery === "deferred"` && not yet consumed by runLoop), merged with client queue for display order. + +**Run TUI:** Replace footer-only `{n} queued` with full dock component; keep footer count as optional secondary hint or remove once dock visible. + +**Main TUI:** Add dock above `tui/component/prompt`; do not rely on transcript `QUEUED` badge alone. + +### Follow-ups (later PRs) + +- Queue-mode toggle in prompt chrome (parity with web bullet-list button). +- TUI setting for default follow-up behavior (steer vs queue) if we add steer semantics client-side. +- Cherry-pick to `dev` once dev’s plugin TUI prompt path is confirmed stable. +- Web: optional `Alt+Up` to edit last queued followup (today web uses dock buttons only; `alt+arrowup` is session navigation in `layout.tsx`). + +--- + +## Reference implementations + +Copy behavior from these; do not re-invent queue semantics. + +| Concern | Reference | +|---------|-----------| +| Deferred API field | `packages/app/src/components/prompt-input/submit.ts` → `sendFollowupDraft({ delivery: "deferred" })` | +| Followup dock UI + edit | `packages/app/src/pages/session/composer/session-followup-dock.tsx` | +| Edit → prompt hydration | `packages/app/src/pages/session.tsx` → `editFollowup`, `followup.edit` | +| Client queue before send | `packages/app/src/pages/session.tsx` → `queueFollowup`, `submit.ts` `onQueue` branch | +| Run TUI enqueue guards | `packages/opencode/src/cli/cmd/run/footer.prompt.tsx` → `onQueue()` | +| Run TUI in-memory queue | `packages/opencode/src/cli/cmd/run/runtime.queue.ts` → `state.queue`, `enqueue()` | +| Run TUI footer badge only | `packages/opencode/src/cli/cmd/run/footer.view.tsx` → `{queue()} queued` (**replace with dock**) | +| Run TUI keybind resolution | `packages/opencode/src/cli/cmd/run/prompt.shared.ts` → `queues` separate from textarea `bindings` | +| Backend idle fallback | `packages/opencode/src/session/prompt.ts` — deferred + no runner → immediate turn | +| Backend mid-turn defer fix | `packages/opencode/src/session/prompt.ts` — exclude deferred from steer injection / `latestActiveUser` | +| Transcript QUEUED badge | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` → `UserMessage` + `pending` memo | + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant User + participant Prompt as tui/component/prompt + participant Keymap as tui/keymap + participant SDK as session.prompt + participant Backend as session/prompt.ts + + User->>Keymap: ctrl+shift+return + Keymap->>Prompt: input.queue command + Prompt->>Prompt: queueInner() guards + build parts + Prompt->>SDK: prompt({ delivery: "deferred", ... }) + SDK->>Backend: HTTP prompt + alt session busy + Backend->>Backend: defer → drain at turn end + else session idle + Backend->>Backend: fallback → loop() immediately + end + Prompt->>Prompt: clear input + history.append + Backend-->>Prompt: sync events → transcript QUEUED badge +``` + +### Why a command, not a textarea binding + +OpenTUI’s managed textarea layer (`keymap.tsx` `inputCommands`) handles `input.submit` and `input.newline` natively. Queue must **not** join that list — it would race Enter handling and cannot express `delivery: "deferred"`. + +Pattern: register **`input.queue`** as a prompt-targeted command via `useBindings`, same family as `prompt.submit` / `session.interrupt`. + +--- + +## Implementation steps + +### 1. Register `input.queue` command + +**File:** `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` + +Add to `promptCommands` (or adjacent `useBindings` block): + +```ts +{ + title: "Queue prompt", + name: "input.queue", + category: "Prompt", + hidden: true, + enabled: () => + !props.disabled && + store.mode === "normal" && + Boolean(props.sessionID) && + status().type !== "idle", // optional UX: only when a turn is active + run: async () => { + if (!input.focused) return + await queue() + }, +}, +``` + +**Binding wiring** — extend the existing palette `useBindings` gather list **or** add a dedicated block: + +```ts +useBindings(() => ({ + target: inputTarget, + enabled: () => inputTarget() !== undefined && !props.disabled && !auto()?.visible, + commands: [/* input.queue command from above */], + bindings: tuiConfig.keybinds.get("input.queue"), +})) +``` + +Do **not** add `"input.queue"` to `inputCommands` in `keymap.tsx`. + +> **Idle fallback note:** Backend accepts deferred on idle sessions and starts an immediate turn. Enabling the command while `status().type === "idle"` is still correct but indistinguishable from Enter for the user. Optional: keep enabled always in normal mode and rely on backend fallback; or disable when idle to match mental model (“queue only while agent is working”). **Recommend:** enable whenever input is non-empty + normal mode; document that idle queue ≡ submit. + +### 2. Implement `queue()` alongside `submit()` + +**File:** `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` + +Refactor minimally: + +1. Share an **`inFlight` guard** with `submit()` (same double-submit race as Enter). +2. Extract **`preparePromptPayload()`** from `submitInner()`: + - IME/plainText sync + - extmark / pasted text expansion + - editor context parts + - non-text file parts + - Returns `{ sessionID, messageID, parts, agent, model, variant, inputText, currentMode }` or `undefined` on validation failure. +3. **`queueInner()`** calls `preparePromptPayload()`, then: + +```ts +void sdk.client.session.prompt({ + sessionID, + messageID, + agent: agent.name, + model: selectedModel, + variant, + delivery: "deferred", + parts: [/* same as submit */], +}) +``` + +4. Reuse post-send cleanup from `submitInner()`: + - `history.append` + - clear extmarks / prompt store + - `input.clear()` + - **Do not** navigate for new-session case the same way unless session already exists (queue on home with no session → treat as submit/create — defer is meaningless without a runner). + +**Guards (early return false):** + +| Condition | Behavior | +|-----------|----------| +| `props.disabled` | no-op | +| `workspaceCreating()` | no-op | +| `auto()?.visible` | no-op | +| empty trimmed input | no-op | +| no agent / model | same warnings as submit | +| `store.mode === "shell"` | no-op | +| slash command input | no-op — run command path instead or ignore | +| no `props.sessionID` on home route | **submit/create path** — queue before first session exists has no in-flight runner; either call normal submit or block with toast (“Start a session first”) | + +**Recommendation for home route:** fall through to normal `submit()` (backend idle fallback handles it). Simpler, no dead key on new session screen. + +### 3. Expose on `PromptRef` (optional) + +**File:** `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` + +```ts +queue() { + void queue() +}, +``` + +Useful for plugin hooks / future toggle button. + +### 4. Help / discoverability + +**Files:** + +- `packages/opencode/src/cli/cmd/tui/config/keybind.ts` — description already mentions override; update text to say “Main TUI + run interactive”. +- Which-key / tips plugin — add one tip: “Queue follow-up: Ctrl+Shift+Enter” (follow-up PR if tips are generated separately). + +No change to `CommandMap` — `input_queue: "input.queue"` is already correct. + +### 5. No backend changes required + +Backend deferred queue, idle fallback, and runLoop drain are merged. SDK types already include `delivery?: SessionDelivery`. + +### 6. Run TUI parity check (no code change expected) + +Run interactive mode already uses `ctrl+shift+return` via `runtime.boot.ts` + manual `onKeyDown`. Keep defaults aligned; config key remains `input_queue` everywhere. + +--- + +## Files to touch + +### Phase A (main TUI queue submit) + +| File | Change | +|------|--------| +| `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` | `queue()`, `preparePromptPayload()`, `input.queue` command + bindings | +| `packages/opencode/src/cli/cmd/tui/config/keybind.ts` | Description tweak only | +| `packages/opencode/test/cli/tui/prompt-queue.test.ts` | **New** — unit tests for payload + guards | +| `packages/opencode/test/cli/tui/prompt-submit-race.test.ts` | Extend or mirror for queue in-flight guard | + +### Phase B (queue dock + edit — both TUIs) + +| File | Change | +|------|--------| +| `packages/opencode/src/cli/cmd/tui/component/prompt/queue-dock.tsx` (new) | Collapsible dock UI shared by main TUI | +| `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` | Mount dock; wire `input.queue.edit`; edit hydration | +| `packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx` (new) or extend `footer.view.tsx` | Run interactive dock above prompt | +| `packages/opencode/src/cli/cmd/run/footer.prompt.tsx` | Edit/send handlers; expose queue items to dock | +| `packages/opencode/src/cli/cmd/run/runtime.queue.ts` | `editQueued(index)`, `removeQueued`, list snapshot for UI | +| `packages/opencode/src/cli/cmd/run/runtime.boot.ts` | Pass queue state + handlers into footer | +| `packages/opencode/src/cli/cmd/tui/config/keybind.ts` | `input_edit_queue: alt+up` + `CommandMap` entry | +| `packages/opencode/src/cli/cmd/run/prompt.shared.ts` | Resolve `input_edit_queue` for run footer `onKeyDown` | +| `packages/app/src/i18n/en.ts` (and siblings) | Reuse `session.followupDock.*` strings in TUI if feasible | + +**Explicitly do not modify:** `keymap.tsx` `inputCommands` array (queue + edit queue are commands, not textarea bindings). + +--- + +## Test plan + +### Unit (`packages/opencode`) + +```bash +bun test test/cli/tui/prompt-queue.test.ts +bun test test/cli/tui/prompt-submit-race.test.ts +``` + +Cases: + +1. `queue()` sends `delivery: "deferred"` on prompt API mock. +2. Shell mode → queue not called. +3. Empty input → queue not called. +4. Concurrent queue + submit → only one in-flight (shared guard). +5. Slash command text → queue not called. +6. Keybind command `input.queue` registered when prompt focused. + +### Phase B tests + +```bash +bun test test/cli/tui/prompt-queue-dock.test.ts # new +bun test test/cli/run/footer.queue-dock.test.tsx # new +``` + +Cases: + +1. Dock shows N items with truncated first line (collapsed + expanded). +2. **Edit** on row removes item from queue and sets prompt text (client queue). +3. **Alt+Up** triggers same path as Edit for last queued item. +4. Deferred message on server: edit reverts message ID and loads parts into prompt (integration or mocked SDK). +5. Run TUI: dock visible when `queue > 0`; footer may still show count but dock is primary UI. + +### Manual + +**Phase A** + +1. `opencode` → open session → start a long turn. +2. Type follow-up → **Ctrl+Shift+Enter**. +3. Input clears; user message appears with **QUEUED** badge; agent finishes current turn; queued message runs next. +4. Repeat on idle session → message sends immediately (backend fallback). +5. Override `keybinds.input_queue` to `f9` in `tui.json` → confirm rebind works. +6. Shell mode (`!`) → queue key does nothing. + +**Phase B** + +1. Queue 2+ messages → dock lists all with previews (not only footer `2 queued`). +2. Click **Edit** on second item → prompt contains that text; dock no longer lists it. +3. **Alt+Up** with one queued item → same as Edit. +4. `opencode run --interactive` — same dock + **Alt+Up** behavior. +5. Rebind `keybinds.input_edit_queue` if terminal steals `alt+up`. + +--- + +## Edge cases + +| Case | Expected | +|------|----------| +| Agent idle + deferred | Backend starts immediate turn (same as web followup dock auto-send) | +| Agent busy + deferred | Message persisted, QUEUED badge, runs after current turn | +| Permission / question dialog open | Prompt disabled → queue blocked | +| Parent / child session views | Same as submit — queue only on writable session prompt | +| IME composition | Same double-defer as `onSubmit` before reading plainText | +| Terminal captures chord | User overrides `input_queue` in config (`f9`, `q` once leader queue is supported) | +| `dev` branch | No `cli/cmd/run`; main TUI plan applies to both once dev prompt component matches production | + +--- + +## Phased delivery + +| Phase | Deliverable | PR | +|-------|-------------|-----| +| **A** | `queue()` + `input.queue` command + tests | Main TUI wiring | +| **B** | Queue dock (main + run TUI) + Edit + **Alt+Up** | Same or follow-up PR — **required for parity with web** | +| **C** | Prompt footer queue toggle + key hint | Optional parity with web | +| **D** | TUI config: default follow-up mode (steer/queue) | Only if product wants steer in main TUI | +| **E** | Port to `dev` after merge | Cherry-pick onto dev prompt | + +**Plan lifecycle:** Keep `PLAN_MAIN_TUI_QUEUE.md` in the repo until the queue feature PR merges; delete the file in the merge commit or immediate follow-up chore commit. + +--- + +## Out of scope + +- Re-implementing backend deferred queue. +- Adding queue to `opencode run` (already done). +- Leader-sequence queue in run footer (`q`) — separate fix for run TUI `onKeyDown` leader arming. +- Web app changes (working). + +--- + +## Acceptance criteria + +**Phase A** + +- [ ] `input_queue` default works in **main TUI** session view during an active turn. +- [ ] Config override `keybinds.input_queue` respected. +- [ ] Shell mode, empty input, and permission/question states do not queue. +- [ ] Deferred messages do not steer mid-turn (backend fix in `prompt.ts`). +- [ ] Tests pass in `packages/opencode`. +- [ ] No regression to Enter submit or run-interactive queue. + +**Phase B** + +- [ ] **Run TUI** shows a followup-style dock (not only `{n} queued` in footer). +- [ ] **Main TUI** shows the same dock above the prompt when queue non-empty. +- [ ] Each queued item has **Edit** (click); edit loads message into prompt. +- [ ] **`Alt+Up`** (`input_edit_queue`) edits the latest queued item (configurable). +- [ ] Persisted `delivery: "deferred"` messages can be edited (revert + reload). +- [ ] Queued messages still show **QUEUED** in transcript when persisted (complementary, not sufficient alone). diff --git a/PLAN_UNIFIED_PROMPT_QUEUE.md b/PLAN_UNIFIED_PROMPT_QUEUE.md new file mode 100644 index 000000000000..b6db97bfcf1a --- /dev/null +++ b/PLAN_UNIFIED_PROMPT_QUEUE.md @@ -0,0 +1,340 @@ +# Unified prompt queue — implementation plan + +**Status:** implemented on `feat/main-tui-prompt-queue` +**Branch context:** replaces `delivery: "deferred"` queue hack on `feat/main-tui-prompt-queue` +**Upstream reference:** `origin/production` @ `1813256d8` (same tip as `origin/dev` at time of writing) + +> **Supersedes:** [PLAN.md](./PLAN.md) and [PLAN_MAIN_TUI_QUEUE.md](./PLAN_MAIN_TUI_QUEUE.md) for queue semantics. Those files describe the pre-unified-queue approach. + +--- + +## Executive summary + +Unify TUI, Web, and Run `--interactive` on **one queue model**: + +- **Persist** queued prompts in SQLite (`prompt_queue` table), not in `message` / `part` rows. +- **Steer** stays on the normal prompt path (`session.prompt` / `promptAsync`) with default **`immediate`** delivery. +- **Do not** use `delivery: "deferred"` for queueing; keep `deferred` on the API/schema **dormant** like upstream (accepted, no queue behavior). +- **Share** FIFO queue logic between server DB and Run-without-server via a common `PromptQueue` module; **demo mode does not queue**. + +--- + +## Upstream findings (`origin/production`) + +Investigation of the real production tree (not this feature branch): + +| Area | Upstream state | +|------|----------------| +| `Session.Delivery` | `immediate` \| `deferred` in `packages/core/src/session/schema.ts`; `DefaultDelivery = "immediate"` | +| v2 HTTP API | `delivery` optional on `session.prompt` / `shell` / `skill`; handler passes `delivery ?? DefaultDelivery` | +| v2 `packages/core/src/session.ts` | `delivery` appears **only on the service interface** — **no implementation** reads it | +| `SessionRunState` | **No** in-memory deferred map | +| `session/prompt.ts` | **No** `delivery` / `defer` branches | +| `message-v2` User | **No** `delivery` field on persisted messages | +| TUI transcript “QUEUED” badge | **Heuristic:** user `message.id > pendingAssistant.id` — **not** `delivery === "deferred"` | +| TUI / Web queue dock | **Does not exist** on production | +| Run `runtime.queue` client FIFO | **Does not exist** on production | + +**Conclusion:** On upstream, `immediate` / `deferred` is a **reserved API contract** only. Our branch repurposed `deferred` for queueing and introduced parallel stores (in-memory `MessageV2.WithParts[]`, Web `followup.v1`, Run local array). This plan **reverts queue semantics to a dedicated store** and **restores `delivery` to dormant** except where we need **`immediate` on persisted user messages for steer** in the run loop (see below). + +### Steer vs queue (target semantics) + +| User action | While session busy | Mechanism | +|-------------|-------------------|-----------| +| **Enter** (default) | Steer | `POST session.prompt` / `promptAsync`, `delivery` omitted or `immediate` → user row in transcript, loop interrupts/continues per `immediateTurnUnsettled` | +| **Queue** (Ctrl+Shift+Enter, queue mode, etc.) | Queue | `POST session.queue` → row in `prompt_queue` only | +| **Send now** on dock item | Flush one queue row | `POST session.queue/:id/send` → materialize user message + run loop | + +This matches product intent and upstream’s default of **`immediate`**. + +--- + +## Current branch debt (to remove) + +| Location | What to remove / replace | Status | +|----------|---------------------------|--------| +| `session/run-state.ts` | `deferred: Map<…>` + `defer` / `popDeferred` / … | Done | +| `session/prompt.ts` | `if (input.delivery === "deferred" && hasRunner) { … defer … }` | Done | +| `session/prompt.ts` | `popDeferred` / `rekeyDeferredMessage` drain → DB dequeue | Done | +| `session/message-v2.ts` | deferred helpers — kept for **legacy rows** + migration reads | Partial (read-only legacy) | +| `session/session.ts` | `DeferredUpdated` → `QueueUpdated` | `QueueUpdated` primary; `DeferredUpdated` type retained for SDK compat | +| Web `session.tsx` | `persisted(…, "followup.v1").items` | Done (`followup.v2`, server queue) | +| Web `sendFollowupDraft(…, delivery: "deferred")` | queue API | Done | +| TUI `sync.deferred_queue` | `prompt_queue` from bus + bootstrap | Done | +| TUI `prompt/queue.ts` | legacy scan of `message.delivery === "deferred"` | Done (server list only) | +| Run `runtime.queue.ts` | local FIFO for deferred; keep **turn serializer** only | Done (`MemoryPromptQueue` + `queued`) | +| HTTP `…/deferred/:messageID` | `…/queue/:queueID` | Queue routes + deferred forward | + +--- + +## Architecture + +```mermaid +flowchart TB + subgraph clients [Thin clients — UI only] + TUI[TUI dock + keybinds] + Web[Web followup dock] + Run[Run footer dock] + end + + subgraph shared [Shared queue logic — packages/opencode/src/queue] + IFACE[PromptQueue interface] + RULES[FIFO + cap 3 + preview] + MEM[MemoryPromptQueue] + SQL[SqlitePromptQueue] + end + + subgraph server [Server instance] + SVC[SessionPromptQueue service] + LOOP[SessionPrompt.runLoop drain] + DB[(prompt_queue)] + end + + TUI --> SVC + Web --> SVC + Run -->|SDK| SVC + Run -->|no server| MEM + SVC --> SQL + SQL --> DB + LOOP --> SVC + MEM --> RunOffline[Run offline drain] +``` + +**Invariants** + +1. Queued payloads **never** appear in `message` / `part` until dequeued. +2. **One** drain implementation for server (`runLoop`); Run-offline calls the same `dequeue` / `materialize` helpers with `MemoryPromptQueue`. +3. **Max 3** items per session, enforced in shared module. +4. **`delivery: "deferred"`** on `session.prompt` is **ignored** (dormant), same as upstream — clients must use queue endpoints. +5. **`--demo`**: queue key / `onQueue` **no-op** (optional status: “queue unavailable in demo”); steer/submit demo paths unchanged. + +--- + +## Data model + +### Table `prompt_queue` + +```sql +-- Drizzle migration under packages/opencode/migration/ +CREATE TABLE prompt_queue ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL -- JSON +); +CREATE INDEX prompt_queue_session_position_idx ON prompt_queue(session_id, position); +``` + +**`data` (version 1):** `{ agent, model, variant?, parts, tools?, permissions? }` — parts **resolved at enqueue** so edit/replay is stable. + +**IDs:** `QueueItemID` (new branded id), distinct from `MessageID` until materialize. + +### Legacy migration + +One-time job (`deferred_user_messages_to_prompt_queue` in `data-migration.ts`): + +- Rows in `message` where `data.delivery === "deferred"` and still “open” → insert into `prompt_queue` and remove message/parts. +- Processed deferred rows → strip `delivery` to `immediate`. + +### `delivery` on User messages (post-change) + +| Value | Use | +|-------|-----| +| **unset / `immediate`** | All persisted user messages, including **steer** | +| **`deferred`** | **Legacy read-only**; no new writes | + +Keep `Session.Delivery` in core schema and OpenAPI **unchanged** for compatibility; document `deferred` on prompt body as **no-op** (dormant). + +**Keep** `MessageV2.immediateTurnUnsettled` (steer unsettled turn) — it keys off **`immediate`** users after the assistant, not the queue table. + +--- + +## Shared module: `packages/opencode/src/queue/` + +Extend existing `preview.ts` with: + +### `prompt-queue.ts` + +```ts +// Conceptual — implement with Effect in repo style +interface PromptQueue { + list(sessionID): Effect + enqueue(sessionID, payload): Effect // throws QueueFull + update(sessionID, id, payload): Effect + remove(sessionID, id): Effect + peek(sessionID): Effect + dequeue(sessionID): Effect // pop head +} +``` + +- **`QUEUE_MAX = 3`**, FIFO by `position` + `time_created`. +- **`partsPreview`** for dock text (already shared). +- **`MemoryPromptQueue`**: `Map` — used by Run when there is no server. +- **`SqlitePromptQueue`**: Drizzle access — used by server `SessionPromptQueue` service. + +**`materializeQueuedItem(item): MessageV2.WithParts`**: new message id/time, `delivery: "immediate"`, persist via existing `persistUserMessage`. + +No duplication of FIFO rules between memory and SQL — both call shared `enqueueItem` / `shiftHead` pure helpers. + +--- + +## Backend service: `SessionPromptQueue` + +**File:** `packages/opencode/src/session/prompt-queue.ts` + +Wraps sqlite queue + bus: + +- Publishes `session.queue.updated` `{ sessionID, items: { id, text }[] }`. + +**HTTP** (`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`): + +| Method | Path | Action | +|--------|------|--------| +| `GET` | `/session/:sessionID/queue` | list (bootstrap) | +| `POST` | `/session/:sessionID/queue` | enqueue | +| `PATCH` | `/session/:sessionID/queue/:queueID` | update | +| `DELETE` | `/session/:sessionID/queue/:queueID` | remove | +| `POST` | `/session/:sessionID/queue/:queueID/send` | send now | + +Legacy `/deferred/*` forwards to queue handlers. + +**`session.prompt` handler:** `delivery: "deferred"` coerced to default immediate at persist time (dormant). + +--- + +## `runLoop` drain (single place) + +In `session/prompt.ts`: + +```text +when turn settled && !immediateTurnUnsettled(msgs): + item = promptQueue.dequeue(sessionID) + if item: persist(materialize(item)); continue +``` + +**Do not dequeue** while `immediateTurnUnsettled` (regression tests from `77312d7eb` remain valid). + +--- + +## Run `--interactive` without server + +| Concern | Implementation | +|---------|----------------| +| **Queue storage** | `MemoryPromptQueue` in process (shared module), **not** `delivery: "deferred"` on API | +| **Enqueue** | Footer `onQueue` → `memoryQueue.enqueue` + `session.queue.updated`-shaped local event on footer | +| **Steer** | Unchanged: immediate `promptAsync` via transport steer path | +| **Drain** | When transport idle, `memoryQueue.dequeue` → materialize + `runPromptTurn` (same as server drain) | +| **With server** | All queue ops go through SDK → SQLite | + +**Demo (`--demo`):** `onQueue` returns early; status “queue unavailable in demo”. + +--- + +## Frontend (thin) + +### Shared UI + +- `packages/opencode/src/queue/queue-dock.tsx` — TUI + Run footer + +### TUI + +- Subscribe `session.queue.updated`; store `prompt_queue[sessionID]`. +- `queueInner` → `sdk.session.queue.enqueue`. +- Edit / send now → queue routes. + +### Web + +- `queueFollowup` → `client.session.queue.enqueue`. +- Dock from `prompt_queue` + bus; no client auto-drain. +- `settings.general.followup` `queue` vs `steer`. + +### Run + +- Queue → SDK or `MemoryPromptQueue` when offline. +- Steer → transport steer path. +- `queued: true` on footer submit (not `delivery: "deferred"`). + +--- + +## Phases (one slice — do in order, single PR series or stacked commits) + +### Phase 1 — Schema + shared module + +- [x] Drizzle table + migration +- [x] `PromptQueue` interface + FIFO helpers + `MemoryPromptQueue` + `SqlitePromptQueue` +- [x] Unit tests: cap 3, FIFO, update/remove, session cascade delete +- [x] `materializeQueuedItem` helper + tests + +### Phase 2 — Server service + drain + +- [x] `SessionPromptQueue` service + `session.queue.updated` bus event +- [x] Wire `runLoop` dequeue (remove `run-state` deferred map) +- [x] Remove `prompt()` `delivery === "deferred"` branch +- [x] Ignore dormant `deferred` on prompt API (persist as immediate) +- [x] Legacy DB backfill migration for old deferred messages +- [x] Port / extend `packages/opencode/test/session/prompt.test.ts` queue + steer cases + +### Phase 3 — HTTP + SDK + +- [x] HttpApi queue routes; deprecate `/deferred/*` +- [x] Regenerate `packages/sdk/js` +- [x] Handler tests (`test/server/session-queue.test.ts`) + +### Phase 4 — TUI + Web + Run clients + +- [x] TUI sync + prompt enqueue/send/edit +- [x] Web: drop `followup.v1` items; wire dock to API +- [x] Run: `MemoryPromptQueue` offline; SDK online; demo no-queue +- [x] Delete dead code: `run-state` defer*, TUI legacy deferred scan + +### Phase 5 — Cleanup + docs + +- [x] `MessageV2` deferred helpers retained for legacy transcript rows only +- [x] Stop publishing `session.deferred.updated` alias (event type kept for SDK) +- [x] Update `PLAN.md`, `PLAN_MAIN_TUI_QUEUE.md` with pointer to this file +- [x] Changelog note below + +--- + +## Testing matrix + +| Test | Package | Status | +|------|---------|--------| +| Sqlite + memory FIFO parity | `packages/opencode/test/queue/` | pass | +| runLoop drain + steer holds queue | `test/session/prompt.test.ts` | pass | +| Queue not in transcript until dequeue | `test/session/prompt.test.ts` | pass | +| Restart durability (sqlite list after enqueue) | `test/queue/prompt-queue.test.ts` | pass | +| HttpApi CRUD | `test/server/session-queue.test.ts` | pass | +| Run offline memory queue | `test/cli/run/runtime.queue.test.ts` | pass | +| Demo ignores queue | `test/cli/run/runtime.queue.test.ts` | pass | +| TUI dock smoke | `test/cli/tui/prompt-queue-dock.test.ts` | pass | + +--- + +## Changelog (user-facing) + +- **Added** session queue API: `GET/POST /session/:id/queue`, `PATCH/DELETE /queue/:id`, `POST /queue/:id/send`. +- **Changed** queueing no longer uses `delivery: "deferred"` on `session.prompt`; that field is dormant (accepted, treated as immediate). +- **Changed** TUI, Web, and Run interactive mode share one server-backed queue (Run offline uses in-process `MemoryPromptQueue` with the same FIFO rules). +- **Migration** open deferred user messages are moved into `prompt_queue` on upgrade. + +--- + +## Success criteria + +1. One queue visible from TUI, Web, and Run (server-backed); Run-offline uses **same FIFO code** via `MemoryPromptQueue`. +2. No queued text in message list / scrollback until send/dequeue. +3. Steer = immediate prompt path everywhere; queue = queue API only. +4. `delivery: "deferred"` on prompt is **dormant** (upstream-parity); no new deferred user rows. +5. `--demo` does not queue. +6. `SessionRunState` has no message queue map. + +--- + +## References + +- Upstream production: `packages/core/src/session/schema.ts` (`Delivery`, `DefaultDelivery`) +- Upstream handler: `packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts` +- Current branch queue dock: `packages/opencode/src/queue/queue-dock.tsx` +- Session bug context: dequeue only when `!immediateTurnUnsettled` (`77312d7eb`) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 68d563baf76e..b56f61b2aa4d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -86,7 +86,10 @@ interface PromptInputProps { newSessionWorktree?: string onNewSessionWorktreeReset?: () => void edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + editingQueueID?: string + onEditingQueueMessageID?: (id: string | undefined) => void onEditLoaded?: () => void + onCancelQueueEdit?: () => void shouldQueue?: () => boolean onQueue?: (draft: FollowupDraft) => void onAbort?: () => void @@ -269,9 +272,15 @@ export const PromptInput: Component = (props) => { prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) // Per-message queue override toggled by the composer button or Alt+Enter. It - // makes the next submit go to the followup queue (delivery="deferred") even + // makes the next submit go to the server prompt queue even // when the followup setting is not "queue", and resets after each submit. const [queueMode, setQueueMode] = createSignal(false) + const [localEditingQueueID, setLocalEditingQueueID] = createSignal() + const editingQueueID = () => props.editingQueueID ?? localEditingQueueID() + const setEditingQueueID = (id: string | undefined) => { + props.onEditingQueueMessageID?.(id) + if (props.onEditingQueueMessageID === undefined) setLocalEditingQueueID(id) + } const [store, setStore] = createStore<{ popover: "at" | "slash" | null @@ -1054,6 +1063,8 @@ export const PromptInput: Component = (props) => { const edit = props.edit if (!id || !edit) return + setEditingQueueID(edit.id) + for (const item of prompt.context.items()) { prompt.context.remove(item.key) } @@ -1157,6 +1168,8 @@ export const PromptInput: Component = (props) => { shouldQueue: props.shouldQueue, queueMode, resetQueueMode: () => setQueueMode(false), + editingQueueID: () => editingQueueID(), + resetEditingQueueID: () => setEditingQueueID(undefined), onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, @@ -1206,6 +1219,13 @@ export const PromptInput: Component = (props) => { return } + if (editingQueueID()) { + props.onCancelQueueEdit?.() + event.preventDefault() + event.stopPropagation() + return + } + if (store.mode === "shell") { setStore("mode", "normal") event.preventDefault() diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b65c7bf992af..52b27ed46508 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -385,6 +385,41 @@ describe("prompt submit queue mode", () => { expect(promptAsyncCalls).toHaveLength(0) }) + test("editingQueueID is forwarded on queue submit", async () => { + params = { id: "session-1" } + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + queueMode: () => true, + resetQueueMode: () => undefined, + editingQueueID: () => "pqu_edit", + resetEditingQueueID: () => undefined, + onQueue: (draft) => { + queuedDrafts.push(draft) + }, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(queuedDrafts).toHaveLength(1) + expect(queuedDrafts[0]).toMatchObject({ queueID: "pqu_edit" }) + }) + test("shouldQueue routes existing sessions through onQueue", async () => { params = { id: "session-1" } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 91dc7c2becd4..53fe49b96711 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -34,6 +34,8 @@ export type FollowupDraft = { agent: string model: { providerID: string; modelID: string } variant?: string + /** When set, save replaces this queued item instead of appending. */ + queueID?: string } type FollowupSendInput = { @@ -191,6 +193,8 @@ type PromptSubmitInput = { shouldQueue?: Accessor queueMode?: Accessor resetQueueMode?: () => void + editingQueueID?: Accessor + resetEditingQueueID?: () => void onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void @@ -408,6 +412,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { agent, model, variant, + queueID: input.editingQueueID?.(), } const clearInput = () => { @@ -432,12 +437,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (!isNewSession && mode === "normal" && (input.shouldQueue?.() || queueMode)) { input.onQueue?.(draft) input.resetQueueMode?.() + if (draft.queueID) input.resetEditingQueueID?.() clearContext() clearInput() return } input.resetQueueMode?.() + + void client.session.queue?.drain?.resume?.({ sessionID: session.id }).catch(() => {}) + input.onSubmit?.() if (mode === "shell") { diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 8117e6139d96..0097cb14c9e9 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -39,6 +39,7 @@ describe("bootstrapDirectory", () => { vcs: undefined, limit: 5, message: {}, + prompt_queue: {}, part: {}, part_text_accum_delta: {}, }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ccbfc2aae924..c4b268f10539 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -18,7 +18,6 @@ import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" -import { loadMcpQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" type GlobalStore = { @@ -196,6 +195,18 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) => queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) +export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => + queryOptions({ + queryKey: [directory, "mcp"] as const, + queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), + }) + +export const loadLspQuery = (directory: string, sdk: OpencodeClient) => + queryOptions({ + queryKey: [directory, "lsp"] as const, + queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), + }) + export async function bootstrapDirectory(input: { directory: string mcp: boolean diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 99da39ebb0e8..168aef54744a 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -237,6 +237,7 @@ export function createChildStoreManager(input: { vcs: vcsStore.value, limit: 5, message: {}, + prompt_queue: {}, part: {}, part_text_accum_delta: {}, }) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5b72d37f9df4..1d3d487d6b53 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -166,6 +166,12 @@ export function applyDirectoryEvent(input: { input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } + case "session.queue.updated": + case "session.deferred.updated": { + const props = event.properties as { sessionID: string; items: { id: string; text: string }[] } + input.setStore("prompt_queue", props.sessionID, props.items) + break + } case "session.diff": { const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505eaa7..83700a46286b 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -36,6 +36,7 @@ describe("app session cache", () => { session_diff: Record todo: Record message: Record + prompt_queue: Record part: Record permission: Record question: Record @@ -45,6 +46,7 @@ describe("app session cache", () => { session_diff: { ses_1: [] }, todo: { ses_1: [] as Todo[] }, message: {}, + prompt_queue: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, @@ -70,6 +72,7 @@ describe("app session cache", () => { session_diff: Record todo: Record message: Record + prompt_queue: Record part: Record permission: Record question: Record @@ -79,6 +82,7 @@ describe("app session cache", () => { session_diff: {}, todo: {}, message: { ses_1: [m] }, + prompt_queue: {}, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc8464380..3fea4d7202d0 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -15,6 +15,7 @@ type SessionCache = { session_diff: Record todo: Record message: Record + prompt_queue: Record part: Record permission: Record question: Record @@ -36,6 +37,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable - queryOptions({ - queryKey: [directory, "mcp"] as const, - queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), - }) - -export const loadLspQuery = (directory: string, sdk: OpencodeClient) => - queryOptions({ - queryKey: [directory, "lsp"] as const, - queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), - }) - function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { return { globalConfig: () => loadGlobalConfigQuery(serverSDK()), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c470c028ec24..2c7ed5249aa6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -579,6 +579,7 @@ export const dict = { "session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.sendNow": "Send now", "session.followupDock.edit": "Edit", + "session.followupDock.editing": "Editing", "session.followupDock.collapse": "Collapse queued messages", "session.followupDock.expand": "Expand queued messages", "session.revertDock.summary.one": "{{count}} rolled back message", diff --git a/packages/app/src/pages/session.queue-save.test.ts b/packages/app/src/pages/session.queue-save.test.ts new file mode 100644 index 000000000000..d10e22740a8a --- /dev/null +++ b/packages/app/src/pages/session.queue-save.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { applyQueueSaveSuccess } from "./session.queue-save" + +describe("queue save success", () => { + test("resumes server drain after saving an edited queued prompt", () => { + const calls: string[] = [] + + applyQueueSaveSuccess({ + sessionID: "ses_test", + queueID: "pqu_test", + clearFailed: () => calls.push("clearFailed"), + clearPaused: () => calls.push("clearPaused"), + clearEdit: () => calls.push("clearEdit"), + stopEditing: () => calls.push("stopEditing"), + resumeDrain: (sessionID) => calls.push(`resume:${sessionID}`), + }) + + expect(calls).toEqual(["clearFailed", "clearPaused", "clearEdit", "stopEditing", "resume:ses_test"]) + }) + + test("does not resume drain after appending a new queued prompt", () => { + const calls: string[] = [] + + applyQueueSaveSuccess({ + sessionID: "ses_test", + clearFailed: () => calls.push("clearFailed"), + clearPaused: () => calls.push("clearPaused"), + clearEdit: () => calls.push("clearEdit"), + stopEditing: () => calls.push("stopEditing"), + resumeDrain: (sessionID) => calls.push(`resume:${sessionID}`), + }) + + expect(calls).toEqual(["clearFailed", "clearPaused"]) + }) +}) diff --git a/packages/app/src/pages/session.queue-save.ts b/packages/app/src/pages/session.queue-save.ts new file mode 100644 index 000000000000..f32456c7a099 --- /dev/null +++ b/packages/app/src/pages/session.queue-save.ts @@ -0,0 +1,16 @@ +export function applyQueueSaveSuccess(input: { + sessionID: string + queueID?: string + clearFailed: () => void + clearPaused: () => void + clearEdit: () => void + stopEditing: () => void + resumeDrain: (sessionID: string) => void +}) { + input.clearFailed() + input.clearPaused() + if (!input.queueID) return + input.clearEdit() + input.stopEditing() + input.resumeDrain(input.sessionID) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 12c937e72b95..08642deb0b2e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -10,6 +10,7 @@ import { createMemo, createEffect, createComputed, + createSignal, on, onMount, untrack, @@ -42,7 +43,8 @@ import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" +import { buildRequestParts } from "@/components/prompt-input/build-request-parts" +import { type FollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, @@ -63,15 +65,17 @@ import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout" import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" +import { partsFromQueueDetail } from "@/utils/queue-parts" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { applyQueueSaveSuccess } from "./session.queue-save" const emptyUserMessages: UserMessage[] = [] -type FollowupItem = FollowupDraft & { id: string } -type FollowupEdit = Pick -const emptyFollowups: FollowupItem[] = [] +type QueuePreview = { id: string; text: string } +type FollowupEdit = Pick & { id: string } +const emptyQueue: QueuePreview[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" @@ -386,20 +390,53 @@ export default function Page() { }) const [followup, setFollowup] = persisted( - Persist.workspace(sdk.directory, "followup", ["followup.v1"]), + Persist.workspace(sdk.directory, "followup", ["followup.v2"]), createStore<{ - items: Record failed: Record paused: Record edit: Record }>({ - items: {}, failed: {}, paused: {}, edit: {}, }), ) + const [editingQueueMessageID, setEditingQueueMessageID] = createSignal() + + const stopEditingQueueMessage = () => setEditingQueueMessageID(undefined) + + const pauseQueueDrain = (sessionID: string) => { + void sdk.client.session.queue.drain.pause({ sessionID }).catch(() => {}) + } + + const resumeQueueDrain = (sessionID: string) => { + void sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + } + + const cancelQueueEdit = () => { + const sessionID = params.id + if (!sessionID) return + stopEditingQueueMessage() + clearFollowupEdit() + prompt.reset() + resumeQueueDrain(sessionID) + } + + createEffect( + on( + () => params.id, + (id, prev) => { + if (!prev || prev === id || !editingQueueMessageID()) return + stopEditingQueueMessage() + clearFollowupEdit() + prompt.reset() + resumeQueueDrain(prev) + }, + { defer: true }, + ), + ) + createComputed((prev) => { const key = sessionKey() if (key !== prev) { @@ -1365,8 +1402,19 @@ export default function Page() { const queuedFollowups = createMemo(() => { const id = params.id - if (!id) return emptyFollowups - return followup.items[id] ?? emptyFollowups + if (!id) return emptyQueue + return sync.data.prompt_queue[id] ?? emptyQueue + }) + + createEffect(() => { + const sessionID = params.id + if (!sessionID || isChildSession()) return + void sdk.client.session.queue + .list({ sessionID }) + .then((result) => { + if (result.data) sync.set("prompt_queue", sessionID, result.data) + }) + .catch(() => {}) }) const editingFollowup = createMemo(() => { @@ -1377,27 +1425,22 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { - const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) + const item = queuedFollowups().find((entry) => entry.id === input.id) if (!item) return if (input.manual) setFollowup("paused", input.sessionID, undefined) setFollowup("failed", input.sessionID, undefined) - const ok = await sendFollowupDraft({ - client: sdk.client, - sync, - serverSync, - draft: item, - delivery: "deferred", - optimisticBusy: item.sessionDirectory === sdk.directory, - }).catch((err) => { - setFollowup("failed", input.sessionID, input.id) - fail(err) - return false - }) - if (!ok) return + await sdk.client.session.queue + .send({ sessionID: input.sessionID, queueID: input.id }) + .catch((err) => { + setFollowup("failed", input.sessionID, input.id) + fail(err) + throw err + }) + + resumeQueueDrain(input.sessionID) - setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) if (input.manual) resumeScroll() }, })) @@ -1418,38 +1461,66 @@ export default function Page() { return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() }) - const followupText = (item: FollowupDraft) => { - const text = item.prompt - .map((part) => { - if (part.type === "image") return `[image:${part.filename}]` - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - return part.content - }) - .join("") - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => !!line) - - if (text) return text - return `[${language.t("common.attachment")}]` - } + const followupDock = createMemo(() => queuedFollowups()) const queueFollowup = (draft: FollowupDraft) => { - setFollowup("items", draft.sessionID, (items) => [ - ...(items ?? []), - { id: Identifier.ascending("message"), ...draft }, - ]) - setFollowup("failed", draft.sessionID, undefined) - setFollowup("paused", draft.sessionID, undefined) - } + const text = draft.prompt.map((part) => ("content" in part ? part.content : "")).join("") + const images = draft.prompt.filter((part) => part.type === "image") + const { requestParts } = buildRequestParts({ + prompt: draft.prompt, + context: draft.context, + images, + text, + sessionID: draft.sessionID, + messageID: Identifier.ascending("message"), + sessionDirectory: draft.sessionDirectory, + }) - const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) + const save = draft.queueID + ? sdk.client.session.queue.update({ + sessionID: draft.sessionID, + queueID: draft.queueID, + body: { + agent: draft.agent, + model: draft.model, + variant: draft.variant, + parts: requestParts, + }, + }) + : sdk.client.session.queue.enqueue({ + sessionID: draft.sessionID, + agent: draft.agent, + model: draft.model, + variant: draft.variant, + parts: requestParts, + }) + + void save + .then(() => { + applyQueueSaveSuccess({ + sessionID: draft.sessionID, + queueID: draft.queueID, + clearFailed: () => setFollowup("failed", draft.sessionID, undefined), + clearPaused: () => setFollowup("paused", draft.sessionID, undefined), + clearEdit: clearFollowupEdit, + stopEditing: stopEditingQueueMessage, + resumeDrain: resumeQueueDrain, + }) + }) + .catch((err) => { + if (draft.queueID && editingQueueMessageID() === draft.queueID) { + stopEditingQueueMessage() + clearFollowupEdit() + prompt.reset() + resumeQueueDrain(draft.sessionID) + } + fail(err) + }) + } const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { if (sync.session.get(sessionID)?.parentID) return Promise.resolve() - const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) - if (!item) return Promise.resolve() + if (!queuedFollowups().find((entry) => entry.id === id)) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) @@ -1459,17 +1530,42 @@ export default function Page() { const sessionID = params.id if (!sessionID) return if (followupBusy(sessionID)) return + if (!queuedFollowups().find((entry) => entry.id === id)) return - const item = queuedFollowups().find((entry) => entry.id === id) - if (!item) return + const restoreDrain = () => { + if (editingQueueMessageID() !== id) return + stopEditingQueueMessage() + clearFollowupEdit() + resumeQueueDrain(sessionID) + } - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + pauseQueueDrain(sessionID) + setEditingQueueMessageID(id) setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) - setFollowup("edit", sessionID, { - id: item.id, - prompt: item.prompt, - context: item.context, - }) + + void sdk.client.session.queue + .get({ sessionID, queueID: id }) + .then((result) => { + if (editingQueueMessageID() !== id) return + if (!result.data || typeof result.data !== "object" || !("parts" in result.data)) { + restoreDrain() + return + } + const detail = result.data as { id: string; parts: Parameters[0] } + const parts = partsFromQueueDetail(detail.parts, sessionID) + setFollowup("edit", sessionID, { + id: detail.id, + prompt: extractPromptFromParts(parts, { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }), + context: [], + }) + }) + .catch((err) => { + restoreDrain() + fail(err) + }) } const clearFollowupEdit = () => { @@ -1569,22 +1665,6 @@ export default function Page() { const actions = { revert } - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - - const item = queuedFollowups()[0] - if (!item) return - if (followupBusy(sessionID)) return - if (followup.failed[sessionID] === item.id) return - if (followup.paused[sessionID]) return - if (isChildSession()) return - if (composer.blocked()) return - if (busy(sessionID)) return - - void sendFollowup(sessionID, item.id) - }) - createResizeObserver( () => promptDock, ({ height }) => { @@ -1641,6 +1721,8 @@ export default function Page() { }) onCleanup(() => { + const sessionID = params.id + if (sessionID && editingQueueMessageID()) resumeQueueDrain(sessionID) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) @@ -1676,8 +1758,10 @@ export default function Page() { queue: queueEnabled, items: followupDock(), sending: sendingFollowup(), + editingMessageID: editingQueueMessageID(), edit: editingFollowup(), onQueue: queueFollowup, + onEditingQueueMessageID: setEditingQueueMessageID, onAbort: () => { const id = params.id if (!id) return @@ -1688,6 +1772,7 @@ export default function Page() { }, onEdit: editFollowup, onEditLoaded: clearFollowupEdit, + onCancelQueueEdit: cancelQueueEdit, } : undefined } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 6f731234c410..a66db7be701c 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -32,12 +32,15 @@ export function SessionComposerRegion(props: { queue: () => boolean items: { id: string; text: string }[] sending?: string + editingMessageID?: string edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } onQueue: (draft: FollowupDraft) => void onAbort: () => void onSend: (id: string) => void onEdit: (id: string) => void onEditLoaded: () => void + onCancelQueueEdit?: () => void + onEditingQueueMessageID?: (id: string | undefined) => void } revert?: { items: { id: string; text: string }[] @@ -248,10 +251,11 @@ export function SessionComposerRegion(props: { "margin-top": `${-lift()}px`, }} > - + @@ -266,7 +270,10 @@ export function SessionComposerRegion(props: { newSessionWorktree={props.newSessionWorktree} onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} edit={props.followup?.edit} + editingQueueID={props.followup?.editingMessageID} + onEditingQueueMessageID={props.followup?.onEditingQueueMessageID} onEditLoaded={props.followup?.onEditLoaded} + onCancelQueueEdit={props.followup?.onCancelQueueEdit} shouldQueue={props.followup?.queue} onQueue={props.followup?.onQueue} onAbort={props.followup?.onAbort} diff --git a/packages/app/src/pages/session/composer/session-followup-dock.tsx b/packages/app/src/pages/session/composer/session-followup-dock.tsx index 7d744f4e6cb8..b7e3f3124201 100644 --- a/packages/app/src/pages/session/composer/session-followup-dock.tsx +++ b/packages/app/src/pages/session/composer/session-followup-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo } from "solid-js" +import { For, Show, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockTray } from "@opencode-ai/ui/dock-surface" @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language" export function SessionFollowupDock(props: { items: { id: string; text: string }[] sending?: string + editingMessageID?: string onSend: (id: string) => void onEdit: (id: string) => void }) { @@ -16,6 +17,13 @@ export function SessionFollowupDock(props: { collapsed: false, }) + const editing = () => props.editingMessageID + + createEffect(() => { + if (!editing()) return + setStore("collapsed", false) + }) + const toggle = () => setStore("collapsed", (value) => !value) const total = createMemo(() => props.items.length) const label = createMemo(() => @@ -28,6 +36,10 @@ export function SessionFollowupDock(props: { return ( {label()} + + + {language.t("session.followupDock.editing")} + + {preview()} @@ -78,29 +95,56 @@ export function SessionFollowupDock(props: {
- {(item) => ( -
- {item.text} - - -
- )} + + {item.text} + + + + + + } + > + + {language.t("session.followupDock.editing")} + + +
+ ) + }}
diff --git a/packages/app/src/utils/queue-parts.ts b/packages/app/src/utils/queue-parts.ts new file mode 100644 index 000000000000..875b720b236e --- /dev/null +++ b/packages/app/src/utils/queue-parts.ts @@ -0,0 +1,21 @@ +import type { + AgentPartInput, + FilePartInput, + Part, + SubtaskPartInput, + TextPartInput, +} from "@opencode-ai/sdk/v2" +import { Identifier } from "@/utils/id" + +type QueuePart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput + +export function partsFromQueueDetail(parts: readonly QueuePart[], sessionID: string): Part[] { + const messageID = Identifier.ascending("message") + return parts.map((part) => { + const id = Identifier.ascending("part") + if (part.type === "text") return { ...part, id, messageID, sessionID } + if (part.type === "file") return { ...part, id, messageID, sessionID } + if (part.type === "agent") return { ...part, id, messageID, sessionID } + return { ...part, id, messageID, sessionID } + }) +} diff --git a/packages/opencode/migration/20260601120000_prompt_queue/migration.sql b/packages/opencode/migration/20260601120000_prompt_queue/migration.sql new file mode 100644 index 000000000000..9f9850d6cfbe --- /dev/null +++ b/packages/opencode/migration/20260601120000_prompt_queue/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE `prompt_queue` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `position` integer NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `prompt_queue_session_position_idx` ON `prompt_queue` (`session_id`,`position`); diff --git a/packages/opencode/scripts/adhoc-queue-qa-smoke.py b/packages/opencode/scripts/adhoc-queue-qa-smoke.py new file mode 100755 index 000000000000..55dc044dc24a --- /dev/null +++ b/packages/opencode/scripts/adhoc-queue-qa-smoke.py @@ -0,0 +1,769 @@ +#!/usr/bin/env python3 +""" +Ad-hoc QA smoke for unified prompt queue via opencode-local. + +Runs in order: + 1. `opencode-local run --interactive` (demo + mocked LLM) + 2. `opencode-local` full TUI attach (mocked serve + queue API) + +Optional live Nemotron when OPENCODE_QA_LIVE=1 (often slow; may time out). + +Artifacts: OPENCODE_QA_ARTIFACT_DIR (default: /tmp/opencode-queue-qa-) +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path + +try: + import pexpect +except ImportError: + print("pexpect required: pip install pexpect", file=sys.stderr) + sys.exit(2) + +REPO = Path(__file__).resolve().parents[1] +CLI_ENTRY = REPO / "src" / "index.ts" +SOURCE_CLI = ["bun", "run", "--conditions=browser", str(CLI_ENTRY)] + + +def source_cli(extra: list[str], ws: Path) -> list[str]: + """Repo-root bun entry with workspace directory pinned via --dir.""" + return [*SOURCE_CLI, *extra, "--dir", str(ws)] +OPENCODE_LOCAL = os.environ.get("OPENCODE_QA_BIN", shutil.which("opencode-local") or "opencode-local") + + +def cli_argv() -> list[str]: + """Prefer opencode-local; fall back to repo source when the binary lacks /queue routes.""" + if os.environ.get("OPENCODE_QA_FORCE_SOURCE") == "1": + return SOURCE_CLI + if os.environ.get("OPENCODE_QA_BIN"): + return [OPENCODE_LOCAL] + if has_queue_routes(OPENCODE_LOCAL): + return [OPENCODE_LOCAL] + print(f" NOTE {OPENCODE_LOCAL} lacks POST /session/:id/queue; using repo source CLI") + return SOURCE_CLI + + +def has_queue_routes(binary: str) -> bool: + ws = Path(tempfile.mkdtemp(prefix="opencode-queue-probe-")) + try: + subprocess.run(["git", "init", "-q"], cwd=ws, check=True) + (ws / "README.md").write_text("probe\n", encoding="utf-8") + home = ws / ".home" + home.mkdir() + env = { + **os.environ, + "HOME": str(home), + "OPENCODE_TEST_HOME": str(home), + "OPENCODE_DISABLE_PROJECT_CONFIG": "1", + "OPENCODE_PURE": "1", + "OPENCODE_DISABLE_MODELS_FETCH": "1", + } + port = 42000 + (os.getpid() % 500) + serve = subprocess.Popen( + [binary, "serve", "--port", str(port)], + cwd=str(ws), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + base_url: str | None = None + try: + assert serve.stdout is not None + deadline = time.time() + 90 + while time.time() < deadline: + line = serve.stdout.readline() + if not line: + if serve.poll() is not None: + break + continue + match = re.search(r"(https?://[^\s]+)", line) + if match: + base_url = match.group(1).rstrip("/") + break + if not base_url: + return False + session = curl_json("POST", f"{base_url}/session", str(ws), {"title": "probe"}) + sid = session.get("id") + if not sid: + return False + curl_json( + "POST", + f"{base_url}/session/{sid}/queue", + str(ws), + { + "agent": "build", + "model": {"providerID": "test", "modelID": "test-model"}, + "parts": [{"type": "text", "text": "probe"}], + }, + ) + return True + finally: + stop_serve(serve) + except Exception: + return False + finally: + shutil.rmtree(ws, ignore_errors=True) +MODEL_MOCK = "test/test-model" +MODEL_LIVE = os.environ.get("OPENCODE_QA_MODEL", "opencode/nemotron-3-super-free") +ARTIFACT_DIR = Path( + os.environ.get( + "OPENCODE_QA_ARTIFACT_DIR", + f"/tmp/opencode-queue-qa-{os.getpid()}", + ), +) +LIVE = os.environ.get("OPENCODE_QA_LIVE", "") == "1" + + +def test_provider_config(llm_url: str) -> str: + return json.dumps( + { + "formatter": False, + "lsp": False, + "provider": { + "test": { + "name": "Test", + "id": "test", + "env": [], + "npm": "@ai-sdk/openai-compatible", + "models": { + "test-model": { + "id": "test-model", + "name": "Test Model", + "attachment": False, + "reasoning": False, + "temperature": False, + "tool_call": True, + "release_date": "2025-01-01", + "limit": {"context": 100_000, "output": 10_000}, + "cost": {"input": 0, "output": 0}, + "options": {}, + } + }, + "options": {"apiKey": "test-key", "baseURL": llm_url}, + } + }, + } + ) + + +def isolated_env(home: Path, llm_url: str | None = None) -> dict[str, str]: + env = { + **os.environ, + "OPENCODE_TEST_HOME": str(home), + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local/share"), + "XDG_STATE_HOME": str(home / ".local/state"), + "XDG_CACHE_HOME": str(home / ".cache"), + "OPENCODE_DISABLE_PROJECT_CONFIG": "1", + "OPENCODE_PURE": "1", + "OPENCODE_DISABLE_AUTOUPDATE": "1", + "OPENCODE_DISABLE_AUTOCOMPACT": "1", + "OPENCODE_DISABLE_MODELS_FETCH": "1", + "OPENCODE_AUTH_CONTENT": "{}", + "TERM": os.environ.get("TERM", "xterm-256color"), + "COLORTERM": os.environ.get("COLORTERM", "truecolor"), + } + if llm_url: + env["OPENCODE_CONFIG_CONTENT"] = test_provider_config(llm_url) + return env + + +def strip_ansi(text: str) -> str: + return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", text) + + +@dataclass +class QaReport: + passed: list[str] = field(default_factory=list) + failed: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + def ok(self, name: str) -> None: + self.passed.append(name) + print(f" PASS {name}") + + def fail(self, name: str, detail: str) -> None: + self.failed.append(f"{name}: {detail}") + print(f" FAIL {name}: {detail}") + + def note(self, text: str) -> None: + self.notes.append(text) + print(f" NOTE {text}") + + +def save_screen(name: str, text: str) -> None: + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + path = ARTIFACT_DIR / f"{name}.txt" + path.write_text(strip_ansi(text), encoding="utf-8") + + +def spawn_pty( + argv: list[str], + env: dict[str, str], + cwd: Path, + *, + run_cwd: Path | None = None, + cols: int = 120, + rows: int = 40, +): + child = pexpect.spawn( + argv[0], + argv[1:], + cwd=str(run_cwd or cwd), + env=env, + encoding="utf-8", + timeout=120, + dimensions=(rows, cols), + ) + child.logfile_read = None + return child + + +def wait_pattern(child: pexpect.spawn, pattern: str | re.Pattern, timeout: float, label: str) -> str: + idx = child.expect([pattern, pexpect.TIMEOUT, pexpect.EOF], timeout=timeout) + if idx == 0: + return child.before + child.after + if idx == 2: + raise RuntimeError(f"{label}: process exited (code {child.exitstatus})") + raise RuntimeError(f"{label}: timed out after {timeout}s") + + +def screen(child: pexpect.spawn) -> str: + chunks: list[str] = [] + while True: + try: + chunk = child.read_nonblocking(size=65536, timeout=0.05) + except pexpect.TIMEOUT: + break + except pexpect.EOF: + break + if not chunk: + break + chunks.append(chunk) + return "".join(chunks) + + +def snapshot(child: pexpect.spawn) -> str: + return strip_ansi((getattr(child, "before", "") or "") + screen(child)) + + +def send_queue_key(child: pexpect.spawn) -> None: + # kitty/xterm modifyOtherKeys: ctrl+shift+enter + child.send("\x1b[13;6u") + time.sleep(0.05) + child.send("\x1b[27;6;13~") + + +def send_submit(child: pexpect.spawn) -> None: + child.send("\r") + + +def close_child(child: pexpect.spawn, grace: float = 2.0) -> None: + if not child.isalive(): + return + try: + child.sendcontrol("c") + time.sleep(0.3) + child.send("/quit\r") + time.sleep(0.5) + except Exception: + pass + try: + child.close(force=True) + except Exception: + pass + if child.isalive(): + child.terminate(force=True) + + +class MockLlm: + def __init__(self) -> None: + self.proc: subprocess.Popen[str] | None = None + self.url: str | None = None + + def start(self) -> str: + script = REPO / "scripts" / "start-mock-llm.ts" + self.proc = subprocess.Popen( + ["bun", "run", str(script)], + cwd=str(REPO), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ, "OPENCODE_PURE": "1"}, + ) + assert self.proc.stdout is not None + line = self.proc.stdout.readline().strip() + if not line.startswith("http"): + err = self.proc.stderr.read(4000) if self.proc.stderr else "" + raise RuntimeError(f"mock LLM failed to start: {line!r} {err}") + self.url = line + return line + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + self.proc.send_signal(signal.SIGTERM) + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def make_workspace() -> Path: + ws = Path(tempfile.mkdtemp(prefix="opencode-qa-")) + subprocess.run(["git", "init", "-q"], cwd=ws, check=True) + (ws / "README.md").write_text("# queue qa\n", encoding="utf-8") + return ws + + +def curl_json(method: str, url: str, directory: str, body: dict | None = None) -> object: + cmd = [ + "curl", + "-sfS", + "-X", + method, + url, + "-H", + f"x-opencode-directory: {directory}", + ] + if body is not None: + cmd += ["-H", "content-type: application/json", "-d", json.dumps(body)] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"curl {method} {url} failed ({result.returncode}): {result.stderr.strip()}") + out = result.stdout.strip() + if not out: + return {} + try: + return json.loads(out) + except json.JSONDecodeError as exc: + raise RuntimeError(f"curl {method} {url} returned non-JSON: {out[:200]!r}") from exc + + +def start_serve(ws: Path, env: dict[str, str], cli: list[str]) -> tuple[subprocess.Popen[str], str]: + port = 41000 + (os.getpid() % 1000) + serve = subprocess.Popen( + [*cli, "serve", "--port", str(port)], + cwd=str(ws), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert serve.stdout is not None + base_url: str | None = None + deadline = time.time() + 120 + while time.time() < deadline: + line = serve.stdout.readline() + if not line: + if serve.poll() is not None: + break + time.sleep(0.1) + continue + match = re.search(r"(https?://[^\s]+)", line) + if match: + base_url = match.group(1).rstrip("/") + break + if not base_url: + rest = serve.stdout.read() if serve.stdout else "" + raise RuntimeError(f"serve did not print listening URL (exit={serve.poll()}): {rest[-500:]!r}") + time.sleep(0.3) + return serve, base_url + + +def stop_serve(serve: subprocess.Popen[str]) -> None: + if serve.poll() is not None: + return + serve.send_signal(signal.SIGTERM) + try: + serve.wait(timeout=10) + except subprocess.TimeoutExpired: + serve.kill() + + +def test_run_interactive_demo(report: QaReport, ws: Path) -> None: + name = "run -i --demo boots footer" + # Always exercise the installed opencode-local binary for --demo. + child = spawn_pty( + [ + OPENCODE_LOCAL, + "run", + "-i", + "--demo", + "-m", + MODEL_LIVE, + "--title", + "qa-run-demo", + ], + isolated_env(ws / ".home-demo"), + ws, + ) + try: + wait_pattern(child, re.compile(r"(?i)(demo|prompt|footer|/help)"), 45, name) + save_screen("run-demo-boot", snapshot(child)) + + child.send("queue-me-on-demo") + send_queue_key(child) + time.sleep(0.8) + buf = snapshot(child) + save_screen("run-demo-queue-key", buf) + if "queue unavailable in demo" in buf.lower(): + report.ok("run -i --demo rejects queue (ctrl+shift+enter)") + elif re.search(r"\bqueued\b|message[s]? queued", buf, re.I): + report.ok("run -i --demo queue key shows dock (binary may use memory queue)") + else: + report.fail( + "run -i --demo queue key", + f"expected demo queue rejection or dock; tail: {buf[-400:]!r}", + ) + + child.send("/quit\r") + child.expect(pexpect.EOF, timeout=15) + report.ok("run -i --demo /quit exits") + except Exception as exc: + save_screen("run-demo-error", str(exc) + "\n" + snapshot(child)) + report.fail(name, str(exc)) + finally: + close_child(child) + + +def test_run_interactive_mock( + report: QaReport, + ws: Path, + llm_url: str, + cli: list[str], + use_source: bool, +) -> None: + home = ws / ".home-mock" + env = isolated_env(home, llm_url) + run_argv = ( + source_cli(["run", "-i", "-m", MODEL_MOCK, "--title", "qa-run-mock"], ws) + if use_source + else [*cli, "run", "-i", "-m", MODEL_MOCK, "--title", "qa-run-mock"] + ) + child = spawn_pty(run_argv, env, ws, run_cwd=REPO if use_source else None) + try: + wait_pattern(child, re.compile(r"(?i)(test-model|build|prompt)"), 60, "run -i mock boot") + time.sleep(1.0) + + child.send("open-turn-steer\r") + wait_pattern(child, re.compile(r"(?i)(mock-ok|assistant|done|complete)"), 90, "run mock first turn") + save_screen("run-mock-after-steer", snapshot(child)) + + child.send("queue-via-keyboard\r") + send_queue_key(child) + time.sleep(1.2) + buf = snapshot(child) + save_screen("run-mock-queue-key", buf) + if re.search(r"1 message queued|messages queued|\bqueued\b", buf, re.I): + report.ok("run -i mock queue via ctrl+shift+enter shows dock") + else: + report.note("run mock queue key: dock text not seen; checking HTTP queue path next") + + child.send("/quit\r") + child.expect(pexpect.EOF, timeout=20) + report.ok("run -i mock /quit exits") + except Exception as exc: + save_screen("run-mock-error", str(exc)) + report.fail("run -i mock queue flow", str(exc)) + finally: + close_child(child) + + +def test_run_interactive_api_queue(report: QaReport, ws: Path, llm_url: str, cli: list[str]) -> None: + """Hold first turn, seed queue via HTTP (same API as TUI), verify dock in run -i.""" + home = ws / ".home-api" + env = isolated_env(home, llm_url) + serve: subprocess.Popen[str] | None = None + try: + serve, base_url = start_serve(ws, env, cli) + + session = curl_json( + "POST", + f"{base_url}/session", + str(ws), + {"title": "qa-run-api-queue"}, + ) + sid = session["id"] + + for text in ("queue-one", "queue-two", "queue-three"): + curl_json( + "POST", + f"{base_url}/session/{sid}/queue", + str(ws), + { + "agent": "build", + "model": {"providerID": "test", "modelID": "test-model"}, + "parts": [{"type": "text", "text": text}], + }, + ) + + listed = curl_json("GET", f"{base_url}/session/{sid}/queue", str(ws)) + if not isinstance(listed, list) or len(listed) != 3: + raise RuntimeError(f"expected 3 queued items via GET, got {listed!r}") + report.ok("HTTP queue API holds 3 items before run attach") + + use_source = cli == SOURCE_CLI + attach_argv = ( + source_cli( + [ + "run", + "-i", + "-m", + MODEL_MOCK, + "--attach", + base_url, + "--session", + sid, + ], + ws, + ) + if use_source + else [ + *cli, + "run", + "-i", + "-m", + MODEL_MOCK, + "--attach", + base_url, + "--session", + sid, + "--dir", + str(ws), + ] + ) + child = spawn_pty(attach_argv, env, ws, run_cwd=REPO if use_source else None) + try: + wait_pattern( + child, + re.compile(r"3 messages queued|queue-one|queue-two|queue-three", re.I), + 45, + "run attach shows queue dock", + ) + save_screen("run-attach-queue-dock", snapshot(child)) + report.ok("run -i attach shows queued prompts in UI") + child.send("/quit\r") + child.expect(pexpect.EOF, timeout=15) + except Exception as exc: + save_screen("run-attach-queue-dock-error", snapshot(child)) + if "timed out" in str(exc).lower(): + report.note("run attach: queue API OK but dock text not visible in PTY (see artifact)") + else: + raise + finally: + close_child(child) + except Exception as exc: + report.fail("run -i attach queue dock", str(exc)) + finally: + if serve is not None: + stop_serve(serve) + + +def test_tui_full_queue( + report: QaReport, + ws: Path, + llm_url: str, + cli: list[str], + use_source: bool, +) -> None: + """Full `opencode-local` TUI (in-process server + mock LLM).""" + home = ws / ".home-tui-full" + env = isolated_env(home, llm_url) + tui_argv = source_cli(["-m", MODEL_MOCK], ws) if use_source else [*cli, "-m", MODEL_MOCK] + child = spawn_pty(tui_argv, env, ws, run_cwd=REPO if use_source else None) + try: + wait_pattern(child, re.compile(r"(?i)(opencode|build|session|prompt)"), 90, "full TUI boot") + time.sleep(1.5) + child.send("tui-steer-turn\r") + wait_pattern(child, re.compile(r"(?i)(mock-ok|ok|assistant)"), 90, "full TUI first turn") + child.send("tui-queued-msg\r") + send_queue_key(child) + time.sleep(1.2) + buf = snapshot(child) + save_screen("tui-full-queue", buf) + if re.search(r"1 message queued|messages queued|\bqueued\b", buf, re.I): + report.ok("full TUI queue dock via ctrl+shift+enter") + else: + report.fail("opencode-local full TUI queue", f"dock not visible; tail={buf[-500:]!r}") + + child.sendcontrol("c") + time.sleep(0.15) + child.send("q") + time.sleep(0.4) + except Exception as exc: + save_screen("tui-full-error", str(exc)) + report.fail("opencode-local full TUI", str(exc)) + finally: + close_child(child) + + +def test_tui_attach_queue( + report: QaReport, + ws: Path, + llm_url: str, + cli: list[str], + use_source: bool, +) -> None: + home = ws / ".home-tui" + env = isolated_env(home, llm_url) + serve: subprocess.Popen[str] | None = None + try: + serve, base_url = start_serve(ws, env, cli) + + session = curl_json("POST", f"{base_url}/session", str(ws), {"title": "qa-tui-queue"}) + sid = session["id"] + + for text in ("tui-q1", "tui-q2", "tui-q3"): + curl_json( + "POST", + f"{base_url}/session/{sid}/queue", + str(ws), + { + "agent": "build", + "model": {"providerID": "test", "modelID": "test-model"}, + "parts": [{"type": "text", "text": text}], + }, + ) + + listed = curl_json("GET", f"{base_url}/session/{sid}/queue", str(ws)) + if not isinstance(listed, list) or len(listed) != 3: + raise RuntimeError(f"expected 3 queued items via GET, got {listed!r}") + report.ok("TUI attach path: HTTP queue API holds 3 items") + + attach_argv = ( + [*SOURCE_CLI, "attach", base_url, "--session", sid, "--dir", str(ws)] + if use_source + else [*cli, "attach", base_url, "--session", sid] + ) + child = spawn_pty(attach_argv, env, ws, run_cwd=REPO if use_source else None) + try: + wait_pattern( + child, + re.compile(r"3 messages queued|tui-q1|tui-q2|tui-q3", re.I), + 45, + "TUI attach queue dock", + ) + save_screen("tui-attach-queue-dock", snapshot(child)) + report.ok("TUI attach shows queued prompts in UI") + except Exception as exc: + save_screen("tui-attach-queue-dock-error", snapshot(child)) + if "timed out" in str(exc).lower(): + report.note("TUI attach: queue API OK but dock text not visible in PTY (see artifact)") + else: + raise + finally: + close_child(child) + except Exception as exc: + report.fail("opencode-local TUI attach queue dock", str(exc)) + finally: + if serve is not None: + stop_serve(serve) + + +def test_live_nemotron(report: QaReport, ws: Path) -> None: + child = spawn_pty( + [ + OPENCODE_LOCAL, + "run", + "-i", + "-m", + MODEL_LIVE, + "--title", + "qa-live-nemotron", + ], + isolated_env(ws / ".home-live"), + ws, + ) + try: + wait_pattern(child, re.compile(r"(?i)(nemotron|prompt|footer)"), 45, "live nemotron boot") + child.send("Reply with exactly: PING\r") + wait_pattern(child, re.compile(r"\bPING\b"), 120, "live nemotron PING reply") + save_screen("live-nemotron-ping", snapshot(child)) + report.ok(f"live model {MODEL_LIVE} returned PING") + child.send("/quit\r") + child.expect(pexpect.EOF, timeout=15) + except Exception as exc: + save_screen("live-nemotron-error", str(exc)) + report.note(f"live Nemotron skipped/failed ({exc}); use OPENCODE_QA_LIVE=1 only when Zen is reachable") + finally: + close_child(child) + + +def main() -> int: + cli = cli_argv() + use_source = cli == SOURCE_CLI + print(f"CLI: {' '.join(cli)}") + print(f"opencode-local install: {OPENCODE_LOCAL}") + print(f"artifacts: {ARTIFACT_DIR}") + report = QaReport() + ws = make_workspace() + mock = MockLlm() + + try: + llm_url = mock.start() + print(f"mock LLM: {llm_url}") + + print("\n=== Phase 1: run --interactive --demo ===") + test_run_interactive_demo(report, ws) + + print("\n=== Phase 2: run --interactive (mock LLM) ===") + test_run_interactive_mock(report, ws, llm_url, cli, use_source) + test_run_interactive_api_queue(report, ws, llm_url, cli) + + print("\n=== Phase 3: opencode-local full TUI ===") + test_tui_full_queue(report, ws, llm_url, cli, use_source) + + print("\n=== Phase 4: opencode-local attach (queue API seed) ===") + test_tui_attach_queue(report, ws, llm_url, cli, use_source) + + if LIVE: + print("\n=== Phase 5: live Nemotron (optional) ===") + test_live_nemotron(report, ws) + else: + report.note("skip live Nemotron (set OPENCODE_QA_LIVE=1 to enable)") + + finally: + mock.stop() + shutil.rmtree(ws, ignore_errors=True) + + print("\n=== Summary ===") + print(f"passed: {len(report.passed)}") + for item in report.passed: + print(f" + {item}") + if report.failed: + print(f"failed: {len(report.failed)}") + for item in report.failed: + print(f" - {item}") + for item in report.notes: + print(f" ~ {item}") + + summary_path = ARTIFACT_DIR / "summary.txt" + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + summary_path.write_text( + "\n".join( + ["PASSED:"] + + report.passed + + ["FAILED:"] + + report.failed + + ["NOTES:"] + + report.notes + ), + encoding="utf-8", + ) + return 1 if report.failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/opencode/scripts/start-mock-llm.ts b/packages/opencode/scripts/start-mock-llm.ts new file mode 100644 index 000000000000..913342eadf73 --- /dev/null +++ b/packages/opencode/scripts/start-mock-llm.ts @@ -0,0 +1,15 @@ +// Prints mock LLM base URL on stdout for ad-hoc QA drivers (one line, no banner). +import { Effect } from "effect" +import { TestLLMServer } from "../test/lib/llm-server" + +const program = Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("mock-ok") + process.stdout.write(`${llm.url}\n`) + yield* Effect.never +}).pipe(Effect.provide(TestLLMServer.layer)) + +Effect.runPromise(program).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 67ed6130165c..3bd8eeb7ba76 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -78,6 +78,14 @@ type PromptInput = { history?: RunPrompt[] onSubmit: (input: RunPrompt) => boolean | Promise onQueue: (input: RunPrompt) => void + editingQueueID?: Accessor + onEditQueue?: () => void + onEditQueueNext?: () => void + onCancelQueueEdit?: () => boolean + queueEscapeGuard?: Accessor + clearQueueEscapeGuard?: () => void + onUpdateQueued?: (id: string, prompt: RunPrompt) => void + onSendQueuedNow?: (id: string) => void onCycle: () => void onInterrupt: () => boolean onInputClear: () => void @@ -103,6 +111,9 @@ export type PromptState = { onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void + restorePrompt: (prompt: RunPrompt) => void + currentPrompt: () => RunPrompt + focus: () => void bind: (area?: TextareaRenderable) => void } @@ -115,6 +126,8 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { text: prompt.text, parts: structuredClone(prompt.parts), ...(prompt.mode ? { mode: prompt.mode } : {}), + ...(prompt.delivery ? { delivery: prompt.delivery } : {}), + ...(prompt.queueID ? { queueID: prompt.queueID } : {}), } } @@ -915,6 +928,11 @@ export function createPromptState(input: PromptInput): PromptState { const onKeyDown = (event: KeyEvent) => { const key = promptInfo(event) + if (input.queueEscapeGuard?.()) { + event.preventDefault() + input.clearQueueEscapeGuard?.() + return + } if (visible()) { const name = event.name.toLowerCase() const ctrl = event.ctrl && !event.meta && !event.shift @@ -969,6 +987,37 @@ export function createPromptState(input: PromptInput): PromptState { } } + if ( + !shell() && + input.editingQueueID?.() && + key.name === "return" && + !event.shift && + !event.ctrl && + !event.meta && + !event.super + ) { + event.preventDefault() + onSubmit() + return + } + + if (!shell() && input.editingQueueID?.() && promptHit(keys().editQueueCancel, key)) { + event.preventDefault() + if (input.onCancelQueueEdit?.()) return + } + + if (!shell() && input.editingQueueID?.() && promptHit(keys().editQueueNext, key)) { + event.preventDefault() + input.onEditQueueNext?.() + return + } + + if (!shell() && promptHit(keys().editQueues, key)) { + event.preventDefault() + input.onEditQueue?.() + return + } + if (!shell() && promptHit(keys().queues, key)) { event.preventDefault() onQueue() @@ -1143,20 +1192,33 @@ export function createPromptState(input: PromptInput): PromptState { const onSubmit = () => { syncDraft() + if (input.editingQueueID?.()) { + const id = input.editingQueueID() + if (!id) return + input.onSendQueuedNow?.(id) + return + } submitPrompt(clonePrompt(draft)) } + const currentPrompt = () => clonePrompt(draft) + const onQueue = () => { // Queueing is a normal-mode only affordance; shell commands always run now. if (shell()) return syncDraft() if (visible()) hide() + const queued = clonePrompt(draft) + queued.queued = true + if (input.editingQueueID?.()) { + const id = input.editingQueueID() + if (id) input.onUpdateQueued?.(id, queued) + return + } if (!draft.text.trim()) { input.onStatus("empty prompt ignored") return } - const queued = clonePrompt(draft) - queued.delivery = "deferred" input.onQueue(queued) push(queued) resetDraft() @@ -1223,6 +1285,14 @@ export function createPromptState(input: PromptInput): PromptState { }) }) + const focus = () => { + if (!area || area.isDestroyed) { + return + } + + area.focus() + } + return { placeholder, bindings, @@ -1242,6 +1312,9 @@ export function createPromptState(input: PromptInput): PromptState { scheduleRows() }, replaceDraft, + restorePrompt: restore, + currentPrompt, + focus, bind, } } diff --git a/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx b/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx new file mode 100644 index 000000000000..bd96679c5d69 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.queue-dock.tsx @@ -0,0 +1,45 @@ +/** @jsxImportSource @opentui/solid */ +import { createMemo } from "solid-js" +import { QueueDock, type QueuedItem } from "@/queue/queue-dock" +import type { RunFooterTheme } from "./theme" +import { printableBinding } from "./prompt.shared" +import type { FooterKeybinds } from "./types" + +export function RunQueueDock(props: { + items: () => QueuedItem[] + theme: () => RunFooterTheme + keybinds: FooterKeybinds + disabled?: boolean + editing?: () => boolean + editingMessageID?: () => string | undefined + onEdit: (id: string) => void + onSendNow: (id: string) => void +}) { + const hints = createMemo(() => ({ + submit: printableBinding(props.keybinds.inputSubmit, props.keybinds.leader) || "return", + queue: printableBinding(props.keybinds.inputQueue, props.keybinds.leader) || "ctrl+shift+return", + cancel: printableBinding(props.keybinds.inputEditQueueCancel, props.keybinds.leader) || "escape", + edit: printableBinding(props.keybinds.inputEditQueue, props.keybinds.leader) || "alt+up", + editNext: printableBinding(props.keybinds.inputEditQueueNext, props.keybinds.leader) || "alt+down", + })) + + return ( + ({ + text: props.theme().text, + textMuted: props.theme().muted, + primary: props.theme().highlight, + border: props.theme().border, + })} + hints={hints} + disabled={props.disabled} + editing={props.editing} + editingMessageID={props.editingMessageID} + onEdit={props.onEdit} + onSendNow={props.onSendNow} + /> + ) +} + +export type { QueuedItem } diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 4bbf4a558274..a6e4389e98b8 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -46,6 +46,7 @@ import type { FooterSubagentState, FooterView, PermissionReply, + QueueControl, QuestionReject, QuestionReply, RunAgent, @@ -113,7 +114,7 @@ function createEmptySubagentState(): FooterSubagentState { function eventPatch(next: FooterEvent): FooterPatch | undefined { if (next.type === "queue") { - return { queue: next.queue } + return { queue: next.queue, queued: next.queued } } if (next.type === "first") { @@ -192,11 +193,13 @@ export class RunFooter implements FooterApi { private setSubagent: (next: FooterSubagentState) => void private promptRoute: FooterPromptRoute = { type: "composer" } private subagentMenuRows = SUBAGENT_ROWS + private queueRows = 0 private autocomplete = false private interruptTimeout: NodeJS.Timeout | undefined private exitTimeout: NodeJS.Timeout | undefined private interruptHint: string private requestExitHandler: (() => boolean) | undefined + private queueControl: QueueControl | undefined private scrollback: RunScrollbackStream constructor( @@ -207,6 +210,7 @@ export class RunFooter implements FooterApi { phase: "idle", status: "", queue: 0, + queued: [], model: options.modelLabel, duration: "", usage: "", @@ -281,6 +285,7 @@ export class RunFooter implements FooterApi { agent: options.agentLabel, onSubmit: this.handlePrompt, onQueue: this.handleQueue, + queueControl: () => this.queueControl, onPermissionReply: this.handlePermissionReply, onQuestionReply: this.handleQuestionReply, onQuestionReject: this.handleQuestionReject, @@ -332,6 +337,10 @@ export class RunFooter implements FooterApi { } } + public setQueueControl(control: QueueControl | undefined): void { + this.queueControl = control + } + public event(next: FooterEvent): void { if (next.type === "catalog") { if (this.isGone) { @@ -396,6 +405,7 @@ export class RunFooter implements FooterApi { phase: next.phase ?? prev.phase, status: typeof next.status === "string" ? next.status : prev.status, queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue, + queued: next.queued ?? prev.queued, model: typeof next.model === "string" ? next.model : prev.model, duration: typeof next.duration === "string" ? next.duration : prev.duration, usage: typeof next.usage === "string" ? next.usage : prev.usage, @@ -572,7 +582,10 @@ export class RunFooter implements FooterApi { ? 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)) + : Math.max( + base + TEXTAREA_MIN_ROWS + this.queueRows, + Math.min(base + PROMPT_MAX_ROWS + this.queueRows, base + this.rows + this.queueRows), + ) if (height !== this.renderer.footerHeight) { this.renderer.footerHeight = height @@ -595,10 +608,16 @@ export class RunFooter implements FooterApi { } } - private syncLayout = (next: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }): void => { + private syncLayout = (next: { + route: FooterPromptRoute + autocomplete: boolean + subagentRows: number + queueRows: number + }): void => { this.promptRoute = next.route this.autocomplete = next.autocomplete this.subagentMenuRows = next.subagentRows + this.queueRows = next.queueRows if (this.view().type === "prompt") { this.applyHeight() } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 8a342cdcf223..13c9ed3b11b9 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -24,6 +24,10 @@ import { import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu" import { RunFooterSubagentBody } from "./footer.subagent" import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt" +import { queueDockRows } from "@/queue/queue-dock" +import { queueEditCommitPlan, queueEditSwitchPlan } from "@/queue/edit" +import { RunQueueDock } from "./footer.queue-dock" +import type { QueueControl } from "./types" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared" @@ -80,6 +84,7 @@ type RunFooterViewProps = { agent: string onSubmit: (input: RunPrompt) => boolean onQueue: (input: RunPrompt) => void + queueControl?: () => QueueControl | undefined onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -92,13 +97,39 @@ type RunFooterViewProps = { onModelSelect: (model: NonNullable) => void onVariantSelect: (variant: string | undefined) => void onRows: (rows: number) => void - onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void + onLayout: (input: { + route: FooterPromptRoute + autocomplete: boolean + subagentRows: number + queueRows: number + }) => void onStatus: (text: string) => void onSubagentSelect?: (sessionID: string | undefined) => void } export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt" +export function queuedSendPlan(items: FooterState["queued"], id: string, editingID: string | undefined) { + const index = items.findIndex((item) => item.id === id) + const nextID = index < 0 ? undefined : items[index + 1]?.id + if (editingID && editingID !== id) { + return { + saveID: editingID, + editID: editingID, + clearEditor: false, + pauseDrain: true, + resumeDrain: false, + } + } + return { + saveID: editingID === id ? id : undefined, + editID: nextID, + clearEditor: true, + pauseDrain: !!nextID, + resumeDrain: !nextID, + } +} + export function RunFooterView(props: RunFooterViewProps) { const term = useTerminalDimensions() const active = createMemo(() => props.view?.() ?? { type: "prompt" }) @@ -157,7 +188,10 @@ export function RunFooterView(props: RunFooterViewProps) { 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 queued = createMemo(() => props.state().queued) + const [editingQueueID, setEditingQueueID] = createSignal() + const [queueEscapeGuard, setQueueEscapeGuard] = createSignal(false) + const queueControl = () => props.queueControl?.() const duration = createMemo(() => props.state().duration) const usage = createMemo(() => props.state().usage) const interruptKey = createMemo(() => interrupt() || "/exit") @@ -251,7 +285,87 @@ export function RunFooterView(props: RunFooterViewProps) { openTab(next.sessionID) } - const composer = createPromptState({ + + let composer!: ReturnType + + const saveEditorToQueue = async (id: string) => { + const prompt = composer.currentPrompt() + if (queueEditCommitPlan({ text: prompt.text }).type === "remove") { + await queueControl()?.remove(id) + if (editingQueueID() === id) { + setEditingQueueID(undefined) + composer.restorePrompt({ text: "", parts: [] }) + } + return "removed" as const + } + return queueControl()?.update(id, prompt) ? ("saved" as const) : false + } + + const cancelQueueEdit = () => { + const id = editingQueueID() + if (!id) return false + void Promise.resolve(queueControl()?.resumeDrain?.()).catch(() => {}) + setEditingQueueID(undefined) + composer.restorePrompt({ text: "", parts: [] }) + setQueueEscapeGuard(true) + return true + } + + const promptFromPreview = (id: string) => { + const item = queued().find((entry) => entry.id === id) + if (!item) return + return { text: item.text, parts: [], queueID: id, queued: true } satisfies RunPrompt + } + + const beginEditQueue = async (id: string) => { + const control = queueControl() + const prompt = control?.get(id) ?? (control?.load ? await control.load(id) : promptFromPreview(id)) + if (!prompt) return + await control?.pauseDrain?.() + setEditingQueueID(id) + composer.restorePrompt(prompt) + composer.focus() + } + + const sendQueuedAndMaybeEditNext = async (id: string) => { + const plan = queuedSendPlan(queued(), id, editingQueueID()) + const saveResult = plan.saveID ? await saveEditorToQueue(plan.saveID) : undefined + if (saveResult === "removed") { + if (plan.resumeDrain) await queueControl()?.resumeDrain?.() + if (plan.editID) await beginEditQueue(plan.editID) + else composer.focus() + return + } + if (plan.pauseDrain) await queueControl()?.pauseDrain?.() + await queueControl()?.sendNow(id) + if (plan.resumeDrain) await queueControl()?.resumeDrain?.() + if (plan.clearEditor) { + setEditingQueueID(undefined) + composer.restorePrompt({ text: "", parts: [] }) + } + if (plan.editID) await beginEditQueue(plan.editID) + else composer.focus() + } + + const cycleEditQueue = async (dir: -1 | 1) => { + const items = queued() + if (!items.length) return + const current = editingQueueID() + if (!current) { + const index = dir === 1 ? 0 : items.length - 1 + await beginEditQueue(items[index]!.id) + return + } + const index = items.findIndex((item) => item.id === current) + if (index < 0) { + await beginEditQueue(items[0]!.id) + return + } + const next = items[(index + dir + items.length) % items.length] + if (next) await beginEditQueue(queueEditSwitchPlan({ currentID: current, targetID: next.id }).editID) + } + + composer = createPromptState({ directory: props.directory, findFiles: props.findFiles, agents: props.agents, @@ -265,8 +379,41 @@ export function RunFooterView(props: RunFooterViewProps) { width: () => term().width, theme, history: props.history, - onSubmit: props.onSubmit, + onSubmit: (input) => { + if (!input.queued) void Promise.resolve(queueControl()?.resumeDrain?.()).catch(() => {}) + return props.onSubmit(input) + }, onQueue: props.onQueue, + editingQueueID, + queueEscapeGuard, + clearQueueEscapeGuard: () => setQueueEscapeGuard(false), + onUpdateQueued: (id, prompt) => { + if (queueEditCommitPlan({ text: prompt.text }).type === "remove") { + void Promise.resolve(queueControl()?.remove(id)).then(() => { + if (editingQueueID() === id) { + setEditingQueueID(undefined) + composer.restorePrompt({ text: "", parts: [] }) + } + }) + return + } + queueControl()?.update(id, prompt) + }, + onEditQueue: () => { + if (editingQueueID()) void cycleEditQueue(-1).catch(() => {}) + else { + const id = queued()[0]?.id + if (id) void beginEditQueue(id).catch(() => {}) + } + }, + onEditQueueNext: () => { + if (!editingQueueID()) return + void cycleEditQueue(1).catch(() => {}) + }, + onCancelQueueEdit: cancelQueueEdit, + onSendQueuedNow: (id) => { + void sendQueuedAndMaybeEditNext(id).catch(() => {}) + }, onCycle: props.onCycle, onInterrupt: props.onInterrupt, onInputClear: props.onInputClear, @@ -360,6 +507,10 @@ export function RunFooterView(props: RunFooterViewProps) { route: route(), autocomplete: menu(), subagentRows: subagentMenuRows(), + queueRows: queueDockRows({ + count: queued().length, + editing: !!editingQueueID(), + }), }) }) @@ -403,9 +554,33 @@ export function RunFooterView(props: RunFooterViewProps) { backgroundColor={panel() ? "transparent" : theme().surface} gap={0} > - + + + !!editingQueueID()} + editingMessageID={editingQueueID} + onEdit={(id) => { + void beginEditQueue(id).catch(() => {}) + }} + onSendNow={(id) => { + void sendQueuedAndMaybeEditNext(id).catch(() => {}) + }} + /> + + + - 0}> - - {queue()} queued - - 0}> {usage()} diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index a3fc38c70eeb..113a1633ada3 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -24,11 +24,19 @@ export type PromptHistoryState = { draft: string } -export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) { +export function promptInfo(event: { + name: string + ctrl?: boolean + meta?: boolean + option?: boolean + shift?: boolean + super?: boolean +}) { return { name: event.name === " " ? "space" : event.name, ctrl: !!event.ctrl, - meta: !!event.meta, + // Terminals report Alt as meta or option depending on platform/backend. + meta: !!(event.meta || event.option), shift: !!event.shift, super: !!event.super, leader: false, @@ -45,6 +53,9 @@ export type PromptKeys = { next: PromptInfo[] clear: PromptInfo[] queues: PromptInfo[] + editQueues: PromptInfo[] + editQueueNext: PromptInfo[] + editQueueCancel: PromptInfo[] bindings: KeyBinding[] } @@ -162,6 +173,9 @@ export function promptKeys(keybinds: FooterKeybinds): PromptKeys { // "submit"/"newline" actions, so the queue key is dispatched manually in the // footer's onKeyDown handler (see Gap 17 in PLAN.md). queues: promptBindings(keybinds.inputQueue, keybinds.leader), + editQueues: promptBindings(keybinds.inputEditQueue, keybinds.leader), + editQueueNext: promptBindings(keybinds.inputEditQueueNext, keybinds.leader), + editQueueCancel: promptBindings(keybinds.inputEditQueueCancel, keybinds.leader), bindings: textareaBindings(keybinds), } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index e4659258daeb..4f2e09ae2da5 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -27,6 +27,9 @@ const DEFAULT_KEYBINDS: FooterKeybinds = { inputSubmit: [{ key: "return" }], inputNewline: [{ key: "shift+return,ctrl+return,ctrl+j" }], inputQueue: [{ key: "ctrl+shift+return" }], + inputEditQueue: [{ key: "alt+up" }], + inputEditQueueNext: [{ key: "alt+down" }], + inputEditQueueCancel: [{ key: "escape" }], } export type ModelInfo = { @@ -104,6 +107,9 @@ function footerKeybinds(config: Config | undefined): FooterKeybinds { inputSubmit: config.keybinds.get("input.submit"), inputNewline: config.keybinds.get("input.newline"), inputQueue: config.keybinds.get("input.queue"), + inputEditQueue: config.keybinds.get("input.queue.edit"), + inputEditQueueNext: config.keybinds.get("input.queue.edit.next"), + inputEditQueueCancel: config.keybinds.get("input.queue.edit.cancel"), } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue-remote.ts b/packages/opencode/src/cli/cmd/run/runtime.queue-remote.ts new file mode 100644 index 000000000000..f9ea10486b11 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/runtime.queue-remote.ts @@ -0,0 +1,48 @@ +import type { RunInput, RunPrompt } from "./types" + +type QueueDetailPart = RunPrompt["parts"][number] & { + type: string + text?: string + synthetic?: boolean +} + +export function buildQueuePromptPayload(input: { + agent: string | undefined + model: RunInput["model"] + variant?: string + prompt: RunPrompt +}) { + return { + agent: input.agent, + model: input.model, + variant: input.variant, + parts: [{ type: "text" as const, text: input.prompt.text }, ...input.prompt.parts], + } +} + +export function buildQueueSendPayload(input: { + agent: string | undefined + model: RunInput["model"] + variant?: string + prompt?: RunPrompt +}) { + if (!input.prompt) return null + return buildQueuePromptPayload({ + agent: input.agent, + model: input.model, + variant: input.variant, + prompt: input.prompt, + }) +} + +export function runPromptFromQueueDetail(input: { id: string; parts?: readonly QueueDetailPart[] }): RunPrompt { + const parts = input.parts ?? [] + return { + text: parts + .flatMap((part) => (part.type === "text" && !part.synthetic ? [part.text ?? ""] : [])) + .join("\n"), + parts: parts.filter((part): part is RunPrompt["parts"][number] => part.type !== "text"), + queueID: input.id, + queued: true, + } +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index 20b7feb21144..4ed7f19dc3f0 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -10,8 +10,12 @@ // // Resolves when the footer closes and all in-flight work finishes. import * as Locale from "@/util/locale" +import { MemoryPromptQueue, PromptQueue, type PromptQueueData } from "@/queue/prompt-queue" +import { runPromptPreview } from "@/queue/preview" +import { ModelID, ProviderID } from "@/provider/schema" +import type { SessionID } from "@/session/schema" import { isExitCommand, isNewCommand } from "./prompt.shared" -import type { FooterApi, FooterEvent, RunPrompt } from "./types" +import type { FooterApi, FooterEvent, QueueControl, QueuedPromptPreview, RunPrompt } from "./types" type Trace = { write(type: string, data?: unknown): void @@ -27,15 +31,59 @@ export type QueueInput = { footer: FooterApi initialInput?: string trace?: Trace + demo?: boolean + sessionID?: SessionID + agent?: string + model?: { providerID: string; modelID: string; variant?: string } + memoryQueue?: MemoryPromptQueue + enqueueRemote?: (prompt: RunPrompt) => Promise + pauseQueueDrainRemote?: () => Promise + resumeQueueDrainRemote?: () => Promise + getQueueRemote?: (queueID: string) => Promise + updateQueueRemote?: (queueID: string, prompt: RunPrompt) => Promise + removeQueueRemote?: (queueID: string) => Promise + sendQueueRemote?: (queueID: string, prompt?: RunPrompt) => Promise onSend?: (prompt: RunPrompt) => void onNewSession?: () => void | Promise run: (prompt: RunPrompt, signal: AbortSignal) => Promise } +const isQueuedPrompt = (prompt: RunPrompt) => prompt.queued === true || prompt.delivery === "deferred" + +function queueDataFromRunPrompt( + prompt: RunPrompt, + ctx: { agent: string; model: { providerID: string; modelID: string; variant?: string } }, +): PromptQueueData { + return { + version: 1, + agent: ctx.agent, + model: { + providerID: ProviderID.make(ctx.model.providerID), + modelID: ModelID.make(ctx.model.modelID), + variant: ctx.model.variant, + }, + parts: [{ type: "text", text: prompt.text }, ...(prompt.parts as PromptQueueData["parts"])], + } +} + type State = { queue: RunPrompt[] ctrl?: AbortController closed: boolean + nextQueueID: number +} + +function withQueueID(prompt: RunPrompt, state: State): RunPrompt { + if (prompt.queueID) return prompt + state.nextQueueID += 1 + return { ...prompt, queueID: `queue-${state.nextQueueID}` } +} + +function queueSnapshot(state: State): QueuedPromptPreview[] { + return state.queue.map((prompt) => ({ + id: prompt.queueID ?? prompt.text, + text: runPromptPreview(prompt) || prompt.text.trim().slice(0, 80) || "[queued]", + })) } function defer(): Deferred { @@ -61,6 +109,7 @@ export async function runPromptQueue(input: QueueInput): Promise { const state: State = { queue: [], closed: input.footer.isClosed, + nextQueueID: 0, } let draining: Promise | undefined @@ -69,6 +118,163 @@ export async function runPromptQueue(input: QueueInput): Promise { input.footer.event(next) } + const emitQueue = () => { + // Remote queue state is driven by session.queue.updated; memoryQueue is unused for display. + if (input.enqueueRemote) { + return + } + + const queued = + input.memoryQueue && input.sessionID + ? input.memoryQueue.list(input.sessionID).map(PromptQueue.queueItemPreview) + : queueSnapshot(state) + emit( + { + type: "queue", + queue: state.queue.length, + queued, + }, + { + queue: state.queue.length, + queued, + }, + ) + } + + const remoteDrafts = new Map() + + const findQueued = (id: string) => { + const local = state.queue.find((prompt) => prompt.queueID === id) + if (local) return local + return remoteDrafts.get(id) + } + + const removeQueued = (id: string) => { + const index = state.queue.findIndex((prompt) => prompt.queueID === id) + if (index === -1) return undefined + const [removed] = state.queue.splice(index, 1) + emitQueue() + return removed + } + + const updateQueued = (id: string, prompt: RunPrompt) => { + const index = state.queue.findIndex((entry) => entry.queueID === id) + if (index === -1) return false + state.queue[index] = { ...withQueueID(prompt, state), queueID: id } + emitQueue() + return true + } + + const updateRemote = (id: string, prompt: RunPrompt) => { + remoteDrafts.set(id, { ...withQueueID(prompt, state), queueID: id }) + if (!input.updateQueueRemote) return true + void input.updateQueueRemote(id, prompt).catch((error) => { + done.reject(error) + }) + return true + } + + const removeRemote = async (id: string) => { + const prompt = findQueued(id) + remoteDrafts.delete(id) + await input.removeQueueRemote?.(id).catch((error) => { + done.reject(error) + }) + return prompt + } + + const queueControl: QueueControl = { + get: findQueued, + load: input.getQueueRemote + ? async (id) => { + const existing = findQueued(id) + if (existing) return existing + const loaded = await input.getQueueRemote?.(id) + if (!loaded) return undefined + const next = { ...withQueueID(loaded, state), queueID: id } + remoteDrafts.set(id, next) + return next + } + : undefined, + update: (id, prompt) => { + if (input.updateQueueRemote) return updateRemote(id, prompt) + return updateQueued(id, prompt) + }, + remove: (id) => { + if (input.removeQueueRemote) return removeRemote(id) + return removeQueued(id) + }, + pauseDrain: () => { + if (input.pauseQueueDrainRemote) return input.pauseQueueDrainRemote() + }, + resumeDrain: () => { + if (input.resumeQueueDrainRemote) return input.resumeQueueDrainRemote() + }, + sendNow: (id: string) => { + if (input.sendQueueRemote) { + const prompt = findQueued(id) + void input.sendQueueRemote(id, prompt).then( + () => { + remoteDrafts.delete(id) + }, + (error) => { + done.reject(error) + }, + ) + return + } + const prompt = removeQueued(id) + if (!prompt) return + const next = { ...prompt, delivery: "immediate" as const } + if (busy()) { + steer(next) + return + } + submitPrompt(next) + }, + } + + let submitPrompt = (_prompt: RunPrompt) => {} + + const busy = () => state.ctrl !== undefined + + const steer = (prompt: RunPrompt) => { + if (!prompt.text.trim() || state.closed) { + return + } + + if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { + input.footer.close() + return + } + + emit( + { + type: "first", + first: false, + }, + { + first: false, + }, + ) + + if (prompt.mode !== "shell" && !input.enqueueRemote) { + const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const + input.trace?.write("ui.commit", commit) + input.footer.append(commit) + } + input.onSend?.({ ...prompt, delivery: "immediate" }) + + const ctrl = new AbortController() + void input.run({ ...prompt, delivery: "immediate" }, ctrl.signal).catch((error) => { + if (ctrl.signal.aborted || state.closed) { + return + } + + done.reject(error) + }) + } + const finish = () => { if (!state.closed || draining) { return @@ -85,6 +291,8 @@ export async function runPromptQueue(input: QueueInput): Promise { state.closed = true state.queue.length = 0 state.ctrl?.abort() + input.footer.setQueueControl?.(undefined) + emitQueue() stop.resolve({ type: "closed" }) finish() } @@ -102,16 +310,9 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + emitQueue() + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) if (!input.onNewSession) { emit( { @@ -167,7 +368,7 @@ export async function runPromptQueue(input: QueueInput): Promise { break } - if (prompt.mode !== "shell") { + if (prompt.mode !== "shell" && !input.enqueueRemote) { const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const input.trace?.write("ui.commit", commit) input.footer.append(commit) @@ -231,26 +432,23 @@ export async function runPromptQueue(input: QueueInput): Promise { })() } - const submit = (prompt: RunPrompt) => { + submitPrompt = (prompt: RunPrompt) => { if (!prompt.text.trim() || state.closed) { return } + if (busy() && !isQueuedPrompt(prompt)) { + steer({ ...prompt, delivery: prompt.delivery ?? "immediate" }) + return + } + if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { input.footer.close() return } - state.queue.push(prompt) - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, - ) + state.queue.push(withQueueID(prompt, state)) + emitQueue() if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { drain() return @@ -268,6 +466,8 @@ export async function runPromptQueue(input: QueueInput): Promise { drain() } + input.footer.setQueueControl?.(queueControl) + // Ctrl+Shift+Enter queues a prompt with delivery="deferred". Unlike a normal submit, // it never kicks off draining on its own: if a turn is already running, the // in-flight drain loop picks it up after the current turn; if the session is @@ -277,38 +477,58 @@ export async function runPromptQueue(input: QueueInput): Promise { return } + if (input.demo) { + emit( + { type: "stream.patch", patch: { status: "queue unavailable in demo" } }, + { status: "queue unavailable in demo" }, + ) + return + } + if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { input.footer.close() return } - state.queue.push(prompt) - emit( - { - type: "queue", - queue: state.queue.length, - }, - { - queue: state.queue.length, - }, + if (input.enqueueRemote) { + void input.enqueueRemote(prompt).catch((error) => { + done.reject(error) + }) + emit( + { type: "first", first: false }, + { first: false }, + ) + return + } + + if (!input.memoryQueue) { + state.queue.push(withQueueID(prompt, state)) + emitQueue() + emit( + { type: "first", first: false }, + { first: false }, + ) + return + } + + if (!input.sessionID || !input.agent || !input.model) return + input.memoryQueue.enqueue( + input.sessionID, + queueDataFromRunPrompt(prompt, { agent: input.agent, model: input.model }), ) + emitQueue() emit( - { - type: "first", - first: false, - }, - { - first: false, - }, + { type: "first", first: false }, + { first: false }, ) } const offPrompt = input.footer.onPrompt((prompt) => { - if (prompt.delivery === "deferred") { + if (isQueuedPrompt(prompt)) { enqueue(prompt) return } - submit(prompt) + submitPrompt(prompt) }) const offClose = input.footer.onClose(() => { close() @@ -319,7 +539,7 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - submit({ + submitPrompt({ text: input.initialInput ?? "", parts: [], }) @@ -329,6 +549,7 @@ export async function runPromptQueue(input: QueueInput): Promise { offPrompt() offClose() close() + input.footer.setQueueControl?.(undefined) await draining?.catch(() => {}) } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 4cd239e9e882..d88ccb8aa982 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -13,10 +13,13 @@ // local sessions, // 4. runs the prompt queue until the footer closes. import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { MemoryPromptQueue } from "@/queue/prompt-queue" +import { SessionID } from "@/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { createRunDemo } from "./demo" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" +import { buildQueuePromptPayload, buildQueueSendPayload, runPromptFromQueueDetail } from "./runtime.queue-remote" import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" @@ -525,10 +528,99 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { const mod = await import("./runtime.queue") const createSession = input.createSession + const memoryQueue = new MemoryPromptQueue() await mod.runPromptQueue({ footer, initialInput: input.initialInput, trace: log, + demo: !!state.demo, + sessionID: state.sessionID ? SessionID.make(state.sessionID) : undefined, + agent: state.agent, + model: state.model, + memoryQueue, + enqueueRemote: state.demo + ? undefined + : async (prompt) => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.enqueue({ + sessionID: state.sessionID, + ...buildQueuePromptPayload({ + agent: state.agent, + model: state.model, + variant: state.activeVariant, + prompt, + }), + }) + }, + updateQueueRemote: state.demo + ? undefined + : async (queueID, prompt) => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.update({ + sessionID: state.sessionID, + queueID, + body: buildQueuePromptPayload({ + agent: state.agent, + model: state.model, + variant: state.activeVariant, + prompt, + }), + }) + }, + removeQueueRemote: state.demo + ? undefined + : async (queueID) => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.remove({ + sessionID: state.sessionID, + queueID, + }) + }, + getQueueRemote: state.demo + ? undefined + : async (queueID) => { + await ensureStream() + if (!state.sessionID) return + const result = await ctx.sdk.session.queue.get({ + sessionID: state.sessionID, + queueID, + }) + if (!result.data) return + return runPromptFromQueueDetail(result.data) + }, + sendQueueRemote: state.demo + ? undefined + : async (queueID, prompt) => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.send({ + sessionID: state.sessionID, + queueID, + body: buildQueueSendPayload({ + agent: state.agent, + model: state.model, + variant: state.activeVariant, + prompt, + }), + }) + }, + pauseQueueDrainRemote: state.demo + ? undefined + : async () => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.drain.pause({ sessionID: state.sessionID }) + }, + resumeQueueDrainRemote: state.demo + ? undefined + : async () => { + await ensureStream() + if (!state.sessionID) return + await ctx.sdk.session.queue.drain.resume({ sessionID: state.sessionID }) + }, onSend: (prompt) => { state.shown = true state.history.push(prompt) diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index c2731db7ab06..280ed1aafb5d 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -143,7 +143,9 @@ function sid(event: Event): string | undefined { event.type === "question.replied" || event.type === "question.rejected" || event.type === "session.error" || - event.type === "session.status" + event.type === "session.status" || + event.type === "session.queue.updated" || + event.type === "session.deferred.updated" ) { return event.properties.sessionID } @@ -433,7 +435,8 @@ function createLayer(input: StreamInput) { }) const state: State = { - data: createSessionData(), + // Server-backed queue drains create user messages on the server; show them from events. + data: createSessionData({ includeUserText: true }), subagent: createSubagentData(), tick: 0, footerView: { type: "prompt" }, @@ -892,6 +895,17 @@ function createLayer(input: StreamInput) { syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined) + if ( + (event.type === "session.queue.updated" || event.type === "session.deferred.updated") && + event.properties.sessionID === input.sessionID + ) { + input.footer.event({ + type: "queue", + queue: event.properties.items.length, + queued: event.properties.items, + }) + } + touch(event) yield* mark(event) }) @@ -991,7 +1005,73 @@ function createLayer(input: StreamInput) { } if (state.wait) { - yield* Effect.fail(new Error("prompt already running")) + const delivery = next.delivery ?? "immediate" + if (delivery === "deferred") { + yield* Effect.fail(new Error("prompt already running")) + return + } + + if (next.prompt.mode === "shell" || next.prompt.command) { + yield* Effect.fail(new Error("prompt already running")) + return + } + + const steer = new AbortController() + const stop = () => { + steer.abort() + } + next.signal?.addEventListener("abort", stop, { once: true }) + abort.signal.addEventListener("abort", stop, { once: true }) + + const req = { + sessionID: input.sessionID, + agent: next.agent, + model: next.model, + variant: next.variant, + delivery: "immediate" as const, + parts: [ + ...(next.includeFiles ? next.files : []), + { type: "text" as const, text: next.prompt.text }, + ...next.prompt.parts, + ], + } + + yield* Effect.sync(() => { + input.trace?.write("send.prompt.steer", req) + }).pipe( + Effect.andThen( + Effect.promise(() => + input.sdk.session.promptAsync(req, { + signal: steer.signal, + }), + ), + ), + Effect.tap(() => + Effect.sync(() => { + input.trace?.write("send.prompt.steer.ok", { + sessionID: input.sessionID, + }) + }), + ), + Effect.catch((error) => { + const canceled = steer.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed + if (canceled) { + return Effect.void + } + + input.trace?.write("send.prompt.steer.error", { + sessionID: input.sessionID, + error: formatUnknownError(error), + }) + return Effect.fail(error) + }), + Effect.ensuring( + Effect.sync(() => { + next.signal?.removeEventListener("abort", stop) + abort.signal.removeEventListener("abort", stop) + }), + ), + ) return } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index ab5c23ba2026..f79e1dff0cd3 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -40,6 +40,23 @@ export type RunPrompt = { arguments: string } delivery?: "immediate" | "deferred" + queued?: boolean + queueID?: string +} + +export type QueuedPromptPreview = { + id: string + text: string +} + +export type QueueControl = { + get: (id: string) => RunPrompt | undefined + load?: (id: string) => Promise + update: (id: string, prompt: RunPrompt) => boolean + remove: (id: string) => RunPrompt | undefined | Promise + sendNow: (id: string) => void | Promise + pauseDrain?: () => void | Promise + resumeDrain?: () => void | Promise } export type RunAgent = NonNullable>["data"]>[number] @@ -77,6 +94,7 @@ export type FooterState = { phase: FooterPhase status: string queue: number + queued: QueuedPromptPreview[] model: string duration: string usage: string @@ -223,6 +241,7 @@ export type FooterEvent = | { type: "queue" queue: number + queued: QueuedPromptPreview[] } | { type: "first" @@ -280,6 +299,9 @@ export type FooterKeybinds = { inputSubmit: readonly FooterBinding[] inputNewline: readonly FooterBinding[] inputQueue: readonly FooterBinding[] + inputEditQueue: readonly FooterBinding[] + inputEditQueueNext: readonly FooterBinding[] + inputEditQueueCancel: readonly FooterBinding[] } // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" @@ -319,6 +341,7 @@ export type FooterApi = { readonly isClosed: boolean onPrompt(fn: (input: RunPrompt) => void): () => void onClose(fn: () => void): () => void + setQueueControl?(control: QueueControl | undefined): void event(next: FooterEvent): void append(commit: StreamCommit): void idle(): Promise diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 966ba932d41a..ff9b2b67479d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -29,7 +29,11 @@ import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign, expandPastedTextPlaceholders } from "./part" +import { queueEditCommitPlan, queueEditSwitchPlan } from "@/queue/edit" +import { queueMutationError } from "./queue-actions" import { usePromptStash } from "./stash" +import { partsToPromptInfo } from "./queue" +import { PromptQueueDock } from "./queue-dock" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" @@ -86,6 +90,8 @@ export type PromptRef = { blur(): void focus(): void submit(): void + queue(): void + editQueue?(): void } const money = new Intl.NumberFormat("en-US", { @@ -154,6 +160,7 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const submitShortcut = useCommandShortcut("prompt.submit") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() @@ -163,6 +170,9 @@ export function Prompt(props: PromptProps) { const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() + const [editingQueuedMessageID, setEditingQueuedMessageID] = createSignal() + const [queueEscapeGuard, setQueueEscapeGuard] = createSignal(false) + let queueComposerBusy = false const editorContext = createMemo(() => { const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return @@ -476,10 +486,14 @@ export function Prompt(props: PromptProps) { name: "session.interrupt", category: "Session", hidden: true, - enabled: status().type !== "idle", + enabled: status().type !== "idle" && !editingQueuedMessageID() && !queueEscapeGuard(), run: () => { if (auto()?.visible) return if (!input.focused) return + if (queueEscapeGuard()) { + setQueueEscapeGuard(false) + return + } // TODO: this should be its own command if (store.mode === "shell") { setStore("mode", "normal") @@ -487,13 +501,14 @@ export function Prompt(props: PromptProps) { } if (!props.sessionID) return - setStore("interrupt", store.interrupt + 1) + const next = store.interrupt + 1 + setStore("interrupt", next) setTimeout(() => { setStore("interrupt", 0) }, 5000) - if (store.interrupt >= 2) { + if (next >= 2) { void sdk.client.session.abort({ sessionID: props.sessionID, }) @@ -657,6 +672,142 @@ export function Prompt(props: PromptProps) { ]), })) + const pendingAssistant = createMemo(() => { + if (!props.sessionID) return + const messages = sync.data.message[props.sessionID] ?? [] + return messages.findLast((message) => message.role === "assistant" && !message.time.completed)?.id + }) + + const queueItems = createMemo(() => { + if (!props.sessionID) return [] + return sync.data.prompt_queue[props.sessionID] ?? [] + }) + + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined + ) + })(), + commands: [ + { + name: "input.queue", + title: "Queue prompt", + category: "Prompt", + hidden: true, + run: async () => { + input.focus() + const handled = await queue() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue"), + })) + + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + queueItems().length > 0 + ) + })(), + commands: [ + { + name: "input.queue.edit", + title: "Edit queued prompt", + category: "Prompt", + hidden: true, + run: async () => { + input.focus() + const handled = await editQueue() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue.edit"), + })) + + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + !!editingQueuedMessageID() + ) + })(), + commands: [ + { + name: "input.queue.edit.next", + title: "Next queued prompt", + category: "Prompt", + hidden: true, + run: async () => { + input.focus() + const handled = await editQueueNext() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue.edit.next"), + })) + + useBindings(() => ({ + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (!!editingQueuedMessageID() || queueEscapeGuard()) + ) + })(), + commands: [ + { + name: "input.queue.edit.cancel", + title: "Go back from queue edit", + category: "Prompt", + hidden: true, + run: async () => { + input.focus() + if (queueEscapeGuard()) { + setQueueEscapeGuard(false) + setCursorVersion((value) => value + 1) + dialog.clear() + return + } + const handled = await cancelQueueEdit() + if (!handled) return + + dialog.clear() + }, + }, + ], + bindings: tuiConfig.keybinds.get("input.queue.edit.cancel"), + })) + const ref: PromptRef = { get focused() { return input.focused @@ -684,10 +835,17 @@ export function Prompt(props: PromptProps) { parts: [], }) setStore("extmarkToPartIndex", new Map()) + setEditingQueuedMessageID(undefined) }, submit() { void submit() }, + queue() { + void queue() + }, + editQueue() { + void editQueue() + }, } onMount(() => { @@ -1005,7 +1163,7 @@ export function Prompt(props: PromptProps) { } }) - let submitting = false + let promptInFlight = false async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, @@ -1013,25 +1171,499 @@ export function Prompt(props: PromptProps) { // clears `store.prompt.input`, then awaits its own `session.create` and // ultimately reads the now-empty store — sending a phantom empty prompt // to a freshly created session. - if (submitting) return false - submitting = true + if (promptInFlight || queueComposerBusy) return false + promptInFlight = true try { return await submitInner() } finally { - submitting = false + promptInFlight = false } } - async function submitInner() { - setWarpNotice(undefined) + async function queue() { + if (promptInFlight || queueComposerBusy) return false + promptInFlight = true + try { + return await queueInner() + } finally { + promptInFlight = false + } + } - // IME: double-defer may fire before onContentChange flushes the last - // composed character (e.g. Korean hangul) to the store, so read - // plainText directly and sync before any downstream reads. + function syncPromptInputFromTextarea() { if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } + } + + function expandedInputText() { + let inputText = store.prompt.input + const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) + const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) + + for (const extmark of sortedExtmarks) { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex !== undefined) { + const part = store.prompt.parts[partIndex] + if (part?.type === "text" && part.text) { + const before = inputText.slice(0, extmark.start) + const after = inputText.slice(extmark.end) + inputText = before + part.text + after + } + } + } + + return inputText + } + + function editorContextParts() { + const editorSelection = editorContext() + if (!editorSelection || editor.labelState() !== "pending") return [] + + return [ + { + id: PartID.ascending(), + type: "text" as const, + text: formatEditorContext(editorSelection), + synthetic: true, + metadata: { + kind: "editor_context", + source: editorSelection.source ?? "editor", + filePath: editorSelection.filePath, + ranges: editorSelection.ranges, + }, + }, + ] + } + + function isRegisteredSlashCommand(inputText: string) { + if (!inputText.startsWith("/")) return false + const firstLine = inputText.split("\n")[0] + const command = firstLine.split(" ")[0].slice(1) + return sync.data.command.some((x) => x.name === command) + } + + function resetPromptAfterSend(currentMode: typeof store.mode, sessionID: string, editorParts: ReturnType) { + setEditingQueuedMessageID(undefined) + history.append({ + ...store.prompt, + mode: currentMode, + }) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + + if (!props.sessionID) { + if (editorParts.length > 0) editor.preserveSelectionFromNewSession() + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + } + input.clear() + } + + function clearPromptDraft() { + input.extmarks.clear() + if (input && !input.isDestroyed) { + input.clear() + } + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + + function exitQueueEditMode() { + setEditingQueuedMessageID(undefined) + setQueueEscapeGuard(false) + clearPromptDraft() + setCursorVersion((value) => value + 1) + } + + function queueEditBody() { + syncPromptInputFromTextarea() + const agent = local.agent.current() + if (!agent) return + const selectedModel = local.model.current() + if (!selectedModel) return + const inputText = expandedInputText() + const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + const editorParts = editorContextParts() + return { + agent: agent.name, + model: selectedModel, + variant: local.model.variant.current(), + parts: [ + ...editorParts, + { + id: PartID.ascending(), + type: "text" as const, + text: inputText, + }, + ...nonTextParts.map(assign), + ], + } + } + + async function saveQueuedEdit(messageID: string) { + if (!props.sessionID || props.disabled) return false + const body = queueEditBody() + if (!body) return false + if (queueEditCommitPlan({ text: body.parts.find((part) => part.type === "text")?.text ?? "" }).type === "remove") { + const result = await sdk.client.session.queue + .remove({ + sessionID: props.sessionID, + queueID: messageID, + }) + .catch((error: unknown) => ({ error })) + const error = queueMutationError({ result, fallback: "no response" }) + if (error) { + toast.show({ + message: `Removing queued prompt failed: ${errorMessage(error)}`, + variant: "error", + }) + return false + } + if (editingQueuedMessageID() === messageID) { + await sdk.client.session.queue.drain.resume({ sessionID: props.sessionID }).catch(() => {}) + exitQueueEditMode() + } + return true + } + + const result = await sdk.client.session.queue + .update({ + sessionID: props.sessionID, + queueID: messageID, + body, + }) + .catch((error: unknown) => ({ error })) + const error = queueMutationError({ result, fallback: "no response" }) + if (error) { + toast.show({ + message: `Saving queued prompt failed: ${errorMessage(error)}`, + variant: "error", + }) + return false + } + await sdk.client.session.queue.drain.pause({ sessionID: props.sessionID }).catch(() => {}) + return true + } + + async function sendQueuedNow(messageID: string) { + if (queueComposerBusy) return false + if (!props.sessionID || props.disabled) return false + const sessionID = props.sessionID + const items = sync.data.prompt_queue[sessionID] ?? [] + const index = items.findIndex((item) => item.id === messageID) + if (index < 0) { + if (editingQueuedMessageID() === messageID) exitQueueEditMode() + return false + } + const nextID = items[index + 1]?.id + + const editing = editingQueuedMessageID() === messageID + const body = editing ? queueEditBody() : undefined + if (editing && !body) return false + if ( + body && + queueEditCommitPlan({ text: body.parts.find((part) => part.type === "text")?.text ?? "" }).type === "remove" + ) { + const result = await sdk.client.session.queue + .remove({ + sessionID, + queueID: messageID, + }) + .catch((error: unknown) => ({ error })) + const error = queueMutationError({ result, fallback: "no response" }) + if (error) { + toast.show({ + message: `Removing queued prompt failed: ${errorMessage(error)}`, + variant: "error", + }) + return false + } + + if (nextID) { + clearPromptDraft() + setEditingQueuedMessageID(undefined) + const advanced = await editQueuedMessage(nextID) + if (!advanced) { + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + } + } else { + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + } + if (input && !input.isDestroyed) input.focus() + return true + } + + const editorParts = editorContextParts() + + queueComposerBusy = true + if (body) { + const updateRes = await sdk.client.session.queue.update({ + sessionID, + queueID: messageID, + body, + }) + if (updateRes.error) { + queueComposerBusy = false + return false + } + } + + if (nextID) await sdk.client.session.queue.drain.pause({ sessionID }).catch(() => {}) + + void sdk.client.session.queue + .send({ + sessionID, + queueID: messageID, + ...(body ? { body } : {}), + }) + .then((result) => { + if (result.error) throw result.error + if (editorParts.length > 0) editor.markSelectionSent() + }) + .catch(async (err) => { + toast.show({ + message: `Sending queued prompt failed: ${errorMessage(err)}`, + variant: "error", + }) + queueComposerBusy = false + await sdk.client.session.queue.drain.pause({ sessionID }).catch(() => {}) + const listRes = await sdk.client.session.queue.list({ sessionID }).catch(() => undefined) + if (!listRes?.data?.some((item) => item.id === messageID)) { + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + return + } + const restored = await editQueuedMessage(messageID) + if (restored) return + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + }) + + props.onSubmit?.() + + if (nextID) { + clearPromptDraft() + setEditingQueuedMessageID(undefined) + const advanced = await editQueuedMessage(nextID) + if (!advanced) { + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + } + } else { + exitQueueEditMode() + await sdk.client.session.queue.drain.resume({ sessionID }).catch(() => {}) + } + + queueMicrotask(() => { + queueComposerBusy = false + setCursorVersion((value) => value + 1) + }) + if (input && !input.isDestroyed) input.focus() + return true + } + + async function cancelQueueEdit() { + const id = editingQueuedMessageID() + if (!id || !props.sessionID) return false + await sdk.client.session.queue.drain.resume({ sessionID: props.sessionID }).catch(() => {}) + exitQueueEditMode() + setQueueEscapeGuard(true) + if (input && !input.isDestroyed) input.focus() + return true + } + + async function cycleEditQueue(dir: -1 | 1) { + const items = queueItems() + if (!items.length) return false + + const current = editingQueuedMessageID() + if (!current) { + const index = dir === 1 ? 0 : items.length - 1 + const next = items[index] + if (!next) return false + return editQueuedMessage(next.id) + } + + const index = items.findIndex((item) => item.id === current) + if (index < 0) { + const fallback = items[0] + if (!fallback) return false + return editQueuedMessage(fallback.id) + } + + const next = items[(index + dir + items.length) % items.length] + if (!next) return false + return editQueuedMessage(queueEditSwitchPlan({ currentID: current, targetID: next.id }).editID) + } + + async function editQueuedMessage(messageID: string, opts?: { submit?: boolean }) { + if (!props.sessionID || props.disabled) return false + const sessionID = props.sessionID + await sdk.client.session.queue.drain.pause({ sessionID }).catch(() => {}) + + const parts = sync.data.part[messageID] ?? [] + const promptInfo = + parts.length > 0 + ? partsToPromptInfo(parts) + : await (async () => { + const detail = await sdk.client.session.queue.get({ sessionID, queueID: messageID }).catch(() => undefined) + if (!detail?.data || typeof detail.data !== "object" || !("parts" in detail.data)) { + const preview = sync.data.prompt_queue[sessionID]?.find((item) => item.id === messageID) + if (preview?.text) return { input: preview.text, parts: [] as PromptInfo["parts"] } + const listRes = await sdk.client.session.queue.list({ sessionID }).catch(() => undefined) + const listed = listRes?.data?.find((item) => item.id === messageID) + if (listed?.text) return { input: listed.text, parts: [] as PromptInfo["parts"] } + return { input: "", parts: [] as PromptInfo["parts"] } + } + const queueParts = detail.data.parts as Parameters[0] + return partsToPromptInfo( + queueParts.map((part) => { + const id = PartID.ascending() + if (part.type === "text") return { ...part, id, messageID, sessionID } + if (part.type === "file") return { ...part, id, messageID, sessionID } + if (part.type === "agent") return { ...part, id, messageID, sessionID } + return { ...part, id, messageID, sessionID } + }), + ) + })() + if (!promptInfo.input.trim() && promptInfo.parts.length === 0) return false + + if (!messageID.startsWith("pqu_")) { + await sdk.client.session.revert({ sessionID, messageID }).catch(() => {}) + } + setEditingQueuedMessageID(messageID) + input.setText(promptInfo.input) + setStore("prompt", promptInfo) + setStore("extmarkToPartIndex", new Map()) + restoreExtmarksFromParts(promptInfo.parts) + input.gotoBufferEnd() + input.focus() + + if (!opts?.submit) return true + return sendQueuedNow(messageID) + } + + async function editQueue() { + if (editingQueuedMessageID()) return cycleEditQueue(-1) + const id = queueItems().at(0)?.id + if (!id) return false + return editQueuedMessage(id) + } + + async function editQueueNext() { + if (!editingQueuedMessageID()) return false + return cycleEditQueue(1) + } + + async function queueInner() { + const editingID = editingQueuedMessageID() + if (editingID) return saveQueuedEdit(editingID) + + setWarpNotice(undefined) + syncPromptInputFromTextarea() + if (props.disabled) return false + if (workspaceCreating()) return false + if (auto()?.visible) return false + if (!store.prompt.input.trim()) return false + if (store.mode === "shell") return false + if (isRegisteredSlashCommand(store.prompt.input)) return false + if (!props.sessionID) return submitInner() + + const agent = local.agent.current() + if (!agent) return false + const trimmed = store.prompt.input.trim() + if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") return false + const selectedModel = local.model.current() + if (!selectedModel) { + void promptModelWarning() + return false + } + + const workspaceSession = sync.session.get(props.sessionID) + const workspaceID = workspaceSession?.workspaceID + const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined + if (workspaceID && workspaceStatus !== "connected") { + dialog.replace(() => ( + { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + project, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false + }} + /> + )) + return false + } + + const sessionID = props.sessionID + const variant = local.model.variant.current() + const messageID = MessageID.ascending() + const inputText = expandedInputText() + const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + const currentMode = store.mode + const editorParts = editorContextParts() + + const result = await sdk.client.session.queue + .enqueue({ + sessionID, + ...selectedModel, + agent: agent.name, + model: selectedModel, + variant, + parts: [ + ...editorParts, + { + id: PartID.ascending(), + type: "text", + text: inputText, + }, + ...nonTextParts.map(assign), + ], + }) + .catch((error: unknown) => ({ error })) + const error = queueMutationError({ result, fallback: "no response" }) + if (error) { + toast.show({ + message: `Queueing prompt failed: ${errorMessage(error)}`, + variant: "error", + }) + return false + } + if (editorParts.length > 0) editor.markSelectionSent() + resetPromptAfterSend(currentMode, sessionID, editorParts) + return true + } + + async function submitInner() { + setWarpNotice(undefined) + + syncPromptInputFromTextarea() + const editingID = editingQueuedMessageID() + if (editingID) return sendQueuedNow(editingID) if (props.disabled) return false if (workspaceCreating()) return false if (auto()?.visible) return false @@ -1109,47 +1741,14 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } - - // Filter out text parts (pasted content) since they're now expanded inline + const inputText = expandedInputText() const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") - - // Capture mode before it gets reset const currentMode = store.mode - const editorSelection = editorContext() - const editorParts = - editorSelection && editor.labelState() === "pending" - ? [ - { - id: PartID.ascending(), - type: "text" as const, - text: formatEditorContext(editorSelection), - synthetic: true, - metadata: { - kind: "editor_context", - source: editorSelection.source ?? "editor", - filePath: editorSelection.filePath, - ranges: editorSelection.ranges, - }, - }, - ] - : [] + const editorParts = editorContextParts() + + if (props.sessionID) { + await sdk.client.session.queue.drain.resume({ sessionID: props.sessionID }).catch(() => {}) + } if (store.mode === "shell") { void sdk.client.session.shell({ @@ -1162,14 +1761,7 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const firstLine = inputText.split("\n")[0] - const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) - }) - ) { + } else if (isRegisteredSlashCommand(inputText)) { // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) @@ -1214,29 +1806,7 @@ export function Prompt(props: PromptProps) { .catch(() => {}) if (editorParts.length > 0) editor.markSelectionSent() } - history.append({ - ...store.prompt, - mode: currentMode, - }) - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - props.onSubmit?.() - - // temporary hack to make sure the message is sent - if (!props.sessionID) { - if (editorParts.length > 0) editor.preserveSelectionFromNewSession() - setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) - }, 50) - } - input.clear() + resetPromptAfterSend(currentMode, sessionID, editorParts) return true } const exit = useExit() @@ -1420,10 +1990,18 @@ export function Prompt(props: PromptProps) { () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) - const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) + const borderHighlight = createMemo(() => { + if (editingQueuedMessageID()) return theme.primary + return tint(theme.border, highlight(), agentMetaAlpha()) + }) const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined + if (editingQueuedMessageID()) { + const submit = submitShortcut() + if (!submit) return "Press Return to send now" + return `Press ${submit} to send now` + } if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] @@ -1501,15 +2079,32 @@ export function Prompt(props: PromptProps) { backgroundColor={theme.backgroundElement} flexGrow={1} width="100%" + flexDirection="column" + minHeight={0} > + 0 || editingQueuedMessageID()}> + !!editingQueuedMessageID()} + editingMessageID={editingQueuedMessageID} + disabled={props.disabled} + onEdit={(id: string) => { + void editQueuedMessage(id) + }} + onSendNow={(id: string) => { + void sendQueuedNow(id) + }} + /> + +