diff --git a/AGENTS.md b/AGENTS.md
index 09b2ad11..698c55e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ This is the source-of-truth for automated contributors (LLM agents, codegen, etc
## Architectural invariants
1. **Project ≠ Prompt.** Session state, compressed tool results, and world snapshots live outside the model; the prompt is always a small slice.
-2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on.
+2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` (written by the no-progress loop detector and by mid-turn steering, composed in that order) → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on.
3. **One inference per step.** No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON **array** of `1..N` tool calls (`[{tool, args}, ...]`); a "solo" step is just a length-1 array (`[{...}]`). `N` is capped by `agent.maxParallelToolCalls` (default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline.
4. **Grammar-constrained tool calls.** The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to **array-only** (`root ::= tool-call-array`) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (`qwen-think`, `gemma4-think`) prepend a `...` / `<|channel>thought...` block to the array; the seam between the close sentinel and the leading `[` of the array routes through a dedicated **bounded** `prelude-trail-ws ::= ( [ \t\n\r] ){0,8}` rule rather than the global unbounded `ws`. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts) "bounds the whitespace between the reasoning-close sentinel and the tool-call array".
@@ -147,6 +147,36 @@ Speculative batching (the runtime guessing that the model "should" have batched
- Tests are colocated with source: `build-prompt.test.ts` next to `build-prompt.ts`.
- Config lives in `src/config/` — read it before touching env vars.
+## Mouse support
+
+The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse/`:
+
+1. **Reporting** — `enableMouseTracking` writes `\x1b[?1000h\x1b[?1006h` (button events + SGR coordinates). 1002/1003 motion tracking is deliberately **not** requested: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream. Paired with a `process.on("exit")` restore, like `alt-screen.ts`.
+2. **Decoding** — `decodeMouseEvents` is a pure function over a stdin chunk returning `{ events, text, rest }`. It understands SGR and legacy X10, buffers a report split across two reads, and passes a lone trailing `ESC` straight through (buffering it would delay the Escape key by one keystroke).
+3. **Stream split** — `createMouseStdin` reads the real TTY, hands Ink a `PassThrough` carrying only the keyboard bytes, and proxies `isTTY` / `setRawMode` / `ref` / `unref` to the real stdin. Without this the reports reach Ink's key parser and get typed into the chat buffer.
+4. **Hit testing** — `MouseTargetRegistry` resolves a cell to a component. Ink exposes no absolute positions, but every node keeps its Yoga node, and `absoluteRect` sums `getComputedLeft/Top` up the parent chain — the same walk `render-node-to-output.ts` does when painting, so the rectangle is exactly where the node was drawn. Ancestors with `overflow: hidden` clip the result. Ties resolve innermost-first (higher layer, then smaller box, then later mount).
+5. **Layers** — `MOUSE_LAYER_BASE` / `_PANEL` / `_MODAL`. `TuiApp` raises the registry floor to `_MODAL` whenever a modal, confirm or picker owns the keyboard (`isPanelModalOpen`, shared with `handleAppKey`), so a click cannot reach the list rendered behind a modal.
+
+**Interaction contract.** First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow key (`synthetic-key.ts`), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (`rowColToCursor`, clamped to the line length).
+
+**Re-running a message.** Every finalised **user** bubble carries `[try again]` beside `[copy]` (`components/chat-try-again-button.tsx`). It re-submits that message's source through `handleEditorSubmit` — the function Enter calls — so a re-run inherits the operator's routing rather than growing a second submit path: idle starts a turn, and while a turn is running `tui.whileBusySubmit` (Ctrl+T) decides between steering and queueing. Assistant and system bubbles do not get one; their text is not a command anyone gave the agent, and "have another go at the same question" is a different feature (it would have to drop the last turn, not append one). The composer draft is snapshotted before the submit and written back after it, because every landing on that path blanks `inputValue` (`startNewRun`, `message_queued`, `message_steered`). The label flips to `[sent]` for 2s and swallows clicks while it is up — the badge is the double-click guard, since a terminal reports a double-click as two presses and a turn is not free. Both buttons share one footer row, so `estimateMessageHeight` still charges one row per message whatever the role.
+
+**The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v38, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys.
+
+## Selecting and copying text
+
+Three mechanisms, deliberately, because no single one covers every terminal.
+
+1. **Shift+drag** — on iTerm2, kitty, WezTerm, Alacritty, foot, Windows Terminal and VS Code the terminal keeps a shift-modified drag for itself and never reports it, so native selection (and the terminal's own copy-on-select / ⌘C) is one modifier away at zero cost. This is the primary answer and it is now advertised in the `/mouse on` confirmation.
+2. **Selection pause** (`mouse/selection-passthrough.ts`) — a shift-modified press that *does* reach the app is proof this terminal has no bypass (Apple Terminal). The app reads it as "I was trying to select", calls `MouseTrackingController.suspend()` for 10s and says so in chat; reporting comes back on its own. Inert everywhere the bypass works. `suspend()`/`resume()` write the same escape pairs `disable()` does but keep the controller — and its `process.on("exit")` restore — alive, and `disable()` while suspended writes nothing rather than disabling modes the terminal never re-enabled.
+3. **The per-message `[copy]` button** (`components/chat-copy-button.tsx`) — one under every finalised bubble, in `muted` + `dimColor` so a column of them stays quiet. It copies the message *source*: raw text, before markdown rendering, borders and wrapping, which is strictly better than what a drag over the same rows would have given. Label flips to `[copied!]` for 2s (`[copy failed]` when the clipboard refuses); the timer lives in a ref, is restarted rather than stacked on a re-click, and is cleared on unmount.
+
+**In-app selection was considered and rejected.** It needs motion reports (1002/1003, off on purpose), a readback of what character is painted in each cell (Ink exposes sizes only — `mouse-registry` reconstructs geometry from Yoga, never from painted text), and every component under the selection rectangle to become selection-aware. The result would still be worse: no access to the scrollback above the alt screen, no honouring of the terminal's own copy gesture, and the copied text would carry borders and wrap points. The reasoning is written out at the top of `selection-passthrough.ts`.
+
+**Clipboard** (`src/tui/clipboard/`) — `createClipboardWriter` emits **OSC 52** *and* runs the platform command (`pbcopy` / `wl-copy` / `xclip` / `clip`), because the two fail in opposite situations: OSC 52 is the only thing that survives SSH but is advisory and never answers, while the platform command is authoritative but targets the wrong machine remotely and does not exist headless. Success is reported if either landed — optimistic for OSC 52 by design, since the alternative is a false "failed" on every SSH session. Payloads too large for a safe OSC 52 skip it and rely on the platform command. **Nothing is attempted when stdout is not a TTY**, which is also what keeps `vitest` from overwriting the clipboard of whoever runs the suite. Everything is injectable; `ClipboardProvider` overrides the shared default in tests.
+
+**Testing.** Escape sequences, decoder and stream split are unit-tested; `mouse-app.test.tsx` drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target.
+
## Module map
| Folder | Responsibility |
@@ -156,6 +186,9 @@ Speculative batching (the runtime guessing that the model "should" have batched
| `src/cli/` | `run`, `index`, `repl`, `tui`, `serve` commands |
| `src/http/` | OpenAI-compatible HTTP API + atomic admin routes for `atomic-agent serve` |
| `src/llm/` | HTTP client for external llama-server + GBNF grammar |
+| `src/llm/run-mode/` | Resolves the operator run mode (`local` / `cloud` / `fusion`) against the configured providers |
+| `src/agent/routing/` | Fusion step routing: complexity score, cutoff rule, per-session router |
+| `src/tui/run-mode/` | Run-section mode strip + dial overlay (state, actions, reducer, keys, orchestrator) |
| `src/prompt/` | Prompt builder, stable prefix, token budget. See [PROMPT.md](PROMPT.md) for full anatomy of the stable prefix and variable tail. |
| `src/session/` | Session state + sqlite persistence |
| `src/agent/` | Agent loop + step executor + parallel batch executor (`batch-executor.ts`) + resource-class taxonomy (`tool-resource-class.ts`) + no-progress loop detector |
@@ -166,7 +199,7 @@ Speculative batching (the runtime guessing that the model "should" have batched
| `src/tracing/` | Structured logger + metrics + trace recorder (`src/tracing/trace/`) |
| `src/replay/` | Trace-based replay: drift detection + optional LLM re-inference |
| `src/memory/` | Memory fabric: ProfileStore (key/value facts, pinned + contextual) + MemoryStore (FTS5 freeform notes) + async end-of-turn reflection that writes into both. See [MEMORY.md](MEMORY.md). |
-| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`). See §"Concurrency contract". |
+| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`) + `steering-inbox.ts` (out-of-band per-session mailbox for messages that arrive mid-turn). See §"Concurrency contract" and §"Mid-turn steering". |
| `src/tasks/` | Durable queue of deferred `runTurn` submissions: `TaskStore` (SQLite), `TaskRunner` (drain + retry/backoff), `task-backoff`, `task-schedule` (cron / interval / at resolver). See §"Durable tasks" and §"Background autonomy". |
| `src/scheduler/` | One-process `Scheduler` (single `setInterval`) that polls `TaskStore.listDue` via `TaskRunner.runDue`. The **only** periodic timer in the runtime. See §"Background autonomy". |
| `src/http/route-webhooks.ts` + `webhook-template.ts` + `webhook-session-store.ts` | Generic `POST /api/webhooks/:name` ingress. Always materialises into a `TaskRecord`, never calls `runTurn` directly. See §"Background autonomy". |
@@ -177,6 +210,8 @@ Speculative batching (the runtime guessing that the model "should" have batched
| `src/channels/telegram/` | `TelegramChannel` (lifecycle + live-control), `inbound-handler` (slash commands + dispatch into `runTurn`), `outbound-sender` (chunked replies + 429 retry), `approval-bridge` (inline-keyboard approvals with 8-min auto-deny), `pairing-mode` (60s window for first-DM owner claim), `telegram-settings` (`config.json` + `.env` persistence), `telegram-bot-factory` (grammy adapter). The **only** module that imports `grammy`. See §"Telegram remote-control channel". |
| `src/tui/telegram/` | TUI "Telegram" tab: `telegram-panel-state` + `telegram-actions` + `telegram-panel-reducer` (pure UI state slice), `tui-telegram-orchestrator` (the only TUI module that touches `runtime.telegramChannel`), `telegram-key-bindings`, and the `telegram-panel` / `telegram-token-prompt` / `telegram-pairing-modal` components. See §"Telegram remote-control channel". |
| `src/mcp/` | MCP (Model Context Protocol) **client** subsystem. `McpManager` (lifecycle for N `McpClient` instances), `mcp-client` (the **only** file that imports `@modelcontextprotocol/sdk` — together with `mcp-sampling-handler` for SDK type shapes), `mcp-tool-adapter` (`McpToolMeta` → `ToolDefinition`), `mcp-resource-class` (per-server trust → `ResourceClass` resolver), `mcp-descriptor-builder` (rare-tier descriptors), `mcp-grammar-builder` (dynamic `mcp-server-tool` GBNF fragment), `mcp-sampling-handler` (forwards `sampling/createMessage` to `LlamaServerClient` with `slotId: -1`), `mcp-resource-tools` + `mcp-prompt-tools` (aggregate read-only `mcp.{resource,prompt}.*` tools dispatching by `server` arg). See §"MCP client". |
+| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler), `selection-passthrough` (momentary hand-back of native drag-to-select). See §"Mouse support" and §"Selecting and copying text". |
+| `src/tui/clipboard/` | Clipboard writer (OSC 52 + platform command, both injectable) and its React provider. See §"Selecting and copying text". |
## Secrets and process environment
@@ -203,10 +238,14 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register
### Registry and transport
-- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `openrouter`.
+- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `qwen-openai-compatible`, `openrouter`, `aimlapi`, `gemini`, `subscription-cli`.
+- **`subscription-cli`** ([src/llm/provider/subscription-cli/](src/llm/provider/subscription-cli/)) — drives an already-signed-in vendor CLI (`claude`, `codex`) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised by `entry.subscriptionCli.cli`; every CLI-specific byte (argv builders, output parsers, hints) lives behind a `CliAdapterDescriptor`, so a new vendor CLI is a descriptor plus a `SUBSCRIPTION_CLIS` entry and never a new provider kind. It declares `native_tools` while never returning `tool_calls`: an empty `toolCalls` sends step-executor down its guarded recovery ladder, whereas `grammar` would throw out of `parseToolCalls` on any drift and pay for a second CLI invocation on the repair path.
- **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`.
- **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider).
- **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools.
+- **Vendor presets** ([src/tui/providers/provider-presets.ts](src/tui/providers/provider-presets.ts)) — 19 named cloud/local endpoints (Anthropic, Groq, Moonshot, Perplexity, Qwen/DashScope, SambaNova, …) that all resolve to the existing `openai-compatible` kind with `baseUrl` prefilled. Adding a vendor is a preset entry, not a provider kind. The bar for a new entry: `/v1/models` answers 200 with a `data` array, or 401/403 **while a bogus sibling path on the same host answers 404** — a gateway that rejects everything before routing proves nothing.
+- **Bundled catalogs** — `OPENROUTER_MODELS_CATALOG` (split across `openrouter-frontier-chat-models.ts` / `openrouter-open-weight-chat-models.ts`) and `AIMLAPI_MODELS_CATALOG` are offline snapshots regenerated from each vendor's public `/models` endpoint; the shared row builders live in [model-catalog-entry.ts](src/llm/provider/model-catalog-entry.ts). Refresh = re-pull the endpoint, remap (`context_length`, `input_modalities` → vision, `supported_parameters` → tools, price × 1e6 → USD/1M) and update the date in each file header. `scoreChat` in the OpenRouter fetcher **ranks** vendors; it must not gate them — the Anthropic/Gemini exclusions it used to carry hid ~40 served models from the picker.
+- **Model search** ([src/llm/provider/model-search.ts](src/llm/provider/model-search.ts)) — one ranked, multi-term scorer over model ids plus catalog metadata (vendor, `vision`/`text`, `tools`, `cache`, context shorthand like `1m`, `free`/`cheap`/`routed`). Terms are ANDed, matches are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order so the picker does not jitter per keystroke. Used by `filterModelIds` (TUI modal picker + Cloud pane) and by `atomic-agent models search`. Row rendering is shared through [format-model-details.ts](src/llm/provider/format-model-details.ts) — do not re-implement the price/context/capability strings in a frontend.
### Bootstrap wiring
@@ -218,6 +257,14 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm
`ProviderRegistry.setActive(id)` / `swapActive(id)` closes the previous provider and switches the active text backend without process restart. TUI **Providers** tab ([src/tui/providers/](src/tui/providers/)) is the only surface that calls this seam.
+### Credential check before save
+
+A cloud provider is verified before anything reaches disk. [src/llm/provider/verify/](src/llm/provider/verify/) is UI-free: `verifyProviderKey(target)` posts one `max_tokens: 1` completion through `openAiFetch` (deliberately not `openAiPostJson` — a key check must not spend the retry budget), and `classifyVerifyResponse` maps the answer onto `ok | invalid_key | no_balance | model_unavailable | rate_limited | unreachable | timeout | provider_error | cancelled`. Status codes alone do not settle it: prepaid services answer 401/403 once credit runs out, OpenAI sends `429 insufficient_quota`, and Gemini answers 400 for a bad key, so the body is consulted for billing/key wording first. `pickProbeModels` picks the cheapest **paid** OpenRouter model — a free model answers 200 on a key with no balance, which would make the check meaningless.
+
+`verifyWizardBeforeSave` ([src/tui/providers/verify-wizard-before-save.ts](src/tui/providers/verify-wizard-before-save.ts)) is the single seam; both the wizard (`ProvidersOrchestrator.completeWizard`) and first-run onboarding (`CloudProviderOnboarding`) go through it. Only `invalid_key` and `no_balance` block a save (`isBlockingVerifyStatus`); everything else saves and reports, so an offline machine stays configurable. Esc cancels a check in flight (`cancelSubmit` → `providers_wizard_verify_cancelled`), and a verdict arriving after a cancel is dropped.
+
+Pinned by [src/llm/provider/verify/classify-verify-response.test.ts](src/llm/provider/verify/classify-verify-response.test.ts), [verify-provider-key.test.ts](src/llm/provider/verify/verify-provider-key.test.ts), [pick-probe-models.test.ts](src/llm/provider/verify/pick-probe-models.test.ts), [src/tui/providers/verify-wizard-before-save.test.ts](src/tui/providers/verify-wizard-before-save.test.ts), [providers-wizard-target.test.ts](src/tui/providers/providers-wizard-target.test.ts) and the `completeWizard` cases in [providers-orchestrator.test.ts](src/tui/providers/providers-orchestrator.test.ts).
+
### Locked invariants
1. **Local llama-server path unchanged when no cloud provider is active.** Grammar, slots, and GBNF tests remain the reference behaviour.
@@ -228,6 +275,8 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm
6. **Every default tool ships a structured `argsJsonSchema`.** `ToolDescriptor.argsJsonSchema` is consumed exclusively by `descriptorsToOpenAiTools` ([openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts)) to populate `function.parameters` on the OpenAI `tools` payload. Without it, cloud providers fall back to `{ type: "object", additionalProperties: true }` — which is what we shipped originally and what enabled the `os.shell.run` silent-arg-drop bug (model double-serialised `args` into a JSON string, the provider accepted it, the tool coerced the non-array to `[]` without warning, the model never learned). The canonical map lives in [src/prompt/default-tool-args-schemas.ts](src/prompt/default-tool-args-schemas.ts); it is merged into `DEFAULT_TOOL_DESCRIPTORS` via `attachDefaultArgsJsonSchema`. MCP descriptors carry the server's `inputSchema` verbatim through the same field. Adding a new tool **requires** an entry in `DEFAULT_TOOL_ARGS_SCHEMAS` (pinned by [src/prompt/default-tool-args-schemas.test.ts](src/prompt/default-tool-args-schemas.test.ts) "attaches a schema to every default descriptor that has one registered"). Local llama-server with GBNF does **not** consume this field — the grammar already constrains the shape.
7. **`os.shell.run` rejects non-array `args` structurally.** A non-array, non-JSON-array-string `args` value now returns `{ status: "error" }` instead of silently dropping the operator's intent. JSON-stringified arrays (the cloud `native_tools` double-serialise pattern) are auto-coerced back to `string[]`. Pinned by [src/tools/os/os-tools.test.ts](src/tools/os/os-tools.test.ts) ("returns a structured error when `args` is an object" / "is a scalar string" / "recovers a JSON-stringified array `args`").
+8. **Subscription CLIs are driven, never impersonated.** `subscription-cli` shells out to the vendor CLI's documented headless mode (`--print`) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes `--bare` (whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clears `ANTHROPIC_API_KEY`. It always disables the child's own tools as far as the CLI allows — `--tools ""` + `--strict-mcp-config` on `claude`, `-s read-only` + `--ignore-user-config` on `codex`, which confines rather than removes them — so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which would `E2BIG` on a full two-zone prompt. Pinned by [claude-cli-adapter.test.ts](src/llm/provider/subscription-cli/claude-cli-adapter.test.ts) ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv").
+
### Embeddings
Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-provider-registry.ts](src/memory/embeddings/embedding-provider-registry.ts)) with `OpenAiEmbeddingProvider` / `OpenRouterEmbeddingProvider` for `POST /v1/embeddings`. Hybrid recall degradation contract unchanged.
@@ -982,6 +1031,7 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w
| `ProfileStore` / `MemoryStore` / `SessionStore` | Anything holding a handle | **Yes** — all three use `better-sqlite3`, which is **synchronous**: there is no race window between read and write inside a single statement, so concurrent sessions are safe. **This is a load-bearing assumption.** Replacing the driver with an async one would require a redesign. |
| `ReflectionRunner.pending` | Per-session `Map` | **Yes** — reflection on session A is never aborted by reflection on session B. `agent-loop.runTurn` calls `reflectionRunner.abortPending({ sessionId: state.id })` at the start of every turn so a stale reflection from the previous same-session turn cannot race the next one. `abortPending()` with no argument cancels every in-flight reflection (used at runtime shutdown). |
| Trace recorder | Per-session, dispatched via `AsyncLocalStorage` | **Yes** — no global pointer to mix traces across sessions. |
+| `SteeringInbox` | Per-session `Map`, drained only by the turn running on that session | **Yes** — a steer on session A is invisible to session B, and only one turn per session can drain (`TurnController` invariant 1). |
### What the scheduler / webhook paths may and may not assume
@@ -991,10 +1041,32 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w
- **May not** assume exclusive browser ownership across sessions; the browser is shared at process scope (see table).
- **Must not** hold a stale `SessionState` reference between `enqueue` and `run`. `executeTurn` writes its result to `sessionStore`; the correct pattern is to **re-read the latest session inside the queued callback** (see [src/sidecar/main.ts](src/sidecar/main.ts) `send_message` for the canonical example).
+### Mid-turn steering
+
+Per-session FIFO is correct for *starting* turns and wrong for *correcting* one. An operator who watches the agent head the wrong way should not have to abort the turn or wait it out to say "no, do X instead". `SteeringInbox` ([src/runtime/steering-inbox.ts](src/runtime/steering-inbox.ts)) is the out-of-band channel for that, and it is deliberately **not** a second queue:
+
+- **It never starts a turn.** `runtime.steer(sessionId, text)` returns `false` when `turnController.isBusy(sessionId)` is false, and queues nothing. A `false` return means "not steered" — the caller falls back to `runTurn` or to its own pending-message queue. There is still exactly one path into `AgentLoop.runTurn`.
+- **It lands at a step boundary.** `AgentLoop.runTurn` drains the inbox at the top of every step, before building that step's prompt. Effect is visible one step later at the earliest — never mid-inference, never mid-tool-call. A turn parked in a long `os.shell.run` will not react until that call returns.
+- **It writes to the transcript.** Each drained message is recorded as a real `user` `ConversationTurn`. The transcript must reflect what the operator actually said; `packConversation` already guarantees the last `user` turn stays visible, and `findCurrentMacroTurnStart` treats the steer as part of the macro-turn in progress. Note this **does** count toward reflection segmentation cadence (`state.turnCount` is untouched, but the turn list grows) — a steer is a real user message, so that is the intended reading.
+- **It shares `### notice` with the loop detector.** Both write the one-shot notice slot; `composeSteerNotice` ([src/agent/steer-notice.ts](src/agent/steer-notice.ts)) appends rather than overwrites, loop-detector text first. The message text is repeated inside `### notice` even though it is already in `### conversation`: the notice sits immediately before `### respond`, which is the block small local models reliably act on. Long pastes are clipped inline and point back at the transcript copy.
+- **Nothing is silently lost.** A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on `RunTurnResult.undelivered`. Callers MUST re-route it (the TUI pushes it onto its pending-message queue). `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process.
+- **Bounded.** `MAX_PENDING_STEERS` (16) per session; `push` refuses past the cap rather than evicting the oldest, so the caller learns the message did not land.
+
+**TUI surface.** The editor stays live for the whole turn, so Enter has to mean something while the agent is working. `tui.whileBusySubmit` (`"steer" | "queue"`, default `"steer"`) decides which, `Ctrl+T` flips it in-app and persists the flip, and the prompt meta-row shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is running. `/steer ` and `/queue ` land one message in the other mode without changing the default; bare `/steer` / `/queue` switch it.
+
+**Why a toggle and not a modifier key.** `Alt+Enter`, `Shift+Enter` and `Ctrl+Enter` are all already "insert a newline" (`key.meta || key.shift || key.ctrl` in [src/tui/components/multi-line-editor.tsx](src/tui/components/multi-line-editor.tsx)), so the second gesture cannot live on a Return modifier without taking multi-line input away. `Ctrl+T` is added to `isGlobalHotkey` so the editor does not swallow it as literal text.
+
+`ChatOrchestrator.steerMessage` falls back to `sendMessage` (the queue) whenever `runtime.steer` returns false — the turn can end between the keypress and the dispatch — and pushes anything that comes back on `RunTurnResult.undelivered` onto the same queue. The user bubble is rendered on the `steer_applied` event rather than optimistically at submit time, so a steer that misses the turn and falls back to the queue appears exactly once.
+
+**Host surfaces.** The sidecar exposes it as the `steer_message` NDJSON request (`{sessionId, text}` -> `{steered}`) plus the `steer_applied` / `steer_undelivered` events; `serve` exposes `POST /api/sessions/{id}/steer` with body `{text}` — `200 {steered:true}`, `409` when the session is idle (the message is refused, not swallowed — retry with `POST /v1/chat/completions`), `429` when the inbox is full. Neither handler goes through `turnController.enqueue`: enqueueing would park the message behind the turn it is meant to redirect.
+
+Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts), on the TUI side [src/tui/submit-handler.test.ts](src/tui/submit-handler.test.ts) (mode routing, slash overrides, fallback when no steer callback is wired), [src/tui/app-key-bindings.test.ts](src/tui/app-key-bindings.test.ts) (Ctrl+T) and [src/config/config-schema.test.ts](src/config/config-schema.test.ts) (default + validation), and on the host side [src/sidecar/steer-message.test.ts](src/sidecar/steer-message.test.ts) (resolves while a turn is blocked mid-inference) and the `POST /api/sessions/{id}/steer` cases in [src/http/route-sessions.test.ts](src/http/route-sessions.test.ts).
+
### Extension points
- `TurnController.isBusy(sessionId)` / `busySessionIds()` — observability hook for UI and scheduler.
- `TurnController.emit(sessionId, event)` — single dispatch path for `AgentLoopEvent` to the per-session hook.
+- `runtime.steer(sessionId, text)` — fold a message into the turn already running on that session. Returns `false` (and queues nothing) when the session is idle. See §"Mid-turn steering".
- `runtime.executeTurn(session, msg, opts)` — bypasses the queue. Used by sidecar from inside an already-acquired `enqueue` callback so it does not deadlock against itself. CLI / TUI / HTTP go through the public `runtime.runTurn` instead.
### Risk (acknowledged)
@@ -1328,6 +1400,24 @@ Slash commands: `/memory` opens the tab; `/memory dump` keeps the legacy profile
4. **Note detail exposes link neighbours when `memory.links.enabled`.** `g` runs `linkStore.expand`; Enter on a neighbour opens that note by id.
5. **Config gates surface hints, not crashes.** Disabled channels show an empty list + `channelHint` string.
+## New terminal window (Ctrl+N)
+
+**Ctrl+N** in the TUI (and the `/window` slash command, alias `/newwindow`) opens a **new OS terminal window** running a fresh `atomic-agent tui` in the same working directory. It is a second agent in a second process — not a second view of the current session, which the per-session runtime lock would not allow. `/new` remains the in-process "fresh session, warm runtime" reset; the two are deliberately different commands.
+
+The resolver is split so the platform logic is unit-reachable without opening windows:
+
+- [src/tui/build-terminal-launch.ts](src/tui/build-terminal-launch.ts) — **pure**. `buildTerminalLaunch({platform, execPath, argv, isSea, cwd, env, hasBinary})` → `{cmd, args, label}` or `null`. macOS drives `osascript` → `Terminal` (or `iTerm` when `TERM_PROGRAM === "iTerm.app"`); Linux probes `$ATOMIC_AGENT_TERMINAL` → `$TERMINAL` → gnome-terminal / konsole / xfce4-terminal / kitty / alacritty / wezterm / x-terminal-emulator / xterm through the injected `hasBinary`; Windows uses `wt.exe -w -1 nt` when present, else `cmd.exe /c start … cmd /k`.
+- [src/tui/open-terminal-window.ts](src/tui/open-terminal-window.ts) — the effectful half: `detached: true, stdio: "ignore"` + `unref()` so the new window outlives this process, `spawn` injectable, every failure returned as `{ok: false, reason}` and never thrown into the render loop. Also owns the `isOnPath` PATH probe (no `which` shell-out).
+
+Two details that are easy to regress:
+
+1. **`argv[1]` must be dropped for a SEA build** and kept under plain node — same reasoning as the self-update relaunch in [src/tui/tui-command.ts](src/tui/tui-command.ts); `tui` is always appended explicitly.
+2. **`ATOMIC_AGENT_STATE_DIR` travels inside the command line.** A spawned terminal starts a login shell and inherits nothing from us, so without the inline assignment the second window would silently attach to a different state dir.
+
+The POSIX command line ends with `exec "${SHELL:-sh}"` on Linux because `-e` closes the window the instant the agent exits, which would eat a startup error. macOS `do script` already leaves the shell alive, so it does not need this.
+
+Pinned by [src/tui/build-terminal-launch.test.ts](src/tui/build-terminal-launch.test.ts) (per-platform argv shapes, SEA split, state-dir passthrough, shell + AppleScript escaping, `null` on a headless box), [src/tui/open-terminal-window.test.ts](src/tui/open-terminal-window.test.ts) (detach/unref, error-as-value, PATH probe), [src/tui/app-key-bindings.test.ts](src/tui/app-key-bindings.test.ts) (Ctrl+N fires only outside modals / the slash palette / a pending approval) and [src/tui/commands/slash-command-handler.test.ts](src/tui/commands/slash-command-handler.test.ts) (`/window` vs `/new`).
+
## Vision (multimodal input)
Image recognition is an opt-in feature wired through the active **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `LlamaServerProvider` for local `/v1/chat/completions`, `OpenAiProvider` / `OpenRouterProvider` for cloud. The text agent loop is unchanged — vision lives outside the conversation transcript, exposed only via the `vision.describe` tool.
@@ -1703,6 +1793,7 @@ The remaining asymmetry: `tools` is populated only when the **primary's** transp
8. **A cross-transport fallover parses the response with the served link's transport, not the primary's**, and the turn reaches the fallback's answer instead of `loop_failed`. Pinned by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts) (real `AgentLoop` + `step-executor`, both unary and streaming).
9. **Breaker state is partitioned by session** — one partition's success does not clear another's armed cooldown, and a keyless call shares one default partition. Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts) ("partition isolation").
10. **The cooldown ladder must be non-decreasing** — a decreasing `cooldownMs` is rejected at parse time so "escalating" stays true. Pinned by [src/config/llm-config.test.ts](src/config/llm-config.test.ts).
+11. **A `preferredProviderId` changes only the STARTING link.** It is ignored while that specific provider is in cooldown, never sets or clears `overrideId`, and is never reported as a probe; a failure on a preferred start resumes the scan from the chain head (a preferred leg is commonly the tail, and advancing "after" it would strand a recoverable turn). Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts).
### TUI: the Fallback pane
@@ -1725,6 +1816,90 @@ The LLM tab gains a fourth pane, `fallback`, reached with `←`/`→` after Loca
5. **`provider_switched` is mirrored into `fallbackPanel.lastSwitch`; the pane never invents a live countdown.** Pinned by [src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts](src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts), [src/tui/components/llm-fallback-rows.test.tsx](src/tui/components/llm-fallback-rows.test.tsx).
6. **Empty chain / nothing-addable shows a hint, not a broken list.** Pinned by [src/tui/components/llm-fallback-rows.test.tsx](src/tui/components/llm-fallback-rows.test.tsx), [src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts](src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts).
+## Run modes (Local / Cloud / Fusion)
+
+An operator-facing mode that names the *pair* of providers a turn may use, layered directly on top of the fallback chain above. `local` uses the configured llama-server provider, `cloud` the configured cloud provider, and `fusion` runs both: the cloud leg orchestrates, the local leg executes.
+
+### Config and the non-contradiction rule
+
+`llm.runMode` (sibling of `llm.fallback`; [src/config/llm-run-mode-config.ts](src/config/llm-run-mode-config.ts)):
+
+```jsonc
+"runMode": {
+ "mode": "local" | "cloud" | "fusion",
+ "localProvider": "local-llama", // optional pin; default = first llama-server-kind provider
+ "cloudProvider": "openrouter", // optional pin; default = first non-llama-server provider
+ "fusion": { "cloudShare": 40, "subRunners": "local" }
+}
+```
+
+**`llm.activeTextProvider` stays authoritative**; `runMode.mode` is additive. `resolveRunMode` ([src/llm/run-mode/resolve-run-mode.ts](src/llm/run-mode/resolve-run-mode.ts)) derives the effective mode from which provider is active, and honours a stored `fusion` only while the cloud leg is the active one. Consequences, all deliberate: a mode switch must write both keys in one go (`setRunModeInConfig`, [src/tui/persist-run-mode.ts](src/tui/persist-run-mode.ts)); an operator who changes provider by hand in Manage → LLM simply drops out of fusion on the next read, with no reconciliation step and no state that lies; and because fusion pins the cloud provider as primary, `resolveFallbackChain` hoists it to the chain head and appends local at the tail **with no changes of its own**.
+
+**`cloudShare` is a dial, not a quota.** It moves a cutoff on a bounded per-step score; it does not promise that N% of steps reach the cloud. `0` behaves exactly like `local`, `100` exactly like `cloud`. Do not "fix" it into a running-counter scheduler — a quota necessarily sends some trivial steps to the cloud and keeps some hard ones local, which is the opposite of the intent.
+
+### Degradation
+
+Reported, never silent ([src/llm/run-mode/run-mode-degradation.ts](src/llm/run-mode/run-mode-degradation.ts)): cloud/fusion with no cloud provider stays `local`; fusion with no local provider runs cloud-only; fusion with `llm.toolTransport` pinned still runs but warns, because a pinned transport sends one leg the wrong wire shape.
+
+### Fusion routing policy
+
+The loop is one inference per step, so the split is defined per step ([src/agent/routing/](src/agent/routing/)):
+
+| Step | Route | Why |
+|---|---|---|
+| Step 0 | cloud (whenever `cloudShare > 0`) | Forms the plan and the first tool batch; exactly one call per turn, so cost is bounded |
+| Continuation | scored, with hysteresis | The bulk; mechanical read → edit chains score low and stay local |
+| Parse-repair retry | same leg as the attempt it repairs | Inherited for free by spreading the original `LlmStreamParams`. A repair must be judged by the model that made the mistake, against the same transport |
+| Memory sub-runners | local by default (`fusion.subRunners`) | Cold-path structured-JSON jobs on the reserved reflection slot, already KV-warm locally |
+| MCP sampling | **not covered** — still hard-wired local ([src/mcp/mcp-sampling-handler.ts](src/mcp/mcp-sampling-handler.ts)) | Bypasses the provider registry entirely |
+
+**The final synthesis step is deliberately not special-cased.** The loop cannot know a step is final until the model returns `reply`, so a flag for it would be a lie. Instead the score's dominant term is context pressure, so a step carrying the whole turn escalates on its own. Making "always synthesise on cloud" explicit would need a loop-level change (a post-`reply` re-synthesis pass), not a routing flag.
+
+### The complexity score
+
+Integer 0-100, weights summing to 100 so it is directly comparable to `cloudShare` ([src/agent/routing/compute-step-complexity.ts](src/agent/routing/compute-step-complexity.ts)): context pressure (40) + turn depth (25) + transient notice (20) + tail growth (15). A step routes to the cloud when `score >= 100 - cloudShare`.
+
+**`cacheReused` is deliberately excluded.** It is produced by `slotManager.acquire`, which now runs *after* routing, so feeding it back in would be circular. Do not add it.
+
+`ROUTING_HYSTERESIS` (±10) is load-bearing, not cosmetic: llama-server reuses its KV cache by longest common prefix, so alternating legs every step forces it to reprocess the tail that grew in between. Hysteresis produces runs of consecutive local steps, which is what makes the local cache pay off.
+
+### Slot affinity and provider lifetime
+
+Two things fusion had to fix in the layers below it:
+
+* **Slot affinity follows the routed provider**, via `StepDependencies.resolveSlotAffinity`. Reading it off the *active* provider (cloud, no affinity) would have run every locally-routed step at `slotId: -1` with `cachePrompt` off — a full prompt reprocess per step.
+* **Pinned providers survive an active swap** (`ProviderRegistry.setPinnedProviderIds`). `close()` is a no-op on both shipped kinds today, so this is not a live crash, but the interface promises teardown and switching *into* fusion would otherwise close the leg it is about to route to.
+
+Cost attribution follows the **served** link (`CompletionResult.servedProviderId`), for the same reason `servedTransport` exists: pricing a local completion against the cloud provider's catalog reports a cost that was never incurred.
+
+### TUI surface
+
+A one-row pill strip under the status bar in chat mode ([src/tui/components/run-mode-bar.tsx](src/tui/components/run-mode-bar.tsx)) reading `> Local . Cloud . Fusion 40%`, plus a dial overlay ([src/tui/components/run-mode-picker.tsx](src/tui/components/run-mode-picker.tsx)).
+
+Note it is **not** `DebugPane`'s `SubTabBar` — that component only renders in debug mode, so its `section === "run"` branch is unreachable — and **not** `cycleSubTab`, which returns `TuiTab`s; run modes are not tabs and forcing them in would drag in `getCurrentSection`, `tab_changed`, `NAV_SLOT_ORDER` and the persisted `initialLayout` contract.
+
+**Keys.** `Ctrl+R` cycles Local -> Cloud -> Fusion from any section (free: this file binds only Ctrl+C and Ctrl+B, and `MultiLineEditor` ignores every ctrl chord outside `a/e/u/k/w/c/o`). The overlay claims keys inside `handleAppKey`, beside the approval and update prompts rather than through `submit-handler` — it needs `<-`/`->` and digits, which the focused chat editor would otherwise consume — and swallows **every** key while open. `/run` (own command now, no longer a `/chat` alias) still returns to the Run section and additionally opens the picker; `/run fusion 60` switches directly.
+
+**Persistence.** `RunModeOrchestrator` ([src/tui/run-mode/run-mode-orchestrator.ts](src/tui/run-mode/run-mode-orchestrator.ts)) is the **only** TUI writer of `llm.runMode`. It persists both keys first, then hot-applies the provider swap, so a failed swap still leaves a file that boots into the requested mode — and it refuses to write a mode that would immediately resolve to something else, surfacing the degradation sentence instead.
+
+**A switch republishes the providers mirror.** Everything that names the *model* — the composer's meta row above all — reads `state.providersPanel.rows`, not this panel's mirror, and a mode switch is the one path that moves the active provider without going through `ProvidersOrchestrator`. So a successful `setMode` emits `providers_refresh_requested` *on the bus* once the swap has landed. Emitting rather than dispatching is load-bearing: the bus is bridged into the reducer one way, so a dispatched request reaches the reducer (which ignores it) and never the orchestrator that rebuilds the rows.
+
+**An unconfigured mode routes to the screen that fixes it** ([src/tui/run-mode/run-mode-setup.ts](src/tui/run-mode/run-mode-setup.ts)), rather than only refusing. Per leg, because the two legs are repaired in different places: a missing cloud provider opens Manage → LLM → Cloud with the add-provider wizard, a missing llama-server opens Manage → LLM → Local (the backend/model/daemon checklist — no wizard, which would only hide it). Fusion fixes its cloud leg first. One implementation serves all three entry points — the `n` key, the overlay's clickable row, and the switch itself — so the gestures cannot drift apart. Note this also closed a silent hole: `resolveRunMode` does not degrade a `local` request that has no local leg, so the orchestrator used to write `runMode.mode: "local"` while leaving a cloud provider active, with no swap and no message. A missing leg is now checked directly, ahead of the resolver's degradation.
+
+#### Locked invariants (Pinned by tests)
+
+1. **`activeTextProvider` wins**: a stored `fusion` with the local leg active resolves to `local`, and that is not a degradation. Pinned by [src/llm/run-mode/resolve-run-mode.test.ts](src/llm/run-mode/resolve-run-mode.test.ts).
+2. **Every unavailable mode degrades to a reachable one and says why.** Pinned by [src/llm/run-mode/resolve-run-mode.test.ts](src/llm/run-mode/resolve-run-mode.test.ts), [src/llm/run-mode/run-mode-degradation.test.ts](src/llm/run-mode/run-mode-degradation.test.ts).
+3. **`cloudShare` 0 / 100 are exact**, and step 0 always orchestrates when the cloud leg is in play. Pinned by [src/agent/routing/decide-routing-role.test.ts](src/agent/routing/decide-routing-role.test.ts).
+4. **The score is a bounded integer, monotonic in each term, and NaN-free on zero budgets.** Pinned by [src/agent/routing/compute-step-complexity.test.ts](src/agent/routing/compute-step-complexity.test.ts).
+5. **Hysteresis is per session** and drops when fusion is switched off. Pinned by [src/agent/routing/step-router.test.ts](src/agent/routing/step-router.test.ts).
+6. **No router (or a declining one) leaves `LlmStreamParams` byte-identical to today**, and the slot follows the ROUTED provider. Pinned by [src/agent/step-executor-routing.test.ts](src/agent/step-executor-routing.test.ts).
+7. **A preferred leg is a starting link, not an override** — health still wins, and the served id/transport are stamped from the link that answered. Pinned by [src/runtime/llm-fallback-seam.test.ts](src/runtime/llm-fallback-seam.test.ts).
+8. **A pinned provider survives an active swap.** Pinned by [src/llm/provider/registry/provider-registry.test.ts](src/llm/provider/registry/provider-registry.test.ts).
+9. **Mode and active provider move in ONE config write.** Pinned by [src/tui/persist-run-mode.test.ts](src/tui/persist-run-mode.test.ts).
+10. **The overlay owns the keyboard while open and Esc reverts the draft.** Pinned by [src/tui/run-mode/run-mode-key-bindings.test.ts](src/tui/run-mode/run-mode-key-bindings.test.ts), [src/tui/run-mode/run-mode-reducer.test.ts](src/tui/run-mode/run-mode-reducer.test.ts).
+11. **Bare `/run` keeps its historical "return to Run" behaviour.** Pinned by [src/tui/commands/slash-command-handler.test.ts](src/tui/commands/slash-command-handler.test.ts).
+
## Traceability and replay
Every run produces an append-only NDJSON trace at `/traces/.ndjson` — one event per line. Tracing is on by default for `atomic-agent run` / TUI / `atomic-agent serve`, and off by default in sidecar mode so the Tauri host decides whether to opt in.
diff --git a/README.md b/README.md
index 1b8ff1ee..67536811 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,9 @@ The installer downloads the release archive, verifies the checksum, and installs
atomic-agent
```
+> [!TIP]
+> Need a second agent? Press **Ctrl+N** (or run `/window`) inside the TUI — it opens a new terminal window with a fresh atomic-agent in the same directory.
+
> [!TIP]
> Coming from Hermes or OpenClaw? Run `/import` in the TUI for a one-shot migration: sessions, cron jobs, and optionally your provider keys.
@@ -194,7 +197,7 @@ Atomic Agent drives a full desktop tool surface. Dangerous actions are routed th
| **Skills** | View and run Markdown skill playbooks (scripts are approval-gated), install more from ClawHub. Ships with 17 starter skills (Docker, GitHub, Notion, Obsidian, PDF, and more), auto-installed on first run. |
| **Vision** | Optional `vision.describe` for multimodal models with `mmproj`, kept outside the text transcript. |
| **MCP** | Connect external MCP servers; their tools, resources, and prompts join the same registry. |
-| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, and AI/ML API providers when configured, with live model catalogs and mid-session switching. Reasoning-only completions from reasoning models are recovered instead of failing the turn. |
+| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, AI/ML API, and Gemini providers when configured, with live model catalogs and mid-session switching. Your existing **Claude Code and OpenAI Codex subscriptions** work too, driven through their own signed-in CLIs with no API key. Reasoning-only completions from reasoning models are recovered instead of failing the turn. |
| **Telegram** | Single-user remote control with owner pairing, inline approval buttons, and opt-in result reports from scheduled tasks. |
### Memory That Grows Outside the Prompt
@@ -226,10 +229,41 @@ atomic-agent task list
atomic-agent trace list --limit 10
```
-Handy slash commands: `/help` lists every command, `/tools` lists the built-in tool families, `/model` jumps to the LLM panel and reopens the model picker for the active cloud provider, `/privacy` shows what leaves the machine (`/privacy analytics off` turns analytics off). The chat log scrolls with PgUp / PgDn (fn+arrows on macOS).
+Handy slash commands: `/help` lists every command, `/tools` lists the built-in tool families, `/run` switches run mode, `/model` jumps to the LLM panel and reopens the model picker for the active cloud provider, `/privacy` shows what leaves the machine (`/privacy analytics off` turns analytics off). The chat log scrolls with PgUp / PgDn (fn+arrows on macOS).
+
+**Mouse.** The TUI is clickable: the Run / Observe / Manage bar and its sub-tabs, sidebar sessions and tasks, every list row (skills, tasks, memory, MCP, models, providers), the session / theme / slash pickers, approval buttons, tool cards, and the prompt itself — clicking in the input places the caret. A click selects a row, a second click on the selected row opens it, and the wheel scrolls the chat or walks the focused panel.
+
+While mouse reporting is on the terminal hands clicks to the app, which means its own drag-to-select is unavailable (iTerm2, GNOME Terminal and Windows Terminal let you hold Shift to bypass; Apple Terminal does not). Turn it off whenever you want to select text: `/mouse off` in the app, `atomic-agent tui --no-mouse` for one run, or `"tui": { "mouse": false }` in `/config.json`. With mouse off, wheel scrolling still works through the terminal's alternate-scroll mode, exactly as before.
Cloud provider setup pulls each provider's full live model catalog, hundreds of models, instead of a short hardcoded list; OpenAI-compatible servers are asked for their own `/v1/models`. The picker filters as you type, and `/model` switches models mid-session.
+A cloud key is checked before it is saved. The key screen refuses an empty key, and finishing the wizard asks the provider for a one-token completion from its cheapest model: a key that is rejected, or attached to an account with no balance, never reaches `.env` and never becomes the active provider. A provider that cannot be reached at all still saves, with a line saying the key went unverified — an offline or proxied machine stays configurable. Local servers have no account to check and are left alone.
+
+
+
+
+Run modes: Local, Cloud, Fusion
+
+A strip under the status bar shows which pair of models the next turn will use, and `Ctrl+R` cycles it:
+
+- **Local** — your llama-server model only.
+- **Cloud** — your cloud provider only.
+- **Fusion** — the cloud model orchestrates, the local model executes.
+
+Fusion exists because the two halves of a turn have different needs. Planning the work and reconciling a pile of tool output is where a big model earns its price; the mechanical middle of a turn — read a file, edit it, read the next one — mostly does not. Fusion sends the first step of every turn to the cloud, scores each following step, and keeps the cheap ones local.
+
+```
+/run # open the picker
+/run local # switch directly
+/run fusion 60 # switch and set the cloud share
+```
+
+The **cloud share** is a dial, not a quota. It does not promise that 60% of steps go to the cloud; it lowers the bar a step has to clear to get there, using a score built from how full the context is, how deep into the turn you are, how much tool output the step is carrying, and whether the model just tripped the loop detector. `0` behaves exactly like Local and `100` exactly like Cloud.
+
+Health still wins over the split: if the cloud provider starts failing mid-turn, the usual fallback chain takes over and the turn finishes locally. Background memory work (reflection, distillation, query rewriting) stays local by default, since it is cold-path JSON that would multiply cost for no visible gain.
+
+Selecting a mode you cannot run — Cloud or Fusion with no cloud provider configured — leaves you where you are and says so, rather than failing on the next message.
+
@@ -256,6 +290,17 @@ Managed mode downloads the backend, pulls GGUF models, selects the active model,
The managed chat daemon stops when the last session exits, freeing the RAM and VRAM the model was holding; set `localModels.managed.stopOnExit: false` in `config.json` to keep the model warm between sessions. Daemons started standalone with `models start` are never touched.
+Cloud models are searchable from the same command — by id, vendor, or capability, across every configured cloud provider:
+
+```bash
+atomic-agent models search claude vision
+atomic-agent models search free tools --json
+atomic-agent models search "1m cache" --provider openrouter --limit 10
+atomic-agent models search kimi --refresh # pull live /models lists first
+```
+
+Every term has to match (`claude vision` is not a substring of any id), results are ranked best-first, and the same query works in the TUI Cloud pane — press `f`.
+
@@ -423,6 +468,7 @@ Local-first bounds where control lives, not where packets go. Network egress hap
- an HTTP tool calls a requested endpoint;
- a web search provider answers a query;
- a configured cloud LLM or embedding provider receives its request;
+- a `subscription-cli` provider is active and the vendor CLI (`claude` or `codex`) receives your prompt on its stdin, then sends it on under its own account;
- an MCP server receives a tool call you routed to it;
- the Telegram channel is enabled and the bot exchanges messages with your paired chat, including opt-in scheduled task reports;
- you install a skill from ClawHub;
@@ -467,6 +513,24 @@ Useful environment variables:
- `ATOMIC_AGENT_BROWSER_EXECUTABLE_PATH`: explicit Chromium-family executable path.
- `ATOMIC_AGENT_BROWSER_CDP_URL`: attach to an already-running browser via CDP.
+The run mode (Local / Cloud / Fusion) is stored under `llm.runMode`, alongside the providers it names:
+
+```json
+{
+ "llm": {
+ "activeTextProvider": "openrouter",
+ "runMode": {
+ "mode": "fusion",
+ "localProvider": "local-llama",
+ "cloudProvider": "openrouter",
+ "fusion": { "cloudShare": 40, "subRunners": "local" }
+ }
+ }
+}
+```
+
+`localProvider` / `cloudProvider` are optional — the legs default to the first `llama-server`-kind provider and the first non-`llama-server` provider. `cloudShare` is the 0-100 dial described above. `subRunners` (`local` | `cloud` | `follow`) decides where background memory work runs. `activeTextProvider` remains authoritative: change it by hand and the mode follows it, so the two can never disagree.
+
Secrets for skills and channels belong in `/.env`, not in `config.json`:
```text
@@ -481,6 +545,57 @@ Shell-exported variables win over `.env`. The built-in parser intentionally supp
+
+Claude Code / OpenAI Codex subscriptions (no API key)
+
+Drives a vendor CLI you are already signed into, so a flat-rate subscription can power the agent with no API key and no per-token billing. Two are supported: `claude` (Claude Code) and `codex` (OpenAI Codex).
+
+**Prerequisite:** the CLI installed and signed in — `claude` then `/login`, or `npm i -g @openai/codex` then `codex login`. Atomic only spawns the binary; it never reads, copies, or replays its OAuth tokens or keychain entries.
+
+In the TUI: **Providers → `n` →** pick the subscription row, then type a model. For Claude that is `sonnet`, `opus`, `haiku`, `fable`, or a pinned id like `claude-sonnet-5`; **for Codex leave it blank** — under a ChatGPT login Codex rejects explicit model ids (`not supported when using Codex with a ChatGPT account`) and resolves one itself. There is no API-key screen, because there is no key. Equivalent `config.json`:
+
+```json
+{
+ "llm": {
+ "activeTextProvider": "claude-cli",
+ "providers": [
+ {
+ "id": "claude-cli",
+ "kind": "subscription-cli",
+ "defaultChatModel": "sonnet",
+ "subscriptionCli": { "cli": "claude" }
+ }
+ ]
+ }
+}
+```
+
+Optional keys inside `subscriptionCli`: `binPath` (absolute path when the CLI is not on `PATH`), `extraArgs` (appended verbatim — e.g. `["--effort", "high"]`), `streaming` (set `false` to buffer), `maxBudgetUsd`.
+
+Swap `"cli": "claude"` for `"cli": "codex"` to drive Codex instead, and drop `defaultChatModel`.
+
+Each completion spawns the CLI fresh with the prompt on **stdin** (a two-zone prompt exceeds the 128 KiB argv limit). For `claude` it runs `claude --print` with these flags, which are load-bearing rather than cosmetic:
+
+- **`--tools ""`** — disables Claude Code's own Bash/Edit/Write. Without it a second agent would act on your machine outside Atomic's approval ladder.
+- **`--strict-mcp-config`** with no config — keeps your MCP servers out of what should be a stateless completion.
+- **`--system-prompt`** — replaces Claude Code's coding-agent prompt, which would otherwise compete with the prompt Atomic already built.
+- **`--no-session-persistence`** — Atomic owns session state; CLI-side history would double-count context.
+- **`--bare` is never passed.** Its own docs say OAuth and keychain are never read under it, which would defeat the whole feature.
+
+For `codex` it runs `codex exec --json` with `--ephemeral`, `--skip-git-repo-check`, `--ignore-user-config` and `-s read-only`. Three differences are worth knowing, because Codex is a more opinionated agent than Claude's headless mode:
+
+- **There is no `--tools ""` equivalent.** `-s read-only` confines Codex's own tools to reading; it cannot remove them. Left to itself, Codex will try to *perform* the request with its own tools instead of emitting Atomic's tool-call protocol — in testing it answered "I can't find `probe.txt`" after looking in its own working directory. The fix is an explicit completion-engine instruction prepended to the prompt (Codex has no system-prompt flag). It works — verified turns drive `os.fs.read` → `reply` and `os.fs.read` → `os.fs.write` → `reply` with no parse retries — but it is a prompt-level guarantee, not a structural one like `--tools ""`.
+- **Codex exits 0 even when the turn fails.** A bad model id, an expired login and a rate limit all produce a clean exit with a `turn.failed` event, so the adapter treats a missing `turn.completed` as a failure rather than trusting the exit code.
+- **No streaming.** `codex exec --json` emits the answer in one `item.completed`, with no incremental text events, so this provider buffers instead of pretending to stream.
+
+Not supported on either CLI: vision, embeddings (they stay on the local daemon), and the sampling knobs `temperature` / `top_p` / `top_k` / `seed` / `stop` / `maxTokens` — neither CLI exposes a flag for them, so they are dropped rather than silently approximated. Reconfiguring `binPath` or `extraArgs` means editing `config.json`; the model is changeable from the LLM tab.
+
+Two things worth knowing before you switch a long-running agent onto either: each completion pays roughly 0.8 s of process startup, and subscription plans have session and weekly caps that an autonomous multi-step agent reaches much faster than interactive use. When a cap is hit, the CLI's own message is surfaced verbatim.
+
+> [!NOTE]
+> Whether driving a subscription CLI from another agent is acceptable use is the vendor's call, not this project's. Atomic uses the officially documented headless mode and nothing else; the decision to use it is yours.
+
+
Qwen / Tinker tagged tool calls (opt-in compatibility provider)
diff --git a/package.json b/package.json
index b537c551..20bed2d1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "atomic-agent",
- "version": "0.2.2",
+ "version": "0.3.0",
"description": "Lightweight local operator agent (browser + OS) runtime for Tauri apps. Connects to an external llama.cpp server via HTTP and exposes a sidecar NDJSON protocol plus a debug CLI.",
"license": "MIT",
"type": "module",
diff --git a/src/agent/agent-loop-steering.test.ts b/src/agent/agent-loop-steering.test.ts
new file mode 100644
index 00000000..2d2a0457
--- /dev/null
+++ b/src/agent/agent-loop-steering.test.ts
@@ -0,0 +1,282 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { AgentLoop, type AgentLoopEvent } from "./agent-loop.js";
+import { buildDefaultToolRegistry } from "../tools/index.js";
+import { SlotManager } from "../llm/slot-manager.js";
+import { createEmptySessionState } from "../session/session-state.js";
+import { SteeringInbox } from "../runtime/steering-inbox.js";
+import type { CompletionResult } from "../llm/llama-server-client.js";
+import type {
+ CapabilitiesSummary,
+ SkillCatalogEntry,
+ ToolDescriptor,
+} from "../prompt/stable-prefix.js";
+
+/**
+ * Mid-turn steering. Pins:
+ * - A message pushed while the turn is running reaches the NEXT
+ * step's prompt as a `### notice` block — never the step already
+ * in flight, and never a later turn.
+ * - It is also recorded as a real `user` turn, so the transcript
+ * does not lie about what the operator said.
+ * - The loop-detector's own one-shot notice is composed with, not
+ * clobbered by, a steer landing in the same step.
+ * - Nothing is ever silently lost: a message that arrives too late
+ * to be drained comes back on `RunTurnResult.undelivered`, on the
+ * normal path and on the cancelled path alike.
+ * - Without a `steeringInbox` dep the loop behaves exactly as before.
+ */
+
+function makeCompletion(content: string): CompletionResult {
+ return {
+ content,
+ reasoningContent: "",
+ stop: true,
+ truncated: false,
+ timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 },
+ cacheHitTokens: 0,
+ slotId: 0,
+ modelId: "mock",
+ };
+}
+
+const TOOLS: ToolDescriptor[] = [
+ { name: "finish", summary: "Finish the session.", argsSchema: '{"summary": string}' },
+];
+
+const CAPS: CapabilitiesSummary = {
+ platform: "darwin",
+ arch: "arm64",
+ browserChannel: "chrome",
+ workingDir: "/work",
+ hasClipboard: true,
+ hasWmctrl: false,
+ hasNotifications: true,
+};
+
+const SKILLS: SkillCatalogEntry[] = [];
+
+const NOOP = JSON.stringify({ tool: "noop", args: {} });
+const REPLY = JSON.stringify({ tool: "reply", args: { text: "done" } });
+
+interface Harness {
+ loop: AgentLoop;
+ tails: string[];
+ events: AgentLoopEvent[];
+}
+
+function buildLoop(opts: {
+ inbox?: SteeringInbox;
+ onStep?: (stepIndex: number) => void;
+ steps?: number;
+}): Harness {
+ const tails: string[] = [];
+ const events: AgentLoopEvent[] = [];
+ const totalSteps = opts.steps ?? 2;
+ let calls = 0;
+ const registry = buildDefaultToolRegistry();
+ // A trivial non-terminal tool so the turn takes more than one step —
+ // steering only exists between step boundaries, so a one-step turn
+ // could not exercise it.
+ registry.register({
+ name: "noop",
+ description: "does nothing",
+ readonly: true,
+ run: async () => ({
+ tool: "noop",
+ status: "ok" as const,
+ summary: "noop",
+ details: {},
+ truncated: false,
+ }),
+ });
+ const loop = new AgentLoop({
+ registry,
+ slotManager: new SlotManager(2),
+ grammar: 'root ::= "ok"',
+ llmComplete: async () => {
+ calls += 1;
+ opts.onStep?.(calls - 1);
+ return makeCompletion(calls < totalSteps ? NOOP : REPLY);
+ },
+ toolDescriptors: TOOLS,
+ capabilities: CAPS,
+ skillCatalog: SKILLS,
+ ...(opts.inbox ? { steeringInbox: opts.inbox } : {}),
+ onEvent: (event) => {
+ events.push(event);
+ if (event.type === "llm_event" && event.event.type === "prompt_captured") {
+ tails.push(event.event.tail);
+ }
+ },
+ });
+ return { loop, tails, events };
+}
+
+describe("AgentLoop mid-turn steering", () => {
+ let workingDir: string;
+
+ beforeEach(() => {
+ workingDir = mkdtempSync(join(tmpdir(), "atomic-agent-steer-"));
+ });
+
+ afterEach(() => {
+ rmSync(workingDir, { recursive: true, force: true });
+ });
+
+ it("folds a message sent during step 0 into step 1's prompt", async () => {
+ const inbox = new SteeringInbox();
+ const { loop, tails } = buildLoop({
+ inbox,
+ // Pushed while step 0's inference is in flight — the realistic
+ // shape of "the operator typed while the agent was working".
+ onStep: (step) => {
+ if (step === 0) inbox.push("s-steer", "actually, check the logs first");
+ },
+ });
+ const session = createEmptySessionState({ id: "s-steer", workingDir });
+ await loop.runTurn(session, {
+ userMessage: "do the thing",
+ maxSteps: 4,
+ signal: new AbortController().signal,
+ });
+
+ expect(tails).toHaveLength(2);
+ // Step 0 was already committed when the message arrived.
+ expect(tails[0]).not.toContain("actually, check the logs first");
+ expect(tails[1]).toContain("### notice");
+ expect(tails[1]).toContain("actually, check the logs first");
+ });
+
+ it("does not leak the notice into the step after that", async () => {
+ const inbox = new SteeringInbox();
+ const { loop, tails } = buildLoop({
+ inbox,
+ steps: 3,
+ onStep: (step) => {
+ if (step === 0) inbox.push("s-once", "one-shot please");
+ },
+ });
+ await loop.runTurn(createEmptySessionState({ id: "s-once", workingDir }), {
+ userMessage: "go",
+ maxSteps: 4,
+ signal: new AbortController().signal,
+ });
+ // The NOTICE is one-shot. The message itself stays visible in
+ // `### conversation` forever — it is a real user turn, and that is
+ // the point — so assert on the notice framing, not on the text.
+ expect(tails[1]).toContain("### notice");
+ expect(tails[1]).toMatch(/Take it into account before your next action/);
+ expect(tails[2]).not.toMatch(/Take it into account before your next action/);
+ expect(tails[2]).toContain("one-shot please");
+ });
+
+ it("records the steer as a real user turn and emits steer_applied", async () => {
+ const inbox = new SteeringInbox();
+ const { loop, events } = buildLoop({
+ inbox,
+ onStep: (step) => {
+ if (step === 0) inbox.push("s-turn", "and use the staging db");
+ },
+ });
+ const result = await loop.runTurn(
+ createEmptySessionState({ id: "s-turn", workingDir }),
+ { userMessage: "deploy", maxSteps: 4, signal: new AbortController().signal },
+ );
+
+ const userTurns = result.session.turns.filter((t) => t.kind === "user");
+ expect(userTurns.map((t) => (t as { text: string }).text)).toEqual([
+ "deploy",
+ "and use the staging db",
+ ]);
+ expect(events).toContainEqual({
+ type: "steer_applied",
+ text: "and use the staging db",
+ stepIndex: 1,
+ });
+ });
+
+ it("delivers several messages queued between two steps in one notice", async () => {
+ const inbox = new SteeringInbox();
+ const { loop, tails } = buildLoop({
+ inbox,
+ onStep: (step) => {
+ if (step === 0) {
+ inbox.push("s-multi", "first correction");
+ inbox.push("s-multi", "second correction");
+ }
+ },
+ });
+ await loop.runTurn(createEmptySessionState({ id: "s-multi", workingDir }), {
+ userMessage: "go",
+ maxSteps: 4,
+ signal: new AbortController().signal,
+ });
+ expect(tails[1]).toContain("first correction");
+ expect(tails[1]).toContain("second correction");
+ expect(tails[1]).toContain("2 new messages");
+ });
+
+ it("hands back a message that arrived too late to be drained", async () => {
+ const inbox = new SteeringInbox();
+ const { loop } = buildLoop({
+ inbox,
+ // Pushed during the FINAL inference: the loop terminates on this
+ // step's `reply`, so no further step boundary exists to drain it.
+ onStep: (step) => {
+ if (step === 1) inbox.push("s-late", "too late to steer");
+ },
+ });
+ const result = await loop.runTurn(
+ createEmptySessionState({ id: "s-late", workingDir }),
+ { userMessage: "go", maxSteps: 4, signal: new AbortController().signal },
+ );
+ expect(result.reason).toBe("reply");
+ expect(result.undelivered).toEqual(["too late to steer"]);
+ // And it really is gone from the inbox — it is the caller's now.
+ expect(inbox.peek("s-late")).toEqual([]);
+ });
+
+ it("hands back pending messages when the turn is cancelled", async () => {
+ const inbox = new SteeringInbox();
+ const controller = new AbortController();
+ const { loop } = buildLoop({
+ inbox,
+ steps: 5,
+ onStep: (step) => {
+ if (step === 0) {
+ inbox.push("s-cancel", "never delivered");
+ controller.abort();
+ }
+ },
+ });
+ const result = await loop.runTurn(
+ createEmptySessionState({ id: "s-cancel", workingDir }),
+ { userMessage: "go", maxSteps: 4, signal: controller.signal },
+ );
+ expect(result.undelivered).toEqual(["never delivered"]);
+ });
+
+ it("returns no undelivered messages on an ordinary turn", async () => {
+ const { loop } = buildLoop({ inbox: new SteeringInbox() });
+ const result = await loop.runTurn(
+ createEmptySessionState({ id: "s-plain", workingDir }),
+ { userMessage: "go", maxSteps: 4, signal: new AbortController().signal },
+ );
+ expect(result.undelivered).toEqual([]);
+ });
+
+ it("behaves exactly as before when no inbox is wired in", async () => {
+ const { loop, tails } = buildLoop({});
+ const result = await loop.runTurn(
+ createEmptySessionState({ id: "s-none", workingDir }),
+ { userMessage: "go", maxSteps: 4, signal: new AbortController().signal },
+ );
+ expect(result.reason).toBe("reply");
+ expect(result.undelivered).toEqual([]);
+ for (const tail of tails) expect(tail).not.toContain("### notice");
+ });
+});
diff --git a/src/agent/agent-loop.ts b/src/agent/agent-loop.ts
index d4367270..5abbb265 100644
--- a/src/agent/agent-loop.ts
+++ b/src/agent/agent-loop.ts
@@ -48,6 +48,7 @@ import {
formatForcedLoopReply,
} from "./loop-detector.js";
import type { BatchLoopSignal } from "./batch-executor.js";
+import { composeSteerNotice } from "./steer-notice.js";
import { getConfig } from "../config/index.js";
import type { AgentMetrics } from "../tracing/agent-metrics.js";
import type { StructuredLogger } from "../tracing/structured-logger.js";
@@ -164,6 +165,14 @@ export interface AgentLoopDependencies {
*/
lessonLifecycle?: LessonLifecycleHook;
onEvent?: (event: AgentLoopEvent) => void;
+ /**
+ * Out-of-band channel for user messages that arrive while this turn is
+ * already running (`SteeringInbox`). Drained at the top of every step
+ * and folded into that step's `### notice`; see §"Mid-turn steering"
+ * in AGENTS.md. Absent in tests and in surfaces that do not offer
+ * steering, in which case the loop behaves exactly as before.
+ */
+ steeringInbox?: SteeringDrain;
metrics?: AgentMetrics;
logger?: StructuredLogger;
}
@@ -247,6 +256,15 @@ export interface LessonLifecycleHook {
}): void;
}
+/**
+ * Read side of the steering inbox as the loop needs it. Declared
+ * structurally (like {@link MemoryContextProvider}) so `src/agent/` does
+ * not import from `src/runtime/`, which imports it.
+ */
+export interface SteeringDrain {
+ drain(sessionId: string): readonly string[];
+}
+
export interface RunTurnOptions {
maxSteps: number;
signal: AbortSignal;
@@ -264,6 +282,13 @@ export type AgentLoopReason =
export type AgentLoopEvent =
| { type: "user_message"; text: string }
+ /**
+ * A message the user sent mid-turn was folded into the prompt for
+ * step `stepIndex`. Distinct from `user_message`, which marks the
+ * message that *started* the turn — UIs render this one inline in the
+ * running turn rather than as the opening of a new one.
+ */
+ | { type: "steer_applied"; text: string; stepIndex: number }
| { type: "turn_started"; turnIndex: number }
| {
type: "turn_finished";
@@ -326,6 +351,14 @@ export interface RunTurnResult {
session: SessionState;
reason: AgentLoopReason;
stepCount: number;
+ /**
+ * Steering messages that were pushed but never reached a step — the
+ * turn ended (or was cancelled) before the loop could drain them.
+ * Callers MUST re-route these, normally onto their own message queue,
+ * otherwise a message the user watched being accepted vanishes. Empty
+ * on every ordinary turn.
+ */
+ undelivered?: readonly string[];
}
export class AgentLoop {
@@ -451,6 +484,27 @@ export class AgentLoop {
}
this.deps.onEvent?.({ type: "step_started", stepIndex: i });
const started = Date.now();
+ // Mid-turn steering: anything the user sent since the previous
+ // step boundary joins this step's prompt. It is recorded as a
+ // real `user` turn (the transcript must reflect what was said,
+ // and `packConversation` always keeps the last user turn visible)
+ // AND repeated in `### notice`, which is the tail-most block the
+ // model reads before `### respond`. `composeSteerNotice` appends
+ // to whatever the loop detector already left in `pendingNotice`
+ // rather than overwriting it — both nudges matter.
+ const steered = this.deps.steeringInbox?.drain(state.id) ?? [];
+ for (const text of steered) {
+ state = recordTurn(state, userTurn(text));
+ this.deps.onEvent?.({ type: "steer_applied", text, stepIndex: i });
+ }
+ if (steered.length > 0) {
+ pendingNotice = composeSteerNotice(pendingNotice, steered);
+ this.deps.logger?.info("mid-turn steering applied", {
+ sessionId: state.id,
+ stepIndex: i,
+ count: steered.length,
+ });
+ }
const noticeForThisStep = pendingNotice;
pendingNotice = undefined;
try {
@@ -708,7 +762,12 @@ export class AgentLoop {
stepCount: stepsTaken,
durationMs,
});
- return { session: state, reason: "cancelled", stepCount: stepsTaken };
+ return {
+ session: state,
+ reason: "cancelled",
+ stepCount: stepsTaken,
+ undelivered: this.flushSteering(state.id),
+ };
}
// Symmetric with the cancelled path above: set terminal state,
// emit `loop_completed` + `turn_finished`, increment turnCount,
@@ -739,7 +798,12 @@ export class AgentLoop {
// returned earlier without calling the hook (cancellation
// carries neither success nor failure signal).
invokeLessonLifecycle(this.deps, state.id, surfacedLessonIds, "failure");
- return { session: state, reason: "failed", stepCount: stepsTaken };
+ return {
+ session: state,
+ reason: "failed",
+ stepCount: stepsTaken,
+ undelivered: this.flushSteering(state.id),
+ };
}
}
@@ -890,7 +954,26 @@ export class AgentLoop {
}
}
- return { session: state, reason, stepCount: stepsTaken };
+ return {
+ session: state,
+ reason,
+ stepCount: stepsTaken,
+ undelivered: this.flushSteering(state.id),
+ };
+ }
+
+ /**
+ * Empty the steering inbox on the way out of a turn.
+ *
+ * A message pushed after the loop's last drain — during the final
+ * inference, or at any point in a turn that was cancelled before it
+ * stepped — would otherwise sit in the inbox until some unrelated
+ * later turn happened to pick it up, out of order and out of context.
+ * Handing it back to the caller keeps "the message you sent always
+ * goes somewhere" true on every exit path.
+ */
+ private flushSteering(sessionId: string): readonly string[] {
+ return this.deps.steeringInbox?.drain(sessionId) ?? [];
}
}
diff --git a/src/agent/routing/compute-step-complexity.test.ts b/src/agent/routing/compute-step-complexity.test.ts
new file mode 100644
index 00000000..104fa6cf
--- /dev/null
+++ b/src/agent/routing/compute-step-complexity.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ computeStepComplexity,
+ type StepComplexitySignals,
+} from "./compute-step-complexity.js";
+
+const base: StepComplexitySignals = {
+ promptTokens: 0,
+ stablePrefixTokens: 0,
+ stepIndex: 0,
+ maxSteps: 25,
+ conversationMaxTokens: 32_000,
+ hasTransientNotice: false,
+};
+
+const at = (over: Partial): number =>
+ computeStepComplexity({ ...base, ...over });
+
+describe("computeStepComplexity", () => {
+ it("scores a fresh, empty step at zero", () => {
+ expect(at({})).toBe(0);
+ });
+
+ it("saturates at 100 when every signal is maxed", () => {
+ expect(
+ at({
+ promptTokens: 64_000,
+ stablePrefixTokens: 0,
+ stepIndex: 25,
+ hasTransientNotice: true,
+ }),
+ ).toBe(100);
+ });
+
+ it("always returns an integer inside 0-100", () => {
+ const samples = [
+ at({ promptTokens: 7_777, stablePrefixTokens: 1_234, stepIndex: 3 }),
+ at({ promptTokens: 31_999, stablePrefixTokens: 12_001, stepIndex: 7 }),
+ at({ promptTokens: 1, stablePrefixTokens: 0, stepIndex: 1 }),
+ ];
+ for (const score of samples) {
+ expect(Number.isInteger(score)).toBe(true);
+ expect(score).toBeGreaterThanOrEqual(0);
+ expect(score).toBeLessThanOrEqual(100);
+ }
+ });
+
+ it("is monotonic in context pressure", () => {
+ const low = at({ promptTokens: 4_000, stablePrefixTokens: 4_000 });
+ const high = at({ promptTokens: 16_000, stablePrefixTokens: 16_000 });
+ expect(high).toBeGreaterThan(low);
+ });
+
+ it("is monotonic in turn depth", () => {
+ expect(at({ stepIndex: 12 })).toBeGreaterThan(at({ stepIndex: 2 }));
+ });
+
+ it("is monotonic in tail growth at a fixed prompt size", () => {
+ const mostlyStable = at({ promptTokens: 20_000, stablePrefixTokens: 19_000 });
+ const mostlyTail = at({ promptTokens: 20_000, stablePrefixTokens: 1_000 });
+ expect(mostlyTail).toBeGreaterThan(mostlyStable);
+ });
+
+ it("adds exactly the transient-notice weight", () => {
+ const quiet = at({ promptTokens: 8_000, stablePrefixTokens: 6_000 });
+ const noisy = at({
+ promptTokens: 8_000,
+ stablePrefixTokens: 6_000,
+ hasTransientNotice: true,
+ });
+ expect(noisy - quiet).toBe(20);
+ });
+
+ it("treats a tail larger than the prompt as zero, never negative", () => {
+ expect(at({ promptTokens: 100, stablePrefixTokens: 5_000 })).toBe(0);
+ });
+
+ it("survives zero and non-finite budgets without producing NaN", () => {
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
+ const score = at({
+ promptTokens: 10_000,
+ conversationMaxTokens: bad,
+ maxSteps: bad,
+ stepIndex: 5,
+ });
+ expect(Number.isInteger(score)).toBe(true);
+ expect(score).toBeGreaterThanOrEqual(0);
+ }
+ });
+});
diff --git a/src/agent/routing/compute-step-complexity.ts b/src/agent/routing/compute-step-complexity.ts
new file mode 100644
index 00000000..c88b0072
--- /dev/null
+++ b/src/agent/routing/compute-step-complexity.ts
@@ -0,0 +1,94 @@
+/**
+ * Signals available at routing time — i.e. after `buildPrompt` but
+ * BEFORE `slotManager.acquire`, because the slot depends on which
+ * provider we route to.
+ *
+ * That ordering is why `cacheReused` is deliberately absent: it is
+ * produced by `slotManager.acquire`, so feeding it back into the
+ * routing decision would be circular. Do not add it.
+ */
+export interface StepComplexitySignals {
+ /** `prompt.tokens.total` for the step about to run. */
+ promptTokens: number;
+ /** `prompt.tokens.stablePrefix` — the KV-stable head of the prompt. */
+ stablePrefixTokens: number;
+ /** 0-based index of this step inside the current turn. */
+ stepIndex: number;
+ /** `config.agent.maxSteps` — the turn's step budget. */
+ maxSteps: number;
+ /** `config.agent.conversationMaxTokens` — the conversation budget. */
+ conversationMaxTokens: number;
+ /**
+ * Whether a one-shot notice is being rendered into this step's prompt
+ * (loop detector fired, or a tool batch was trimmed). The model just
+ * did something wrong, so the step deserves the stronger model.
+ */
+ hasTransientNotice: boolean;
+}
+
+/**
+ * Weights sum to 100 so the score is directly comparable to the
+ * operator's `cloudShare` dial without any rescaling.
+ */
+const WEIGHT_CONTEXT_PRESSURE = 40;
+const WEIGHT_TURN_DEPTH = 25;
+const WEIGHT_TRANSIENT_NOTICE = 20;
+const WEIGHT_TAIL_GROWTH = 15;
+
+/**
+ * The tail is judged against half the conversation budget: a turn whose
+ * accumulated tool output has eaten that much is already synthesis-shaped,
+ * and waiting for the full budget would only escalate on the very last
+ * step or two.
+ */
+const TAIL_BUDGET_FRACTION = 2;
+
+function clamp01(value: number): number {
+ if (!Number.isFinite(value) || value <= 0) return 0;
+ return value >= 1 ? 1 : value;
+}
+
+function ratio(numerator: number, denominator: number): number {
+ if (!Number.isFinite(denominator) || denominator <= 0) return 0;
+ return clamp01(numerator / denominator);
+}
+
+/**
+ * Score one step's difficulty on a bounded 0-100 scale.
+ *
+ * Deliberately a *heuristic over cheap signals*, not a model call: it
+ * runs before every inference in fusion mode, so it has to be free and
+ * deterministic. The four terms, in weight order:
+ *
+ * 1. **Context pressure** (40) — how full the context is. This is the
+ * dominant term on purpose. It is also how a final synthesis step
+ * ends up on the cloud without the loop being able to know a step is
+ * final: by the time the model is ready to answer, it is carrying the
+ * whole turn's context.
+ * 2. **Turn depth** (25) — later steps in a long turn are the ones that
+ * have to hold more state together.
+ * 3. **Transient notice** (20) — a binary "the model just misbehaved"
+ * signal from the loop detector / batch trimmer.
+ * 4. **Tail growth** (15) — how much of the prompt is accumulated tool
+ * output rather than the stable prefix, i.e. how much raw material
+ * this step has to reconcile.
+ */
+export function computeStepComplexity(
+ signals: StepComplexitySignals,
+): number {
+ const tailTokens = Math.max(
+ 0,
+ signals.promptTokens - signals.stablePrefixTokens,
+ );
+ const score =
+ WEIGHT_CONTEXT_PRESSURE *
+ ratio(signals.promptTokens, signals.conversationMaxTokens) +
+ WEIGHT_TURN_DEPTH * ratio(signals.stepIndex, signals.maxSteps) +
+ WEIGHT_TRANSIENT_NOTICE * (signals.hasTransientNotice ? 1 : 0) +
+ WEIGHT_TAIL_GROWTH *
+ ratio(
+ tailTokens,
+ signals.conversationMaxTokens / TAIL_BUDGET_FRACTION,
+ );
+ return Math.round(score);
+}
diff --git a/src/agent/routing/decide-routing-role.test.ts b/src/agent/routing/decide-routing-role.test.ts
new file mode 100644
index 00000000..a170ff50
--- /dev/null
+++ b/src/agent/routing/decide-routing-role.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ decideRoutingRole,
+ ROUTING_HYSTERESIS,
+} from "./decide-routing-role.js";
+
+describe("decideRoutingRole", () => {
+ it("keeps everything local at cloudShare 0, even a maximal score", () => {
+ expect(
+ decideRoutingRole({ score: 100, cloudShare: 0, stepIndex: 0 }),
+ ).toBe("executor");
+ });
+
+ it("sends everything to the cloud at cloudShare 100, even a zero score", () => {
+ expect(
+ decideRoutingRole({ score: 0, cloudShare: 100, stepIndex: 9 }),
+ ).toBe("orchestrator");
+ });
+
+ it("always orchestrates step 0 when the cloud leg is in play", () => {
+ expect(
+ decideRoutingRole({ score: 0, cloudShare: 1, stepIndex: 0 }),
+ ).toBe("orchestrator");
+ });
+
+ it("routes on the cutoff at 100 - cloudShare with no prior role", () => {
+ // cloudShare 40 ⇒ cutoff 60.
+ expect(
+ decideRoutingRole({ score: 60, cloudShare: 40, stepIndex: 1 }),
+ ).toBe("orchestrator");
+ expect(
+ decideRoutingRole({ score: 59, cloudShare: 40, stepIndex: 1 }),
+ ).toBe("executor");
+ });
+
+ it("makes it harder to leave the local leg", () => {
+ // cutoff 60, previously executor ⇒ effective bar 70.
+ const args = { cloudShare: 40, stepIndex: 1, previousRole: "executor" } as const;
+ expect(decideRoutingRole({ ...args, score: 69 })).toBe("executor");
+ expect(decideRoutingRole({ ...args, score: 70 })).toBe("orchestrator");
+ });
+
+ it("makes it harder to leave the cloud leg", () => {
+ // cutoff 60, previously orchestrator ⇒ effective bar 50.
+ const args = {
+ cloudShare: 40,
+ stepIndex: 1,
+ previousRole: "orchestrator",
+ } as const;
+ expect(decideRoutingRole({ ...args, score: 50 })).toBe("orchestrator");
+ expect(decideRoutingRole({ ...args, score: 49 })).toBe("executor");
+ });
+
+ it("applies the hysteresis symmetrically", () => {
+ expect(ROUTING_HYSTERESIS).toBe(10);
+ const score = 55;
+ expect(
+ decideRoutingRole({
+ score,
+ cloudShare: 40,
+ stepIndex: 1,
+ previousRole: "executor",
+ }),
+ ).toBe("executor");
+ expect(
+ decideRoutingRole({
+ score,
+ cloudShare: 40,
+ stepIndex: 1,
+ previousRole: "orchestrator",
+ }),
+ ).toBe("orchestrator");
+ });
+
+ it("treats a null previous role like no prior state", () => {
+ expect(
+ decideRoutingRole({
+ score: 60,
+ cloudShare: 40,
+ stepIndex: 1,
+ previousRole: null,
+ }),
+ ).toBe("orchestrator");
+ });
+});
diff --git a/src/agent/routing/decide-routing-role.ts b/src/agent/routing/decide-routing-role.ts
new file mode 100644
index 00000000..74e5fce7
--- /dev/null
+++ b/src/agent/routing/decide-routing-role.ts
@@ -0,0 +1,62 @@
+/**
+ * Which leg of a fusion pair serves one inference.
+ *
+ * `orchestrator` is the cloud provider (plans, reconciles, synthesises);
+ * `executor` is the local provider (mechanical continuation steps).
+ */
+export type RoutingRole = "orchestrator" | "executor";
+
+/**
+ * Score margin applied against the direction of travel so a step near
+ * the cutoff does not flip the provider back and forth.
+ *
+ * This is load-bearing, not cosmetic. llama-server reuses its KV cache
+ * by longest common prefix, so every return to the local leg after a
+ * cloud step has to reprocess the tail that grew in between. Hysteresis
+ * produces RUNS of consecutive local steps, which is what makes the
+ * local cache pay for itself.
+ */
+export const ROUTING_HYSTERESIS = 10;
+
+export interface RoutingDecisionArgs {
+ /** 0-100 from `computeStepComplexity`. */
+ score: number;
+ /** 0-100 operator dial from `llm.runMode.fusion.cloudShare`. */
+ cloudShare: number;
+ /** 0-based step index inside the turn. */
+ stepIndex: number;
+ /** Role the previous step of this session resolved to, if any. */
+ previousRole?: RoutingRole | null;
+}
+
+/**
+ * Map a complexity score onto a fusion leg.
+ *
+ * The dial sets a cutoff at `100 - cloudShare`: a bigger share means a
+ * lower bar for reaching the cloud. It is a DIAL, NOT A QUOTA — it does
+ * not promise that N% of steps go to the cloud, and it must not be
+ * turned into a running-counter scheduler, which would necessarily send
+ * some trivial steps to the cloud and keep some hard ones local.
+ *
+ * Two rules override the score:
+ * - `cloudShare` 0 / 100 short-circuit to pure local / pure cloud, so
+ * the extremes are exact rather than merely very likely.
+ * - Step 0 always orchestrates (when the cloud leg is in play at all):
+ * it forms the turn's plan and picks the first tool batch, which
+ * determines everything downstream. It is exactly one call per turn,
+ * so the cost is bounded and predictable.
+ */
+export function decideRoutingRole(args: RoutingDecisionArgs): RoutingRole {
+ if (args.cloudShare <= 0) return "executor";
+ if (args.cloudShare >= 100) return "orchestrator";
+ if (args.stepIndex === 0) return "orchestrator";
+
+ const cutoff = 100 - args.cloudShare;
+ const margin =
+ args.previousRole === "executor"
+ ? ROUTING_HYSTERESIS
+ : args.previousRole === "orchestrator"
+ ? -ROUTING_HYSTERESIS
+ : 0;
+ return args.score >= cutoff + margin ? "orchestrator" : "executor";
+}
diff --git a/src/agent/routing/index.ts b/src/agent/routing/index.ts
new file mode 100644
index 00000000..b5775b8e
--- /dev/null
+++ b/src/agent/routing/index.ts
@@ -0,0 +1,11 @@
+export { computeStepComplexity } from "./compute-step-complexity.js";
+export type { StepComplexitySignals } from "./compute-step-complexity.js";
+export { decideRoutingRole, ROUTING_HYSTERESIS } from "./decide-routing-role.js";
+export type { RoutingDecisionArgs, RoutingRole } from "./decide-routing-role.js";
+export { StepRouter } from "./step-router.js";
+export type {
+ FusionRoutingSnapshot,
+ RouteStepArgs,
+ StepRouterDeps,
+ StepRouting,
+} from "./step-router.js";
diff --git a/src/agent/routing/step-router.test.ts b/src/agent/routing/step-router.test.ts
new file mode 100644
index 00000000..8bda5e10
--- /dev/null
+++ b/src/agent/routing/step-router.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, it } from "vitest";
+
+import { StepRouter, type FusionRoutingSnapshot } from "./step-router.js";
+
+const FUSION: FusionRoutingSnapshot = {
+ cloudProviderId: "openrouter",
+ localProviderId: "local-llama",
+ cloudShare: 40,
+ subRunners: "local",
+ maxSteps: 25,
+ conversationMaxTokens: 32_000,
+};
+
+function router(snapshot: FusionRoutingSnapshot | null = FUSION): StepRouter {
+ return new StepRouter({ resolveFusion: () => snapshot });
+}
+
+/**
+ * Scores 55 with the FUSION snapshot: above the cutoff a prior cloud
+ * step lowers it to (50), below the bare cutoff (60). That band is
+ * exactly where hysteresis is observable.
+ */
+const MEDIUM = { promptTokens: 24_000, stablePrefixTokens: 4_000, stepIndex: 10 };
+/** Scores 68: above the bare cutoff, below the "came from local" bar (70). */
+const HEAVY = { promptTokens: 30_000, stablePrefixTokens: 4_000, stepIndex: 15 };
+
+const step = (over: Partial[0]> = {}) => ({
+ sessionId: "s1",
+ stepIndex: 1,
+ promptTokens: 1_000,
+ stablePrefixTokens: 900,
+ hasTransientNotice: false,
+ ...over,
+});
+
+describe("StepRouter", () => {
+ it("returns null when fusion is not the effective mode", () => {
+ expect(router(null).routeStep(step())).toBeNull();
+ });
+
+ it("routes step 0 to the cloud orchestrator", () => {
+ const routing = router().routeStep(step({ stepIndex: 0 }));
+ expect(routing).toMatchObject({
+ role: "orchestrator",
+ providerId: "openrouter",
+ cloudShare: 40,
+ });
+ });
+
+ it("routes a cheap continuation step to the local executor", () => {
+ const routing = router().routeStep(step());
+ expect(routing).toMatchObject({
+ role: "executor",
+ providerId: "local-llama",
+ });
+ });
+
+ it("escalates a heavy continuation step to the cloud", () => {
+ const routing = router().routeStep(
+ step({
+ stepIndex: 20,
+ promptTokens: 30_000,
+ stablePrefixTokens: 4_000,
+ hasTransientNotice: true,
+ }),
+ );
+ expect(routing?.role).toBe("orchestrator");
+ expect(routing?.complexity).toBeGreaterThanOrEqual(60);
+ });
+
+ it("re-reads the live snapshot on every step", () => {
+ let snapshot: FusionRoutingSnapshot = { ...FUSION, cloudShare: 0 };
+ const r = new StepRouter({ resolveFusion: () => snapshot });
+ expect(r.routeStep(step({ stepIndex: 0 }))?.role).toBe("executor");
+ snapshot = { ...FUSION, cloudShare: 100 };
+ expect(r.routeStep(step({ stepIndex: 1 }))?.role).toBe("orchestrator");
+ });
+
+ it("keeps hysteresis state per session", () => {
+ const r = router();
+ // Drive session A to the cloud (step 0 always orchestrates) and
+ // session B to local (a cheap continuation step).
+ expect(r.routeStep(step({ sessionId: "a", stepIndex: 0 }))?.role).toBe(
+ "orchestrator",
+ );
+ expect(r.routeStep(step({ sessionId: "b", stepIndex: 1 }))?.role).toBe(
+ "executor",
+ );
+ // Identical score, opposite prior roles ⇒ opposite decisions.
+ const a = r.routeStep(step({ sessionId: "a", ...HEAVY }));
+ const b = r.routeStep(step({ sessionId: "b", ...HEAVY }));
+ expect(a?.complexity).toBe(b?.complexity);
+ expect(a?.role).toBe("orchestrator");
+ expect(b?.role).toBe("executor");
+ });
+
+ it("forgets a session on request", () => {
+ const r = router();
+ // Same MEDIUM step decided twice: sticky to the cloud while the
+ // prior role survives, back to the bare cutoff once it is dropped.
+ r.routeStep(step({ sessionId: "a", stepIndex: 0 }));
+ expect(r.routeStep(step({ sessionId: "a", ...MEDIUM }))?.role).toBe(
+ "orchestrator",
+ );
+ r.forgetSession("a");
+ expect(r.routeStep(step({ sessionId: "a", ...MEDIUM }))?.role).toBe(
+ "executor",
+ );
+ });
+
+ it("drops hysteresis state when fusion is switched off", () => {
+ let snapshot: FusionRoutingSnapshot | null = FUSION;
+ const r = new StepRouter({ resolveFusion: () => snapshot });
+ r.routeStep(step({ stepIndex: 0 }));
+ snapshot = null;
+ expect(r.routeStep(step())).toBeNull();
+ snapshot = FUSION;
+ // The prior cloud role is gone, so the bare cutoff applies again.
+ expect(r.routeStep(step({ ...MEDIUM }))?.role).toBe("executor");
+ });
+
+ it("sends sub-runners to the local leg by default", () => {
+ expect(router().subRunnerProviderId("s1")).toBe("local-llama");
+ });
+
+ it("sends sub-runners to the cloud when configured", () => {
+ expect(
+ router({ ...FUSION, subRunners: "cloud" }).subRunnerProviderId("s1"),
+ ).toBe("openrouter");
+ });
+
+ it("follows the session's last main-loop leg when configured", () => {
+ const r = router({ ...FUSION, subRunners: "follow" });
+ r.routeStep(step({ sessionId: "s1", stepIndex: 0 }));
+ expect(r.subRunnerProviderId("s1")).toBe("openrouter");
+ r.forgetSession("s1");
+ expect(r.subRunnerProviderId("s1")).toBe("local-llama");
+ });
+
+ it("has no sub-runner opinion outside fusion", () => {
+ expect(router(null).subRunnerProviderId("s1")).toBeNull();
+ });
+});
diff --git a/src/agent/routing/step-router.ts b/src/agent/routing/step-router.ts
new file mode 100644
index 00000000..a2fb4d77
--- /dev/null
+++ b/src/agent/routing/step-router.ts
@@ -0,0 +1,140 @@
+import type { RunModeSubRunners } from "../../config/llm-run-mode-config.js";
+import { computeStepComplexity } from "./compute-step-complexity.js";
+import { decideRoutingRole, type RoutingRole } from "./decide-routing-role.js";
+
+/**
+ * Live fusion parameters. Re-read before every step so a mode switch or
+ * a dial change made in the TUI takes effect on the next inference
+ * rather than at the next process start — the same late-binding
+ * discipline `bootstrap` uses for `toolTransport` and slot affinity.
+ */
+export interface FusionRoutingSnapshot {
+ cloudProviderId: string;
+ localProviderId: string;
+ cloudShare: number;
+ subRunners: RunModeSubRunners;
+ maxSteps: number;
+ conversationMaxTokens: number;
+}
+
+export interface StepRouterDeps {
+ /** Returns `null` whenever the effective run mode is not fusion. */
+ resolveFusion: () => FusionRoutingSnapshot | null;
+}
+
+export interface RouteStepArgs {
+ sessionId: string;
+ stepIndex: number;
+ promptTokens: number;
+ stablePrefixTokens: number;
+ hasTransientNotice: boolean;
+}
+
+export interface StepRouting {
+ role: RoutingRole;
+ providerId: string;
+ complexity: number;
+ cloudShare: number;
+}
+
+/**
+ * Bound on the per-session role memory. Only exists so a long-lived
+ * `serve` process cannot accumulate one entry per session forever;
+ * eviction is oldest-first, and losing an entry costs nothing but one
+ * step of hysteresis.
+ */
+const MAX_TRACKED_SESSIONS = 256;
+
+/**
+ * Chooses the fusion leg for each inference.
+ *
+ * Deliberately thin: scoring and the cutoff rule live in the two pure
+ * modules beside it, so the only thing here is the per-session memory
+ * that hysteresis needs.
+ *
+ * Note what is NOT here: repair-retry stickiness. The parse-repair call
+ * in `step-executor` spreads the original `LlmStreamParams`, so it
+ * inherits `preferredProviderId` from the attempt it is repairing for
+ * free — which is exactly the required behaviour, since a repair must
+ * be judged by the model that made the mistake and against the same
+ * transport.
+ */
+export class StepRouter {
+ private readonly resolveFusion: StepRouterDeps["resolveFusion"];
+ private readonly lastRole = new Map();
+
+ constructor(deps: StepRouterDeps) {
+ this.resolveFusion = deps.resolveFusion;
+ }
+
+ /** `null` ⇒ not in fusion; the caller leaves provider selection alone. */
+ routeStep(args: RouteStepArgs): StepRouting | null {
+ const fusion = this.resolveFusion();
+ if (!fusion) {
+ this.lastRole.delete(args.sessionId);
+ return null;
+ }
+ const complexity = computeStepComplexity({
+ promptTokens: args.promptTokens,
+ stablePrefixTokens: args.stablePrefixTokens,
+ stepIndex: args.stepIndex,
+ hasTransientNotice: args.hasTransientNotice,
+ maxSteps: fusion.maxSteps,
+ conversationMaxTokens: fusion.conversationMaxTokens,
+ });
+ const role = decideRoutingRole({
+ score: complexity,
+ cloudShare: fusion.cloudShare,
+ stepIndex: args.stepIndex,
+ previousRole: this.lastRole.get(args.sessionId) ?? null,
+ });
+ this.remember(args.sessionId, role);
+ return {
+ role,
+ providerId:
+ role === "orchestrator"
+ ? fusion.cloudProviderId
+ : fusion.localProviderId,
+ complexity,
+ cloudShare: fusion.cloudShare,
+ };
+ }
+
+ /**
+ * Provider for a memory sub-runner (reflection, link generation,
+ * curation votes, query rewriting, distillation).
+ *
+ * `local` by default: these are cold-path structured-JSON jobs that
+ * ride the reserved reflection slot and are already KV-warm on the
+ * local server, so routing them to the cloud multiplies per-turn cost
+ * with no user-visible latency win. `follow` reuses the leg the last
+ * main-loop step of that session used.
+ */
+ subRunnerProviderId(sessionId?: string): string | null {
+ const fusion = this.resolveFusion();
+ if (!fusion) return null;
+ const target: RunModeSubRunners = fusion.subRunners;
+ if (target === "cloud") return fusion.cloudProviderId;
+ if (target === "local") return fusion.localProviderId;
+ const last = sessionId ? this.lastRole.get(sessionId) : undefined;
+ return last === "orchestrator"
+ ? fusion.cloudProviderId
+ : fusion.localProviderId;
+ }
+
+ /** Drop a finished session's hysteresis memory. */
+ forgetSession(sessionId: string): void {
+ this.lastRole.delete(sessionId);
+ }
+
+ private remember(sessionId: string, role: RoutingRole): void {
+ // Re-insert so the Map's insertion order doubles as recency.
+ this.lastRole.delete(sessionId);
+ this.lastRole.set(sessionId, role);
+ while (this.lastRole.size > MAX_TRACKED_SESSIONS) {
+ const oldest = this.lastRole.keys().next();
+ if (oldest.done === true) break;
+ this.lastRole.delete(oldest.value);
+ }
+ }
+}
diff --git a/src/agent/steer-notice.test.ts b/src/agent/steer-notice.test.ts
new file mode 100644
index 00000000..7c2682bc
--- /dev/null
+++ b/src/agent/steer-notice.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { composeSteerNotice, formatSteerNotice } from "./steer-notice.js";
+
+describe("formatSteerNotice", () => {
+ it("carries the message text verbatim", () => {
+ const out = formatSteerNotice(["stop and just summarise"]);
+ expect(out).toContain("stop and just summarise");
+ });
+
+ it("tells the model the message may cancel what it was doing", () => {
+ const out = formatSteerNotice(["never mind"]);
+ expect(out).toMatch(/change or cancel/);
+ });
+
+ it("pluralises when several arrived in one step", () => {
+ const out = formatSteerNotice(["one", "two"]);
+ expect(out).toContain("2 new messages");
+ expect(out).toContain("- one");
+ expect(out).toContain("- two");
+ });
+
+ it("clips a huge paste and points at the full copy in the transcript", () => {
+ const out = formatSteerNotice(["x".repeat(5000)]);
+ expect(out.length).toBeLessThan(1000);
+ expect(out).toContain("### conversation");
+ });
+
+ it("returns empty for no messages", () => {
+ expect(formatSteerNotice([])).toBe("");
+ });
+});
+
+describe("composeSteerNotice", () => {
+ it("keeps an existing loop-detector notice and appends the steer below it", () => {
+ const out = composeSteerNotice("### repeat detected: os.fs.read", ["stop"]);
+ expect(out).toContain("### repeat detected: os.fs.read");
+ expect(out).toContain("stop");
+ expect(out!.indexOf("repeat detected")).toBeLessThan(out!.indexOf("stop"));
+ });
+
+ it("passes the existing notice through untouched when nothing was steered", () => {
+ expect(composeSteerNotice("loop!", [])).toBe("loop!");
+ expect(composeSteerNotice(undefined, [])).toBeUndefined();
+ });
+
+ it("is just the steer block when there was no prior notice", () => {
+ const out = composeSteerNotice(undefined, ["go left"]);
+ expect(out).toBe(formatSteerNotice(["go left"]));
+ });
+});
diff --git a/src/agent/steer-notice.ts b/src/agent/steer-notice.ts
new file mode 100644
index 00000000..605b20da
--- /dev/null
+++ b/src/agent/steer-notice.ts
@@ -0,0 +1,52 @@
+/**
+ * Renders mid-turn user messages into the `### notice` block of the next
+ * step's prompt.
+ *
+ * The block is deliberately imperative and deliberately redundant: the
+ * same text also lands in `### conversation` as a real `user` turn (the
+ * transcript must not lie about what the operator said), but
+ * `### conversation` is a long scroll and the models this runtime
+ * targets are small. `### notice` sits immediately before
+ * `### respond`, which is the one place a 30B local model reliably
+ * reads, so the message is repeated there with an instruction attached.
+ */
+
+/**
+ * Per-message inline cap. A pasted stack trace should not evict the rest
+ * of the tail from the token budget — past this the model is pointed at
+ * the full copy in `### conversation`.
+ */
+const MAX_INLINE_CHARS = 600;
+
+/**
+ * Fold `messages` into an existing one-shot notice (the loop detector
+ * writes to the same slot). The loop-detector text comes first: it
+ * describes what the model just did wrong, which is context for how to
+ * act on the new instruction.
+ */
+export function composeSteerNotice(
+ existing: string | undefined,
+ messages: readonly string[],
+): string | undefined {
+ if (messages.length === 0) return existing;
+ const block = formatSteerNotice(messages);
+ if (existing === undefined || existing.length === 0) return block;
+ return `${existing}\n\n${block}`;
+}
+
+/** The steering block on its own, without the loop-detector prefix. */
+export function formatSteerNotice(messages: readonly string[]): string {
+ if (messages.length === 0) return "";
+ const header =
+ messages.length === 1
+ ? "The user sent a new message while you were working. Take it into account before your next action — it may change or cancel what you were doing:"
+ : `The user sent ${messages.length} new messages while you were working. Take them into account before your next action — they may change or cancel what you were doing:`;
+ const body = messages.map((m) => `- ${clip(m)}`).join("\n");
+ return `${header}\n${body}`;
+}
+
+function clip(text: string): string {
+ const flat = text.trim();
+ if (flat.length <= MAX_INLINE_CHARS) return flat;
+ return `${flat.slice(0, MAX_INLINE_CHARS)}… (full text is the last user turn in ### conversation)`;
+}
diff --git a/src/agent/step-events.ts b/src/agent/step-events.ts
index 9cbbcad0..98f43880 100644
--- a/src/agent/step-events.ts
+++ b/src/agent/step-events.ts
@@ -32,6 +32,21 @@ export interface PromptCapturedTokens {
*/
export type StepEvent =
| { type: "prompt_built"; prompt: BuiltPrompt; slotId: number }
+ /**
+ * Fusion routing picked a leg for this step. Emitted before the slot
+ * is acquired and only while fusion is the effective run mode, so its
+ * absence is the normal single-provider case rather than a gap.
+ */
+ | {
+ type: "step_routed";
+ stepIndex: number;
+ role: "orchestrator" | "executor";
+ providerId: string;
+ /** 0-100 score from `computeStepComplexity`. */
+ complexity: number;
+ /** The operator dial in force for this decision. */
+ cloudShare: number;
+ }
/**
* Trace-oriented sibling of `prompt_built`: carries the salted hash of
* the stable prefix (so per-step records stay small) together with the
diff --git a/src/agent/step-executor-routing.test.ts b/src/agent/step-executor-routing.test.ts
new file mode 100644
index 00000000..f888331b
--- /dev/null
+++ b/src/agent/step-executor-routing.test.ts
@@ -0,0 +1,198 @@
+import { describe, it, expect } from "vitest";
+import { join } from "node:path";
+
+import { executeStep } from "./step-executor.js";
+import type { LlmStreamParams, StepDependencies } from "./step-executor.js";
+import type { StepEvent } from "./step-events.js";
+import { StepRouter, type FusionRoutingSnapshot } from "./routing/index.js";
+import { ToolRegistry } from "../tools/tool-registry.js";
+import { compressToolResult } from "../compressor/result-compressor.js";
+import { SlotManager } from "../llm/slot-manager.js";
+import { PLAIN_INSTRUCT_PROFILE } from "../llm/model-profile.js";
+import { buildGrammar } from "../llm/grammar/build-grammar.js";
+import { createEmptySessionState } from "../session/session-state.js";
+import { DEFAULT_TOOL_DESCRIPTORS } from "../prompt/tool-descriptors.js";
+import type {
+ CapabilitiesSummary,
+ SkillCatalogEntry,
+} from "../prompt/stable-prefix.js";
+
+const CAPS: CapabilitiesSummary = {
+ platform: "darwin",
+ arch: "arm64",
+ browserChannel: "chrome",
+ workingDir: "/work",
+ hasClipboard: true,
+ hasWmctrl: false,
+ hasNotifications: true,
+};
+const SKILLS: SkillCatalogEntry[] = [];
+
+const FUSION: FusionRoutingSnapshot = {
+ cloudProviderId: "cloud",
+ localProviderId: "local",
+ cloudShare: 40,
+ subRunners: "local",
+ maxSteps: 25,
+ conversationMaxTokens: 32_000,
+};
+
+function replyRegistry(): ToolRegistry {
+ const registry = new ToolRegistry();
+ registry.register({
+ name: "reply",
+ description: "reply",
+ readonly: true,
+ async run(args: Record) {
+ return compressToolResult({
+ tool: "reply",
+ status: "ok",
+ output: String(args.text ?? ""),
+ });
+ },
+ });
+ return registry;
+}
+
+function completion(content: string) {
+ return {
+ content,
+ reasoningContent: "",
+ stop: true,
+ truncated: false,
+ timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 },
+ cacheHitTokens: 0,
+ slotId: 0,
+ modelId: "mock",
+ };
+}
+
+const REPLY_BODY = JSON.stringify({ tool: "reply", args: { text: "done" } });
+
+/**
+ * Run one step and report what the LLM seam actually received, plus the
+ * events the step emitted.
+ */
+async function runStep(opts: {
+ router?: StepRouter;
+ resolveSlotAffinity?: (providerId: string) => boolean;
+ supportsSlotAffinity?: boolean;
+ bodies?: string[];
+}): Promise<{ seen: LlmStreamParams[]; events: StepEvent[] }> {
+ const grammar = await buildGrammar(
+ PLAIN_INSTRUCT_PROFILE,
+ join(process.cwd(), "grammars"),
+ );
+ const seen: LlmStreamParams[] = [];
+ const events: StepEvent[] = [];
+ const bodies = opts.bodies ?? [REPLY_BODY];
+ let call = 0;
+
+ const deps = {
+ registry: replyRegistry(),
+ slotManager: new SlotManager(2),
+ llmComplete: async (params: LlmStreamParams) => {
+ seen.push(params);
+ const body = bodies[Math.min(call, bodies.length - 1)]!;
+ call += 1;
+ return completion(body);
+ },
+ grammar,
+ profile: PLAIN_INSTRUCT_PROFILE,
+ supportsSlotAffinity: opts.supportsSlotAffinity ?? false,
+ onEvent: (event: StepEvent) => events.push(event),
+ ...(opts.router ? { stepRouter: opts.router } : {}),
+ ...(opts.resolveSlotAffinity
+ ? { resolveSlotAffinity: opts.resolveSlotAffinity }
+ : {}),
+ } as unknown as StepDependencies;
+
+ await executeStep(
+ {
+ session: createEmptySessionState({ id: "s-route", workingDir: "/w" }),
+ toolDescriptors: DEFAULT_TOOL_DESCRIPTORS,
+ capabilities: CAPS,
+ skillCatalog: SKILLS,
+ stepIndex: 0,
+ signal: new AbortController().signal,
+ userMessage: "x",
+ },
+ deps,
+ );
+ return { seen, events };
+}
+
+const routerWith = (over: Partial = {}): StepRouter =>
+ new StepRouter({ resolveFusion: () => ({ ...FUSION, ...over }) });
+
+describe("executeStep fusion routing", () => {
+ it("sets no preferredProviderId when no router is wired", async () => {
+ const { seen, events } = await runStep({});
+ expect(seen).toHaveLength(1);
+ expect(seen[0]).not.toHaveProperty("preferredProviderId");
+ expect(events.some((e) => e.type === "step_routed")).toBe(false);
+ });
+
+ it("sets no preferredProviderId when the router declines (not fusion)", async () => {
+ const router = new StepRouter({ resolveFusion: () => null });
+ const { seen, events } = await runStep({ router });
+ expect(seen[0]).not.toHaveProperty("preferredProviderId");
+ expect(events.some((e) => e.type === "step_routed")).toBe(false);
+ });
+
+ it("forwards the routed provider to the LLM seam", async () => {
+ // Step 0 always orchestrates ⇒ the cloud leg.
+ const { seen } = await runStep({ router: routerWith() });
+ expect(seen[0]?.preferredProviderId).toBe("cloud");
+ });
+
+ it("forwards the local leg when the dial is fully local", async () => {
+ const { seen } = await runStep({ router: routerWith({ cloudShare: 0 }) });
+ expect(seen[0]?.preferredProviderId).toBe("local");
+ });
+
+ it("emits step_routed describing the decision", async () => {
+ const { events } = await runStep({ router: routerWith() });
+ const routed = events.find((e) => e.type === "step_routed");
+ expect(routed).toMatchObject({
+ type: "step_routed",
+ stepIndex: 0,
+ role: "orchestrator",
+ providerId: "cloud",
+ cloudShare: 40,
+ });
+ });
+
+ it("acquires a real slot when the ROUTED provider has slot affinity", async () => {
+ // The active provider reports no affinity (cloud), but the step is
+ // routed to the local leg, which does. Without `resolveSlotAffinity`
+ // this would run at slotId -1 and reprocess the whole prompt.
+ const { seen } = await runStep({
+ router: routerWith({ cloudShare: 0 }),
+ supportsSlotAffinity: false,
+ resolveSlotAffinity: (id) => id === "local",
+ });
+ expect(seen[0]?.slotId).toBeGreaterThanOrEqual(0);
+ });
+
+ it("drops to slotId -1 when the routed provider has no slot affinity", async () => {
+ const { seen } = await runStep({
+ router: routerWith({ cloudShare: 100 }),
+ supportsSlotAffinity: true,
+ resolveSlotAffinity: (id) => id === "local",
+ });
+ expect(seen[0]?.preferredProviderId).toBe("cloud");
+ expect(seen[0]?.slotId).toBe(-1);
+ });
+
+ it("repairs on the same leg that produced the malformed call", async () => {
+ const { seen } = await runStep({
+ router: routerWith({ cloudShare: 0 }),
+ bodies: ["not json at all", REPLY_BODY],
+ });
+ expect(seen.length).toBeGreaterThanOrEqual(2);
+ // A repair judged by the OTHER leg would parse a different model's
+ // mistake against a different transport.
+ expect(seen[1]?.preferredProviderId).toBe("local");
+ });
+});
diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts
index 321f6066..0854d4b1 100644
--- a/src/agent/step-executor.ts
+++ b/src/agent/step-executor.ts
@@ -84,6 +84,7 @@ import type { ProfileFact } from "../memory/profile-store.js";
import type { AgentMetrics } from "../tracing/agent-metrics.js";
import type { StructuredLogger } from "../tracing/structured-logger.js";
import type { StepEvent } from "./step-events.js";
+import type { StepRouter } from "./routing/index.js";
export type { PromptCapturedTokens, StepEvent } from "./step-events.js";
export interface LlmStreamParams {
@@ -119,6 +120,17 @@ export interface LlmStreamParams {
* instead of waiting for the current step to finish on its own.
*/
signal?: AbortSignal;
+ /**
+ * Fusion routing: the provider id this call should START at. Only the
+ * starting link changes — the fallback chain still owns health, so a
+ * preferred provider in cooldown is ignored and a failure still
+ * advances through the chain.
+ *
+ * Deliberately a provider id rather than a role: the seam stays
+ * ignorant of run modes, and the policy that picked the leg lives in
+ * `src/agent/routing/`. Absent ⇒ today's behaviour, unchanged.
+ */
+ preferredProviderId?: string;
}
export type LlmCompleteStream = (
@@ -145,6 +157,23 @@ export interface StepDependencies {
toolCallAdapter: ToolCallAdapter | null;
/** When false, completions use slotId -1 (cloud providers). */
supportsSlotAffinity: boolean;
+ /**
+ * Fusion step router. When present and fusion is the effective run
+ * mode, it picks the leg for each step; absent (or returning null) ⇒
+ * provider selection is left entirely to the fallback chain, i.e.
+ * today's behaviour.
+ */
+ stepRouter?: StepRouter;
+ /**
+ * Slot affinity for a SPECIFIC provider, used when `stepRouter` routes
+ * a step away from the active provider.
+ *
+ * Without this, fusion would read `supportsSlotAffinity` off the
+ * active (cloud) provider and run every locally-routed step with
+ * `slotId: -1` and `cachePrompt: false` — forcing llama-server to
+ * reprocess the whole prompt on each one.
+ */
+ resolveSlotAffinity?: (providerId: string) => boolean;
/**
* Invoked after every LLM completion (initial call and one-shot parse
* retry alike). Used by the agent loop to feed the served `modelId`
@@ -302,7 +331,33 @@ async function executeStepInner(
? { userMessage: ctx.userMessage }
: {}),
});
- const slot = deps.supportsSlotAffinity
+ // Route BEFORE acquiring a slot: which provider serves this step
+ // decides whether a slot is worth acquiring at all. That ordering is
+ // also why the complexity score cannot use `cacheReused` — it does
+ // not exist yet, and making it an input would be circular.
+ const routing =
+ deps.stepRouter?.routeStep({
+ sessionId: ctx.session.id,
+ stepIndex: ctx.stepIndex,
+ promptTokens: prompt.tokens.total,
+ stablePrefixTokens: prompt.tokens.stablePrefix,
+ hasTransientNotice: ctx.transientNotice !== undefined,
+ }) ?? null;
+ if (routing) {
+ deps.onEvent?.({
+ type: "step_routed",
+ stepIndex: ctx.stepIndex,
+ role: routing.role,
+ providerId: routing.providerId,
+ complexity: routing.complexity,
+ cloudShare: routing.cloudShare,
+ });
+ }
+ const slotAffinity = routing
+ ? (deps.resolveSlotAffinity?.(routing.providerId) ??
+ deps.supportsSlotAffinity)
+ : deps.supportsSlotAffinity;
+ const slot = slotAffinity
? deps.slotManager.acquire(ctx.session.id, prompt.stablePrefix)
: {
slotId: -1,
@@ -348,6 +403,7 @@ async function executeStepInner(
sessionId: ctx.session.id,
toolDescriptors: ctx.toolDescriptors,
signal: ctx.signal,
+ ...(routing ? { preferredProviderId: routing.providerId } : {}),
});
const firstAttempt = await runInitialCompletion({
@@ -1118,6 +1174,7 @@ function buildLlmStreamParams(args: {
sessionId: string;
toolDescriptors: readonly ToolDescriptor[];
signal?: AbortSignal;
+ preferredProviderId?: string;
}): LlmStreamParams {
const base: LlmStreamParams = {
prompt: args.promptText,
@@ -1125,6 +1182,9 @@ function buildLlmStreamParams(args: {
slotId: args.slotId,
sessionId: args.sessionId,
...(args.signal ? { signal: args.signal } : {}),
+ ...(args.preferredProviderId
+ ? { preferredProviderId: args.preferredProviderId }
+ : {}),
};
if (args.deps.toolTransport !== "native_tools") {
return base;
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 22c11fd5..b0cb0510 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -97,7 +97,7 @@ const COMMANDS: CommandDescriptor[] = [
{
name: "models",
summary:
- "Manage the local-LLM runtime + GGUF models (list|pull|use|status|start|stop|update|remove)",
+ "Manage the local-LLM runtime + GGUF models (list|pull|use|status|...) and search cloud models (search)",
run: modelsCommand,
},
{
diff --git a/src/cli/models-command.ts b/src/cli/models-command.ts
index 7d25b8c7..f1258172 100644
--- a/src/cli/models-command.ts
+++ b/src/cli/models-command.ts
@@ -14,6 +14,7 @@ import {
runLocalModelsUseDevice,
runLocalModelsUseEmbedding,
} from "./models-handlers.js";
+import { runModelsSearch } from "./models-search-command.js";
const HELP =
[
@@ -32,6 +33,12 @@ const HELP =
" (stops daemon first; does not auto-restart)",
" remove Delete a downloaded model (refuses if active + daemon running)",
"",
+ "Cloud subcommands (no local runtime needed):",
+ " search Search configured cloud providers' models by id,",
+ " vendor and capability (`claude vision`, `free tools`,",
+ " `1m cache`). Flags: --provider --limit ",
+ " --json --refresh (pull live lists first)",
+ "",
"GPU subcommands:",
" devices List GPU devices (llama-server --list-devices); active marked with *",
" use-device Set the managed daemon's GPU (auto-picks best discrete by default)",
@@ -45,6 +52,7 @@ const HELP =
"",
"Examples:",
" atomic-agent models list",
+ " atomic-agent models search claude vision",
" atomic-agent models pull qwen-3.5-4b",
" atomic-agent models use qwen-3.5-4b",
" atomic-agent models pull-embedding nomic-embed-text-v1.5",
@@ -63,6 +71,8 @@ export async function modelsCommand(args: string[]): Promise {
}
try {
switch (sub) {
+ case "search":
+ return await runModelsSearch(args.slice(1));
case "list":
return runLocalModelsList();
case "pull":
diff --git a/src/cli/models-search-command.test.ts b/src/cli/models-search-command.test.ts
new file mode 100644
index 00000000..ae239017
--- /dev/null
+++ b/src/cli/models-search-command.test.ts
@@ -0,0 +1,209 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getUserConfigPath } from "../config/config-file.js";
+import { resetConfigCache } from "../config/index.js";
+import { USER_CONFIG_VERSION } from "../config/config-schema.js";
+
+import {
+ collectHits,
+ parseModelsSearchArgs,
+ runModelsSearch,
+} from "./models-search-command.js";
+
+describe("parseModelsSearchArgs", () => {
+ it("joins bare words into one query and reads the flags", () => {
+ const parsed = parseModelsSearchArgs([
+ "claude",
+ "vision",
+ "--limit",
+ "5",
+ "--json",
+ "--provider",
+ "or",
+ ]);
+ expect(parsed).toEqual({
+ query: "claude vision",
+ provider: "or",
+ limit: 5,
+ json: true,
+ refresh: false,
+ });
+ });
+
+ it("rejects a non-positive limit and unknown flags", () => {
+ expect(() => parseModelsSearchArgs(["x", "--limit", "0"])).toThrow(/--limit/);
+ expect(() => parseModelsSearchArgs(["x", "--nope"])).toThrow(/unknown flag/);
+ });
+});
+
+describe("runModelsSearch", () => {
+ let stateDir: string;
+ let out: string[];
+ let err: string[];
+
+ function writeConfig(): void {
+ writeFileSync(
+ getUserConfigPath(stateDir),
+ JSON.stringify({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "or",
+ activeEmbeddingProvider: "or",
+ toolTransport: "auto",
+ providers: [
+ { id: "or", kind: "openrouter", defaultChatModel: "openrouter/auto" },
+ {
+ id: "vllm",
+ kind: "openai-compatible",
+ baseUrl: "http://127.0.0.1:8000",
+ defaultChatModel: "local/mistral",
+ },
+ ],
+ },
+ }),
+ "utf8",
+ );
+ resetConfigCache();
+ }
+
+ beforeEach(() => {
+ stateDir = mkdtempSync(join(tmpdir(), "atomic-models-search-"));
+ process.env.ATOMIC_AGENT_STATE_DIR = stateDir;
+ resetConfigCache();
+ out = [];
+ err = [];
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
+ out.push(String(chunk));
+ return true;
+ });
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
+ err.push(String(chunk));
+ return true;
+ });
+ });
+
+ afterEach(() => {
+ rmSync(stateDir, { recursive: true, force: true });
+ delete process.env.ATOMIC_AGENT_STATE_DIR;
+ resetConfigCache();
+ vi.restoreAllMocks();
+ });
+
+ it("finds catalog models by id and prints provider, context, price and caps", async () => {
+ writeConfig();
+ const code = await runModelsSearch(["qwen"]);
+ expect(code).toBe(0);
+ expect(out.join("")).toMatch(/^or\s+qwen\//m);
+ expect(out.join("")).toMatch(/tools/);
+ });
+
+ it("ANDs terms across id and capability tags", async () => {
+ writeConfig();
+ // The old TUI filter answered this with nothing: "qwen vision" is not
+ // a substring of any id.
+ expect(await runModelsSearch(["qwen", "vision", "--json"])).toBe(0);
+ const rows = JSON.parse(out.join("")) as {
+ id: string;
+ supportsVision: boolean;
+ }[];
+ expect(rows.length).toBeGreaterThan(0);
+ for (const row of rows) {
+ expect(row.id).toMatch(/qwen/);
+ expect(row.supportsVision).toBe(true);
+ }
+ });
+
+ it("includes models an entry carries under userModels", async () => {
+ // Read straight off the entry: `parseLlmProviderEntry` currently
+ // drops `userModels` on the way out of config.json, so this path
+ // cannot be reached through a config fixture.
+ const hits = await collectHits(
+ [
+ {
+ id: "vllm",
+ kind: "openai-compatible",
+ baseUrl: "http://127.0.0.1:8000",
+ userModels: [
+ {
+ id: "local/mistral",
+ kind: "chat",
+ contextWindow: 32_000,
+ },
+ ],
+ },
+ ],
+ false,
+ );
+ expect(hits).toEqual([{ providerId: "vllm", id: "local/mistral" }]);
+ });
+
+ it("narrows to one provider entry and caps the result count", async () => {
+ writeConfig();
+ // `vllm` ships no bundled catalog, so restricting to it finds nothing
+ // to search rather than silently falling back to the other provider.
+ expect(await runModelsSearch(["--provider", "vllm", "qwen"])).toBe(1);
+ expect(err.join("")).toMatch(/no searchable cloud models/);
+
+ out.length = 0;
+ expect(await runModelsSearch(["qwen", "--limit", "1"])).toBe(0);
+ expect(out.join("").trimEnd().split("\n")).toHaveLength(1);
+ });
+
+ it("exits 1 with one line — never a stack trace — when nothing matches", async () => {
+ writeConfig();
+ expect(await runModelsSearch(["definitely-not-a-model"])).toBe(1);
+ expect(out.join("")).toBe("");
+ expect(err.join("")).toMatch(/no model matches/);
+ });
+
+ it("exits 1 on a missing query or an unknown provider id", async () => {
+ writeConfig();
+ expect(await runModelsSearch([])).toBe(1);
+ expect(err.join("")).toMatch(/expects a query/);
+
+ err.length = 0;
+ expect(await runModelsSearch(["--provider", "nope", "qwen"])).toBe(1);
+ expect(err.join("")).toMatch(/no configured provider/);
+ });
+
+ it("says so instead of printing nothing when no provider ships a catalog", async () => {
+ // Default config: one local llama-server entry, no cloud catalog.
+ expect(await runModelsSearch(["qwen"])).toBe(1);
+ expect(err.join("")).toMatch(/no searchable cloud models/);
+ });
+
+ // Last in the file on purpose: a live refresh writes the fetcher's
+ // module-global pick cache, which outlives this test.
+ it("--refresh searches the live catalog, not just the bundled snapshot", async () => {
+ writeConfig();
+ expect(await runModelsSearch(["brand-new-model"])).toBe(1);
+
+ out.length = 0;
+ err.length = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ id: "vendor/brand-new-model",
+ name: "Brand New",
+ context_length: 256_000,
+ pricing: { prompt: "0.000001", completion: "0.000004" },
+ supported_parameters: ["tools"],
+ architecture: { input_modalities: ["text"] },
+ },
+ ],
+ }),
+ })),
+ );
+ expect(await runModelsSearch(["brand-new-model", "--refresh"])).toBe(0);
+ expect(out.join("")).toContain("vendor/brand-new-model");
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/src/cli/models-search-command.ts b/src/cli/models-search-command.ts
new file mode 100644
index 00000000..46bffca4
--- /dev/null
+++ b/src/cli/models-search-command.ts
@@ -0,0 +1,228 @@
+import { getConfig } from "../config/index.js";
+import { catalogForProvider } from "../llm/provider/catalog-for-provider.js";
+import {
+ formatCapabilitySummary,
+ formatContextWindow,
+ formatTokenPrice,
+} from "../llm/provider/format-model-details.js";
+import type { ModelCatalogEntry } from "../llm/provider/model-resolver.js";
+import { searchModels } from "../llm/provider/model-search.js";
+import { fetchOpenAiCompatModels } from "../llm/provider/openai/fetch-openai-compat-models.js";
+import {
+ listAimlapiChatPicks,
+ refreshAimlapiChatCatalogFromApi,
+} from "../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js";
+import {
+ listOpenRouterChatPicks,
+ refreshOpenRouterChatCatalogFromApi,
+} from "../llm/provider/openrouter/fetch-openrouter-chat-catalog.js";
+import { resolveLlmConfig } from "../llm/provider/registry/provider-types.js";
+import type { LlmProviderConfigEntry } from "../llm/provider/registry/provider-types.js";
+
+/**
+ * `atomic-agent models search ` — the cloud half of `models`.
+ *
+ * The rest of this command group manages local GGUF weights. Cloud
+ * models were only ever searchable from inside the TUI, which is no
+ * help when picking a `defaultChatModel` for a config file or checking
+ * what a provider charges. Same scorer as the TUI picker
+ * (`searchModels`), same rendering (`format-model-details`), so a query
+ * that works in one surface works in the other.
+ */
+
+export type ModelSearchHit = {
+ providerId: string;
+ id: string;
+ entry?: ModelCatalogEntry | undefined;
+};
+
+export type ModelsSearchOptions = {
+ query: string;
+ provider: string | null;
+ limit: number;
+ json: boolean;
+ refresh: boolean;
+};
+
+const DEFAULT_LIMIT = 30;
+
+export function parseModelsSearchArgs(args: readonly string[]): ModelsSearchOptions {
+ const terms: string[] = [];
+ let provider: string | null = null;
+ let limit = DEFAULT_LIMIT;
+ let json = false;
+ let refresh = false;
+ for (let i = 0; i < args.length; i += 1) {
+ const arg = args[i]!;
+ if (arg === "--json") json = true;
+ else if (arg === "--refresh") refresh = true;
+ else if (arg === "--provider") provider = args[++i] ?? null;
+ else if (arg === "--limit") {
+ const raw = Number.parseInt(args[++i] ?? "", 10);
+ if (!Number.isFinite(raw) || raw <= 0) {
+ throw new Error("--limit expects a positive integer");
+ }
+ limit = raw;
+ } else if (arg.startsWith("--")) {
+ throw new Error(`unknown flag: ${arg}`);
+ } else terms.push(arg);
+ }
+ if (provider !== null && provider.length === 0) {
+ throw new Error("--provider expects a provider id");
+ }
+ return { query: terms.join(" "), provider, limit, json, refresh };
+}
+
+/**
+ * Every model this machine could reach, tagged with the provider entry
+ * it came from: the bundled catalog for curated kinds, plus whatever the
+ * entry carries under `userModels`.
+ *
+ * Note that `userModels` cannot currently arrive from `config.json` —
+ * `parseLlmProviderEntry` drops the field even though the schema, the
+ * `LlmProviderConfigEntry` type and `resolveModel` all support it. This
+ * reads whatever the entry actually holds rather than assuming the
+ * config parser is the only way one gets populated.
+ */
+export async function collectHits(
+ entries: readonly LlmProviderConfigEntry[],
+ refresh: boolean,
+): Promise {
+ const hits: ModelSearchHit[] = [];
+ for (const entry of entries) {
+ if (refresh) await refreshCatalog(entry);
+ const seen = new Set();
+ const add = (id: string, catalogEntry?: ModelCatalogEntry): void => {
+ if (seen.has(id)) return;
+ seen.add(id);
+ hits.push({ providerId: entry.id, id, entry: catalogEntry });
+ };
+ // Bundled snapshot first: it is curated, ordered, and the only
+ // source that carries embedding rows.
+ for (const [id, catalogEntry] of catalogForProvider(entry)) add(id, catalogEntry);
+ // Then whatever the live picker cache holds. `listXChatPicks` falls
+ // back to the same snapshot when nothing has been fetched, so this
+ // only ever adds ids — after `--refresh` it is the fresh catalog.
+ for (const pick of livePicks(entry)) add(pick.id, pick.entry);
+ for (const model of entry.userModels ?? []) add(model.id);
+ if (refresh) for (const id of await liveCompatModels(entry)) add(id);
+ }
+ return hits;
+}
+
+/**
+ * A live refresh writes into each fetcher's module cache, which is what
+ * `catalogForProvider` reads through for curated kinds. Failures are
+ * silent on purpose: the bundled snapshot is still a useful answer, and
+ * a search should not fail because a vendor endpoint is down.
+ */
+async function refreshCatalog(entry: LlmProviderConfigEntry): Promise {
+ try {
+ if (entry.kind === "openrouter") await refreshOpenRouterChatCatalogFromApi();
+ else if (entry.kind === "aimlapi") await refreshAimlapiChatCatalogFromApi();
+ } catch {
+ /* keep the bundled snapshot */
+ }
+}
+
+function livePicks(
+ entry: LlmProviderConfigEntry,
+): readonly { id: string; entry: ModelCatalogEntry }[] {
+ if (entry.kind === "openrouter") return listOpenRouterChatPicks();
+ if (entry.kind === "aimlapi") return listAimlapiChatPicks();
+ return [];
+}
+
+async function liveCompatModels(
+ entry: LlmProviderConfigEntry,
+): Promise {
+ if (!entry.baseUrl) return [];
+ if (entry.kind !== "openai-compatible" && entry.kind !== "qwen-openai-compatible") {
+ return [];
+ }
+ try {
+ return await fetchOpenAiCompatModels(entry.baseUrl, entry.apiKey);
+ } catch {
+ return [];
+ }
+}
+
+function formatHit(hit: ModelSearchHit): string {
+ const entry = hit.entry;
+ const details = entry
+ ? [
+ formatContextWindow(entry.contextWindow),
+ formatTokenPrice(hit.id, entry.pricing),
+ formatCapabilitySummary(entry),
+ ].join(" · ")
+ : "metadata unavailable";
+ return `${hit.providerId.padEnd(14)} ${hit.id.padEnd(42)} ${details}`;
+}
+
+export async function runModelsSearch(args: readonly string[]): Promise {
+ let options: ModelsSearchOptions;
+ try {
+ options = parseModelsSearchArgs(args);
+ } catch (err) {
+ process.stderr.write(`${(err as Error).message}\n`);
+ return 1;
+ }
+ if (options.query.length === 0) {
+ process.stderr.write(
+ "models search expects a query, e.g. `models search claude vision`\n",
+ );
+ return 1;
+ }
+
+ const resolved = resolveLlmConfig(getConfig());
+ const entries = resolved.providers.filter((entry) =>
+ options.provider === null ? true : entry.id === options.provider,
+ );
+ if (options.provider !== null && entries.length === 0) {
+ process.stderr.write(`no configured provider with id "${options.provider}"\n`);
+ return 1;
+ }
+
+ const hits = await collectHits(entries, options.refresh);
+ if (hits.length === 0) {
+ process.stderr.write(
+ "no searchable cloud models: the configured providers ship no catalog. " +
+ "Add an openrouter or aimlapi provider, or re-run with --refresh to " +
+ "pull a live /v1/models list.\n",
+ );
+ return 1;
+ }
+
+ const matches = searchModels(hits, options.query).slice(0, options.limit);
+ if (matches.length === 0) {
+ process.stderr.write(`no model matches ${JSON.stringify(options.query)}\n`);
+ return 1;
+ }
+
+ if (options.json) {
+ process.stdout.write(
+ `${JSON.stringify(
+ matches.map((hit) => ({
+ provider: hit.providerId,
+ id: hit.id,
+ ...(hit.entry
+ ? {
+ kind: hit.entry.kind,
+ contextWindow: hit.entry.contextWindow,
+ supportsVision: hit.entry.supportsVision,
+ supportsTools: hit.entry.supportsTools,
+ supportsPromptCache: hit.entry.supportsPromptCache,
+ ...(hit.entry.pricing ? { pricing: hit.entry.pricing } : {}),
+ }
+ : {}),
+ })),
+ null,
+ 2,
+ )}\n`,
+ );
+ return 0;
+ }
+
+ process.stdout.write(`${matches.map(formatHit).join("\n")}\n`);
+ return 0;
+}
diff --git a/src/cli/serve-command.ts b/src/cli/serve-command.ts
index 5ea3ca66..da31dd8a 100644
--- a/src/cli/serve-command.ts
+++ b/src/cli/serve-command.ts
@@ -47,6 +47,7 @@ const HELP =
" GET /api/skills, GET /api/skills/{name} List or inspect installed skills",
" POST /api/skills/install, /uninstall Manage installed skills",
" GET /api/sessions, GET /api/sessions/{id}, DELETE /api/sessions/{id}",
+ " POST /api/sessions/{id}/steer Fold a message into the turn already running",
" POST /api/approval/resolve Resolve a pending approval",
" GET /api/events SSE stream of pending approval requests",
].join("\n") + "\n";
diff --git a/src/config/config-file.test.ts b/src/config/config-file.test.ts
index fcb676ee..ed1b6a75 100644
--- a/src/config/config-file.test.ts
+++ b/src/config/config-file.test.ts
@@ -11,6 +11,7 @@ import {
} from "./config-file.js";
import {
ConfigValidationError,
+ parseUserConfigFile,
USER_CONFIG_DEFAULTS,
USER_CONFIG_VERSION,
} from "./config-schema.js";
@@ -554,3 +555,119 @@ describe("user config file IO", () => {
warn.mockRestore();
});
});
+
+describe("a config written by a newer build", () => {
+ // Regression: two builds share one `~/.atomic-agent/config.json`. The
+ // 0.3.0 build bumped it to v38 and the installed v0.2.2 release then
+ // died on every single command with "unsupported config version 38".
+ // An additive schema has no reason to make version skew fatal.
+ it("is read, not refused", () => {
+ const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
+ const path = join(dir, "config.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ version: USER_CONFIG_VERSION + 5,
+ localModels: { url: "http://127.0.0.1:9999" },
+ somethingFromTheFuture: { enabled: true },
+ }),
+ "utf8",
+ );
+ const parsed = ensureUserConfigFileSync(path);
+ expect(parsed.localModels.url).toBe("http://127.0.0.1:9999");
+ });
+
+ it("is never rewritten back down to this build's version", () => {
+ const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
+ const path = join(dir, "config.json");
+ const future = {
+ version: USER_CONFIG_VERSION + 5,
+ localModels: { url: "http://127.0.0.1:9999" },
+ somethingFromTheFuture: { enabled: true },
+ };
+ writeFileSync(path, JSON.stringify(future), "utf8");
+ ensureUserConfigFileSync(path);
+ const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record<
+ string,
+ unknown
+ >;
+ expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5);
+ expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true });
+ });
+
+ it("keeps its keys and its version when an older build persists a setting", () => {
+ const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
+ const path = join(dir, "config.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ version: USER_CONFIG_VERSION + 5,
+ localModels: { url: "http://127.0.0.1:9999" },
+ somethingFromTheFuture: { enabled: true },
+ }),
+ "utf8",
+ );
+ // Exactly what every `persist-*` helper does: read → merge →
+ // validate → write. Before the passthrough this is where the newer
+ // build's keys died and `version` was stamped back down — the loop
+ // the permissive read prevents was only postponed to here.
+ const prev = ensureUserConfigFileSync(path);
+ writeUserConfigFileSync(
+ path,
+ parseUserConfigFile({ ...prev, log: { level: "debug" } }),
+ );
+
+ const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record<
+ string,
+ unknown
+ >;
+ expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true });
+ expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5);
+ expect((onDisk.log as { level: string }).level).toBe("debug");
+ });
+
+ it("keeps its keys through a whole-file replacement that never saw them", () => {
+ const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-"));
+ const path = join(dir, "config.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ version: USER_CONFIG_VERSION + 5,
+ localModels: { url: "http://127.0.0.1:9999" },
+ somethingFromTheFuture: { enabled: true },
+ }),
+ "utf8",
+ );
+ // `config set` and `PATCH /api/config` validate an operator payload
+ // and replace the file with it. That payload never held the newer
+ // build's keys, so the write path is all that stands between them
+ // and deletion.
+ writeUserConfigFileSync(
+ path,
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ log: { level: "warn" },
+ }),
+ );
+
+ const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record<
+ string,
+ unknown
+ >;
+ expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true });
+ expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5);
+ expect((onDisk.log as { level: string }).level).toBe("warn");
+ // A key this build owns is still the payload's to set: preserving
+ // foreign keys never shadows a parsed value.
+ expect((onDisk.localModels as { url: string }).url).toBe(
+ USER_CONFIG_DEFAULTS.localModels.url,
+ );
+ });
+
+ it("still refuses a version older than the oldest supported one", () => {
+ const dir = mkdtempSync(join(tmpdir(), "atomic-old-config-"));
+ const path = join(dir, "config.json");
+ writeFileSync(path, JSON.stringify({ version: 2 }), "utf8");
+ expect(() => ensureUserConfigFileSync(path)).toThrow(/unsupported config version/);
+ });
+});
diff --git a/src/config/config-file.ts b/src/config/config-file.ts
index 197db5dc..3af48e76 100644
--- a/src/config/config-file.ts
+++ b/src/config/config-file.ts
@@ -8,6 +8,7 @@ import {
import { dirname, join } from "node:path";
import {
+ collectUnknownUserConfigKeys,
ConfigValidationError,
ENV_DEFAULTS,
parseUserConfigFile,
@@ -60,15 +61,65 @@ export function readUserConfigFileSync(path: string): UserConfigFile | null {
/**
* Atomically write the user config file: tmp file + rename. Creates
* the parent directory as needed.
+ *
+ * Top-level keys this build has no parser for are written back rather
+ * than dropped, and a `version` already on disk that is *newer* than
+ * this build's is left standing. That is the other half of the
+ * permissive read in `parseUserConfigFile`: without it a shared
+ * `config.json` survives being read by an older build and then loses the
+ * newer build's keys on the first `config set`.
+ *
+ * The keys come from two places, and both are needed. The carrier
+ * `parseUserConfigFile` leaves on the object covers the read → merge →
+ * validate → write helpers (`persist-*`, `models`, telegram settings),
+ * which spread the file they just parsed. The file on disk covers the
+ * whole-payload replacements — `config set`, `PATCH /api/config` — whose
+ * payload never saw those keys at all. A key this build owns always
+ * wins, so a preserved key can never shadow a parsed value.
*/
export function writeUserConfigFileSync(path: string, data: UserConfigFile): void {
mkdirSync(dirname(path), { recursive: true });
- const payload = JSON.stringify(data, null, 2) + "\n";
+ const payload = JSON.stringify(withForeignKeys(path, data), null, 2) + "\n";
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, payload, "utf8");
renameSync(tmp, path);
}
+/**
+ * Merge what this build does not own — unknown top-level keys, and a
+ * newer `version` — back into the payload about to be written. The file
+ * on disk is read best-effort: unreadable or malformed content carries
+ * nothing forward, which is the status quo, and it is about to be
+ * replaced anyway. Where the carrier and the file hold the same foreign
+ * key the file wins; it is the fresher copy of a value neither this
+ * build nor the caller owns.
+ */
+function withForeignKeys(
+ path: string,
+ data: UserConfigFile,
+): Record {
+ let onDisk: unknown = null;
+ try {
+ if (existsSync(path)) onDisk = JSON.parse(readFileSync(path, "utf8"));
+ } catch {
+ onDisk = null;
+ }
+ const preserved = {
+ ...collectUnknownUserConfigKeys(data),
+ ...collectUnknownUserConfigKeys(onDisk),
+ };
+ const out: Record = { ...data };
+ for (const [key, value] of Object.entries(preserved)) {
+ if (key in out) continue;
+ out[key] = value;
+ }
+ const diskVersion = readVersionField(onDisk);
+ if (diskVersion !== null && diskVersion > data.version) {
+ out.version = diskVersion;
+ }
+ return out;
+}
+
/**
* Ensure the user config file exists and is at the current schema
* version.
@@ -95,6 +146,17 @@ export function ensureUserConfigFileSync(path: string): UserConfigFile {
return USER_CONFIG_DEFAULTS;
}
const parsed = parseUserConfigFile(raw.parsed);
+ // A file written by a NEWER build is read and left exactly as it is.
+ // Rewriting it would silently delete whatever that build added — and
+ // since both builds share one `config.json`, the two would then take
+ // turns destroying each other's keys on every launch. Reading is safe
+ // (the schema is additive); writing is not ours to do.
+ if (
+ raw.originalVersion !== null &&
+ raw.originalVersion > USER_CONFIG_VERSION
+ ) {
+ return parsed;
+ }
if (raw.originalVersion !== USER_CONFIG_VERSION) {
writeUserConfigFileSync(path, parsed);
process.stderr.write(
diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts
index 2f72bcf5..df1187b3 100644
--- a/src/config/config-schema.test.ts
+++ b/src/config/config-schema.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
ConfigValidationError,
+ UNKNOWN_USER_CONFIG_KEYS,
USER_CONFIG_DEFAULTS,
USER_CONFIG_VERSION,
parseUserConfigFile,
@@ -84,8 +85,50 @@ describe("parseUserConfigFile", () => {
).toBe(1);
});
- it("rejects unsupported version", () => {
- expect(() => parseUserConfigFile({ version: 99 })).toThrow(
+ it("reads a version newer than this build instead of refusing it", () => {
+ // Knowingly replaces "rejects unsupported version", which pinned
+ // `{version: 99}` as fatal. That is what made two builds sharing one
+ // `config.json` mutually exclusive: the newer one wrote v38 and the
+ // installed v0.2.2 then failed every command. The schema is additive,
+ // so a newer file parses fine — unknown keys are simply not read.
+ const parsed = parseUserConfigFile({
+ version: 99,
+ localModels: { url: "http://127.0.0.1:9999" },
+ });
+ expect(parsed.localModels.url).toBe("http://127.0.0.1:9999");
+ });
+
+ it("carries top-level keys it cannot parse, through a persist round trip", () => {
+ // The newer build's keys have to survive more than the read: every
+ // `persist-*` helper spreads the parsed file into a draft and hands
+ // it straight back here, so the carrier has to be picked up again on
+ // input or the second validation drops what the first one saved.
+ const parsed = parseUserConfigFile({
+ version: 99,
+ somethingFromTheFuture: { enabled: true },
+ log: { level: "warn" },
+ });
+ expect(parsed[UNKNOWN_USER_CONFIG_KEYS]).toEqual({
+ somethingFromTheFuture: { enabled: true },
+ });
+
+ const reparsed = parseUserConfigFile({ ...parsed, log: { level: "debug" } });
+ expect(reparsed[UNKNOWN_USER_CONFIG_KEYS]).toEqual({
+ somethingFromTheFuture: { enabled: true },
+ });
+ expect(reparsed.log.level).toBe("debug");
+ });
+
+ it("leaves the carrier off a file it fully understands", () => {
+ const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION });
+ expect(parsed[UNKNOWN_USER_CONFIG_KEYS]).toBeUndefined();
+ });
+
+ it("still rejects a non-integer version", () => {
+ expect(() => parseUserConfigFile({ version: "38" })).toThrow(
+ ConfigValidationError,
+ );
+ expect(() => parseUserConfigFile({ version: 38.5 })).toThrow(
ConfigValidationError,
);
});
@@ -175,6 +218,37 @@ describe("parseUserConfigFile", () => {
expect(parsed.tui.theme).toBe("auto");
});
+ it("enables tui.mouse by default when migrating from v37", () => {
+ const parsed = parseUserConfigFile({ version: 37 });
+ expect(parsed.version).toBe(USER_CONFIG_VERSION);
+ expect(parsed.tui.mouse).toBe(true);
+ });
+
+ it("preserves tui.mouse: false so an operator's opt-out survives", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { theme: "auto", mouse: false },
+ });
+ expect(parsed.tui.mouse).toBe(false);
+ });
+
+ it("accepts the string forms parseBool understands for tui.mouse", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { mouse: "off" },
+ });
+ expect(parsed.tui.mouse).toBe(false);
+ });
+
+ it("rejects a non-boolean tui.mouse", () => {
+ expect(() =>
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { mouse: 42 },
+ }),
+ ).toThrow(/tui.mouse/);
+ });
+
it("rejects a non-string tui.theme", () => {
expect(() =>
parseUserConfigFile({
@@ -992,4 +1066,41 @@ describe("parseUserConfigFile", () => {
}),
).toThrow(/timeoutMs/);
});
+ it("upgrades a v37 file to the current version untouched", () => {
+ // v38 only ADDED the optional `llm.runMode` sub-key, so a v37 file
+ // needs no migration code — absence already is the v37 behaviour.
+ const parsed = parseUserConfigFile({ version: 37 });
+ expect(parsed.version).toBe(USER_CONFIG_VERSION);
+ expect(USER_CONFIG_VERSION).toBe(38);
+ expect(parsed.llm?.runMode).toBeUndefined();
+ });
});
+
+describe("tui.whileBusySubmit", () => {
+ it("defaults to steer for a config file that predates the key", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { theme: "auto" },
+ });
+ expect(parsed.tui.whileBusySubmit).toBe("steer");
+ });
+
+ it("round-trips an explicit queue preference", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { theme: "nord", whileBusySubmit: "queue" },
+ });
+ expect(parsed.tui.whileBusySubmit).toBe("queue");
+ expect(parsed.tui.theme).toBe("nord");
+ });
+
+ it("rejects an unknown mode instead of silently defaulting", () => {
+ expect(() =>
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ tui: { theme: "auto", whileBusySubmit: "interrupt" },
+ }),
+ ).toThrow(/whileBusySubmit/);
+ });
+});
+
diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts
index e1102371..1ca6d23e 100644
--- a/src/config/config-schema.ts
+++ b/src/config/config-schema.ts
@@ -4,6 +4,7 @@ import {
parseUserLlmFileConfig,
type UserLlmFileConfig,
} from "./llm-config.js";
+import type { UserLlmRunModeConfig } from "./llm-run-mode-config.js";
export type { ApprovalLevel } from "../approval/approval-level.js";
import type { DotenvLoadResult } from "./load-dotenv.js";
@@ -662,12 +663,15 @@ export interface AtomicAgentConfig {
maxImagesPerCall: number;
};
/**
- * TUI appearance. Mirrors `UserConfigFile.tui`. `theme` is `"auto"`
- * (OSC 11 autodetect) or a registered theme name. Consumed by the TUI
- * startup path; the rest of the runtime ignores it.
+ * TUI appearance and input. Mirrors `UserConfigFile.tui`. `theme` is
+ * `"auto"` (OSC 11 autodetect) or a registered theme name; `mouse`
+ * toggles terminal mouse reporting. Consumed by the TUI startup path;
+ * the rest of the runtime ignores it.
*/
tui: {
theme: string;
+ whileBusySubmit: WhileBusySubmitMode;
+ mouse: boolean;
};
/**
* Anonymous product analytics (PostHog). Mirrors
@@ -725,6 +729,19 @@ export interface AtomicAgentConfig {
* are re-applied after the merge and cannot be overridden.
*/
extraBody?: Record;
+ /**
+ * Settings for a `subscription-cli` provider: which already
+ * signed-in vendor CLI to drive (`claude`, `codex`) and how to
+ * invoke it. There is no API key on these entries — the CLI
+ * authenticates from its own session.
+ */
+ subscriptionCli?: {
+ cli: "claude" | "codex";
+ binPath?: string;
+ extraArgs?: string[];
+ streaming?: boolean;
+ maxBudgetUsd?: number;
+ };
userModels?: ReadonlyArray<{
id: string;
kind: "chat" | "embedding";
@@ -768,6 +785,14 @@ export interface AtomicAgentConfig {
probeThrottleMs?: number;
failureWindowMs?: number;
};
+ /**
+ * Operator run mode: `local` (llama-server only), `cloud` (cloud
+ * provider only) or `fusion` (cloud orchestrates, local executes).
+ * `activeTextProvider` stays authoritative — this block is additive
+ * and is reconciled by `resolveRunMode`. See AGENTS.md §"Run modes
+ * (Local / Cloud / Fusion)".
+ */
+ runMode?: UserLlmRunModeConfig;
};
}
@@ -886,6 +911,23 @@ export interface UserManagedEmbeddingLlmConfig {
url: string;
}
+/**
+ * Carrier for top-level keys a *newer* build wrote and this build has no
+ * parser for. `parseUserConfigFile` hangs them on the object it returns
+ * and `writeUserConfigFileSync` puts them back into the file, so an
+ * older build reading a shared `config.json` no longer deletes the newer
+ * build's settings the first time it persists anything.
+ *
+ * A symbol rather than a field, deliberately: no JSON key can collide
+ * with it, `JSON.stringify` skips it (so a preserved key is written once,
+ * by the merge in `writeUserConfigFileSync`, never twice), and every
+ * `{ ...prev, tui: { … } }` draft in the `persist-*` helpers carries it
+ * along for free — object spread copies own enumerable symbol properties.
+ */
+export const UNKNOWN_USER_CONFIG_KEYS: unique symbol = Symbol.for(
+ "atomic-agent.userConfig.unknownKeys",
+);
+
/**
* User-facing keys that live in `/config.json`. The file
* format is versioned; bump `USER_CONFIG_VERSION` on breaking schema
@@ -893,6 +935,11 @@ export interface UserManagedEmbeddingLlmConfig {
*/
export interface UserConfigFile {
version: typeof USER_CONFIG_VERSION;
+ /**
+ * Present only when the parsed file carried top-level keys this build
+ * does not know. See {@link UNKNOWN_USER_CONFIG_KEYS}.
+ */
+ readonly [UNKNOWN_USER_CONFIG_KEYS]?: Readonly>;
localModels: {
url: string;
mode: LocalLlmMode;
@@ -1350,9 +1397,17 @@ export interface UserConfigFile {
* the matching GitHub theme) or a registered theme name (e.g. `dracula`,
* `nord`). Persisted from the in-app `/theme` picker. Older files are
* transparently upgraded with `tui: { theme: "auto" }`.
+ *
+ * `mouse` (config v38, default `true`) turns terminal mouse reporting
+ * on: clicking panels, list rows, the nav bar and the prompt, plus
+ * wheel scrolling. Turning it off restores the terminal's own
+ * drag-to-select, which mouse reporting takes over — see `/mouse` and
+ * `--no-mouse`. Older files are upgraded with `mouse: true`.
*/
tui: {
theme: string;
+ whileBusySubmit: WhileBusySubmitMode;
+ mouse: boolean;
};
/**
* Anonymous product analytics (PostHog). Added in config v33. Older
@@ -1412,7 +1467,15 @@ export interface UserConfigFile {
// is absent, a legacy `approvalRequired: false` maps to level 5 and
// `true`/absent maps to level 1 — both preserve the old behaviour
// exactly. The legacy key is never written back.
-export const USER_CONFIG_VERSION = 37 as const;
+// v38: new optional `llm.runMode` block — the operator run mode
+// (`local` | `cloud` | `fusion`) plus the fusion cloud-share dial and
+// the sub-runner target. Absence IS the v37 behaviour: it is an
+// optional sub-key of an already-optional block, so no migration code
+// exists; the bump only records the schema change. The same bump adds
+// `tui.mouse` (terminal mouse reporting, default `true`) and
+// `tui.whileBusySubmit` (`steer` | `queue`, default `steer`) — both are
+// defaulted in, so older files upgrade without a migration step.
+export const USER_CONFIG_VERSION = 38 as const;
/**
* Config v21+ flips the full memory-v2 fabric on by default. Upgrades
@@ -1529,6 +1592,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [
34,
35,
36,
+ 37,
USER_CONFIG_VERSION,
];
@@ -1767,6 +1831,8 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = {
},
tui: {
theme: "auto",
+ whileBusySubmit: "steer",
+ mouse: true,
},
analytics: {
enabled: true,
@@ -2634,22 +2700,86 @@ export function parseMcpServers(
return out;
}
+/**
+ * Top-level keys this build's parser consumes. Derived from
+ * `USER_CONFIG_DEFAULTS` so a newly added block registers itself, plus
+ * the two input-only keys that have no default entry: the optional `llm`
+ * block, and the legacy `telemetry` alias that is read here and written
+ * back as `tracing`. Everything outside this set was written by another
+ * build and is preserved verbatim instead of parsed.
+ */
+const KNOWN_USER_CONFIG_KEYS: ReadonlySet = new Set([
+ ...Object.keys(USER_CONFIG_DEFAULTS),
+ "llm",
+ "telemetry",
+]);
+
+/**
+ * The top-level keys of `raw` this build has no parser for, unioned with
+ * any already carried on it under {@link UNKNOWN_USER_CONFIG_KEYS}. The
+ * union is what makes a `persist-*` round trip lossless: those helpers
+ * spread an already-parsed file (whose unknown keys live only on the
+ * carrier) and hand the draft back to `parseUserConfigFile`. Literal keys
+ * win over carried ones — they are what the file being read says now.
+ */
+export function collectUnknownUserConfigKeys(
+ raw: unknown,
+): Record {
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
+ const obj = raw as Record & {
+ [UNKNOWN_USER_CONFIG_KEYS]?: Readonly>;
+ };
+ const out: Record = { ...(obj[UNKNOWN_USER_CONFIG_KEYS] ?? {}) };
+ for (const [key, value] of Object.entries(obj)) {
+ if (KNOWN_USER_CONFIG_KEYS.has(key)) continue;
+ out[key] = value;
+ }
+ return out;
+}
+
/**
* Validate and normalise a raw JSON payload into a `UserConfigFile`.
* Missing sub-keys are filled with defaults — this lets us add new
- * fields without breaking existing installations. Unknown top-level
- * keys are preserved silently (forward compat).
+ * fields without breaking existing installations. Top-level keys this
+ * build has no parser for are carried on the returned object under
+ * {@link UNKNOWN_USER_CONFIG_KEYS} (forward compat).
*/
export function parseUserConfigFile(raw: unknown): UserConfigFile {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw new ConfigValidationError("", "expected JSON object");
}
const obj = raw as Record;
+ const unknownKeys = collectUnknownUserConfigKeys(obj);
const version = obj.version ?? USER_CONFIG_VERSION;
- if (
- typeof version !== "number" ||
- !SUPPORTED_INPUT_VERSIONS.includes(version)
- ) {
+ if (typeof version !== "number" || !Number.isInteger(version)) {
+ throw new ConfigValidationError(
+ "version",
+ `expected an integer version; got ${JSON.stringify(version)}`,
+ );
+ }
+ // A version *newer* than this build is read, not refused.
+ //
+ // Every bump this schema has ever taken is additive: new keys arrive
+ // with defaults and the parser reads field by field, so a file written
+ // by a newer build parses correctly here — the keys this build does not
+ // know are not parsed but are carried on the returned object (see
+ // `UNKNOWN_USER_CONFIG_KEYS`), and `writeUserConfigFileSync` puts them
+ // back into the file rather than dropping them. Without that, reading
+ // would be safe but the operator's first `config set` would delete the
+ // newer build's keys and stamp `version` back down — the loop this
+ // permissiveness exists to prevent, merely postponed.
+ //
+ // Refusing was actively harmful. Two builds share one `config.json`,
+ // so the moment the newer one wrote its version the older one died on
+ // *every* command — `models status`, `config get`, the TUI — with a
+ // validation error naming 33 acceptable versions and no way out.
+ // Running two versions side by side is normal (a release plus a build
+ // under test), and an additive schema has no reason to make it fatal.
+ //
+ // The other half of this contract lives in `ensureUserConfigFileSync`,
+ // which must not rewrite a newer file back down to this build's shape —
+ // reading it is safe, overwriting it would delete the newer build's keys.
+ if (version < USER_CONFIG_VERSION && !SUPPORTED_INPUT_VERSIONS.includes(version)) {
throw new ConfigValidationError(
"version",
`unsupported config version ${JSON.stringify(version)}; expected one of ${SUPPORTED_INPUT_VERSIONS.join(", ")}`,
@@ -3421,6 +3551,11 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
tui.theme ?? USER_CONFIG_DEFAULTS.tui.theme,
"tui.theme",
),
+ whileBusySubmit: parseWhileBusySubmit(
+ tui.whileBusySubmit ?? USER_CONFIG_DEFAULTS.tui.whileBusySubmit,
+ "tui.whileBusySubmit",
+ ),
+ mouse: parseBool(tui.mouse ?? USER_CONFIG_DEFAULTS.tui.mouse, "tui.mouse"),
},
analytics: {
enabled: parseBool(
@@ -3451,6 +3586,9 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
servers: parseMcpServers(mcp.servers, "mcp.servers"),
},
...(llmBlock !== undefined ? { llm: llmBlock } : {}),
+ ...(Object.keys(unknownKeys).length > 0
+ ? { [UNKNOWN_USER_CONFIG_KEYS]: unknownKeys }
+ : {}),
};
}
@@ -3461,6 +3599,33 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
* only enforces the string shape — an unknown name falls back to the
* autodetect path at startup, never crashes. Anything non-string throws.
*/
+/**
+ * What Enter does in the TUI while a turn is already running.
+ *
+ * `steer` folds the message into the turn in flight (it reaches the
+ * model at the next step boundary); `queue` parks it and runs it as its
+ * own turn once the current one closes. Default is `steer` — an
+ * operator who types *while* the agent is working is usually reacting
+ * to what they see it doing.
+ */
+export type WhileBusySubmitMode = "steer" | "queue";
+
+/**
+ * Parse `tui.whileBusySubmit`. Older config files predate the key and
+ * are transparently upgraded to the `steer` default by the `??` at the
+ * call site, so there is no migration step.
+ */
+export function parseWhileBusySubmit(
+ raw: unknown,
+ field: string,
+): WhileBusySubmitMode {
+ if (raw === "steer" || raw === "queue") return raw;
+ throw new ConfigValidationError(
+ field,
+ `expected "steer" or "queue", got ${JSON.stringify(raw)}`,
+ );
+}
+
export function parseThemeName(raw: unknown, field: string): string {
if (typeof raw !== "string") {
throw new ConfigValidationError(
diff --git a/src/config/index.ts b/src/config/index.ts
index 312d7bcb..60188a00 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -11,12 +11,14 @@ export type {
WebSearchConfig,
WebSearchProviderName,
WebhookConfig,
+ WhileBusySubmitMode,
} from "./config-schema.js";
export {
ConfigValidationError,
USER_CONFIG_DEFAULTS,
USER_CONFIG_VERSION,
parseUserConfigFile,
+ parseWhileBusySubmit,
} from "./config-schema.js";
export {
ensureUserConfigFileSync,
@@ -40,7 +42,22 @@ export {
type UserLlmFileConfig,
type UserLlmFallbackConfig,
type UserLlmProviderEntry,
+ type UserSubscriptionCliOptions,
+ type SubscriptionCliName,
+ SUBSCRIPTION_CLIS,
} from "./llm-config.js";
+export {
+ DEFAULT_FUSION_CLOUD_SHARE,
+ parseLlmRunModeConfig,
+ type RunModeName,
+ type RunModeSubRunners,
+ type UserLlmFusionConfig,
+ type UserLlmRunModeConfig,
+} from "./llm-run-mode-config.js";
+export {
+ SUBSCRIPTION_CLI_KIND,
+ usesExternalCliAuth,
+} from "./provider-auth-mode.js";
export type {
DotenvLoadResult,
DotenvReadFailure,
diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts
index 544099de..ee0aad96 100644
--- a/src/config/llm-config.test.ts
+++ b/src/config/llm-config.test.ts
@@ -231,6 +231,160 @@ describe("llm-config", () => {
});
});
+ const withProviderField = (extra: Record) => ({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "openrouter",
+ activeEmbeddingProvider: "local-llama",
+ toolTransport: "auto" as const,
+ providers: [
+ { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
+ { id: "openrouter", kind: "openrouter", defaultChatModel: "gpt", ...extra },
+ ],
+ },
+ });
+
+ it("round-trips promptCache and providerPreferences on a provider entry", () => {
+ const parsed = parseUserConfigFile(
+ withProviderField({
+ promptCache: "explicit-markers",
+ providerPreferences: { order: ["anthropic"], allow_fallbacks: false },
+ }),
+ );
+ expect(parsed.llm?.providers[1]).toMatchObject({
+ promptCache: "explicit-markers",
+ providerPreferences: { order: ["anthropic"], allow_fallbacks: false },
+ });
+ });
+
+ it("rejects an unknown promptCache mode", () => {
+ expect(() =>
+ parseUserConfigFile(withProviderField({ promptCache: "always" })),
+ ).toThrow(/llm\.providers\[1\]\.promptCache/);
+ });
+
+ it("rejects a non-object providerPreferences", () => {
+ expect(() =>
+ parseUserConfigFile(withProviderField({ providerPreferences: ["anthropic"] })),
+ ).toThrow(/llm\.providers\[1\]\.providerPreferences/);
+ });
+
+ const withUserModels = (userModels: unknown) => ({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "model-studio",
+ activeEmbeddingProvider: "local-llama",
+ toolTransport: "auto" as const,
+ providers: [
+ { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
+ {
+ id: "model-studio",
+ kind: "qwen-openai-compatible",
+ baseUrl: "https://example.invalid/compatible-mode",
+ defaultChatModel: "qwen3.8-27b",
+ userModels,
+ },
+ ],
+ },
+ });
+
+ it("round-trips userModels on a provider entry", () => {
+ const parsed = parseUserConfigFile(
+ withUserModels([
+ {
+ id: "qwen3.8-27b",
+ kind: "chat",
+ contextWindow: 262144,
+ supportsVision: true,
+ supportsTools: "strict",
+ supportsPromptCache: true,
+ reasoningFormat: "delta_reasoning_content",
+ pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 },
+ },
+ { id: "text-embedding-v4", kind: "embedding", dim: 1024 },
+ ]),
+ );
+
+ // resolveModel reads userModels as its highest-priority source, so
+ // the parser dropping these rows is the difference between a
+ // hand-configured model and the 128k/no-pricing defaults.
+ expect(parsed.llm?.providers[1]?.userModels).toEqual([
+ {
+ id: "qwen3.8-27b",
+ kind: "chat",
+ contextWindow: 262144,
+ dim: undefined,
+ supportsVision: true,
+ supportsTools: "strict",
+ supportsPromptCache: true,
+ reasoningFormat: "delta_reasoning_content",
+ pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 },
+ },
+ {
+ id: "text-embedding-v4",
+ kind: "embedding",
+ contextWindow: undefined,
+ dim: 1024,
+ supportsVision: undefined,
+ supportsTools: undefined,
+ supportsPromptCache: undefined,
+ reasoningFormat: undefined,
+ pricing: undefined,
+ },
+ ]);
+ });
+
+ it("omits userModels when the entry does not configure any", () => {
+ const parsed = parseUserConfigFile(withUserModels(undefined));
+ expect(parsed.llm?.providers[1]?.userModels).toBeUndefined();
+ });
+
+ it("rejects a userModels row with an unknown kind", () => {
+ expect(() =>
+ parseUserConfigFile(
+ withUserModels([{ id: "qwen3.8-27b", kind: "completion" }]),
+ ),
+ ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.kind/);
+ });
+
+ it("rejects a userModels row with a malformed contextWindow", () => {
+ expect(() =>
+ parseUserConfigFile(
+ withUserModels([
+ { id: "a", kind: "chat" },
+ { id: "b", kind: "chat", contextWindow: "262144" },
+ ]),
+ ),
+ ).toThrow(/llm\.providers\[1\]\.userModels\[1\]\.contextWindow/);
+ });
+
+ it("rejects userModels pricing that is missing a rate", () => {
+ expect(() =>
+ parseUserConfigFile(
+ withUserModels([
+ { id: "a", kind: "chat", pricing: { input: 0.0004 } },
+ ]),
+ ),
+ ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.pricing\.output/);
+ });
+
+ it("rejects duplicate model ids within one provider's userModels", () => {
+ expect(() =>
+ parseUserConfigFile(
+ withUserModels([
+ { id: "a", kind: "chat" },
+ { id: "a", kind: "chat" },
+ ]),
+ ),
+ ).toThrow(/userModels\[1\]\.id/);
+ });
+
+ it("rejects a non-array userModels", () => {
+ expect(() =>
+ parseUserConfigFile(withUserModels({ "qwen3.8-27b": { kind: "chat" } })),
+ ).toThrow(/userModels/);
+ });
+
it("rejects a non-object extraBody", () => {
expect(() =>
parseUserConfigFile({
@@ -257,4 +411,98 @@ describe("llm-config", () => {
}),
).toThrow(/extraBody/);
});
+ it("accepts subscription-cli entries and round-trips subscriptionCli", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "claude-cli",
+ activeEmbeddingProvider: "local-llama",
+ toolTransport: "auto",
+ providers: [
+ {
+ id: "local-llama",
+ kind: "llama-server",
+ url: "http://127.0.0.1:19091",
+ },
+ {
+ id: "claude-cli",
+ kind: "subscription-cli",
+ defaultChatModel: "sonnet",
+ subscriptionCli: {
+ cli: "claude",
+ binPath: "/opt/homebrew/bin/claude",
+ extraArgs: ["--effort", "high"],
+ streaming: false,
+ maxBudgetUsd: 5,
+ },
+ },
+ ],
+ },
+ });
+ const entry = parsed.llm?.providers.find((p) => p.id === "claude-cli");
+ // parseLlmProviderEntry is a whitelist that rebuilds the entry from
+ // known keys, so an unparsed field would be silently dropped on the
+ // next config rewrite. Pin the whole block, not just `cli`.
+ expect(entry?.subscriptionCli).toEqual({
+ cli: "claude",
+ binPath: "/opt/homebrew/bin/claude",
+ extraArgs: ["--effort", "high"],
+ streaming: false,
+ maxBudgetUsd: 5,
+ });
+ });
+
+ it("rejects a subscription-cli entry with no subscriptionCli block", () => {
+ expect(() =>
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "claude-cli",
+ activeEmbeddingProvider: "claude-cli",
+ toolTransport: "auto",
+ providers: [{ id: "claude-cli", kind: "subscription-cli" }],
+ },
+ }),
+ ).toThrow(/subscriptionCli/);
+ });
+
+ it("rejects an unknown cli name", () => {
+ expect(() =>
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "gemini-cli",
+ activeEmbeddingProvider: "gemini-cli",
+ toolTransport: "auto",
+ providers: [
+ {
+ id: "gemini-cli",
+ kind: "subscription-cli",
+ subscriptionCli: { cli: "gemini" },
+ },
+ ],
+ },
+ }),
+ ).toThrow(/subscriptionCli\.cli/);
+ });
+
+ it("rejects non-string extraArgs", () => {
+ expect(() =>
+ parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "claude-cli",
+ activeEmbeddingProvider: "claude-cli",
+ toolTransport: "auto",
+ providers: [
+ {
+ id: "claude-cli",
+ kind: "subscription-cli",
+ subscriptionCli: { cli: "claude", extraArgs: ["--effort", 3] },
+ },
+ ],
+ },
+ }),
+ ).toThrow(/extraArgs\[1\]/);
+ });
});
diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts
index ed3f3a6c..fd860b3b 100644
--- a/src/config/llm-config.ts
+++ b/src/config/llm-config.ts
@@ -1,7 +1,38 @@
import { ConfigValidationError } from "./config-validation-error.js";
+import {
+ parseLlmRunModeConfig,
+ type UserLlmRunModeConfig,
+} from "./llm-run-mode-config.js";
+import { SUBSCRIPTION_CLI_KIND } from "./provider-auth-mode.js";
export type UserLlmToolTransport = "auto" | "grammar" | "native_tools";
+/** Vendor CLIs a `subscription-cli` provider knows how to drive. */
+export const SUBSCRIPTION_CLIS = ["claude", "codex"] as const;
+export type SubscriptionCliName = (typeof SUBSCRIPTION_CLIS)[number];
+
+/**
+ * Settings for a provider backed by an already-signed-in vendor CLI.
+ * The CLI authenticates itself from its own session, so there is no
+ * `apiKey` / `apiKeyEnvVar` anywhere in this block.
+ */
+export type UserSubscriptionCliOptions = {
+ /** Which CLI to drive. Required when `kind` is `subscription-cli`. */
+ cli: SubscriptionCliName;
+ /** Absolute path to the binary. Omit to resolve it from `PATH`. */
+ binPath?: string;
+ /**
+ * Extra argv appended verbatim to every invocation. The escape hatch
+ * for flags we do not model (`--effort high`) and for correcting a
+ * vendor CLI whose interface moved, without waiting for a release.
+ */
+ extraArgs?: string[];
+ /** Opt out of the streaming path and always buffer. */
+ streaming?: boolean;
+ /** Passed through as the CLI's own spend ceiling where it has one. */
+ maxBudgetUsd?: number;
+};
+
export type UserLlmProviderEntry = {
id: string;
kind: string;
@@ -22,6 +53,18 @@ export type UserLlmProviderEntry = {
supportsTools?: boolean;
supportsVision?: boolean;
requestTimeoutMs?: number;
+ /**
+ * Prompt-caching policy for this provider. Declared in the config
+ * schema and on `LlmProviderConfigEntry`; no provider reads it yet,
+ * so today it only has to survive the round-trip through config.
+ */
+ promptCache?: "auto" | "off" | "explicit-markers";
+ /**
+ * Vendor routing preferences (e.g. OpenRouter's `provider` block).
+ * Same status as `promptCache`: carried through config, not yet read
+ * by any provider.
+ */
+ providerPreferences?: Record;
/**
* Vendor-specific fields merged into the OpenAI-compatible chat body
* for `openai-compatible` / `qwen-openai-compatible` providers. Lets a
@@ -36,6 +79,46 @@ export type UserLlmProviderEntry = {
* after the merge and cannot be overridden from config.
*/
extraBody?: Record;
+ /**
+ * Hand-written model metadata for this provider. `resolveModel`
+ * reads it as its highest-priority source (userModels > bundled
+ * catalog > defaults), so it is the documented way to teach the
+ * runtime about a model the bundled catalog does not know: context
+ * window, capabilities and pricing.
+ */
+ userModels?: ReadonlyArray;
+ /** Present only on `subscription-cli` entries; see the type above. */
+ subscriptionCli?: UserSubscriptionCliOptions;
+};
+
+/**
+ * One hand-configured model on a provider entry. Mirrors
+ * `UserModelConfigEntry` in the provider registry — the shape
+ * `resolveModel` merges over the bundled catalog.
+ *
+ * Note `supportsTools` here is a support *level*, not the boolean of
+ * the same name on the provider entry: a model can advertise strict or
+ * parallel tool calling independently of whether the transport does.
+ */
+export type UserModelEntry = {
+ id: string;
+ kind: "chat" | "embedding";
+ contextWindow?: number;
+ dim?: number;
+ supportsVision?: boolean;
+ supportsTools?: "none" | "basic" | "parallel" | "strict";
+ supportsPromptCache?: boolean;
+ reasoningFormat?:
+ | "none"
+ | "delta_reasoning"
+ | "delta_thinking"
+ | "delta_reasoning_content";
+ pricing?: {
+ input: number;
+ output: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+ };
};
export type UserLlmFallbackConfig = {
@@ -53,6 +136,7 @@ export type UserLlmFileConfig = {
toolTransport: UserLlmToolTransport;
providers: UserLlmProviderEntry[];
fallback?: UserLlmFallbackConfig;
+ runMode?: UserLlmRunModeConfig;
};
const PROVIDER_ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
@@ -63,6 +147,7 @@ const PROVIDER_KINDS = new Set([
"openrouter",
"aimlapi",
"gemini",
+ SUBSCRIPTION_CLI_KIND,
]);
function parseProviderId(raw: unknown, field: string): string {
@@ -120,6 +205,18 @@ export function parseLlmProviderEntry(
`expected one of ${[...PROVIDER_KINDS].join(", ")}`,
);
}
+ const subscriptionCli = parseSubscriptionCliOptions(
+ obj.subscriptionCli,
+ `${field}.subscriptionCli`,
+ );
+ // A `subscription-cli` entry without a `cli` has no binary to drive, so
+ // fail at load rather than at the first inference an hour into a run.
+ if (kind === SUBSCRIPTION_CLI_KIND && !subscriptionCli) {
+ throw new ConfigValidationError(
+ `${field}.subscriptionCli`,
+ `required when kind is ${SUBSCRIPTION_CLI_KIND}`,
+ );
+ }
return {
id,
kind,
@@ -172,11 +269,81 @@ export function parseLlmProviderEntry(
"expected positive number",
);
})(),
- extraBody: parseOptionalExtraBody(obj.extraBody, `${field}.extraBody`),
+ promptCache: parseOptionalEnum<
+ NonNullable
+ >(obj.promptCache, `${field}.promptCache`, PROMPT_CACHE_MODES),
+ providerPreferences: parseOptionalPlainObject(
+ obj.providerPreferences,
+ `${field}.providerPreferences`,
+ ),
+ extraBody: parseOptionalPlainObject(obj.extraBody, `${field}.extraBody`),
+ userModels: parseOptionalUserModels(obj.userModels, `${field}.userModels`),
+ subscriptionCli,
};
}
-function parseOptionalExtraBody(
+function parseSubscriptionCliOptions(
+ raw: unknown,
+ field: string,
+): UserSubscriptionCliOptions | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ if (typeof raw !== "object" || Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected object");
+ }
+ const obj = raw as Record;
+ const cli = obj.cli;
+ if (
+ typeof cli !== "string" ||
+ !(SUBSCRIPTION_CLIS as readonly string[]).includes(cli)
+ ) {
+ throw new ConfigValidationError(
+ `${field}.cli`,
+ `expected one of ${SUBSCRIPTION_CLIS.join(", ")}`,
+ );
+ }
+ const out: UserSubscriptionCliOptions = { cli: cli as SubscriptionCliName };
+ const binPath = parseOptionalString(obj.binPath, `${field}.binPath`);
+ if (binPath !== undefined) out.binPath = binPath;
+ if (obj.extraArgs !== undefined && obj.extraArgs !== null) {
+ if (!Array.isArray(obj.extraArgs)) {
+ throw new ConfigValidationError(
+ `${field}.extraArgs`,
+ "expected array of strings",
+ );
+ }
+ out.extraArgs = obj.extraArgs.map((value, i) => {
+ if (typeof value !== "string") {
+ throw new ConfigValidationError(
+ `${field}.extraArgs[${i}]`,
+ "expected string",
+ );
+ }
+ return value;
+ });
+ }
+ if (obj.streaming !== undefined && obj.streaming !== null) {
+ if (typeof obj.streaming !== "boolean") {
+ throw new ConfigValidationError(`${field}.streaming`, "expected boolean");
+ }
+ out.streaming = obj.streaming;
+ }
+ if (obj.maxBudgetUsd !== undefined && obj.maxBudgetUsd !== null) {
+ if (
+ typeof obj.maxBudgetUsd !== "number" ||
+ !Number.isFinite(obj.maxBudgetUsd) ||
+ obj.maxBudgetUsd <= 0
+ ) {
+ throw new ConfigValidationError(
+ `${field}.maxBudgetUsd`,
+ "expected positive number",
+ );
+ }
+ out.maxBudgetUsd = obj.maxBudgetUsd;
+ }
+ return out;
+}
+
+function parseOptionalPlainObject(
raw: unknown,
field: string,
): Record | undefined {
@@ -187,6 +354,139 @@ function parseOptionalExtraBody(
return { ...(raw as Record) };
}
+const PROMPT_CACHE_MODES = new Set(["auto", "off", "explicit-markers"]);
+const TOOLS_SUPPORT_LEVELS = new Set(["none", "basic", "parallel", "strict"]);
+const REASONING_FORMATS = new Set([
+ "none",
+ "delta_reasoning",
+ "delta_thinking",
+ "delta_reasoning_content",
+]);
+
+function parseOptionalBoolean(
+ raw: unknown,
+ field: string,
+): boolean | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ if (typeof raw !== "boolean") {
+ throw new ConfigValidationError(field, "expected boolean");
+ }
+ return raw;
+}
+
+function parseOptionalEnum(
+ raw: unknown,
+ field: string,
+ allowed: ReadonlySet,
+): T | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ if (typeof raw !== "string" || !allowed.has(raw)) {
+ throw new ConfigValidationError(field, `expected ${[...allowed].join("|")}`);
+ }
+ return raw as T;
+}
+
+/**
+ * Prices are per-token rates, so 0 is legal (free tiers) but negative
+ * or non-finite is not — a NaN rate would poison every cost estimate
+ * downstream rather than fail loudly.
+ */
+function parseRate(raw: unknown, field: string): number {
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
+ throw new ConfigValidationError(field, "expected a non-negative number");
+ }
+ return raw;
+}
+
+function parseUserModelPricing(
+ raw: unknown,
+ field: string,
+): UserModelEntry["pricing"] | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ if (typeof raw !== "object" || Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected object");
+ }
+ const obj = raw as Record;
+ const pricing: NonNullable = {
+ input: parseRate(obj.input, `${field}.input`),
+ output: parseRate(obj.output, `${field}.output`),
+ };
+ if (obj.cacheRead !== undefined && obj.cacheRead !== null) {
+ pricing.cacheRead = parseRate(obj.cacheRead, `${field}.cacheRead`);
+ }
+ if (obj.cacheWrite !== undefined && obj.cacheWrite !== null) {
+ pricing.cacheWrite = parseRate(obj.cacheWrite, `${field}.cacheWrite`);
+ }
+ return pricing;
+}
+
+function parseUserModelEntry(raw: unknown, field: string): UserModelEntry {
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected object");
+ }
+ const obj = raw as Record;
+ if (typeof obj.id !== "string" || obj.id.length === 0) {
+ throw new ConfigValidationError(`${field}.id`, "expected non-empty string");
+ }
+ if (obj.kind !== "chat" && obj.kind !== "embedding") {
+ throw new ConfigValidationError(`${field}.kind`, "expected chat|embedding");
+ }
+ return {
+ id: obj.id,
+ kind: obj.kind,
+ contextWindow:
+ obj.contextWindow === undefined || obj.contextWindow === null
+ ? undefined
+ : parsePositiveInt(obj.contextWindow, `${field}.contextWindow`),
+ dim:
+ obj.dim === undefined || obj.dim === null
+ ? undefined
+ : parsePositiveInt(obj.dim, `${field}.dim`),
+ supportsVision: parseOptionalBoolean(
+ obj.supportsVision,
+ `${field}.supportsVision`,
+ ),
+ supportsTools: parseOptionalEnum<
+ NonNullable
+ >(obj.supportsTools, `${field}.supportsTools`, TOOLS_SUPPORT_LEVELS),
+ supportsPromptCache: parseOptionalBoolean(
+ obj.supportsPromptCache,
+ `${field}.supportsPromptCache`,
+ ),
+ reasoningFormat: parseOptionalEnum<
+ NonNullable
+ >(obj.reasoningFormat, `${field}.reasoningFormat`, REASONING_FORMATS),
+ pricing: parseUserModelPricing(obj.pricing, `${field}.pricing`),
+ };
+}
+
+function parseOptionalUserModels(
+ raw: unknown,
+ field: string,
+): UserModelEntry[] | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ if (!Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected array");
+ }
+ // `resolveModel` looks a model up by id with `.find`, so a duplicate
+ // id would silently shadow the later row. Reject it at parse time
+ // instead of serving whichever copy happens to come first.
+ const seen = new Set();
+ const out: UserModelEntry[] = [];
+ for (let i = 0; i < raw.length; i++) {
+ const entry = parseUserModelEntry(raw[i], `${field}[${i}]`);
+ if (seen.has(entry.id)) {
+ throw new ConfigValidationError(
+ `${field}[${i}].id`,
+ `duplicate model id ${JSON.stringify(entry.id)}`,
+ );
+ }
+ seen.add(entry.id);
+ out.push(entry);
+ }
+ return out;
+}
+
export function parseLlmProviders(
raw: unknown,
field: string,
@@ -343,14 +643,15 @@ export function parseUserLlmFileConfig(
"expected auto|grammar|native_tools",
);
}
+ const providerIds = new Set(providers.map((p) => p.id));
const fallback =
obj.fallback === undefined || obj.fallback === null
? undefined
- : parseLlmFallbackConfig(
- obj.fallback,
- new Set(providers.map((p) => p.id)),
- "llm.fallback",
- );
+ : parseLlmFallbackConfig(obj.fallback, providerIds, "llm.fallback");
+ const runMode =
+ obj.runMode === undefined || obj.runMode === null
+ ? undefined
+ : parseLlmRunModeConfig(obj.runMode, providerIds, "llm.runMode");
return {
activeTextProvider,
@@ -358,5 +659,6 @@ export function parseUserLlmFileConfig(
toolTransport: toolTransportRaw,
providers,
...(fallback ? { fallback } : {}),
+ ...(runMode ? { runMode } : {}),
};
}
diff --git a/src/config/llm-run-mode-config.test.ts b/src/config/llm-run-mode-config.test.ts
new file mode 100644
index 00000000..81836607
--- /dev/null
+++ b/src/config/llm-run-mode-config.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+
+import { parseUserConfigFile, USER_CONFIG_VERSION } from "./config-schema.js";
+import { DEFAULT_FUSION_CLOUD_SHARE } from "./llm-run-mode-config.js";
+
+/** Two-provider file (one local leg, one cloud leg) plus a runMode block. */
+const withRunMode = (runMode: unknown) => ({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "openrouter",
+ activeEmbeddingProvider: "local-llama",
+ toolTransport: "auto",
+ providers: [
+ { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
+ { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" },
+ ],
+ runMode,
+ },
+});
+
+describe("llm-run-mode-config", () => {
+ it("round-trips a full runMode block", () => {
+ const parsed = parseUserConfigFile(
+ withRunMode({
+ mode: "fusion",
+ localProvider: "local-llama",
+ cloudProvider: "openrouter",
+ fusion: { cloudShare: 65, subRunners: "follow" },
+ }),
+ );
+ expect(parsed.llm?.runMode).toEqual({
+ mode: "fusion",
+ localProvider: "local-llama",
+ cloudProvider: "openrouter",
+ fusion: { cloudShare: 65, subRunners: "follow" },
+ });
+ });
+
+ it("omits runMode entirely when not configured", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ llm: {
+ activeTextProvider: "local-llama",
+ activeEmbeddingProvider: "local-llama",
+ toolTransport: "auto",
+ providers: [
+ { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" },
+ ],
+ },
+ });
+ expect(parsed.llm?.runMode).toBeUndefined();
+ });
+
+ it("accepts a bare mode and leaves the dial to its default", () => {
+ const parsed = parseUserConfigFile(withRunMode({ mode: "local" }));
+ expect(parsed.llm?.runMode).toEqual({ mode: "local" });
+ // The default is applied by `resolveRunMode`, never written into the
+ // file — an absent dial must stay absent so the default can move.
+ expect(parsed.llm?.runMode?.fusion).toBeUndefined();
+ expect(DEFAULT_FUSION_CLOUD_SHARE).toBe(40);
+ });
+
+ it("rejects an unknown mode", () => {
+ expect(() => parseUserConfigFile(withRunMode({ mode: "hybrid" }))).toThrow(
+ /llm\.runMode\.mode/,
+ );
+ });
+
+ it("rejects a pinned leg that names an unconfigured provider", () => {
+ expect(() =>
+ parseUserConfigFile(withRunMode({ cloudProvider: "anthropic" })),
+ ).toThrow(/llm\.runMode\.cloudProvider/);
+ expect(() =>
+ parseUserConfigFile(withRunMode({ localProvider: "ollama" })),
+ ).toThrow(/llm\.runMode\.localProvider/);
+ });
+
+ it("accepts the inclusive cloudShare bounds", () => {
+ for (const cloudShare of [0, 100]) {
+ const parsed = parseUserConfigFile(withRunMode({ fusion: { cloudShare } }));
+ expect(parsed.llm?.runMode?.fusion?.cloudShare).toBe(cloudShare);
+ }
+ });
+
+ it("rejects a cloudShare outside 0-100 or non-integer", () => {
+ for (const bad of [-1, 101, 42.5, "40", null]) {
+ expect(() =>
+ parseUserConfigFile(withRunMode({ fusion: { cloudShare: bad } })),
+ ).toThrow(/llm\.runMode\.fusion\.cloudShare/);
+ }
+ });
+
+ it("rejects an unknown subRunners target", () => {
+ expect(() =>
+ parseUserConfigFile(withRunMode({ fusion: { subRunners: "remote" } })),
+ ).toThrow(/llm\.runMode\.fusion\.subRunners/);
+ });
+
+ it("rejects a non-object runMode or fusion block", () => {
+ expect(() => parseUserConfigFile(withRunMode("fusion"))).toThrow(
+ /llm\.runMode/,
+ );
+ expect(() => parseUserConfigFile(withRunMode({ fusion: [] }))).toThrow(
+ /llm\.runMode\.fusion/,
+ );
+ });
+});
diff --git a/src/config/llm-run-mode-config.ts b/src/config/llm-run-mode-config.ts
new file mode 100644
index 00000000..65535c2d
--- /dev/null
+++ b/src/config/llm-run-mode-config.ts
@@ -0,0 +1,170 @@
+import { ConfigValidationError } from "./config-validation-error.js";
+
+/**
+ * Operator-facing run mode. Names the *pair* of providers a turn is
+ * allowed to use, not a single model:
+ *
+ * - `local` — the configured llama-server provider only.
+ * - `cloud` — the configured cloud provider only.
+ * - `fusion` — cloud orchestrates, local executes. See
+ * AGENTS.md §"Run modes (Local / Cloud / Fusion)".
+ */
+export type RunModeName = "local" | "cloud" | "fusion";
+
+/**
+ * Where fusion sends the memory sub-runners (reflection, link
+ * generation, curation votes, query rewriting, distillation).
+ *
+ * `local` (default) keeps them on the executor: they are cold-path
+ * structured-JSON jobs that ride the reserved reflection slot and are
+ * already KV-warm locally, so routing them to the cloud multiplies
+ * per-turn cost for no user-visible latency win. `cloud` sends them to
+ * the orchestrator; `follow` reuses whatever the last main-loop step
+ * used.
+ */
+export type RunModeSubRunners = "local" | "cloud" | "follow";
+
+export type UserLlmFusionConfig = {
+ /**
+ * How much of a turn leans on the cloud orchestrator, 0-100.
+ *
+ * This is a DIAL, NOT A QUOTA. It does not promise that N% of steps
+ * reach the cloud; it moves the cutoff on a bounded per-step
+ * complexity score (`src/agent/routing/compute-step-complexity.ts`):
+ * a step routes to the cloud when `score >= 100 - cloudShare`. `0`
+ * behaves exactly like `local`, `100` exactly like `cloud`.
+ *
+ * Resist "fixing" this into a running-counter scheduler — a quota
+ * necessarily sends some trivial steps to the cloud and keeps some
+ * hard ones local, which is the opposite of the intent.
+ */
+ cloudShare?: number;
+ subRunners?: RunModeSubRunners;
+};
+
+export type UserLlmRunModeConfig = {
+ mode?: RunModeName;
+ /** Pin the local leg. Default: the first `llama-server`-kind provider. */
+ localProvider?: string;
+ /** Pin the cloud leg. Default: the first non-`llama-server` provider. */
+ cloudProvider?: string;
+ fusion?: UserLlmFusionConfig;
+};
+
+/** Default cloud share when fusion is selected without an explicit dial. */
+export const DEFAULT_FUSION_CLOUD_SHARE = 40;
+
+const RUN_MODE_NAMES: readonly RunModeName[] = ["local", "cloud", "fusion"];
+const SUB_RUNNER_TARGETS: readonly RunModeSubRunners[] = [
+ "local",
+ "cloud",
+ "follow",
+];
+
+function parseKnownProviderId(
+ raw: unknown,
+ providerIds: ReadonlySet,
+ field: string,
+): string {
+ if (typeof raw !== "string" || raw.length === 0) {
+ throw new ConfigValidationError(field, "expected non-empty string");
+ }
+ if (!providerIds.has(raw)) {
+ throw new ConfigValidationError(
+ field,
+ `unknown provider id ${JSON.stringify(raw)}`,
+ );
+ }
+ return raw;
+}
+
+function parseCloudShare(raw: unknown, field: string): number {
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
+ throw new ConfigValidationError(field, "expected an integer 0-100");
+ }
+ if (raw < 0 || raw > 100) {
+ throw new ConfigValidationError(
+ field,
+ `expected an integer 0-100, got ${raw}`,
+ );
+ }
+ return raw;
+}
+
+function parseFusion(
+ raw: unknown,
+ field: string,
+): UserLlmFusionConfig {
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected object");
+ }
+ const obj = raw as Record;
+ const out: UserLlmFusionConfig = {};
+ if (obj.cloudShare !== undefined) {
+ out.cloudShare = parseCloudShare(obj.cloudShare, `${field}.cloudShare`);
+ }
+ if (obj.subRunners !== undefined) {
+ const target = obj.subRunners;
+ if (
+ typeof target !== "string" ||
+ !SUB_RUNNER_TARGETS.includes(target as RunModeSubRunners)
+ ) {
+ throw new ConfigValidationError(
+ `${field}.subRunners`,
+ `expected ${SUB_RUNNER_TARGETS.join("|")}`,
+ );
+ }
+ out.subRunners = target as RunModeSubRunners;
+ }
+ return out;
+}
+
+/**
+ * Validate the `llm.runMode` block. `providerIds` is the set of ids the
+ * sibling `llm.providers` array declares — a pinned leg that names a
+ * provider which does not exist is a config error, not a silent
+ * degradation, because the operator meant something specific.
+ */
+export function parseLlmRunModeConfig(
+ raw: unknown,
+ providerIds: ReadonlySet,
+ field: string,
+): UserLlmRunModeConfig {
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ throw new ConfigValidationError(field, "expected object");
+ }
+ const obj = raw as Record;
+ const out: UserLlmRunModeConfig = {};
+
+ if (obj.mode !== undefined) {
+ const mode = obj.mode;
+ if (
+ typeof mode !== "string" ||
+ !RUN_MODE_NAMES.includes(mode as RunModeName)
+ ) {
+ throw new ConfigValidationError(
+ `${field}.mode`,
+ `expected ${RUN_MODE_NAMES.join("|")}`,
+ );
+ }
+ out.mode = mode as RunModeName;
+ }
+ if (obj.localProvider !== undefined) {
+ out.localProvider = parseKnownProviderId(
+ obj.localProvider,
+ providerIds,
+ `${field}.localProvider`,
+ );
+ }
+ if (obj.cloudProvider !== undefined) {
+ out.cloudProvider = parseKnownProviderId(
+ obj.cloudProvider,
+ providerIds,
+ `${field}.cloudProvider`,
+ );
+ }
+ if (obj.fusion !== undefined) {
+ out.fusion = parseFusion(obj.fusion, `${field}.fusion`);
+ }
+ return out;
+}
diff --git a/src/config/load-config.test.ts b/src/config/load-config.test.ts
index 96cf2e85..25f0f996 100644
--- a/src/config/load-config.test.ts
+++ b/src/config/load-config.test.ts
@@ -31,6 +31,7 @@ describe("loadConfig", () => {
delete process.env.ATOMIC_AGENT_LLAMA_API_KEY;
delete process.env.ATOMIC_AGENT_LLAMA_MAX_TOKENS;
delete process.env.ATOMIC_AGENT_BROWSER_CHANNEL;
+ delete process.env.ATOMIC_AGENT_GRAMMARS_DIR;
delete process.env.ATOMIC_LOADCONFIG_TEST_KEY;
resetConfigCache();
vi.restoreAllMocks();
@@ -212,4 +213,31 @@ describe("loadConfig", () => {
resetConfigCache();
expect(loadConfig().paths.localModelsDataDir).toBe(override);
});
+
+ it("resolves grammarsDir without consulting the working directory", () => {
+ // The Ctrl+N "new terminal window" spawn starts the agent by absolute
+ // path from the operator's home, so cwd holds no `grammars/` and the
+ // old cwd-relative default died on ENOENT tool-call.gbnf. Standing in
+ // an empty temp dir reproduces exactly that shape.
+ const elsewhere = mkdtempSync(join(tmpdir(), "atomic-cwd-"));
+ const originalCwd = process.cwd();
+ try {
+ process.chdir(elsewhere);
+ resetConfigCache();
+ const grammarsDir = loadConfig().paths.grammarsDir;
+ expect(grammarsDir.startsWith(elsewhere)).toBe(false);
+ expect(existsSync(join(grammarsDir, "tool-call.gbnf"))).toBe(true);
+ } finally {
+ process.chdir(originalCwd);
+ rmSync(elsewhere, { recursive: true, force: true });
+ }
+ });
+
+ it("still lets ATOMIC_AGENT_GRAMMARS_DIR win over the packaged copy", () => {
+ const override = join(stateDir, "custom-grammars");
+ mkdirSync(override);
+ process.env.ATOMIC_AGENT_GRAMMARS_DIR = override;
+ resetConfigCache();
+ expect(loadConfig().paths.grammarsDir).toBe(override);
+ });
});
diff --git a/src/config/load-config.ts b/src/config/load-config.ts
index 49f9a958..71757c79 100644
--- a/src/config/load-config.ts
+++ b/src/config/load-config.ts
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
import {
ENV_DEFAULTS,
@@ -64,8 +65,19 @@ function resolvePath(raw: string | undefined, fallback: string): string {
// Asset directories (e.g. `grammars/`) ship next to the Node SEA binary in
// installed layouts but live under the project root during dev. Env overrides
-// win first; otherwise prefer the binary-adjacent copy and fall back to
-// `/` so `npm run`-style dev invocations still work.
+// win first; otherwise prefer the binary-adjacent copy, then the copy that
+// ships alongside this module, and only then `/`.
+//
+// The module-relative step is what makes `node /abs/path/dist/cli/index.js`
+// work from an unrelated directory — exactly what the Ctrl+N "new terminal
+// window" spawn does, which used to die on `ENOENT .../grammars/tool-call.gbnf`
+// because cwd was the operator's home rather than the install root. Two levels
+// up from this file is the tree root in both layouts: `dist/config/` under a
+// build, `src/config/` under tsx.
+//
+// cwd stays last rather than being dropped: a checkout whose `dist/` was
+// copied elsewhere, or any layout we have not thought of, still resolves as it
+// always did when run from the project root.
function resolveAssetDir(envKey: string, relativeDefault: string): string {
const raw = readEnv(envKey);
if (raw) {
@@ -75,6 +87,15 @@ function resolveAssetDir(envKey: string, relativeDefault: string): string {
if (existsSync(nextToBinary)) {
return nextToBinary;
}
+ const nextToModule = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+ relativeDefault,
+ );
+ if (existsSync(nextToModule)) {
+ return nextToModule;
+ }
return resolve(process.cwd(), relativeDefault);
}
@@ -468,6 +489,8 @@ export function loadConfig(): AtomicAgentConfig {
},
tui: {
theme: user.tui.theme,
+ whileBusySubmit: user.tui.whileBusySubmit,
+ mouse: user.tui.mouse,
},
analytics: {
enabled: user.analytics.enabled,
@@ -507,5 +530,6 @@ function mapUserLlmToRuntime(
};
}),
...(llm.fallback ? { fallback: llm.fallback } : {}),
+ ...(llm.runMode ? { runMode: llm.runMode } : {}),
};
}
diff --git a/src/config/provider-auth-mode.test.ts b/src/config/provider-auth-mode.test.ts
new file mode 100644
index 00000000..f3c26b02
--- /dev/null
+++ b/src/config/provider-auth-mode.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ SUBSCRIPTION_CLI_KIND,
+ usesExternalCliAuth,
+} from "./provider-auth-mode.js";
+
+describe("usesExternalCliAuth", () => {
+ it("is true for a subscription-cli entry that names a cli", () => {
+ expect(
+ usesExternalCliAuth({
+ kind: SUBSCRIPTION_CLI_KIND,
+ subscriptionCli: { cli: "claude" },
+ }),
+ ).toBe(true);
+ expect(
+ usesExternalCliAuth({
+ kind: SUBSCRIPTION_CLI_KIND,
+ subscriptionCli: { cli: "codex" },
+ }),
+ ).toBe(true);
+ });
+
+ it("is false for the kind without a cli block", () => {
+ expect(usesExternalCliAuth({ kind: SUBSCRIPTION_CLI_KIND })).toBe(false);
+ });
+
+ it("is false for every key-carrying kind", () => {
+ for (const kind of [
+ "llama-server",
+ "openai-compatible",
+ "qwen-openai-compatible",
+ "openrouter",
+ "aimlapi",
+ "gemini",
+ ]) {
+ expect(usesExternalCliAuth({ kind })).toBe(false);
+ // Even a hand-edited config that bolts the block onto another kind
+ // must not be treated as CLI-authenticated.
+ expect(usesExternalCliAuth({ kind, subscriptionCli: { cli: "claude" } })).toBe(
+ false,
+ );
+ }
+ });
+});
diff --git a/src/config/provider-auth-mode.ts b/src/config/provider-auth-mode.ts
new file mode 100644
index 00000000..3cd1fce1
--- /dev/null
+++ b/src/config/provider-auth-mode.ts
@@ -0,0 +1,33 @@
+import type { UserLlmProviderEntry } from "./llm-config.js";
+
+/**
+ * Provider kind that authenticates by delegating to an already-signed-in
+ * vendor CLI (`claude`, `codex`) instead of carrying an API key. Lives
+ * here rather than in the provider folder so the config and TUI layers
+ * can classify an entry without importing the provider implementation.
+ */
+export const SUBSCRIPTION_CLI_KIND = "subscription-cli";
+
+/**
+ * Whether this entry gets its credentials from an external CLI's own
+ * session rather than from an API key we resolve.
+ *
+ * Callers use it wherever "has no API key" would otherwise be read as
+ * "not configured": the TUI startup gate and the providers panel both
+ * treat a keyless entry as unusable, which is right for every kind that
+ * existed before subscription CLIs and wrong for this one.
+ *
+ * Deliberately does NOT probe the binary. Both call sites are on
+ * synchronous hot paths (startup, panel refresh), spawning the CLI there
+ * would add ~800ms per TUI launch, and a transient PATH problem would
+ * bounce the user into the local-model setup wizard. A missing binary
+ * surfaces on the first completion and through `health()` instead.
+ */
+export function usesExternalCliAuth(
+ entry: Pick,
+): boolean {
+ return (
+ entry.kind === SUBSCRIPTION_CLI_KIND &&
+ Boolean(entry.subscriptionCli?.cli)
+ );
+}
diff --git a/src/http/route-sessions.test.ts b/src/http/route-sessions.test.ts
index 82424173..18ef9e8e 100644
--- a/src/http/route-sessions.test.ts
+++ b/src/http/route-sessions.test.ts
@@ -59,3 +59,102 @@ describe("/api/sessions", () => {
expect(second.status).toBe(200);
});
});
+
+describe("POST /api/sessions/{id}/steer", () => {
+ let harness: Harness;
+
+ beforeEach(async () => {
+ harness = await startTestHarness();
+ });
+
+ afterEach(async () => {
+ await harness.cleanup();
+ });
+
+ async function steer(
+ sessionId: string,
+ body: unknown,
+ ): Promise {
+ return fetch(`${harness.baseUrl}/api/sessions/${sessionId}/steer`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ /** Hold the session lock so `turnController.isBusy` is true. */
+ async function whileBusy(
+ sessionId: string,
+ fn: () => Promise,
+ ): Promise {
+ let release!: () => void;
+ const held = new Promise((res) => {
+ release = res;
+ });
+ let result!: T;
+ const turn = harness.runtime.turnController.enqueue({
+ sessionId,
+ origin: "http",
+ run: async () => {
+ result = await fn();
+ release();
+ await held;
+ return null;
+ },
+ });
+ await turn;
+ return result;
+ }
+
+ it("accepts a steer while the session has a turn in flight", async () => {
+ const session = harness.runtime.createSession();
+ const response = await whileBusy(session.id, () =>
+ steer(session.id, { text: "actually, stop and summarise" }),
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ steered: true,
+ sessionId: session.id,
+ });
+ expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([
+ "actually, stop and summarise",
+ ]);
+ });
+
+ it("409s on an idle session instead of silently swallowing the message", async () => {
+ const session = harness.runtime.createSession();
+ const response = await steer(session.id, { text: "anyone home?" });
+ expect(response.status).toBe(409);
+ const body = (await response.json()) as { error: { message: string } };
+ expect(body.error.message).toContain("/v1/chat/completions");
+ expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([]);
+ });
+
+ it("409s for a session id that never existed", async () => {
+ const response = await steer("s-nope", { text: "hello" });
+ expect(response.status).toBe(409);
+ });
+
+ it("rejects a missing or blank text", async () => {
+ const session = harness.runtime.createSession();
+ expect((await steer(session.id, {})).status).toBe(400);
+ expect((await steer(session.id, { text: " " })).status).toBe(400);
+ expect((await steer(session.id, { text: 42 })).status).toBe(400);
+ });
+
+ it("429s once the per-session inbox is full", async () => {
+ const session = harness.runtime.createSession();
+ const statuses = await whileBusy(session.id, async () => {
+ const out: number[] = [];
+ // 16 fit (MAX_PENDING_STEERS); the 17th must be refused rather
+ // than evicting one the operator already saw accepted.
+ for (let i = 0; i < 17; i += 1) {
+ out.push((await steer(session.id, { text: `m${i}` })).status);
+ }
+ return out;
+ });
+ expect(statuses.slice(0, 16).every((s) => s === 200)).toBe(true);
+ expect(statuses[16]).toBe(429);
+ });
+});
+
diff --git a/src/http/route-sessions.ts b/src/http/route-sessions.ts
index ad3ddca7..27ccf1d0 100644
--- a/src/http/route-sessions.ts
+++ b/src/http/route-sessions.ts
@@ -1,5 +1,10 @@
import { openaiError } from "./openai-errors.js";
-import { sendError, sendJson, type HttpHandler } from "./request-context.js";
+import {
+ readJsonBody,
+ sendError,
+ sendJson,
+ type HttpHandler,
+} from "./request-context.js";
/**
* `GET /api/sessions` — list recent sessions in the current working
@@ -61,6 +66,65 @@ export function createGetSessionHandler(): HttpHandler {
};
}
+/**
+ * `POST /api/sessions/{id}/steer` — fold `{ text }` into the turn
+ * already running on that session.
+ *
+ * This is NOT a way to send a message: it never starts a turn and never
+ * queues behind one (see §"Mid-turn steering" in AGENTS.md). When the
+ * session is idle there is nothing to steer, and the caller is told so
+ * with `409` rather than having the message silently disappear — the
+ * correct follow-up is `POST /v1/chat/completions`. `429` means the
+ * per-session steering inbox is full; the turn has not read any of them
+ * yet, so piling on more would only bloat one prompt.
+ */
+export function createSteerSessionHandler(): HttpHandler {
+ return async (req, res, ctx) => {
+ const id = ctx.params.id;
+ if (!id) {
+ sendError(res, 400, openaiError("session id is required"));
+ return;
+ }
+ let body: Record;
+ try {
+ body = await readJsonBody>(req);
+ } catch (err) {
+ sendError(
+ res,
+ 400,
+ openaiError(err instanceof Error ? err.message : "invalid body"),
+ );
+ return;
+ }
+ const text = body.text;
+ if (typeof text !== "string" || text.trim().length === 0) {
+ sendError(res, 400, openaiError("text must be a non-empty string"));
+ return;
+ }
+ if (!ctx.runtime.turnController.isBusy(id)) {
+ sendError(
+ res,
+ 409,
+ openaiError(
+ `session ${id} has no turn in flight — send the message with POST /v1/chat/completions instead`,
+ ),
+ );
+ return;
+ }
+ if (!ctx.runtime.steer(id, text)) {
+ sendError(
+ res,
+ 429,
+ openaiError(
+ `steering inbox for session ${id} is full — the running turn has not consumed the pending messages yet`,
+ ),
+ );
+ return;
+ }
+ sendJson(res, 200, { steered: true, sessionId: id });
+ };
+}
+
/**
* `DELETE /api/sessions/{id}` — purge the session row. Idempotent:
* returns 200 whether or not the row existed so orchestrators can
diff --git a/src/http/route-table.ts b/src/http/route-table.ts
index 93e9cf1f..582f0d9f 100644
--- a/src/http/route-table.ts
+++ b/src/http/route-table.ts
@@ -19,6 +19,7 @@ import {
createDeleteSessionHandler,
createGetSessionHandler,
createListSessionsHandler,
+ createSteerSessionHandler,
} from "./route-sessions.js";
import {
createApprovalEventsHandler,
@@ -61,6 +62,11 @@ export function buildRouteTable(): RouteDefinition[] {
{ method: "GET", path: "/api/sessions", handler: createListSessionsHandler() },
{ method: "GET", path: "/api/sessions/{id}", handler: createGetSessionHandler() },
{ method: "DELETE", path: "/api/sessions/{id}", handler: createDeleteSessionHandler() },
+ {
+ method: "POST",
+ path: "/api/sessions/{id}/steer",
+ handler: createSteerSessionHandler(),
+ },
{ method: "POST", path: "/api/approval/resolve", handler: createResolveApprovalHandler() },
{ method: "GET", path: "/api/events", handler: createApprovalEventsHandler() },
{ method: "POST", path: "/api/tasks", handler: createCreateTaskHandler() },
diff --git a/src/llm/fallback/index.ts b/src/llm/fallback/index.ts
index a4dc1f11..cfd7ec33 100644
--- a/src/llm/fallback/index.ts
+++ b/src/llm/fallback/index.ts
@@ -11,7 +11,10 @@ export {
type ResolvedFallbackChain,
} from "./fallback-config.js";
export { shouldAdvance, type AdvanceDecision } from "./should-advance.js";
-export { runWithFallback } from "./run-with-fallback.js";
+export {
+ runWithFallback,
+ type RunWithFallbackOptions,
+} from "./run-with-fallback.js";
export {
primeStream,
replayPrimedStream,
diff --git a/src/llm/fallback/provider-fallback-chain.test.ts b/src/llm/fallback/provider-fallback-chain.test.ts
index d93b9581..59fe5a50 100644
--- a/src/llm/fallback/provider-fallback-chain.test.ts
+++ b/src/llm/fallback/provider-fallback-chain.test.ts
@@ -409,3 +409,101 @@ describe("ProviderFallbackChain", () => {
});
});
});
+
+describe("ProviderFallbackChain — fusion preferred start", () => {
+ it("starts at the preferred leg instead of the chain primary", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ expect(chain.pickProvider("s1", "local")).toEqual({
+ providerId: "local",
+ isProbe: false,
+ });
+ });
+
+ it("never marks a preferred pick as a probe", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ expect(chain.pickProvider("s1", "local").isProbe).toBe(false);
+ });
+
+ it("never sets the sticky override", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ chain.pickProvider("s1", "local");
+ expect(chain.activeOverrideFor("s1")).toBeNull();
+ });
+
+ it("never clears an override that a real fallover established", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ chain.advanceFrom("cloud", http(429), "s1");
+ expect(chain.activeOverrideFor("s1")).toBe("local");
+ chain.pickProvider("s1", "local");
+ expect(chain.activeOverrideFor("s1")).toBe("local");
+ });
+
+ it("accepts a preferred id that is not a chain member", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud"]),
+ });
+ expect(chain.pickProvider("s1", "local").providerId).toBe("local");
+ });
+
+ it("ignores the preference while THAT leg is in cooldown", () => {
+ const clock = makeClock();
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ now: clock.now,
+ });
+ // A 429 on the local leg trips its breaker immediately.
+ chain.advanceFrom("local", http(429), "s1");
+ expect(chain.pickProvider("s1", "local").providerId).not.toBe("local");
+ // Once the cooldown elapses the preference is honoured again.
+ clock.advance(DEFAULT_FALLBACK_TIMING.cooldownMs[0]! + 1);
+ expect(chain.pickProvider("s1", "local").providerId).toBe("local");
+ });
+
+ it("honours the preference while a DIFFERENT leg is in cooldown", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ chain.advanceFrom("cloud", http(429), "s1");
+ expect(chain.pickProvider("s1", "local").providerId).toBe("local");
+ });
+
+ it("resumes from the chain head when a preferred TAIL start fails", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ // Without `restartFromHead` this returns null (nothing after the
+ // tail) and the turn dies even though the cloud leg is healthy.
+ expect(
+ chain.advanceFrom("local", http(503), "s1", { restartFromHead: true }),
+ ).toBe("cloud");
+ });
+
+ it("keeps the default advance behaviour when the flag is absent", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ expect(chain.advanceFrom("local", http(503), "s1")).toBeNull();
+ });
+
+ it("leaves an unpreferred pick byte-identical to today", () => {
+ const chain = new ProviderFallbackChain({
+ resolve: () => chainOf(["cloud", "local"]),
+ });
+ expect(chain.pickProvider("s1")).toEqual({
+ providerId: "cloud",
+ isProbe: false,
+ });
+ expect(chain.pickProvider("s1", undefined)).toEqual({
+ providerId: "cloud",
+ isProbe: false,
+ });
+ });
+});
diff --git a/src/llm/fallback/provider-fallback-chain.ts b/src/llm/fallback/provider-fallback-chain.ts
index 05658ab0..43dbe6ca 100644
--- a/src/llm/fallback/provider-fallback-chain.ts
+++ b/src/llm/fallback/provider-fallback-chain.ts
@@ -36,6 +36,17 @@ export interface ProviderSwitchNotice {
reason: string;
}
+/** Extra switching policy for one `advanceFrom` call. */
+export interface AdvanceOptions {
+ /**
+ * Resume the scan from the chain head rather than from just after
+ * `fromId`. Set when the failed attempt was a fusion-preferred start,
+ * whose position in the chain carries no "already tried everything
+ * above me" meaning.
+ */
+ restartFromHead?: boolean;
+}
+
/** What `pickProvider` decided for the turn about to run. */
export interface ProviderPick {
/** Provider id to route this turn through. */
@@ -117,8 +128,20 @@ export class ProviderFallbackChain {
* chain, drops a stale override that no longer names a chain member,
* and — when the primary's cooldown has elapsed and the probe throttle
* allows — routes this one turn back to the primary as a probe.
+ *
+ * `preferredId` is the fusion router's chosen leg for THIS call. It
+ * changes only the starting link, and health still wins: the
+ * preference is ignored while that specific provider is in cooldown,
+ * and a failure still advances through the chain as usual. It never
+ * sets or clears `overrideId` and is never reported as a probe —
+ * both of those are primary-recovery concepts, and a fusion pick is
+ * not a fallover. The id need not be a chain member; `advanceFrom`
+ * already restarts from the chain head for a non-member.
*/
- pickProvider(partitionKey: string = DEFAULT_PARTITION): ProviderPick {
+ pickProvider(
+ partitionKey: string = DEFAULT_PARTITION,
+ preferredId?: string,
+ ): ProviderPick {
const { chain } = this.resolve();
const primary = chain[0];
if (!primary) {
@@ -135,6 +158,16 @@ export class ProviderFallbackChain {
this.clearOverride(p);
}
+ // Fusion routing preference, checked before the override/probe
+ // logic so a healthy preferred leg is honoured — but only while
+ // that leg itself is healthy, so a tripped breaker still wins.
+ if (preferredId !== undefined && preferredId.length > 0) {
+ const preferred = this.breaker(p, preferredId);
+ if (this.now() >= preferred.cooldownUntil) {
+ return { providerId: preferredId, isProbe: false };
+ }
+ }
+
if (!p.overrideId) {
return { providerId: primary, isProbe: false };
}
@@ -161,6 +194,7 @@ export class ProviderFallbackChain {
fromId: string,
err: unknown,
partitionKey: string = DEFAULT_PARTITION,
+ options?: AdvanceOptions,
): string | null {
const decision = shouldAdvance(err);
if (!decision.advance) return null;
@@ -171,8 +205,14 @@ export class ProviderFallbackChain {
const idx = chain.indexOf(fromId);
// Next healthy link after `fromId`. When `fromId` is not in the chain
- // (raced config edit) start from the top.
- const startFrom = idx < 0 ? 0 : idx + 1;
+ // (raced config edit) start from the top — and likewise when the
+ // failure came from a fusion-preferred start, which can sit anywhere
+ // in the chain and is commonly its TAIL. Advancing "after" the tail
+ // would strand a recoverable turn with the rest of the chain untried.
+ // The `candidate === fromId` guard below keeps the failed link out
+ // of the scan either way.
+ const startFrom =
+ idx < 0 || options?.restartFromHead === true ? 0 : idx + 1;
for (let i = startFrom; i < chain.length; i += 1) {
const candidate = chain[i]!;
if (candidate === fromId) continue;
diff --git a/src/llm/fallback/run-with-fallback.ts b/src/llm/fallback/run-with-fallback.ts
index 0ae01894..a4cca257 100644
--- a/src/llm/fallback/run-with-fallback.ts
+++ b/src/llm/fallback/run-with-fallback.ts
@@ -17,14 +17,31 @@ import type { ProviderFallbackChain } from "./provider-fallback-chain.js";
* chunks is never restarted (mirrors the openai-http "stream is live"
* contract), so failures after the first chunk propagate as-is.
*/
+export interface RunWithFallbackOptions {
+ /**
+ * Provider to START at for this unit of work (fusion routing). Only
+ * the starting link changes: the switching policy below is untouched,
+ * so a failure still advances through the chain and a preferred
+ * provider in cooldown is ignored.
+ */
+ preferredProviderId?: string;
+}
+
export async function runWithFallback(
chain: ProviderFallbackChain,
attempt: (providerId: string) => Promise,
partitionKey?: string,
+ options?: RunWithFallbackOptions,
): Promise {
- const pick = chain.pickProvider(partitionKey);
+ const pick = chain.pickProvider(partitionKey, options?.preferredProviderId);
let currentId = pick.providerId;
let wasProbe = pick.isProbe;
+ // True only while we are still sitting on the fusion-preferred start.
+ // A failure there resumes the chain from its head, because a preferred
+ // leg's position in the chain says nothing about what has been tried.
+ let onPreferredStart =
+ options?.preferredProviderId !== undefined &&
+ currentId === options.preferredProviderId;
// Guard against a pathological empty chain: no provider to try.
if (!currentId) {
@@ -37,12 +54,15 @@ export async function runWithFallback(
chain.recordSuccess(currentId, wasProbe, partitionKey);
return result;
} catch (err) {
- const nextId = chain.advanceFrom(currentId, err, partitionKey);
+ const nextId = chain.advanceFrom(currentId, err, partitionKey, {
+ restartFromHead: onPreferredStart,
+ });
if (nextId === null) throw err;
currentId = nextId;
// Only the very first pick can be a probe; every advance is a real
// fallover on the working path.
wasProbe = false;
+ onPreferredStart = false;
}
}
}
diff --git a/src/llm/index.ts b/src/llm/index.ts
index 775baf9a..2ec45121 100644
--- a/src/llm/index.ts
+++ b/src/llm/index.ts
@@ -62,3 +62,12 @@ export type {
VisionRequest,
VisionResult,
} from "./provider/index.js";
+export {
+ describeRunModeDegradation,
+ resolveRunMode,
+} from "./run-mode/index.js";
+export type {
+ ResolvedRunMode,
+ RunModeDegradation,
+ RunModeDegradationReason,
+} from "./run-mode/index.js";
diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts
index b4cf05d3..008e9877 100644
--- a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts
+++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts
@@ -48,7 +48,33 @@ describe("AIMLAPI_MODELS_CATALOG", () => {
}
});
- it("retires all legacy Anthropic Claude and Google Gemini ids", () => {
+ it("lists the vendor-prefixed Claude and Gemini ids aimlapi serves today", () => {
+ // The catalog used to carry no Claude and no Gemini row at all. Both
+ // are on aimlapi's `openai/chat-completions` surface under
+ // vendor-prefixed ids, so the provider can reach them; only the old
+ // unprefixed spellings below are actually gone.
+ for (const id of [
+ "anthropic/claude-opus-5",
+ "anthropic/claude-sonnet-5",
+ "google/gemini-3.7-flash",
+ "google/gemini-3.5-flash",
+ ]) {
+ expect(AIMLAPI_MODELS_CATALOG.get(id)?.kind).toBe("chat");
+ expect(AIMLAPI_CHAT_MODEL_ORDER).toContain(id);
+ }
+ });
+
+ it("keeps every chat row on the picker order, without duplicates", () => {
+ expect(new Set(AIMLAPI_CHAT_MODEL_ORDER).size).toBe(
+ AIMLAPI_CHAT_MODEL_ORDER.length,
+ );
+ const chatIds = [...AIMLAPI_MODELS_CATALOG]
+ .filter(([, entry]) => entry.kind === "chat")
+ .map(([id]) => id);
+ expect([...AIMLAPI_CHAT_MODEL_ORDER].sort()).toEqual([...chatIds].sort());
+ });
+
+ it("keeps the retired unprefixed Claude and Gemini ids out", () => {
const retired = [
"claude-opus-4-8",
"claude-sonnet-4-6",
diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts
index d53468e5..375c34dc 100644
--- a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts
+++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts
@@ -1,51 +1,5 @@
import type { ModelCatalogEntry } from "../model-resolver.js";
-
-type ChatModelSpec = {
- id: string;
- contextWindow: number;
- supportsVision: boolean;
- supportsTools?: "basic" | "parallel";
- supportsPromptCache?: boolean;
-};
-
-type EmbeddingModelSpec = {
- id: string;
- contextWindow: number;
- dim?: number;
-};
-
-function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] {
- return [
- spec.id,
- {
- id: spec.id,
- kind: "chat",
- contextWindow: spec.contextWindow,
- supportsVision: spec.supportsVision,
- supportsTools: spec.supportsTools ?? "parallel",
- supportsPromptCache: spec.supportsPromptCache ?? false,
- reasoningFormat: "none",
- },
- ];
-}
-
-function embeddingModel(
- spec: EmbeddingModelSpec,
-): readonly [string, ModelCatalogEntry] {
- return [
- spec.id,
- {
- id: spec.id,
- kind: "embedding",
- contextWindow: spec.contextWindow,
- ...(spec.dim !== undefined ? { dim: spec.dim } : {}),
- supportsVision: false,
- supportsTools: "none",
- supportsPromptCache: false,
- reasoningFormat: "none",
- },
- ];
-}
+import { chatModel, embeddingModel } from "../model-catalog-entry.js";
/**
* Static fallback catalog for aimlapi.com.
@@ -57,13 +11,20 @@ function embeddingModel(
* `contextWindow` / `supportsVision` / `supportsTools` for ids that
* have been hand-verified against the live API.
*
- * Curated down to current-generation chat models only — legacy OpenAI
- * (gpt-4o / gpt-4.1 / o-series), all Anthropic Claude, and all Google
- * Gemini ids were retired. Every id here was verified against
- * `https://api.aimlapi.com/v1/models` with `type === "chat-completion"`.
- * Models that only expose `type: "responses"` (`openai/gpt-5-pro`,
- * `openai/gpt-5-3-codex`, etc.) are intentionally excluded — they 404
- * on `/v1/chat/completions`.
+ * Curated down to current-generation chat models only: legacy OpenAI
+ * (gpt-4o / gpt-4.1 / o-series) and the unprefixed `claude-*` /
+ * `google/gemini-2.x` ids stay retired because aimlapi no longer serves
+ * them. Claude and Gemini themselves are back — aimlapi lists them under
+ * vendor-prefixed ids (`anthropic/claude-opus-5`,
+ * `google/gemini-3.7-flash`) on the `openai/chat-completions` surface,
+ * so they work through this provider like any other row.
+ *
+ * Every id here was re-verified on 2026-08-19 against
+ * `https://api.aimlapi.com/v1/models` with `type ===
+ * "openai/chat-completions"`. Models that only expose `type:
+ * "responses"` (`openai/gpt-5-pro`, `openai/gpt-5-3-codex`) or only
+ * `anthropic/messages` are intentionally excluded — they 404 on
+ * `/v1/chat/completions`.
*/
export const AIMLAPI_MODELS_CATALOG: ReadonlyMap =
new Map([
@@ -112,6 +73,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap =
contextWindow: 2_000_000,
supportsVision: true,
}),
+ chatModel({
+ id: "x-ai/grok-4-6",
+ contextWindow: 500_000,
+ supportsVision: true,
+ }),
// DeepSeek
chatModel({
id: "deepseek/deepseek-v4-flash",
@@ -131,6 +97,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap =
contextWindow: 262_144,
supportsVision: false,
}),
+ chatModel({
+ id: "moonshot/kimi-k3",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ }),
// ByteDance Seed
chatModel({
id: "bytedance/dola-seed-2-0-pro",
@@ -143,6 +114,97 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap =
contextWindow: 524_288,
supportsVision: false,
}),
+ // Anthropic Claude (verified `openai/chat-completions`, not the
+ // `anthropic/messages` surface that 404s on /v1/chat/completions)
+ chatModel({
+ id: "anthropic/claude-opus-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "anthropic/claude-sonnet-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "anthropic/claude-fable-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "anthropic/claude-opus-4-8",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "anthropic/claude-haiku-4.5",
+ contextWindow: 200_000,
+ supportsVision: true,
+ }),
+ // Google Gemini
+ chatModel({
+ id: "google/gemini-3.7-flash",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "google/gemini-3.5-flash",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "google/gemini-3.5-flash-lite",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ }),
+ chatModel({
+ id: "google/gemini-3.1-pro-preview",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ }),
+ // Alibaba Qwen
+ chatModel({
+ id: "alibaba/qwen3.8-max",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ }),
+ chatModel({
+ id: "alibaba/qwen3.7-max",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ }),
+ chatModel({
+ id: "alibaba/qwen3.6-flash",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ }),
+ chatModel({
+ id: "alibaba/qwen3-vl-plus",
+ contextWindow: 262_144,
+ supportsVision: true,
+ }),
+ // Zhipu GLM
+ chatModel({
+ id: "zhipu/glm-5-3",
+ contextWindow: 1_024_000,
+ supportsVision: false,
+ }),
+ chatModel({
+ id: "zhipu/glm-5.2",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ }),
+ // Mistral
+ chatModel({
+ id: "mistralai/mistral-large-2512",
+ contextWindow: 262_144,
+ supportsVision: false,
+ }),
+ chatModel({
+ id: "mistralai/mistral-medium-3-5",
+ contextWindow: 262_144,
+ supportsVision: false,
+ }),
// Embeddings (verified against `/v1/models`)
embeddingModel({
id: "text-embedding-3-small",
@@ -169,7 +231,9 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap =
}),
]);
-/** TUI chat-picker order when offline. Verified ids only. */
+/**
+ * TUI chat-picker order when offline: catalog order, verified ids only.
+ */
export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [
"openai/gpt-5.5-2026-04-23",
"openai/gpt-5.4-2026-03-05",
@@ -179,11 +243,30 @@ export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [
"openai/gpt-oss-20b",
"x-ai/grok-4-3",
"x-ai/grok-4-fast-reasoning",
+ "x-ai/grok-4-6",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
"moonshot/kimi-k2-7-code",
+ "moonshot/kimi-k3",
"bytedance/dola-seed-2-0-pro",
"minimax/minimax-m3",
+ "anthropic/claude-opus-5",
+ "anthropic/claude-sonnet-5",
+ "anthropic/claude-fable-5",
+ "anthropic/claude-opus-4-8",
+ "anthropic/claude-haiku-4.5",
+ "google/gemini-3.7-flash",
+ "google/gemini-3.5-flash",
+ "google/gemini-3.5-flash-lite",
+ "google/gemini-3.1-pro-preview",
+ "alibaba/qwen3.8-max",
+ "alibaba/qwen3.7-max",
+ "alibaba/qwen3.6-flash",
+ "alibaba/qwen3-vl-plus",
+ "zhipu/glm-5-3",
+ "zhipu/glm-5.2",
+ "mistralai/mistral-large-2512",
+ "mistralai/mistral-medium-3-5",
];
/**
diff --git a/src/llm/provider/completion-types.ts b/src/llm/provider/completion-types.ts
index d6cd5644..41109097 100644
--- a/src/llm/provider/completion-types.ts
+++ b/src/llm/provider/completion-types.ts
@@ -98,6 +98,15 @@ export interface CompletionResult {
* authoritative.
*/
servedTransport?: ToolCallTransport;
+ /**
+ * Id of the provider that actually served this completion. Stamped by
+ * the same wrapper, and for the same reason as `servedTransport`:
+ * under fusion routing (and after any fallover) the link that answered
+ * is not necessarily `llm.activeTextProvider`, so anything that
+ * attributes the result — cost/pricing lookup above all — has to ask
+ * who served it rather than who was active.
+ */
+ servedProviderId?: string;
}
export interface OpenAiToolCall {
diff --git a/src/llm/provider/format-model-details.ts b/src/llm/provider/format-model-details.ts
new file mode 100644
index 00000000..daa4cbcf
--- /dev/null
+++ b/src/llm/provider/format-model-details.ts
@@ -0,0 +1,57 @@
+import type { ModelCatalogEntry } from "./model-resolver.js";
+
+/**
+ * How a catalog row is described to a human: context window, price per
+ * 1M tokens, capability summary.
+ *
+ * Lifted out of `src/tui/providers/providers-model-options.ts` so the
+ * `models search` CLI prints the same strings as the TUI picker without
+ * a CLI -> TUI import. `src/llm/` is the layer both frontends already
+ * depend on.
+ */
+
+export function formatContextWindow(tokens: number): string {
+ if (tokens >= 1_000_000) {
+ const millions = tokens / 1_000_000;
+ return `${formatCompactNumber(millions)}M`;
+ }
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
+ return `${tokens}`;
+}
+
+export function formatTokenPrice(
+ modelId: string,
+ pricing: ModelCatalogEntry["pricing"],
+): string {
+ if (!pricing) return "price unknown";
+ if (modelId === "openrouter/auto") return "routed";
+ if (pricing.input === 0 && pricing.output === 0) return "free";
+ return `$${formatPrice(pricing.input)}/$${formatPrice(pricing.output)}`;
+}
+
+export function formatEmbeddingTokenPrice(
+ pricing: ModelCatalogEntry["pricing"],
+): string {
+ if (!pricing) return "$?";
+ if (pricing.input === 0) return "free";
+ return `$${formatPrice(pricing.input)}`;
+}
+
+export function formatCapabilitySummary(entry: ModelCatalogEntry): string {
+ const modality = entry.supportsVision ? "vision" : "text";
+ const tools = entry.supportsTools === "none" ? null : "tools";
+ const cache = entry.supportsPromptCache ? "cache" : null;
+ return [modality, tools, cache].filter(Boolean).join(" · ");
+}
+
+function formatCompactNumber(value: number): string {
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
+}
+
+export function formatPrice(value: number): string {
+ if (value === 0) return "0";
+ if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
+ return Number.isInteger(value)
+ ? String(value)
+ : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
+}
diff --git a/src/llm/provider/index.ts b/src/llm/provider/index.ts
index dd57217b..2b7c1b7b 100644
--- a/src/llm/provider/index.ts
+++ b/src/llm/provider/index.ts
@@ -29,6 +29,12 @@ export { resolveModel, type ResolvedModel } from "./model-resolver.js";
export { CostAccumulator, type CostAccumulatorSnapshot } from "./cost-accumulator.js";
export { OpenAiProvider, type OpenAiProviderOptions } from "./openai/index.js";
export { OpenRouterProvider } from "./openrouter/index.js";
+export {
+ CLAUDE_CLI_CHAT_MODELS,
+ CLAUDE_CLI_DEFAULT_CHAT_MODEL,
+ SubscriptionCliProvider,
+ type SubscriptionCliProviderOptions,
+} from "./subscription-cli/index.js";
export {
GeminiProvider,
type GeminiProviderOptions,
diff --git a/src/llm/provider/model-catalog-entry.ts b/src/llm/provider/model-catalog-entry.ts
new file mode 100644
index 00000000..8ed65410
--- /dev/null
+++ b/src/llm/provider/model-catalog-entry.ts
@@ -0,0 +1,65 @@
+import type { ModelCatalogEntry } from "./model-resolver.js";
+
+/**
+ * Row builders shared by the bundled provider catalogs.
+ *
+ * OpenRouter and aimlapi both ship a static `ReadonlyMap` and both used to declare their own private
+ * `chatModel` / `embeddingModel` helpers. The two copies had already
+ * drifted — one defaulted `supportsTools` to `"parallel"`, the other
+ * hard-coded it — so the shared version keeps every field explicit and
+ * lets each catalog omit what its API genuinely does not publish
+ * (`pricing` is absent from the aimlapi payload, so aimlapi rows carry
+ * no price rather than a made-up one).
+ */
+
+export type ChatModelSpec = {
+ readonly id: string;
+ readonly contextWindow: number;
+ readonly supportsVision: boolean;
+ readonly supportsTools?: "none" | "basic" | "parallel" | "strict";
+ readonly supportsPromptCache?: boolean;
+ readonly pricing?: { readonly input: number; readonly output: number };
+};
+
+export type EmbeddingModelSpec = {
+ readonly id: string;
+ readonly contextWindow: number;
+ readonly dim?: number;
+ readonly pricing?: { readonly input: number; readonly output: number };
+};
+
+export type CatalogRow = readonly [string, ModelCatalogEntry];
+
+export function chatModel(spec: ChatModelSpec): CatalogRow {
+ return [
+ spec.id,
+ {
+ id: spec.id,
+ kind: "chat",
+ contextWindow: spec.contextWindow,
+ supportsVision: spec.supportsVision,
+ supportsTools: spec.supportsTools ?? "parallel",
+ supportsPromptCache: spec.supportsPromptCache ?? false,
+ reasoningFormat: "none",
+ ...(spec.pricing ? { pricing: spec.pricing } : {}),
+ },
+ ];
+}
+
+export function embeddingModel(spec: EmbeddingModelSpec): CatalogRow {
+ return [
+ spec.id,
+ {
+ id: spec.id,
+ kind: "embedding",
+ contextWindow: spec.contextWindow,
+ ...(spec.dim !== undefined ? { dim: spec.dim } : {}),
+ supportsVision: false,
+ supportsTools: "none",
+ supportsPromptCache: false,
+ reasoningFormat: "none",
+ ...(spec.pricing ? { pricing: spec.pricing } : {}),
+ },
+ ];
+}
diff --git a/src/llm/provider/model-search.test.ts b/src/llm/provider/model-search.test.ts
new file mode 100644
index 00000000..91fd07c3
--- /dev/null
+++ b/src/llm/provider/model-search.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from "vitest";
+
+import type { ModelCatalogEntry } from "./model-resolver.js";
+import {
+ modelSearchTags,
+ searchModelIds,
+ searchModels,
+ splitQueryTerms,
+} from "./model-search.js";
+
+function entry(over: Partial = {}): ModelCatalogEntry {
+ return {
+ id: over.id ?? "x",
+ kind: "chat",
+ contextWindow: 128_000,
+ supportsVision: false,
+ supportsTools: "parallel",
+ supportsPromptCache: false,
+ reasoningFormat: "none",
+ ...over,
+ } as ModelCatalogEntry;
+}
+
+const CATALOG: readonly { id: string; entry: ModelCatalogEntry }[] = [
+ {
+ id: "anthropic/claude-opus-5",
+ entry: entry({
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 5, output: 25 },
+ }),
+ },
+ {
+ id: "anthropic/claude-haiku-4.5",
+ entry: entry({
+ contextWindow: 200_000,
+ supportsVision: true,
+ pricing: { input: 0.8, output: 4 },
+ }),
+ },
+ {
+ id: "qwen/qwen3.6-flash",
+ entry: entry({ contextWindow: 1_000_000, pricing: { input: 0.19, output: 1.13 } }),
+ },
+ {
+ id: "openai/gpt-oss-20b",
+ entry: entry({ contextWindow: 131_072, pricing: { input: 0, output: 0 } }),
+ },
+];
+
+const ids = (rows: readonly { id: string }[]): readonly string[] =>
+ rows.map((row) => row.id);
+
+describe("splitQueryTerms", () => {
+ it("lowercases, trims and drops empty terms", () => {
+ expect(splitQueryTerms(" Claude VISION ")).toEqual(["claude", "vision"]);
+ expect(splitQueryTerms(" ")).toEqual([]);
+ });
+});
+
+describe("searchModels", () => {
+ it("returns everything, in order, for an empty query", () => {
+ expect(searchModels(CATALOG, "")).toBe(CATALOG);
+ expect(searchModels(CATALOG, " ")).toBe(CATALOG);
+ });
+
+ it("keeps the old substring behaviour for a single term", () => {
+ expect(ids(searchModels(CATALOG, "claude"))).toEqual([
+ "anthropic/claude-opus-5",
+ "anthropic/claude-haiku-4.5",
+ ]);
+ expect(ids(searchModels(CATALOG, "OPUS"))).toEqual(["anthropic/claude-opus-5"]);
+ });
+
+ it("ANDs multiple terms instead of matching the raw string", () => {
+ // "claude vision" is not a substring of any id — this is the query
+ // the old single-`includes` filter answered with an empty list.
+ expect(ids(searchModels(CATALOG, "claude vision"))).toEqual([
+ "anthropic/claude-opus-5",
+ "anthropic/claude-haiku-4.5",
+ ]);
+ expect(ids(searchModels(CATALOG, "claude 1m"))).toEqual([
+ "anthropic/claude-opus-5",
+ ]);
+ expect(searchModels(CATALOG, "claude qwen")).toEqual([]);
+ });
+
+ it("matches capability and price tags off the catalog entry", () => {
+ expect(ids(searchModels(CATALOG, "free"))).toEqual(["openai/gpt-oss-20b"]);
+ // The tag follows the rendered price, so a router row is "routed",
+ // never "free", and never "cheap" either.
+ const auto = [
+ { id: "openrouter/auto", entry: entry({ pricing: { input: 0, output: 0 } }) },
+ ];
+ expect(ids(searchModels(auto, "routed"))).toEqual(["openrouter/auto"]);
+ expect(searchModels(auto, "free")).toEqual([]);
+ expect(searchModels(auto, "cheap")).toEqual([]);
+ expect(ids(searchModels(CATALOG, "cache"))).toEqual(["anthropic/claude-opus-5"]);
+ expect(ids(searchModels(CATALOG, "cheap"))).toEqual([
+ "anthropic/claude-haiku-4.5",
+ "qwen/qwen3.6-flash",
+ ]);
+ });
+
+ it("matches the vendor prefix", () => {
+ expect(ids(searchModels(CATALOG, "anthropic"))).toEqual([
+ "anthropic/claude-opus-5",
+ "anthropic/claude-haiku-4.5",
+ ]);
+ });
+
+ it("ranks exact ids and prefixes above buried substrings", () => {
+ const rows = [
+ { id: "vendor/needs-opus-handling", entry: entry() },
+ { id: "opus", entry: entry() },
+ { id: "opus-mini", entry: entry() },
+ ];
+ expect(ids(searchModels(rows, "opus"))).toEqual([
+ "opus",
+ "opus-mini",
+ "vendor/needs-opus-handling",
+ ]);
+ });
+
+ it("keeps input order between equally ranked rows", () => {
+ // The catalogs are hand-ordered and this runs on every keystroke, so
+ // equal matches must not shuffle under the cursor.
+ const rows = [
+ { id: "a/model-one", entry: entry() },
+ { id: "a/model-two", entry: entry() },
+ { id: "a/model-three", entry: entry() },
+ ];
+ expect(ids(searchModels(rows, "model"))).toEqual([
+ "a/model-one",
+ "a/model-two",
+ "a/model-three",
+ ]);
+ });
+
+ it("falls back to a subsequence match, ranked last", () => {
+ const rows = [
+ { id: "openai/gpt-oss-20b", entry: entry() },
+ { id: "vendor/gpt", entry: entry() },
+ ];
+ // "gpto" is nobody's substring; it is a subsequence of the first id.
+ expect(ids(searchModels(rows, "gpto"))).toEqual(["openai/gpt-oss-20b"]);
+ });
+
+ it("still matches ids with no catalog entry, on the id alone", () => {
+ const rows = [{ id: "some-local-model" }, { id: "other" }];
+ expect(ids(searchModels(rows, "local"))).toEqual(["some-local-model"]);
+ // No entry means no tags, so a capability term cannot match.
+ expect(searchModels(rows, "vision")).toEqual([]);
+ });
+});
+
+describe("modelSearchTags", () => {
+ it("derives tags from the entry and nothing else", () => {
+ expect(modelSearchTags(undefined)).toEqual([]);
+ expect(
+ modelSearchTags(
+ entry({
+ contextWindow: 200_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0, output: 0 },
+ }),
+ ),
+ ).toEqual(["chat", "vision", "tools", "cache", "200k", "free"]);
+ });
+});
+
+describe("searchModelIds", () => {
+ it("searches plain ids and uses the lookup for metadata when given", () => {
+ const all = CATALOG.map((row) => row.id);
+ const lookup = (id: string): ModelCatalogEntry | undefined =>
+ CATALOG.find((row) => row.id === id)?.entry;
+ expect(searchModelIds(all, "vision", lookup)).toEqual([
+ "anthropic/claude-opus-5",
+ "anthropic/claude-haiku-4.5",
+ ]);
+ // Without the lookup the same query has no metadata to match on.
+ expect(searchModelIds(all, "vision")).toEqual([]);
+ expect(searchModelIds(all, "")).toBe(all);
+ });
+});
diff --git a/src/llm/provider/model-search.ts b/src/llm/provider/model-search.ts
new file mode 100644
index 00000000..44ff57ef
--- /dev/null
+++ b/src/llm/provider/model-search.ts
@@ -0,0 +1,156 @@
+import {
+ formatContextWindow,
+ formatTokenPrice,
+} from "./format-model-details.js";
+import type { ModelCatalogEntry } from "./model-resolver.js";
+
+/**
+ * Ranked, multi-term search over model ids and their catalog metadata.
+ *
+ * The picker used to filter with one case-insensitive `includes` over
+ * the id, which is fine for 18 rows and useless for the 300-400 the
+ * live OpenRouter catalog returns: "the cheap Claude with vision" is
+ * not a substring of anything. Here a query is split into terms, every
+ * term has to match (AND), and a term may match the id, the vendor, or
+ * a capability tag derived from the catalog entry — so `claude vision`,
+ * `1m cache` and `free tools` all narrow the list.
+ *
+ * Matches are ranked, best first, and equal ranks keep input order: the
+ * bundled catalogs are hand-ordered and the picker re-runs this on
+ * every keystroke, so rows must not jitter between presses.
+ */
+
+export type ModelSearchItem = {
+ readonly id: string;
+ readonly entry?: ModelCatalogEntry | undefined;
+};
+
+/** Metadata lookup for callers that hold ids and a catalog separately. */
+export type ModelEntryLookup = (id: string) => ModelCatalogEntry | undefined;
+
+/**
+ * Per-term match strength. Summed across terms into the row score, so a
+ * row matching one term exactly and another loosely still outranks a row
+ * that matches both loosely.
+ */
+const RANK = {
+ exactId: 6,
+ idPrefix: 5,
+ vendor: 4,
+ wordStart: 3,
+ substring: 2,
+ tag: 2,
+ subsequence: 1,
+ none: 0,
+} as const;
+
+export function splitQueryTerms(query: string): readonly string[] {
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
+}
+
+/**
+ * Searchable tags for a row: what an operator would type that is not
+ * part of the id. Everything here is derived from the catalog entry, so
+ * a row without metadata simply has fewer ways to be found.
+ */
+export function modelSearchTags(
+ entry: ModelCatalogEntry | undefined,
+ modelId?: string,
+): readonly string[] {
+ if (!entry) return [];
+ const tags: string[] = [entry.kind];
+ tags.push(entry.supportsVision ? "vision" : "text");
+ if (entry.supportsTools !== "none") tags.push("tools");
+ if (entry.supportsPromptCache) tags.push("cache");
+ if (entry.contextWindow > 0) {
+ tags.push(formatContextWindow(entry.contextWindow).toLowerCase());
+ }
+ // Price tags mirror what the row displays, so searching for what you
+ // can see works: `openrouter/auto` renders as "routed", not "free",
+ // even though its list price is zero.
+ const priceLabel = formatTokenPrice(modelId ?? entry.id, entry.pricing);
+ if (priceLabel === "free" || priceLabel === "routed") tags.push(priceLabel);
+ else if (entry.pricing && entry.pricing.input > 0 && entry.pricing.input < 1) {
+ tags.push("cheap");
+ }
+ return tags;
+}
+
+function rankTerm(
+ term: string,
+ id: string,
+ vendor: string,
+ tags: readonly string[],
+): number {
+ if (id === term) return RANK.exactId;
+ if (id.startsWith(term)) return RANK.idPrefix;
+ if (vendor === term || vendor.startsWith(term)) return RANK.vendor;
+ const at = id.indexOf(term);
+ if (at >= 0) {
+ // A term that starts a word ("opus" in "claude-opus-5") is a better
+ // hit than one buried mid-token ("pus").
+ const before = at === 0 ? "" : id[at - 1]!;
+ return at === 0 || /[^a-z0-9]/.test(before) ? RANK.wordStart : RANK.substring;
+ }
+ if (tags.includes(term)) return RANK.tag;
+ return isSubsequence(term, id) ? RANK.subsequence : RANK.none;
+}
+
+/** Typo tolerance: every character of `term`, in order, somewhere in `id`. */
+function isSubsequence(term: string, id: string): boolean {
+ let i = 0;
+ for (const ch of id) {
+ if (ch === term[i]) i += 1;
+ if (i === term.length) return true;
+ }
+ return term.length === 0;
+}
+
+export function scoreModel(
+ item: ModelSearchItem,
+ terms: readonly string[],
+): number {
+ const id = item.id.toLowerCase();
+ const slash = id.indexOf("/");
+ const vendor = slash > 0 ? id.slice(0, slash) : "";
+ const tags = modelSearchTags(item.entry, item.id);
+ let total = 0;
+ for (const term of terms) {
+ const rank = rankTerm(term, id, vendor, tags);
+ // AND semantics: one unmatched term drops the row entirely.
+ if (rank === RANK.none) return RANK.none;
+ total += rank;
+ }
+ return total;
+}
+
+/**
+ * Rows matching `query`, best match first. An empty query returns
+ * `items` untouched — the caller renders the full catalog.
+ */
+export function searchModels(
+ items: readonly T[],
+ query: string,
+): readonly T[] {
+ const terms = splitQueryTerms(query);
+ if (terms.length === 0) return items;
+ const scored: { item: T; score: number; index: number }[] = [];
+ items.forEach((item, index) => {
+ const score = scoreModel(item, terms);
+ if (score > 0) scored.push({ item, score, index });
+ });
+ scored.sort((a, b) => b.score - a.score || a.index - b.index);
+ return scored.map((row) => row.item);
+}
+
+/** `searchModels` for callers that hold plain ids plus an optional catalog. */
+export function searchModelIds(
+ ids: readonly string[],
+ query: string,
+ lookup?: ModelEntryLookup,
+): readonly string[] {
+ const terms = splitQueryTerms(query);
+ if (terms.length === 0) return ids;
+ const items = ids.map((id) => ({ id, entry: lookup?.(id) }));
+ return searchModels(items, query).map((item) => item.id);
+}
diff --git a/src/llm/provider/openai/openai-stream-consumer.test.ts b/src/llm/provider/openai/openai-stream-consumer.test.ts
new file mode 100644
index 00000000..01add17e
--- /dev/null
+++ b/src/llm/provider/openai/openai-stream-consumer.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+
+import { createOpenAiStreamConsumer } from "./openai-stream-consumer.js";
+import type { StreamFinalResult } from "../completion-types.js";
+
+/** An SSE body carrying one `data:` line per event, then `[DONE]`. */
+function sseBody(events: readonly unknown[]): ReadableStream {
+ const encoder = new TextEncoder();
+ const chunks = [
+ ...events.map((e) => `data: ${JSON.stringify(e)}\n\n`),
+ "data: [DONE]\n\n",
+ ];
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ });
+}
+
+function toolCallDelta(
+ index: number,
+ fn: { name?: string; arguments?: string },
+ extra: Record = {},
+): unknown {
+ return {
+ choices: [
+ {
+ index: 0,
+ delta: { tool_calls: [{ index, function: fn, ...extra }] },
+ },
+ ],
+ };
+}
+
+async function drain(
+ body: ReadableStream,
+): Promise {
+ const consumer = createOpenAiStreamConsumer("none");
+ const it = consumer.consume(body, new AbortController().signal);
+ let last: IteratorResult;
+ do {
+ last = (await it.next()) as IteratorResult;
+ } while (!last.done);
+ return last.value;
+}
+
+describe("openai stream consumer — tool call names", () => {
+ it("keeps the name whole when the gateway repeats it in every chunk", async () => {
+ // AI/ML API fronting Anthropic does exactly this. Concatenating turned
+ // `reply` into `replyreplyreplyreplyreply`, and the agent then refused
+ // its own protocol tool with "tool not registered in this agent".
+ const result = await drain(
+ sseBody([
+ toolCallDelta(0, { name: "reply", arguments: '{"text"' }, {
+ id: "call_1",
+ type: "function",
+ }),
+ toolCallDelta(0, { name: "reply", arguments: ':"hi' }),
+ toolCallDelta(0, { name: "reply", arguments: ' there"}' }),
+ { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] },
+ ]),
+ );
+ expect(result.toolCalls?.[0]?.function.name).toBe("reply");
+ expect(result.toolCalls?.[0]?.function.arguments).toBe(
+ '{"text":"hi there"}',
+ );
+ });
+
+ it("takes the spec shape, where the name arrives once", async () => {
+ const result = await drain(
+ sseBody([
+ toolCallDelta(0, { name: "os.fs.read", arguments: "{}" }, {
+ id: "call_1",
+ type: "function",
+ }),
+ { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] },
+ ]),
+ );
+ expect(result.toolCalls?.[0]?.function.name).toBe("os.fs.read");
+ });
+
+ it("still assembles a name that is genuinely split across chunks", async () => {
+ const result = await drain(
+ sseBody([
+ toolCallDelta(0, { name: "os.fs" }, { id: "c", type: "function" }),
+ toolCallDelta(0, { name: ".read", arguments: "{}" }),
+ { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] },
+ ]),
+ );
+ expect(result.toolCalls?.[0]?.function.name).toBe("os.fs.read");
+ });
+
+ it("assembles a split that ends in the name's doubled last letter", async () => {
+ // `os.proc.kil` + `l`. The fragment is a suffix of what we already
+ // hold, so dropping partial repeats left `os.proc.kil` — a name no
+ // registry has. Every tool whose name ends in a doubled letter
+ // (`os.proc.kill`, `browser.scroll`, `memory.*.recall`) splits this
+ // way, so the suffix signal is loss, not de-duplication.
+ const result = await drain(
+ sseBody([
+ toolCallDelta(0, { name: "os.proc.kil" }, { id: "c", type: "function" }),
+ toolCallDelta(0, { name: "l", arguments: "{}" }),
+ { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] },
+ ]),
+ );
+ expect(result.toolCalls?.[0]?.function.name).toBe("os.proc.kill");
+ });
+
+ it("keeps parallel calls apart when both repeat their names", async () => {
+ const result = await drain(
+ sseBody([
+ toolCallDelta(0, { name: "os.fs.read", arguments: "{}" }, { id: "a" }),
+ toolCallDelta(1, { name: "os.shell.run", arguments: "{}" }, { id: "b" }),
+ toolCallDelta(0, { name: "os.fs.read" }),
+ toolCallDelta(1, { name: "os.shell.run" }),
+ { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] },
+ ]),
+ );
+ expect(result.toolCalls?.map((c) => c.function.name)).toEqual([
+ "os.fs.read",
+ "os.shell.run",
+ ]);
+ });
+});
diff --git a/src/llm/provider/openai/openai-stream-consumer.ts b/src/llm/provider/openai/openai-stream-consumer.ts
index a866e3c6..4a4d2398 100644
--- a/src/llm/provider/openai/openai-stream-consumer.ts
+++ b/src/llm/provider/openai/openai-stream-consumer.ts
@@ -120,7 +120,9 @@ function applyToolCallDeltas(
};
if (delta.id) current.id = delta.id;
if (delta.type) current.type = delta.type;
- if (delta.function?.name) current.function.name += delta.function.name;
+ if (delta.function?.name) {
+ appendToolName(current.function, delta.function.name);
+ }
if (delta.function?.arguments) {
current.function.arguments += delta.function.arguments;
}
@@ -128,6 +130,39 @@ function applyToolCallDeltas(
}
}
+/**
+ * Accumulate a tool call's function name across stream deltas.
+ *
+ * The OpenAI streaming shape sends the name **once**, in the first delta
+ * for a given `index`, and streams only `arguments` after that. Plenty of
+ * gateways do not: AI/ML API fronting Anthropic repeats the whole name in
+ * every chunk of the call. Blind concatenation turned a five-chunk `reply`
+ * into `replyreplyreplyreplyreply`, which then failed the registry lookup
+ * as `tool not registered in this agent` — the model had done nothing
+ * wrong, and the transcript showed the tool it actually asked for nowhere.
+ *
+ * Only one case is detectable: a fragment equal to the whole name we
+ * already hold is that repeat, and is dropped. Everything else is a
+ * continuation and is appended, so a gateway that really does split a
+ * name across chunks still assembles.
+ *
+ * A *partial* repeat cannot be separated from a continuation even in
+ * principle — `["search", "search"]` is both a repeated `search` and a
+ * split `searchsearch` — and guessing by suffix costs real tool names:
+ * every name ending in a doubled letter (`os.proc.kill`, `browser.scroll`,
+ * `os.git.diff`, the `memory.*.recall` trio) split before its last
+ * character would lose that character and fail the same registry lookup
+ * this function exists to keep working.
+ */
+function appendToolName(fn: { name: string }, fragment: string): void {
+ if (!fn.name) {
+ fn.name = fragment;
+ return;
+ }
+ if (fn.name === fragment) return;
+ fn.name += fragment;
+}
+
function buildFinalResult(args: {
content: string;
reasoningContent: string;
diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts
index c0479104..0e079a68 100644
--- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts
+++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts
@@ -29,7 +29,7 @@ describe("refreshOpenRouterChatCatalogFromApi", () => {
vi.unstubAllGlobals();
});
- it("filters out Anthropic and keeps tool-capable models", async () => {
+ it("keeps Anthropic alongside every other tool-capable model", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
@@ -78,7 +78,10 @@ describe("refreshOpenRouterChatCatalogFromApi", () => {
expect(picks.some((p) => p.id === "openrouter/auto")).toBe(true);
expect(picks.some((p) => p.id === "qwen/qwen3.6-35b-a3b")).toBe(true);
expect(picks.some((p) => p.id === "qwen/qwen3.5-35b-a3b")).toBe(false);
- expect(picks.some((p) => p.id.startsWith("anthropic/"))).toBe(false);
+ // Was `toBe(false)`: `scoreChat` used to return -1 for every
+ // `anthropic/*` id, which hid the whole Claude line from the picker.
+ // Vendor is a ranking input now, not a gate.
+ expect(picks.some((p) => p.id === "anthropic/claude-sonnet-4")).toBe(true);
});
it("keeps every advertised model instead of a capped head", async () => {
diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts
index 510e0be2..15f3ca67 100644
--- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts
+++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts
@@ -49,10 +49,26 @@ function hasTools(m: OpenRouterApiModel): boolean {
return readAdvertisedTools(m) ?? true;
}
+/**
+ * Ranking, not gatekeeping.
+ *
+ * This function used to return -1 for every `anthropic/*` id and
+ * everything matching `/gemini/i`, which removed ~40 currently served
+ * models — the whole Claude 5 and Gemini 3.x lines — from the picker
+ * with no way for an operator to get them back. Nothing in the runtime
+ * needs that: both families speak the same OpenAI-shaped
+ * `/v1/chat/completions` OpenRouter exposes for everything else, and
+ * `native_tools` transport is what the picker already requires via
+ * `hasTools`. The exclusions are gone; the families are scored instead,
+ * so the models this agent is tuned for still sort to the top.
+ *
+ * A negative score is now reserved for rows that genuinely cannot be
+ * used: non-chat surfaces (embeddings, rerank, TTS) and models that
+ * explicitly advertise no tool support.
+ */
function scoreChat(m: OpenRouterApiModel): number {
const id = m.id ?? "";
- if (!id || id.startsWith("anthropic/")) return -1;
- if (/gemini/i.test(id)) return -1;
+ if (!id) return -1;
if (/qwen3\.5/i.test(id)) return -1;
if (/embed|rerank|moderation|ocr|tts|transcribe/i.test(id)) return -1;
if (!hasTools(m)) return -1;
@@ -62,6 +78,10 @@ function scoreChat(m: OpenRouterApiModel): number {
else if (ctx >= 200_000) s += 5;
if (/qwen3\.7|qwen3\.6/i.test(id)) s += 20;
if (/gpt-5\./i.test(id)) s += 15;
+ if (/claude-(opus|sonnet|fable|haiku)-5|claude-opus-4\.8/i.test(id)) s += 18;
+ else if (id.startsWith("anthropic/")) s += 6;
+ if (/gemini-3\./i.test(id)) s += 14;
+ else if (/gemini/i.test(id)) s += 4;
if (/deepseek.*v4|deepseek.*v3/i.test(id)) s += 10;
if (/kimi-k2\.6/i.test(id)) s += 12;
else if (/kimi-k2/i.test(id)) s += 8;
@@ -146,8 +166,8 @@ let inFlight: Promise | null = null;
/**
* Pull the public OpenRouter model list and rebuild the TUI picker
- * (non-Anthropic, `tools`-capable chat models). Falls back to the static
- * catalog on network/parse errors.
+ * (every `tools`-capable chat model OpenRouter advertises). Falls back to
+ * the static catalog on network/parse errors.
*
* Concurrent callers share one request: the TUI triggers this from both
* the panel prefetch and the wizard's picker step, and doubling the
diff --git a/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts
new file mode 100644
index 00000000..f100ff52
--- /dev/null
+++ b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts
@@ -0,0 +1,168 @@
+import { chatModel, type CatalogRow } from "../model-catalog-entry.js";
+
+/**
+ * Hosted frontier chat models on OpenRouter — the vendors that only ship
+ * behind an API.
+ *
+ * Generated from `https://openrouter.ai/api/v1/models` on 2026-08-19 and
+ * hand-curated down to the current generation of each family: every row's
+ * `contextWindow`, `supportsVision` (`architecture.input_modalities`
+ * contains `image`), `supportsPromptCache` (`pricing.input_cache_read` is
+ * published) and `pricing` (USD per 1M tokens) comes from that response,
+ * and every id advertises `tools` in `supported_parameters`.
+ *
+ * Anthropic and Gemini rows live here because they are no longer filtered
+ * out — see the note on `scoreChat` in `fetch-openrouter-chat-catalog.ts`.
+ */
+export const OPENROUTER_FRONTIER_CHAT_MODELS: readonly CatalogRow[] = [
+ // Anthropic — Claude 5 / 4.8
+ chatModel({
+ id: "anthropic/claude-opus-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 5, output: 25 },
+ }),
+ chatModel({
+ id: "anthropic/claude-opus-5-fast",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 10, output: 50 },
+ }),
+ chatModel({
+ id: "anthropic/claude-sonnet-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 10 },
+ }),
+ chatModel({
+ id: "anthropic/claude-fable-5",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 10, output: 50 },
+ }),
+ chatModel({
+ id: "anthropic/claude-opus-4.8",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 5, output: 25 },
+ }),
+ chatModel({
+ id: "anthropic/claude-haiku-4.5",
+ contextWindow: 200_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 1, output: 5 },
+ }),
+ // Google — Gemini 3.x
+ chatModel({
+ id: "google/gemini-3.7-flash",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.375, output: 1.875 },
+ }),
+ chatModel({
+ id: "google/gemini-3.6-flash",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.75, output: 3.75 },
+ }),
+ chatModel({
+ id: "google/gemini-3.5-flash",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 1.5, output: 9 },
+ }),
+ chatModel({
+ id: "google/gemini-3.5-flash-lite",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.3, output: 2.5 },
+ }),
+ chatModel({
+ id: "google/gemini-3.1-pro-preview",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 12 },
+ }),
+ // OpenAI — GPT-5.x
+ chatModel({
+ id: "openai/gpt-5.6-sol",
+ contextWindow: 1_050_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2.5, output: 15 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.6-terra",
+ contextWindow: 1_050_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 12 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.6-luna",
+ contextWindow: 1_050_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.2, output: 1.2 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.5",
+ contextWindow: 1_050_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 5, output: 30 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.4",
+ contextWindow: 1_050_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2.5, output: 15 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.4-mini",
+ contextWindow: 400_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.75, output: 4.5 },
+ }),
+ chatModel({
+ id: "openai/gpt-5.4-nano",
+ contextWindow: 400_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.2, output: 1.25 },
+ }),
+ // xAI — Grok 4.x
+ chatModel({
+ id: "x-ai/grok-4.6",
+ contextWindow: 500_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 6 },
+ }),
+ chatModel({
+ id: "x-ai/grok-4.5",
+ contextWindow: 500_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 6 },
+ }),
+ chatModel({
+ id: "x-ai/grok-4.3",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 1.25, output: 2.5 },
+ }),];
diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts
index d87d16e5..05a87b45 100644
--- a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts
+++ b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts
@@ -5,23 +5,46 @@ import {
} from "./openrouter-models-catalog.js";
describe("OPENROUTER_MODELS_CATALOG", () => {
- it("does not list Anthropic chat models", () => {
- for (const [id, entry] of OPENROUTER_MODELS_CATALOG) {
- if (entry.kind !== "chat") continue;
- expect(id.startsWith("anthropic/")).toBe(false);
+ it("lists the current Anthropic chat models", () => {
+ // The previous snapshot asserted the opposite: no `anthropic/*` row
+ // was allowed here, mirroring the vendor filter that used to sit in
+ // `scoreChat`. Both are gone — OpenRouter serves Claude on the same
+ // OpenAI-shaped chat-completions surface as everything else, so
+ // hiding it only cost operators the models they asked for.
+ for (const id of ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5"]) {
+ expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat");
+ expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id);
+ }
+ });
+
+ it("lists the current Gemini chat models", () => {
+ for (const id of ["google/gemini-3.7-flash", "google/gemini-3.5-flash"]) {
+ expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat");
+ expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id);
}
});
- it("does not list Gemini chat models", () => {
+ it("gives every chat row a positive context window and a price", () => {
for (const [id, entry] of OPENROUTER_MODELS_CATALOG) {
if (entry.kind !== "chat") continue;
- expect(/gemini/i.test(id)).toBe(false);
- }
- for (const id of OPENROUTER_CHAT_MODEL_ORDER) {
- expect(/gemini/i.test(id)).toBe(false);
+ expect(entry.contextWindow, id).toBeGreaterThan(0);
+ expect(entry.pricing, id).toBeDefined();
+ expect(entry.pricing!.input, id).toBeGreaterThanOrEqual(0);
+ expect(entry.pricing!.output, id).toBeGreaterThanOrEqual(0);
}
});
+ it("keeps the picker order free of duplicates and in sync with the map", () => {
+ // The chat rows now come from two sibling modules, so a copy/paste
+ // between them would otherwise land silently as a duplicate key.
+ const order = OPENROUTER_CHAT_MODEL_ORDER;
+ expect(new Set(order).size).toBe(order.length);
+ const chatIds = [...OPENROUTER_MODELS_CATALOG]
+ .filter(([, entry]) => entry.kind === "chat")
+ .map(([id]) => id);
+ expect([...order].sort()).toEqual([...chatIds].sort());
+ });
+
it("orders TUI chat picks with openrouter/auto first", () => {
expect(OPENROUTER_CHAT_MODEL_ORDER[0]).toBe("openrouter/auto");
for (const id of OPENROUTER_CHAT_MODEL_ORDER) {
diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.ts b/src/llm/provider/openrouter/openrouter-models-catalog.ts
index 61a314ce..012de6aa 100644
--- a/src/llm/provider/openrouter/openrouter-models-catalog.ts
+++ b/src/llm/provider/openrouter/openrouter-models-catalog.ts
@@ -1,205 +1,109 @@
import type { ModelCatalogEntry } from "../model-resolver.js";
-
-type Price = { input: number; output: number };
-
-type ChatModelSpec = {
- id: string;
- contextWindow: number;
- supportsVision: boolean;
- supportsPromptCache?: boolean;
- pricing: Price;
-};
-
-type EmbeddingModelSpec = ChatModelSpec & {
- dim: number;
-};
-
-function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] {
- return [
- spec.id,
- {
- id: spec.id,
- kind: "chat",
- contextWindow: spec.contextWindow,
- supportsVision: spec.supportsVision,
- supportsTools: "parallel",
- supportsPromptCache: spec.supportsPromptCache ?? false,
- reasoningFormat: "none",
- pricing: spec.pricing,
- },
- ];
-}
-
-function embeddingModel(spec: EmbeddingModelSpec): readonly [string, ModelCatalogEntry] {
- return [
- spec.id,
- {
- id: spec.id,
- kind: "embedding",
- contextWindow: spec.contextWindow,
- dim: spec.dim,
- supportsVision: false,
- supportsTools: "none",
- supportsPromptCache: spec.supportsPromptCache ?? false,
- reasoningFormat: "none",
- pricing: spec.pricing,
- },
- ];
-}
+import { embeddingModel } from "../model-catalog-entry.js";
+import { OPENROUTER_FRONTIER_CHAT_MODELS } from "./openrouter-frontier-chat-models.js";
+import { OPENROUTER_OPEN_WEIGHT_CHAT_MODELS } from "./openrouter-open-weight-chat-models.js";
/**
- * Static fallback catalog (May 2026 OpenRouter slugs). The TUI wizard
- * prefers {@link refreshOpenRouterChatCatalogFromApi} when online; this
- * map backs offline runs and `resolveModel` metadata.
+ * Static fallback catalog, regenerated from the public OpenRouter model
+ * list on 2026-08-19. The TUI wizard prefers
+ * {@link refreshOpenRouterChatCatalogFromApi} when online; this map backs
+ * offline runs and `resolveModel` metadata (context window, capabilities,
+ * price per 1M tokens).
+ *
+ * The chat rows live in two sibling files — hosted frontier models and
+ * open-weight ones — to stay inside the 300-line limit. Embedding rows
+ * stay here; there are two of them and OpenRouter has not changed their
+ * pricing since the previous snapshot.
*/
export const OPENROUTER_MODELS_CATALOG: ReadonlyMap =
new Map([
- chatModel({
- id: "openrouter/auto",
- contextWindow: 2_000_000,
- supportsVision: true,
- pricing: { input: 0, output: 0 },
- }),
- chatModel({
- id: "qwen/qwen3.7-max",
- contextWindow: 1_000_000,
- supportsVision: false,
- pricing: { input: 1.25, output: 3.75 },
- }),
- chatModel({
- id: "qwen/qwen3.6-35b-a3b",
- contextWindow: 262_144,
- supportsVision: true,
- pricing: { input: 0.15, output: 1 },
- }),
- chatModel({
- id: "qwen/qwen3.6-flash",
- contextWindow: 1_000_000,
- supportsVision: true,
- pricing: { input: 0.19, output: 1.13 },
- }),
- chatModel({
- id: "openai/gpt-5.5",
- contextWindow: 1_050_000,
- supportsVision: true,
- supportsPromptCache: true,
- pricing: { input: 5, output: 30 },
- }),
- chatModel({
- id: "openai/gpt-5.4",
- contextWindow: 1_050_000,
- supportsVision: true,
- supportsPromptCache: true,
- pricing: { input: 2.5, output: 15 },
- }),
- chatModel({
- id: "openai/gpt-5.4-mini",
- contextWindow: 400_000,
- supportsVision: true,
- supportsPromptCache: true,
- pricing: { input: 0.75, output: 4.5 },
- }),
- chatModel({
- id: "openai/gpt-5.4-nano",
- contextWindow: 400_000,
- supportsVision: true,
- supportsPromptCache: true,
- pricing: { input: 0.2, output: 1.25 },
- }),
- chatModel({
- id: "x-ai/grok-4.3",
- contextWindow: 1_000_000,
- supportsVision: true,
- pricing: { input: 1.25, output: 2.5 },
- }),
- chatModel({
- id: "deepseek/deepseek-v4-flash",
- contextWindow: 1_048_576,
- supportsVision: false,
- pricing: { input: 0.1, output: 0.2 },
- }),
- chatModel({
- id: "deepseek/deepseek-v4-pro",
- contextWindow: 1_048_576,
- supportsVision: false,
- pricing: { input: 0.43, output: 0.87 },
- }),
- chatModel({
- id: "moonshotai/kimi-k2.7-code",
- contextWindow: 262_144,
- supportsVision: true,
- pricing: { input: 0.95, output: 4.0 },
- }),
- chatModel({
- id: "mistralai/mistral-medium-3-5",
- contextWindow: 262_144,
- supportsVision: true,
- pricing: { input: 1.5, output: 7.5 },
- }),
- chatModel({
- id: "minimax/minimax-m3",
- contextWindow: 1_048_576,
- supportsVision: true,
- pricing: { input: 0.3, output: 1.2 },
- }),
- chatModel({
- id: "minimax/minimax-m2.7",
- contextWindow: 204_800,
- supportsVision: false,
- pricing: { input: 0.28, output: 1.2 },
- }),
- chatModel({
- id: "z-ai/glm-4.7-flash",
- contextWindow: 202_752,
- supportsVision: false,
- pricing: { input: 0.06, output: 0.4 },
- }),
- chatModel({
- id: "z-ai/glm-5.2",
- contextWindow: 1_048_576,
- supportsVision: false,
- pricing: { input: 1, output: 4 },
- }),
- chatModel({
- id: "z-ai/glm-5.1",
- contextWindow: 202_752,
- supportsVision: false,
- pricing: { input: 0.98, output: 3.08 },
- }),
+ [
+ "openrouter/auto",
+ {
+ id: "openrouter/auto",
+ kind: "chat",
+ contextWindow: 2_000_000,
+ supportsVision: true,
+ supportsTools: "parallel",
+ supportsPromptCache: false,
+ reasoningFormat: "none",
+ // Routed: the price is whatever model OpenRouter picks.
+ pricing: { input: 0, output: 0 },
+ },
+ ],
+ ...OPENROUTER_FRONTIER_CHAT_MODELS,
+ ...OPENROUTER_OPEN_WEIGHT_CHAT_MODELS,
embeddingModel({
id: "openai/text-embedding-3-small",
contextWindow: 8192,
dim: 1536,
- supportsVision: false,
pricing: { input: 0.02, output: 0 },
}),
embeddingModel({
id: "openai/text-embedding-3-large",
contextWindow: 8192,
dim: 3072,
- supportsVision: false,
pricing: { input: 0.13, output: 0 },
}),
]);
-/** Static TUI order when the live API fetch is unavailable. */
+/**
+ * Static TUI order when the live API fetch is unavailable: the curated
+ * catalog order, `openrouter/auto` first.
+ */
export const OPENROUTER_CHAT_MODEL_ORDER: readonly string[] = [
"openrouter/auto",
- "qwen/qwen3.7-max",
- "qwen/qwen3.6-35b-a3b",
- "qwen/qwen3.6-flash",
+ "anthropic/claude-opus-5",
+ "anthropic/claude-opus-5-fast",
+ "anthropic/claude-sonnet-5",
+ "anthropic/claude-fable-5",
+ "anthropic/claude-opus-4.8",
+ "anthropic/claude-haiku-4.5",
+ "google/gemini-3.7-flash",
+ "google/gemini-3.6-flash",
+ "google/gemini-3.5-flash",
+ "google/gemini-3.5-flash-lite",
+ "google/gemini-3.1-pro-preview",
+ "openai/gpt-5.6-sol",
+ "openai/gpt-5.6-terra",
+ "openai/gpt-5.6-luna",
"openai/gpt-5.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
+ "x-ai/grok-4.6",
+ "x-ai/grok-4.5",
"x-ai/grok-4.3",
- "deepseek/deepseek-v4-flash",
+ "qwen/qwen3.8-max",
+ "qwen/qwen3.8-2.4t-a95b",
+ "qwen/qwen3.8-27b",
+ "qwen/qwen3.7-max",
+ "qwen/qwen3.7-plus",
+ "qwen/qwen3.7-flash",
+ "qwen/qwen3.6-35b-a3b",
+ "qwen/qwen3.6-flash",
+ "qwen/qwen3-coder-plus",
"deepseek/deepseek-v4-pro",
+ "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k3",
"moonshotai/kimi-k2.7-code",
- "mistralai/mistral-medium-3-5",
- "minimax/minimax-m3",
- "minimax/minimax-m2.7",
- "z-ai/glm-4.7-flash",
+ "moonshotai/kimi-k2.6",
+ "z-ai/glm-5.3",
"z-ai/glm-5.2",
"z-ai/glm-5.1",
-];
+ "z-ai/glm-4.7-flash",
+ "minimax/minimax-m3",
+ "minimax/minimax-m2.7",
+ "mistralai/mistral-large-2512",
+ "mistralai/mistral-medium-3-5",
+ "mistralai/ministral-8b-2512",
+ "meta-llama/llama-4-maverick",
+ "meta-llama/llama-4-scout",
+ "meta-llama/llama-3.3-70b-instruct",
+ "openai/gpt-oss-120b",
+ "openai/gpt-oss-20b",
+ "nvidia/nemotron-3-ultra-550b-a55b",
+ "nvidia/nemotron-3.5-lightning",
+ "amazon/nova-premier-v1",
+ "amazon/nova-2-lite-v1",
+ "bytedance-seed/seed-2.0-code",];
diff --git a/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts
new file mode 100644
index 00000000..514dfe92
--- /dev/null
+++ b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts
@@ -0,0 +1,246 @@
+import { chatModel, type CatalogRow } from "../model-catalog-entry.js";
+
+/**
+ * Open-weight chat models on OpenRouter — families whose weights are
+ * published, served here by whichever provider OpenRouter routes to.
+ *
+ * Same provenance as the frontier list: generated from
+ * `https://openrouter.ai/api/v1/models` on 2026-08-19, curated to the
+ * current generation of each family, `tools`-capable only.
+ */
+export const OPENROUTER_OPEN_WEIGHT_CHAT_MODELS: readonly CatalogRow[] = [
+ // Qwen
+ chatModel({
+ id: "qwen/qwen3.8-max",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 6 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.8-2.4t-a95b",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 2, output: 6 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.8-27b",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.45, output: 3.2 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.7-max",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 1.475, output: 4.425 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.7-plus",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.32, output: 1.28 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.7-flash",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.03, output: 0.13 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.6-35b-a3b",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.14, output: 1 },
+ }),
+ chatModel({
+ id: "qwen/qwen3.6-flash",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ pricing: { input: 0.188, output: 1.125 },
+ }),
+ chatModel({
+ id: "qwen/qwen3-coder-plus",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.65, output: 3.25 },
+ }),
+ // DeepSeek
+ chatModel({
+ id: "deepseek/deepseek-v4-pro",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.66, output: 1.98 },
+ }),
+ chatModel({
+ id: "deepseek/deepseek-v4-flash",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.083, output: 0.165 },
+ }),
+ // Moonshot AI — Kimi
+ chatModel({
+ id: "moonshotai/kimi-k3",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 3, output: 15 },
+ }),
+ chatModel({
+ id: "moonshotai/kimi-k2.7-code",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.71, output: 3.5 },
+ }),
+ chatModel({
+ id: "moonshotai/kimi-k2.6",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.95, output: 4 },
+ }),
+ // Z.ai — GLM
+ chatModel({
+ id: "z-ai/glm-5.3",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 1.4, output: 4.4 },
+ }),
+ chatModel({
+ id: "z-ai/glm-5.2",
+ contextWindow: 1_048_576,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.966, output: 3.036 },
+ }),
+ chatModel({
+ id: "z-ai/glm-5.1",
+ contextWindow: 204_800,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.966, output: 3.036 },
+ }),
+ chatModel({
+ id: "z-ai/glm-4.7-flash",
+ contextWindow: 202_752,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.06, output: 0.4 },
+ }),
+ // MiniMax
+ chatModel({
+ id: "minimax/minimax-m3",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.3, output: 1.2 },
+ }),
+ chatModel({
+ id: "minimax/minimax-m2.7",
+ contextWindow: 204_800,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.3, output: 1.2 },
+ }),
+ // Mistral
+ chatModel({
+ id: "mistralai/mistral-large-2512",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.5, output: 1.5 },
+ }),
+ chatModel({
+ id: "mistralai/mistral-medium-3-5",
+ contextWindow: 262_144,
+ supportsVision: true,
+ pricing: { input: 1.5, output: 7.5 },
+ }),
+ chatModel({
+ id: "mistralai/ministral-8b-2512",
+ contextWindow: 262_144,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 0.15, output: 0.15 },
+ }),
+ // Meta — Llama
+ chatModel({
+ id: "meta-llama/llama-4-maverick",
+ contextWindow: 1_048_576,
+ supportsVision: true,
+ pricing: { input: 0.2, output: 0.8 },
+ }),
+ chatModel({
+ id: "meta-llama/llama-4-scout",
+ contextWindow: 1_310_720,
+ supportsVision: true,
+ pricing: { input: 0.1, output: 0.3 },
+ }),
+ chatModel({
+ id: "meta-llama/llama-3.3-70b-instruct",
+ contextWindow: 131_072,
+ supportsVision: false,
+ pricing: { input: 0.1, output: 0.32 },
+ }),
+ // OpenAI gpt-oss (open weights)
+ chatModel({
+ id: "openai/gpt-oss-120b",
+ contextWindow: 131_072,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.03, output: 0.17 },
+ }),
+ chatModel({
+ id: "openai/gpt-oss-20b",
+ contextWindow: 131_072,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.03, output: 0.13 },
+ }),
+ // NVIDIA — Nemotron
+ chatModel({
+ id: "nvidia/nemotron-3-ultra-550b-a55b",
+ contextWindow: 512_288,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.6, output: 3.6 },
+ }),
+ chatModel({
+ id: "nvidia/nemotron-3.5-lightning",
+ contextWindow: 1_000_000,
+ supportsVision: false,
+ supportsPromptCache: true,
+ pricing: { input: 0.08, output: 0.2 },
+ }),
+ // Amazon — Nova
+ chatModel({
+ id: "amazon/nova-premier-v1",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ supportsPromptCache: true,
+ pricing: { input: 2.5, output: 12.5 },
+ }),
+ chatModel({
+ id: "amazon/nova-2-lite-v1",
+ contextWindow: 1_000_000,
+ supportsVision: true,
+ pricing: { input: 0.3, output: 2.5 },
+ }),
+ // ByteDance — Seed
+ chatModel({
+ id: "bytedance-seed/seed-2.0-code",
+ contextWindow: 262_144,
+ supportsVision: true,
+ pricing: { input: 0.5, output: 3 },
+ }),];
diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts
index 2fb5eef5..1771eaf9 100644
--- a/src/llm/provider/registry/provider-registry.test.ts
+++ b/src/llm/provider/registry/provider-registry.test.ts
@@ -19,6 +19,7 @@ describe("ProviderRegistry", () => {
expect(kinds).toContain("qwen-openai-compatible");
expect(kinds).toContain("openrouter");
expect(kinds).toContain("gemini");
+ expect(kinds).toContain("subscription-cli");
});
it("resolveLlmConfig synthesizes local-llama when llm block absent", () => {
@@ -87,3 +88,68 @@ describe("ProviderRegistry", () => {
).rejects.toThrow(/unknown llm provider kind/);
});
});
+
+describe("ProviderRegistry pinned providers", () => {
+ /** Minimal stand-in that records whether it was torn down. */
+ function fake(id: string) {
+ const state = { closed: false };
+ const provider = {
+ id,
+ name: id,
+ capabilities: {},
+ toolCallAdapter: null,
+ streamConsumer: null,
+ async complete() {
+ throw new Error("unused");
+ },
+ async *completeStream() {
+ throw new Error("unused");
+ },
+ async describeImage() {
+ throw new Error("unused");
+ },
+ async health() {
+ return { reachable: true, status: 200, error: null, latencyMs: 1 };
+ },
+ async close() {
+ state.closed = true;
+ },
+ };
+ return { provider, state };
+ }
+
+ function registryOf(ids: string[]) {
+ const fakes = ids.map((id) => fake(id));
+ const map = new Map(
+ fakes.map((f) => [f.provider.id, f.provider as never] as const),
+ );
+ // `new ProviderRegistry(...)` is private to the module's factory, so
+ // reach it the same way `fromConfig` does.
+ const registry = Reflect.construct(ProviderRegistry, [ids[0], map]) as
+ ProviderRegistry;
+ return { registry, fakes };
+ }
+
+ it("closes the previous provider on a plain swap", async () => {
+ const { registry, fakes } = registryOf(["cloud", "local"]);
+ await registry.swapActive("local");
+ expect(fakes[0]!.state.closed).toBe(true);
+ });
+
+ it("keeps a pinned provider open across a swap", async () => {
+ // Fusion keeps both legs live; closing the one it is about to route
+ // to would break the executor leg on the very next step.
+ const { registry, fakes } = registryOf(["cloud", "local"]);
+ registry.setPinnedProviderIds(() => new Set(["cloud", "local"]));
+ await registry.swapActive("local");
+ expect(fakes[0]!.state.closed).toBe(false);
+ expect(registry.activeText.id).toBe("local");
+ });
+
+ it("resumes closing once nothing is pinned", async () => {
+ const { registry, fakes } = registryOf(["cloud", "local"]);
+ registry.setPinnedProviderIds(() => new Set());
+ await registry.swapActive("local");
+ expect(fakes[0]!.state.closed).toBe(true);
+ });
+});
diff --git a/src/llm/provider/registry/provider-registry.ts b/src/llm/provider/registry/provider-registry.ts
index a8ac4243..cb3dda62 100644
--- a/src/llm/provider/registry/provider-registry.ts
+++ b/src/llm/provider/registry/provider-registry.ts
@@ -28,6 +28,8 @@ export class ProviderRegistry {
this.providers = providers;
}
+ private pinnedProviderIds?: () => ReadonlySet;
+
static async fromConfig(
config: AtomicAgentConfig,
ctx: Omit & {
@@ -76,6 +78,21 @@ export class ProviderRegistry {
return [...this.providers.keys()];
}
+ /**
+ * Providers that must stay open even when they stop being active.
+ *
+ * Fusion keeps two legs live at once and only one of them can be the
+ * active provider, so switching INTO fusion would otherwise close the
+ * very provider it is about to route to. `close()` is a no-op on both
+ * shipped provider kinds today, which is why this is not currently a
+ * visible crash — but the interface promises teardown, and the first
+ * provider kind that honours it (pooled sockets, a WS transport)
+ * would break fusion silently without this.
+ */
+ setPinnedProviderIds(pinned: () => ReadonlySet): void {
+ this.pinnedProviderIds = pinned;
+ }
+
async swapActive(id: string): Promise {
const next = this.providers.get(id);
if (!next) {
@@ -83,7 +100,7 @@ export class ProviderRegistry {
}
const prev = this.providers.get(this.activeTextId);
this.activeTextId = id;
- if (prev && prev.id !== id) {
+ if (prev && prev.id !== id && !this.pinnedProviderIds?.().has(prev.id)) {
await prev.close().catch(() => undefined);
}
return next;
diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts
index 9aa7de62..cae5fd2f 100644
--- a/src/llm/provider/registry/provider-types.ts
+++ b/src/llm/provider/registry/provider-types.ts
@@ -1,4 +1,6 @@
import type { AtomicAgentConfig } from "../../../config/index.js";
+import type { UserLlmRunModeConfig } from "../../../config/llm-run-mode-config.js";
+import type { UserSubscriptionCliOptions } from "../../../config/llm-config.js";
import type { LlamaServerClient } from "../../llama-server-client.js";
import type { ModelProfile } from "../../model-profile.js";
import type { StructuredLogger } from "../../../tracing/index.js";
@@ -43,6 +45,11 @@ export type LlmProviderConfigEntry = {
* the request from the resolved model or drop the tool contract.
*/
extraBody?: Record;
+ /**
+ * Settings for a `subscription-cli` provider — which vendor CLI to
+ * drive and how to invoke it. Absent on every other kind.
+ */
+ subscriptionCli?: UserSubscriptionCliOptions;
userModels?: ReadonlyArray;
};
@@ -84,6 +91,11 @@ export type ResolvedLlmConfig = {
providers: LlmProviderConfigEntry[];
toolTransport: "auto" | "grammar" | "native_tools";
fallback?: LlmFallbackConfig;
+ /**
+ * Operator run mode. Absent on the synthesized local-only config
+ * below, where `local` is the only reachable mode by construction.
+ */
+ runMode?: UserLlmRunModeConfig;
};
const factories = new Map();
@@ -112,6 +124,7 @@ export function resolveLlmConfig(config: AtomicAgentConfig): ResolvedLlmConfig {
providers: [...llm.providers],
toolTransport: llm.toolTransport,
...(llm.fallback ? { fallback: llm.fallback } : {}),
+ ...(llm.runMode ? { runMode: llm.runMode } : {}),
};
}
return {
diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts
index e3e61f1b..09d17ffe 100644
--- a/src/llm/provider/registry/register-built-in-providers.ts
+++ b/src/llm/provider/registry/register-built-in-providers.ts
@@ -14,6 +14,12 @@ import {
OPENROUTER_APP_REFERER,
OPENROUTER_APP_TITLE,
} from "../openrouter/openrouter-provider.js";
+import { SUBSCRIPTION_CLI_KIND } from "../../../config/provider-auth-mode.js";
+import {
+ registerBuiltInCliAdapters,
+ resolveCliAdapter,
+ SubscriptionCliProvider,
+} from "../subscription-cli/index.js";
import { registerProviderKind } from "./provider-types.js";
let registered = false;
@@ -126,4 +132,37 @@ export function registerBuiltInProviderKinds(): void {
requestTimeoutMs: entry.requestTimeoutMs,
});
});
+
+ registerProviderKind(SUBSCRIPTION_CLI_KIND, (ctx) => {
+ const entry = ctx.entry;
+ const options = entry.subscriptionCli;
+ if (!options) {
+ throw new Error(
+ `${SUBSCRIPTION_CLI_KIND} provider "${entry.id}" requires a subscriptionCli block naming the cli to drive`,
+ );
+ }
+ registerBuiltInCliAdapters();
+ const descriptor = resolveCliAdapter(options.cli);
+ return new SubscriptionCliProvider({
+ id: entry.id,
+ descriptor,
+ // The state dir, not the agent's working directory: with tools
+ // disabled there is nothing to read there anyway, and it keeps a
+ // project-level CLAUDE.md out of the completion.
+ cwd: ctx.config.paths.stateDir,
+ ...(entry.defaultChatModel ? { model: entry.defaultChatModel } : {}),
+ ...(options.binPath ? { binPath: options.binPath } : {}),
+ ...(options.extraArgs ? { extraArgs: options.extraArgs } : {}),
+ ...(options.streaming === undefined
+ ? {}
+ : { streaming: options.streaming }),
+ ...(options.maxBudgetUsd === undefined
+ ? {}
+ : { maxBudgetUsd: options.maxBudgetUsd }),
+ ...(entry.requestTimeoutMs
+ ? { requestTimeoutMs: entry.requestTimeoutMs }
+ : {}),
+ onNotice: (message) => ctx.logger.warn("llm.subscription_cli", { message }),
+ });
+ });
}
diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts
new file mode 100644
index 00000000..3393e5fd
--- /dev/null
+++ b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts
@@ -0,0 +1,311 @@
+import { describe, expect, it } from "vitest";
+
+import { claudeCliAdapter } from "./claude-cli-adapter.js";
+import {
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+} from "./subscription-cli-errors.js";
+
+const input = {
+ model: "sonnet",
+ systemPrompt: "SYSTEM",
+ extraArgs: [] as readonly string[],
+};
+
+/** Captured verbatim from `claude -p --output-format json` v2.1.220. */
+const REAL_ENVELOPE = JSON.stringify({
+ is_error: false,
+ duration_api_ms: 4630,
+ num_turns: 1,
+ stop_reason: "end_turn",
+ session_id: "32c150e6-44a2-422d-a03d-76b18b607b71",
+ total_cost_usd: 0.0363007,
+ usage: {
+ input_tokens: 2,
+ cache_creation_input_tokens: 5777,
+ cache_read_input_tokens: 3289,
+ output_tokens: 4,
+ },
+ modelUsage: {
+ "claude-sonnet-5": { contextWindow: 1_000_000, maxOutputTokens: 64_000 },
+ },
+ permission_denials: [],
+ subtype: "success",
+ api_error_status: null,
+ result: "OK",
+ type: "result",
+});
+
+describe("claudeCliAdapter argv", () => {
+ it("passes the headless, tool-free, stateless flag set", () => {
+ const args = claudeCliAdapter.completeArgs(input);
+ expect(args).toContain("--print");
+ expect(args).toContain("--strict-mcp-config");
+ expect(args).toContain("--no-session-persistence");
+ expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual([
+ "--tools",
+ "",
+ ]);
+ expect(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2)).toEqual([
+ "--model",
+ "sonnet",
+ ]);
+ expect(
+ args.slice(
+ args.indexOf("--output-format"),
+ args.indexOf("--output-format") + 2,
+ ),
+ ).toEqual(["--output-format", "json"]);
+ expect(
+ args.slice(
+ args.indexOf("--system-prompt"),
+ args.indexOf("--system-prompt") + 2,
+ ),
+ ).toEqual(["--system-prompt", "SYSTEM"]);
+ });
+
+ it("never passes flags that would defeat subscription auth or the approval ladder", () => {
+ for (const build of [
+ claudeCliAdapter.completeArgs,
+ claudeCliAdapter.streamArgs,
+ ]) {
+ const args = build({ ...input, responseSchema: { type: "object" } });
+ // --bare makes the CLI read ANTHROPIC_API_KEY only, never OAuth.
+ expect(args).not.toContain("--bare");
+ expect(args).not.toContain("--dangerously-skip-permissions");
+ expect(args).not.toContain("--allow-dangerously-skip-permissions");
+ expect(args).not.toContain("--add-dir");
+ expect(args).not.toContain("--permission-mode");
+ }
+ });
+
+ it("never places the prompt on argv", () => {
+ // Regression guard for E2BIG: a two-zone prompt exceeds the 128 KiB
+ // single-argument limit, so it must travel on stdin.
+ const prompt = "P".repeat(200_000);
+ const args = claudeCliAdapter.completeArgs(input);
+ expect(args.some((arg) => arg.includes(prompt))).toBe(false);
+ expect(args.join(" ").length).toBeLessThan(4096);
+ });
+
+ it("adds --verbose only on the streaming path", () => {
+ expect(claudeCliAdapter.completeArgs(input)).not.toContain("--verbose");
+ const streamArgs = claudeCliAdapter.streamArgs(input);
+ // Verified: `--print` + `--output-format stream-json` errors without it.
+ expect(streamArgs).toContain("--verbose");
+ expect(streamArgs).toContain("--include-partial-messages");
+ expect(
+ streamArgs.slice(
+ streamArgs.indexOf("--output-format"),
+ streamArgs.indexOf("--output-format") + 2,
+ ),
+ ).toEqual(["--output-format", "stream-json"]);
+ });
+
+ it("passes --json-schema only when a schema is set and small enough", () => {
+ expect(claudeCliAdapter.completeArgs(input)).not.toContain("--json-schema");
+
+ const schema = { type: "object", properties: { name: { type: "string" } } };
+ const withSchema = claudeCliAdapter.completeArgs({
+ ...input,
+ responseSchema: schema,
+ });
+ expect(
+ withSchema[withSchema.indexOf("--json-schema") + 1],
+ ).toBe(JSON.stringify(schema));
+
+ const huge = { type: "object", description: "x".repeat(40_000) };
+ expect(
+ claudeCliAdapter.completeArgs({ ...input, responseSchema: huge }),
+ ).not.toContain("--json-schema");
+ });
+
+ it("appends extraArgs verbatim, last", () => {
+ const args = claudeCliAdapter.completeArgs({
+ ...input,
+ extraArgs: ["--effort", "high"],
+ maxBudgetUsd: 5,
+ });
+ expect(args.slice(-2)).toEqual(["--effort", "high"]);
+ expect(args).toContain("--max-budget-usd");
+ expect(args[args.indexOf("--max-budget-usd") + 1]).toBe("5");
+ });
+
+ it("health uses --version, not a real turn", () => {
+ expect(claudeCliAdapter.healthArgs()).toEqual(["--version"]);
+ });
+});
+
+describe("claudeCliAdapter parseResult", () => {
+ it("maps the real success envelope", () => {
+ const result = claudeCliAdapter.parseResult(REAL_ENVELOPE, "sonnet");
+ expect(result.content).toBe("OK");
+ expect(result.finishReason).toBe("stop");
+ expect(result.truncated).toBe(false);
+ expect(result.stop).toBe(true);
+ expect(result.slotId).toBe(-1);
+ expect(result.modelId).toBe("claude-sonnet-5");
+ expect(result.cacheHitTokens).toBe(3289);
+ // prompt tokens = fresh + cache-write + cache-read, matching the
+ // OpenAI `prompt_tokens` semantics the usage meter expects.
+ expect(result.usage).toEqual({
+ promptTokens: 2 + 5777 + 3289,
+ completionTokens: 4,
+ totalTokens: 2 + 5777 + 3289 + 4,
+ });
+ expect(result.timing.predictedMs).toBe(4630);
+ });
+
+ it("treats a tool_use stop as a normal stop", () => {
+ // --json-schema is implemented as a forced tool call, so a perfectly
+ // successful structured completion reports stop_reason tool_use.
+ const result = claudeCliAdapter.parseResult(
+ JSON.stringify({
+ subtype: "success",
+ is_error: false,
+ result: '{"name":"Ada"}',
+ stop_reason: "tool_use",
+ }),
+ "sonnet",
+ );
+ expect(result.finishReason).toBe("stop");
+ expect(result.content).toBe('{"name":"Ada"}');
+ });
+
+ it("reports truncation on max_tokens", () => {
+ const result = claudeCliAdapter.parseResult(
+ JSON.stringify({
+ subtype: "success",
+ is_error: false,
+ result: "half",
+ stop_reason: "max_tokens",
+ }),
+ "sonnet",
+ );
+ expect(result.truncated).toBe(true);
+ expect(result.stop).toBe(false);
+ expect(result.finishReason).toBe("length");
+ });
+
+ it("ignores the internal helper model in modelUsage", () => {
+ // Observed live: a `sonnet` turn also bills a haiku helper turn for
+ // Claude Code's own post-turn summary. Reporting haiku as the model
+ // that served the completion would corrupt cost and model analytics.
+ const result = claudeCliAdapter.parseResult(
+ JSON.stringify({
+ subtype: "success",
+ result: "hi",
+ modelUsage: {
+ "claude-haiku-4-5-20251001": { outputTokens: 13 },
+ "claude-sonnet-5": { outputTokens: 4 },
+ },
+ }),
+ "sonnet",
+ );
+ expect(result.modelId).toBe("claude-sonnet-5");
+ });
+
+ it("keeps the requested model when only a helper model was billed", () => {
+ const result = claudeCliAdapter.parseResult(
+ JSON.stringify({
+ subtype: "success",
+ result: "hi",
+ modelUsage: { "claude-haiku-4-5-20251001": { outputTokens: 13 } },
+ }),
+ "sonnet",
+ );
+ expect(result.modelId).toBe("sonnet");
+ });
+
+ it("falls back to the configured model when modelUsage is absent", () => {
+ const result = claudeCliAdapter.parseResult(
+ JSON.stringify({ subtype: "success", result: "hi" }),
+ "opus",
+ );
+ expect(result.modelId).toBe("opus");
+ });
+
+ it("throws on an error envelope and keeps the message", () => {
+ expect(() =>
+ claudeCliAdapter.parseResult(
+ JSON.stringify({
+ subtype: "error_during_execution",
+ is_error: true,
+ result: "5-hour limit reached; resets at 14:00",
+ }),
+ "sonnet",
+ ),
+ ).toThrow(/5-hour limit reached/);
+ });
+
+ it("maps a 401 to an auth error", () => {
+ expect(() =>
+ claudeCliAdapter.parseResult(
+ JSON.stringify({ subtype: "success", api_error_status: 401 }),
+ "sonnet",
+ ),
+ ).toThrow(SubscriptionCliAuthError);
+ });
+
+ it("throws rather than silently returning empty on non-JSON output", () => {
+ expect(() => claudeCliAdapter.parseResult("not json", "sonnet")).toThrow(
+ SubscriptionCliInvocationError,
+ );
+ });
+});
+
+describe("claudeCliAdapter parseStreamEvent", () => {
+ it("extracts text deltas", () => {
+ expect(
+ claudeCliAdapter.parseStreamEvent(
+ JSON.stringify({
+ type: "stream_event",
+ event: {
+ type: "content_block_delta",
+ delta: { type: "text_delta", text: "1\n2" },
+ },
+ }),
+ ),
+ ).toEqual({ kind: "delta", text: "1\n2" });
+ });
+
+ it("marks the terminal result envelope", () => {
+ const line = JSON.stringify({ type: "result", subtype: "success" });
+ expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({
+ kind: "final",
+ raw: line,
+ });
+ });
+
+ it("surfaces a throttled rate-limit event as a notice, ignores allowed", () => {
+ expect(
+ claudeCliAdapter.parseStreamEvent(
+ JSON.stringify({
+ type: "rate_limit_event",
+ rate_limit_info: { status: "allowed", rateLimitType: "five_hour" },
+ }),
+ ),
+ ).toEqual({ kind: "ignore" });
+ expect(
+ claudeCliAdapter.parseStreamEvent(
+ JSON.stringify({
+ type: "rate_limit_event",
+ rate_limit_info: { status: "rejected", rateLimitType: "five_hour" },
+ }),
+ ),
+ ).toEqual({ kind: "notice", message: "claude rate limit rejected (five_hour)" });
+ });
+
+ it("ignores unknown, empty and malformed lines instead of failing", () => {
+ for (const line of [
+ "",
+ " ",
+ "{ not json",
+ JSON.stringify({ type: "system", subtype: "init" }),
+ JSON.stringify({ type: "assistant", message: {} }),
+ JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }),
+ ]) {
+ expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({ kind: "ignore" });
+ }
+ });
+});
diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.ts
new file mode 100644
index 00000000..5d74631a
--- /dev/null
+++ b/src/llm/provider/subscription-cli/claude-cli-adapter.ts
@@ -0,0 +1,273 @@
+import type { CompletionResult } from "../completion-types.js";
+import type {
+ CliAdapterDescriptor,
+ CliArgsInput,
+ CliStreamEvent,
+} from "./cli-adapter-descriptor.js";
+import {
+ CLAUDE_CLI_CHAT_MODELS,
+ CLAUDE_CLI_CONTEXT_WINDOW,
+ CLAUDE_CLI_DEFAULT_CHAT_MODEL,
+} from "./claude-cli-models.js";
+import {
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+} from "./subscription-cli-errors.js";
+
+/**
+ * Replaces Claude Code's own system prompt for the duration of one
+ * completion. It has to be a replacement, not an append: the default is
+ * a coding-agent prompt that plans, narrates and reaches for tools,
+ * which competes with the complete two-zone prompt atomic-agent already
+ * built. This is also the only steering channel we have outside that
+ * prompt, so it is spent on the output contract.
+ */
+export const CLAUDE_CLI_SYSTEM_PROMPT =
+ "You are an inference backend. The user message is a complete, " +
+ "self-contained prompt that carries its own instructions and output " +
+ "contract. Follow it exactly and emit only what it asks for — no " +
+ "preamble, no commentary, no summary of what you are about to do. " +
+ "Do not use tools; the prompt's own protocol is the only one that applies.";
+
+/**
+ * Above this the schema would eat into the argv budget for no benefit;
+ * the sub-runners that set `responseFormat` all tolerate free-form
+ * content, so dropping the flag degrades gracefully.
+ */
+const MAX_SCHEMA_ARG_BYTES = 32 * 1024;
+
+function baseArgs(input: CliArgsInput): string[] {
+ return [
+ "--print",
+ "--input-format",
+ "text",
+ ...(input.model ? ["--model", input.model] : []),
+ "--system-prompt",
+ input.systemPrompt,
+ // Safety-critical, not an optimisation: Claude Code's built-in
+ // Bash/Edit/Write would otherwise run on the user's machine outside
+ // atomic-agent's approval ladder.
+ "--tools",
+ "",
+ // No --mcp-config is passed, so this drops the user's MCP servers
+ // rather than inheriting them into a stateless completion.
+ "--strict-mcp-config",
+ // atomic-agent owns session state and re-sends the whole prompt each
+ // step; CLI-side history would double-count context and litter the
+ // user's session list.
+ "--no-session-persistence",
+ ];
+}
+
+function tailArgs(input: CliArgsInput): string[] {
+ const out: string[] = [];
+ if (input.responseSchema) {
+ const encoded = JSON.stringify(input.responseSchema);
+ if (encoded.length <= MAX_SCHEMA_ARG_BYTES) {
+ out.push("--json-schema", encoded);
+ }
+ }
+ if (input.maxBudgetUsd !== undefined) {
+ out.push("--max-budget-usd", String(input.maxBudgetUsd));
+ }
+ out.push(...input.extraArgs);
+ return out;
+}
+
+interface ClaudeResultEnvelope {
+ type?: string;
+ subtype?: string;
+ is_error?: boolean;
+ result?: string;
+ stop_reason?: string | null;
+ api_error_status?: number | null;
+ duration_api_ms?: number;
+ permission_denials?: unknown[];
+ usage?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_input_tokens?: number;
+ cache_creation_input_tokens?: number;
+ };
+ modelUsage?: Record;
+}
+
+/**
+ * `stop_reason` doubles as a structured-output signal: with
+ * `--json-schema` the CLI implements the constraint as a forced tool
+ * call and reports `tool_use` even though the text in `result` is the
+ * whole answer. Treat it as a normal stop — we never surface tool calls
+ * from this provider.
+ */
+function toFinishReason(stopReason: string | null | undefined): string | null {
+ if (!stopReason) return null;
+ if (stopReason === "end_turn" || stopReason === "tool_use") return "stop";
+ if (stopReason === "max_tokens") return "length";
+ return stopReason;
+}
+
+/**
+ * `modelUsage` is keyed by every model the CLI billed for this turn,
+ * which includes the small helper model Claude Code uses for its own
+ * side tasks (post-turn summaries). Taking the first key would report
+ * `claude-haiku-4-5` as the model that served a `sonnet` request, so the
+ * requested model stays authoritative and `modelUsage` is used only to
+ * expand an alias into the concrete id it resolved to.
+ */
+function resolveModelId(
+ modelUsage: Record | undefined,
+ requested: string,
+): string {
+ const keys = Object.keys(modelUsage ?? {});
+ if (keys.includes(requested)) return requested;
+ return keys.find((key) => key.includes(requested)) ?? requested;
+}
+
+function parseResult(stdout: string, fallbackModel: string): CompletionResult {
+ let envelope: ClaudeResultEnvelope;
+ try {
+ envelope = JSON.parse(stdout.trim()) as ClaudeResultEnvelope;
+ } catch {
+ throw new SubscriptionCliInvocationError(
+ `claude returned output that is not JSON: ${stdout.slice(0, 500)}`,
+ );
+ }
+ const status = envelope.api_error_status ?? null;
+ if (status === 401 || status === 403) {
+ throw new SubscriptionCliAuthError(
+ "claude",
+ "Run `claude` in a terminal and complete /login, then retry.",
+ `api_error_status ${status}`,
+ );
+ }
+ if (envelope.is_error || (envelope.subtype && envelope.subtype !== "success")) {
+ // The message is the only description of subscription rate limits and
+ // usage caps, so it is passed through rather than summarised away.
+ throw new SubscriptionCliInvocationError(
+ `claude reported ${envelope.subtype ?? "an error"}${
+ status ? ` (api status ${status})` : ""
+ }: ${envelope.result ?? "no detail"}`,
+ );
+ }
+
+ const usage = envelope.usage ?? {};
+ const promptTokens =
+ (usage.input_tokens ?? 0) +
+ (usage.cache_creation_input_tokens ?? 0) +
+ (usage.cache_read_input_tokens ?? 0);
+ const completionTokens = usage.output_tokens ?? 0;
+ const predictedMs = envelope.duration_api_ms ?? 0;
+ const modelId = resolveModelId(envelope.modelUsage, fallbackModel);
+ const truncated = envelope.stop_reason === "max_tokens";
+
+ return {
+ content: envelope.result ?? "",
+ reasoningContent: "",
+ stop: !truncated,
+ truncated,
+ timing: {
+ promptMs: 0,
+ predictedMs,
+ promptTokens,
+ predictedTokens: completionTokens,
+ },
+ cacheHitTokens: usage.cache_read_input_tokens ?? 0,
+ // No slot affinity: every completion is a fresh process.
+ slotId: -1,
+ modelId,
+ usage: {
+ promptTokens,
+ completionTokens,
+ totalTokens: promptTokens + completionTokens,
+ },
+ finishReason: toFinishReason(envelope.stop_reason),
+ };
+}
+
+interface ClaudeStreamLine {
+ type?: string;
+ event?: {
+ type?: string;
+ delta?: { type?: string; text?: string };
+ };
+ rate_limit_info?: { status?: string; rateLimitType?: string };
+}
+
+function parseStreamEvent(line: string): CliStreamEvent {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) return { kind: "ignore" };
+ let parsed: ClaudeStreamLine;
+ try {
+ parsed = JSON.parse(trimmed) as ClaudeStreamLine;
+ } catch {
+ // A partial or unrecognised line is never fatal: the terminal
+ // `result` envelope carries the authoritative text either way.
+ return { kind: "ignore" };
+ }
+ if (parsed.type === "result") return { kind: "final", raw: trimmed };
+ if (parsed.type === "rate_limit_event") {
+ const info = parsed.rate_limit_info ?? {};
+ return info.status && info.status !== "allowed"
+ ? {
+ kind: "notice",
+ message: `claude rate limit ${info.status}${
+ info.rateLimitType ? ` (${info.rateLimitType})` : ""
+ }`,
+ }
+ : { kind: "ignore" };
+ }
+ if (parsed.type === "stream_event") {
+ const event = parsed.event ?? {};
+ if (
+ event.type === "content_block_delta" &&
+ event.delta?.type === "text_delta" &&
+ typeof event.delta.text === "string"
+ ) {
+ return { kind: "delta", text: event.delta.text };
+ }
+ }
+ return { kind: "ignore" };
+}
+
+export const claudeCliAdapter: CliAdapterDescriptor = {
+ cli: "claude",
+ displayName: "Claude Code subscription",
+ defaultBinary: "claude",
+ defaultChatModel: CLAUDE_CLI_DEFAULT_CHAT_MODEL,
+ systemPrompt: CLAUDE_CLI_SYSTEM_PROMPT,
+ staticModels: CLAUDE_CLI_CHAT_MODELS,
+ contextWindow: CLAUDE_CLI_CONTEXT_WINDOW,
+ schemaDelivery: "inline",
+ streamMode: "ndjson",
+ installHint:
+ "Install Claude Code (https://claude.com/claude-code) and run `claude` once to sign in, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.",
+ authHint: "Run `claude` in a terminal and complete /login, then retry.",
+ buildStdin(prompt) {
+ // Claude takes the steering through --system-prompt, so the prompt
+ // reaches the model exactly as atomic-agent built it.
+ return prompt;
+ },
+ completeArgs(input) {
+ return [...baseArgs(input), "--output-format", "json", ...tailArgs(input)];
+ },
+ streamArgs(input) {
+ return [
+ ...baseArgs(input),
+ "--output-format",
+ "stream-json",
+ "--include-partial-messages",
+ // Verified requirement: `--print` with `--output-format=stream-json`
+ // errors out without it.
+ "--verbose",
+ ...tailArgs(input),
+ ];
+ },
+ healthArgs() {
+ // Cheap liveness only. It cannot detect a signed-out CLI — that
+ // surfaces on the first completion as SubscriptionCliAuthError —
+ // but a real turn would cost seconds and tokens on every poll.
+ return ["--version"];
+ },
+ parseResult,
+ parseStreamEvent,
+};
diff --git a/src/llm/provider/subscription-cli/claude-cli-models.ts b/src/llm/provider/subscription-cli/claude-cli-models.ts
new file mode 100644
index 00000000..0a8b8cbe
--- /dev/null
+++ b/src/llm/provider/subscription-cli/claude-cli-models.ts
@@ -0,0 +1,41 @@
+/**
+ * Models the `claude` CLI accepts for `--model`. The CLI exposes no
+ * list command, so this is curated: aliases first because they keep
+ * working across releases, then the pinned ids for reproducibility.
+ *
+ * When Anthropic ships a new model, add its id here — nothing else in
+ * the provider needs to change.
+ */
+export const CLAUDE_CLI_MODEL_ALIASES = [
+ "opus",
+ "sonnet",
+ "haiku",
+ "fable",
+] as const;
+
+export const CLAUDE_CLI_MODEL_IDS = [
+ "claude-opus-5",
+ "claude-sonnet-5",
+ "claude-haiku-4-5",
+ "claude-fable-5",
+ "claude-opus-4-8",
+] as const;
+
+export const CLAUDE_CLI_CHAT_MODELS: readonly string[] = [
+ ...CLAUDE_CLI_MODEL_ALIASES,
+ ...CLAUDE_CLI_MODEL_IDS,
+];
+
+/**
+ * The alias, not a pinned id: a subscription user wants the current
+ * model behind the name they already use in Claude Code.
+ */
+export const CLAUDE_CLI_DEFAULT_CHAT_MODEL = "sonnet";
+
+/**
+ * Conservative floor rather than the 1M ceiling the top models carry.
+ * This only feeds `capabilities.contextWindow`, which the runtime uses
+ * to decide when to compact — overstating it for a `haiku` session
+ * would let the prompt grow past what that model accepts.
+ */
+export const CLAUDE_CLI_CONTEXT_WINDOW = 200_000;
diff --git a/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts
new file mode 100644
index 00000000..d3066323
--- /dev/null
+++ b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts
@@ -0,0 +1,81 @@
+import type { SubscriptionCliName } from "../../../config/llm-config.js";
+import type { CompletionResult } from "../completion-types.js";
+
+/** Everything the argv builders need from one completion request. */
+export interface CliArgsInput {
+ /**
+ * Empty when the operator set no model and the CLI resolves one
+ * itself — Codex under a ChatGPT login rejects every explicit id, so
+ * the flag has to be omitted rather than guessed at.
+ */
+ model: string;
+ systemPrompt: string;
+ /** JSON Schema from `CompletionRequest.responseFormat`, when set. */
+ responseSchema?: Record;
+ /** Path to that schema on disk, for CLIs that take a file. */
+ responseSchemaPath?: string;
+ maxBudgetUsd?: number;
+ extraArgs: readonly string[];
+}
+
+/** One parsed line of a streaming CLI's NDJSON output. */
+export type CliStreamEvent =
+ | { kind: "delta"; text: string }
+ /** Terminal envelope — the same payload the buffered path parses. */
+ | { kind: "final"; raw: string }
+ /** Something worth logging but not worth failing on (rate-limit warnings). */
+ | { kind: "notice"; message: string }
+ | { kind: "ignore" };
+
+/**
+ * Everything that differs between one vendor CLI and another. The
+ * provider class holds no CLI-specific knowledge, so adding a CLI is a
+ * new descriptor plus a `SUBSCRIPTION_CLIS` entry — and a vendor
+ * changing its interface is an edit to one file.
+ */
+export interface CliAdapterDescriptor {
+ readonly cli: SubscriptionCliName;
+ readonly displayName: string;
+ readonly defaultBinary: string;
+ readonly defaultChatModel: string;
+ /** Replaces the CLI's own system prompt for the duration of a turn. */
+ readonly systemPrompt: string;
+ readonly staticModels: readonly string[];
+ readonly contextWindow: number;
+ /**
+ * How the CLI accepts a structured-output schema: `claude` takes it
+ * inline on argv, `codex` takes a path to a file on disk.
+ */
+ readonly schemaDelivery: "inline" | "file" | "none";
+ /** `"none"` means `completeStream` must fall back to buffering. */
+ readonly streamMode: "ndjson" | "none";
+ readonly installHint: string;
+ readonly authHint: string;
+ /**
+ * The text written to the child's stdin. Exists because only some
+ * CLIs have a system-prompt flag; the rest must carry that steering
+ * inside the prompt itself.
+ */
+ buildStdin(prompt: string, systemPrompt: string): string;
+ completeArgs(input: CliArgsInput): string[];
+ streamArgs(input: CliArgsInput): string[];
+ healthArgs(): string[];
+ parseResult(stdout: string, fallbackModel: string): CompletionResult;
+ parseStreamEvent(line: string): CliStreamEvent;
+}
+
+const descriptors = new Map();
+
+export function registerCliAdapter(descriptor: CliAdapterDescriptor): void {
+ descriptors.set(descriptor.cli, descriptor);
+}
+
+export function resolveCliAdapter(
+ cli: SubscriptionCliName,
+): CliAdapterDescriptor {
+ const descriptor = descriptors.get(cli);
+ if (!descriptor) {
+ throw new Error(`unknown subscription cli "${cli}"`);
+ }
+ return descriptor;
+}
diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts
new file mode 100644
index 00000000..fdba207a
--- /dev/null
+++ b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from "vitest";
+
+import { codexCliAdapter } from "./codex-cli-adapter.js";
+import {
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+} from "./subscription-cli-errors.js";
+
+const input = { model: "", systemPrompt: "SYSTEM", extraArgs: [] as readonly string[] };
+
+/** Captured verbatim from `codex exec --json` v0.148.0. */
+const SUCCESS = [
+ JSON.stringify({ type: "thread.started", thread_id: "01a0" }),
+ JSON.stringify({ type: "turn.started" }),
+ JSON.stringify({
+ type: "item.completed",
+ item: { id: "item_0", type: "agent_message", text: "OK" },
+ }),
+ JSON.stringify({
+ type: "turn.completed",
+ usage: {
+ input_tokens: 13459,
+ cached_input_tokens: 5888,
+ cache_write_input_tokens: 0,
+ output_tokens: 5,
+ reasoning_output_tokens: 27,
+ },
+ }),
+].join("\n");
+
+describe("codexCliAdapter argv", () => {
+ it("runs exec headless, sandboxed, and stateless", () => {
+ const args = codexCliAdapter.completeArgs(input);
+ expect(args[0]).toBe("exec");
+ expect(args).toContain("--json");
+ expect(args).toContain("--ephemeral");
+ expect(args).toContain("--skip-git-repo-check");
+ expect(args).toContain("--ignore-user-config");
+ expect(args.slice(args.indexOf("-s"), args.indexOf("-s") + 2)).toEqual([
+ "-s",
+ "read-only",
+ ]);
+ // Trailing `-` is what makes Codex read the prompt from stdin.
+ expect(args[args.length - 1]).toBe("-");
+ });
+
+ it("omits -m entirely when no model is configured", () => {
+ // Verified live: under a ChatGPT login Codex rejects every explicit
+ // model id and resolves one server-side.
+ expect(codexCliAdapter.completeArgs(input)).not.toContain("-m");
+ expect(codexCliAdapter.defaultChatModel).toBe("");
+ expect(codexCliAdapter.staticModels).toEqual([]);
+ });
+
+ it("passes an operator-chosen model when one is set", () => {
+ const args = codexCliAdapter.completeArgs({ ...input, model: "gpt-5.1" });
+ expect(args[args.indexOf("-m") + 1]).toBe("gpt-5.1");
+ });
+
+ it("takes the schema as a file path, never inline", () => {
+ expect(codexCliAdapter.schemaDelivery).toBe("file");
+ const args = codexCliAdapter.completeArgs({
+ ...input,
+ responseSchemaPath: "/tmp/s/schema.json",
+ });
+ expect(args[args.indexOf("--output-schema") + 1]).toBe("/tmp/s/schema.json");
+ expect(args).not.toContain("--json-schema");
+ });
+
+ it("never passes the dangerous escape hatches", () => {
+ const args = codexCliAdapter.completeArgs({
+ ...input,
+ extraArgs: ["--enable", "x"],
+ });
+ expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox");
+ expect(args).not.toContain("--dangerously-bypass-hook-trust");
+ expect(args).not.toContain("--add-dir");
+ });
+
+ it("carries the steering in stdin, since codex has no system-prompt flag", () => {
+ const stdin = codexCliAdapter.buildStdin("PROMPT", "SYSTEM");
+ expect(stdin).toBe("SYSTEM\n\nPROMPT");
+ expect(codexCliAdapter.completeArgs(input)).not.toContain("--system-prompt");
+ });
+});
+
+describe("codexCliAdapter parseResult", () => {
+ it("maps the real success stream", () => {
+ const result = codexCliAdapter.parseResult(SUCCESS, "");
+ expect(result.content).toBe("OK");
+ expect(result.finishReason).toBe("stop");
+ expect(result.slotId).toBe(-1);
+ // cached_input_tokens is a subset of input_tokens here, unlike
+ // Claude's disjoint counters, so it is reported and not added.
+ expect(result.usage).toEqual({
+ promptTokens: 13459,
+ completionTokens: 5 + 27,
+ totalTokens: 13459 + 32,
+ });
+ expect(result.cacheHitTokens).toBe(5888);
+ });
+
+ it("throws on turn.failed even though codex exits 0", () => {
+ // The whole reason this parser cannot trust the exit code.
+ const failed = [
+ JSON.stringify({ type: "turn.started" }),
+ JSON.stringify({
+ type: "turn.failed",
+ error: { message: "The 'x' model is not supported when using Codex with a ChatGPT account." },
+ }),
+ ].join("\n");
+ expect(() => codexCliAdapter.parseResult(failed, "")).toThrow(
+ SubscriptionCliInvocationError,
+ );
+ expect(() => codexCliAdapter.parseResult(failed, "")).toThrow(
+ /not supported when using Codex/,
+ );
+ });
+
+ it("treats a stream with no turn.completed as a failure, not empty content", () => {
+ expect(() =>
+ codexCliAdapter.parseResult(
+ JSON.stringify({ type: "thread.started" }),
+ "",
+ ),
+ ).toThrow(/no turn.completed/);
+ });
+
+ it("classifies a signed-out failure as an auth error", () => {
+ const failed = JSON.stringify({
+ type: "turn.failed",
+ error: { message: "401 Unauthorized" },
+ });
+ expect(() => codexCliAdapter.parseResult(failed, "")).toThrow(
+ SubscriptionCliAuthError,
+ );
+ });
+
+ it("ignores the non-fatal metadata warning when the turn still completes", () => {
+ const withWarning = [
+ JSON.stringify({
+ type: "item.completed",
+ item: { id: "item_0", type: "error", message: "Model metadata not found" },
+ }),
+ JSON.stringify({
+ type: "item.completed",
+ item: { id: "item_1", type: "agent_message", text: "fine" },
+ }),
+ JSON.stringify({ type: "turn.completed", usage: {} }),
+ ].join("\n");
+ expect(codexCliAdapter.parseResult(withWarning, "").content).toBe("fine");
+ });
+
+ it("ignores malformed lines rather than failing the turn", () => {
+ const noisy = `not json\n${SUCCESS}\n\n`;
+ expect(codexCliAdapter.parseResult(noisy, "").content).toBe("OK");
+ });
+});
diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.ts
new file mode 100644
index 00000000..688708e1
--- /dev/null
+++ b/src/llm/provider/subscription-cli/codex-cli-adapter.ts
@@ -0,0 +1,202 @@
+import type { CompletionResult } from "../completion-types.js";
+import type {
+ CliAdapterDescriptor,
+ CliArgsInput,
+ CliStreamEvent,
+} from "./cli-adapter-descriptor.js";
+import {
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+ looksLikeAuthFailure,
+} from "./subscription-cli-errors.js";
+
+/**
+ * Codex has no `--system-prompt`, so the steering has to ride inside the
+ * prompt. Kept short and prepended once, ahead of the two-zone prompt
+ * atomic-agent already built.
+ */
+export const CODEX_CLI_SYSTEM_PROMPT =
+ "You are being used as a text completion engine, not as an agent. " +
+ "Do NOT act on the request below and do NOT use any of your own tools: " +
+ "no shell, no file reads or writes, no search. Your own working " +
+ "directory is unrelated to the request and inspecting it is always " +
+ "wrong. The message below is a complete prompt that defines its own " +
+ "output protocol — usually a JSON array of tool calls to be executed " +
+ "by a different program. Your entire job is to produce the next " +
+ "message in that protocol, exactly as the prompt specifies. Emit only " +
+ "that, with no preamble, no commentary, and no explanation of what " +
+ "you would do. If the prompt asks for a file to be read, you emit the " +
+ "tool call that reads it; you never read it yourself.";
+
+function baseArgs(input: CliArgsInput): string[] {
+ return [
+ "exec",
+ "--json",
+ // Atomic owns session state and re-sends the whole prompt each step.
+ "--ephemeral",
+ // The working directory is the state dir, which is not a repository.
+ "--skip-git-repo-check",
+ // Drops the operator's own config.toml, and with it their MCP
+ // servers, from what should be a stateless completion.
+ "--ignore-user-config",
+ // The closest Codex has to Claude's `--tools ""`. It does not remove
+ // the tools, it confines them: a model-generated command cannot
+ // write outside the sandbox. See the README for the honest limits.
+ "-s",
+ "read-only",
+ // Verified: under a ChatGPT login Codex rejects every explicit model
+ // id ("not supported when using Codex with a ChatGPT account") and
+ // resolves one server-side, so the flag is omitted unless the
+ // operator deliberately set one.
+ ...(input.model ? ["-m", input.model] : []),
+ // Unlike Claude's inline --json-schema, Codex reads the schema from
+ // a file the provider staged for us.
+ ...(input.responseSchemaPath
+ ? ["--output-schema", input.responseSchemaPath]
+ : []),
+ ...input.extraArgs,
+ // Trailing `-`: read the prompt from stdin rather than argv.
+ "-",
+ ];
+}
+
+interface CodexUsage {
+ input_tokens?: number;
+ cached_input_tokens?: number;
+ output_tokens?: number;
+ reasoning_output_tokens?: number;
+}
+
+interface CodexEvent {
+ type?: string;
+ message?: string;
+ item?: { type?: string; text?: string; message?: string };
+ usage?: CodexUsage;
+ error?: { message?: string };
+}
+
+function parseLine(line: string): CodexEvent | null {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) return null;
+ try {
+ return JSON.parse(trimmed) as CodexEvent;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Codex exits 0 even when the turn fails — a bad model id, an expired
+ * login and a rate limit all produce a clean exit with a `turn.failed`
+ * event. The stream is therefore the only reliable success signal, and
+ * this parser treats a missing `turn.completed` as a failure rather than
+ * returning empty content.
+ */
+function parseResult(stdout: string, fallbackModel: string): CompletionResult {
+ let text = "";
+ let usage: CodexUsage | undefined;
+ let completed = false;
+ let failure: string | null = null;
+
+ for (const line of stdout.split("\n")) {
+ const event = parseLine(line);
+ if (!event) continue;
+ if (event.type === "item.completed" && event.item) {
+ if (event.item.type === "agent_message" && event.item.text) {
+ text = event.item.text;
+ } else if (event.item.type === "error" && event.item.message) {
+ // Non-fatal on its own (e.g. "model metadata not found"); only
+ // a turn.failed decides the turn.
+ failure ??= event.item.message;
+ }
+ } else if (event.type === "turn.completed") {
+ completed = true;
+ usage = event.usage;
+ } else if (event.type === "turn.failed") {
+ failure = event.error?.message ?? failure ?? "turn failed";
+ completed = false;
+ } else if (event.type === "error" && event.message) {
+ failure = event.message;
+ }
+ }
+
+ if (!completed) {
+ const detail = failure ?? "codex produced no turn.completed event";
+ if (looksLikeAuthFailure(detail)) {
+ throw new SubscriptionCliAuthError(
+ "codex",
+ "Run `codex login` and sign in with your ChatGPT account, then retry.",
+ detail.slice(0, 500),
+ );
+ }
+ throw new SubscriptionCliInvocationError(`codex turn failed: ${detail}`);
+ }
+
+ // `cached_input_tokens` is a subset of `input_tokens` here, unlike
+ // Claude's disjoint cache counters — so it is reported, not added.
+ const promptTokens = usage?.input_tokens ?? 0;
+ const completionTokens =
+ (usage?.output_tokens ?? 0) + (usage?.reasoning_output_tokens ?? 0);
+
+ return {
+ content: text,
+ reasoningContent: "",
+ stop: true,
+ truncated: false,
+ timing: {
+ promptMs: 0,
+ predictedMs: 0,
+ promptTokens,
+ predictedTokens: completionTokens,
+ },
+ cacheHitTokens: usage?.cached_input_tokens ?? 0,
+ slotId: -1,
+ modelId: fallbackModel || null,
+ usage: {
+ promptTokens,
+ completionTokens,
+ totalTokens: promptTokens + completionTokens,
+ },
+ finishReason: "stop",
+ };
+}
+
+export const codexCliAdapter: CliAdapterDescriptor = {
+ cli: "codex",
+ displayName: "OpenAI Codex subscription",
+ defaultBinary: "codex",
+ // Empty on purpose: Codex picks the model the account supports.
+ defaultChatModel: "",
+ staticModels: [],
+ // Codex does not publish a context window per model here; this only
+ // feeds compaction timing, so a conservative floor is the safe choice.
+ contextWindow: 200_000,
+ schemaDelivery: "file",
+ // No incremental text events were observed on `exec --json` — output
+ // arrives in one `item.completed`. Buffering is therefore honest
+ // rather than a limitation we could paper over.
+ streamMode: "none",
+ systemPrompt: CODEX_CLI_SYSTEM_PROMPT,
+ installHint:
+ "Install the Codex CLI (`npm i -g @openai/codex`) and run `codex login`, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.",
+ authHint:
+ "Run `codex login` and sign in with your ChatGPT account, then retry.",
+ buildStdin(prompt, systemPrompt) {
+ return `${systemPrompt}\n\n${prompt}`;
+ },
+ completeArgs(input) {
+ return baseArgs(input);
+ },
+ streamArgs(input) {
+ // streamMode is "none", so the provider never calls this; keeping it
+ // identical means a future streaming opt-in cannot drift.
+ return baseArgs(input);
+ },
+ healthArgs() {
+ return ["--version"];
+ },
+ parseResult,
+ parseStreamEvent(): CliStreamEvent {
+ return { kind: "ignore" };
+ },
+};
diff --git a/src/llm/provider/subscription-cli/index.ts b/src/llm/provider/subscription-cli/index.ts
new file mode 100644
index 00000000..d2e14491
--- /dev/null
+++ b/src/llm/provider/subscription-cli/index.ts
@@ -0,0 +1,41 @@
+export {
+ claudeCliAdapter,
+ CLAUDE_CLI_SYSTEM_PROMPT,
+} from "./claude-cli-adapter.js";
+export {
+ codexCliAdapter,
+ CODEX_CLI_SYSTEM_PROMPT,
+} from "./codex-cli-adapter.js";
+export {
+ CLAUDE_CLI_CHAT_MODELS,
+ CLAUDE_CLI_CONTEXT_WINDOW,
+ CLAUDE_CLI_DEFAULT_CHAT_MODEL,
+} from "./claude-cli-models.js";
+export {
+ registerCliAdapter,
+ resolveCliAdapter,
+ type CliAdapterDescriptor,
+ type CliArgsInput,
+ type CliStreamEvent,
+} from "./cli-adapter-descriptor.js";
+export { registerBuiltInCliAdapters } from "./register-cli-adapters.js";
+export { resolveCliBinary } from "./resolve-cli-binary.js";
+export {
+ runCliCommand,
+ type CliRunner,
+ type CliRunOptions,
+ type CliRunOutcome,
+} from "./run-cli-completion.js";
+export {
+ streamCliCommand,
+ type CliStreamRunner,
+} from "./stream-cli-completion.js";
+export {
+ SubscriptionCliProvider,
+ type SubscriptionCliProviderOptions,
+} from "./subscription-cli-provider.js";
+export {
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+ SubscriptionCliNotInstalledError,
+} from "./subscription-cli-errors.js";
diff --git a/src/llm/provider/subscription-cli/register-cli-adapters.ts b/src/llm/provider/subscription-cli/register-cli-adapters.ts
new file mode 100644
index 00000000..85d72361
--- /dev/null
+++ b/src/llm/provider/subscription-cli/register-cli-adapters.ts
@@ -0,0 +1,17 @@
+import { registerCliAdapter } from "./cli-adapter-descriptor.js";
+import { claudeCliAdapter } from "./claude-cli-adapter.js";
+import { codexCliAdapter } from "./codex-cli-adapter.js";
+
+let registered = false;
+
+/**
+ * Wire the shipped CLI descriptors into the lookup. Idempotent, and
+ * called from the provider factory so importing the provider class
+ * alone never has a registration side effect.
+ */
+export function registerBuiltInCliAdapters(): void {
+ if (registered) return;
+ registered = true;
+ registerCliAdapter(claudeCliAdapter);
+ registerCliAdapter(codexCliAdapter);
+}
diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts
new file mode 100644
index 00000000..32f7912c
--- /dev/null
+++ b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveCliBinary } from "./resolve-cli-binary.js";
+
+describe("resolveCliBinary", () => {
+ it("prefers a configured binPath on every platform", () => {
+ expect(resolveCliBinary("claude", "/opt/bin/claude", "darwin")).toBe(
+ "/opt/bin/claude",
+ );
+ expect(resolveCliBinary("claude", "C:\\bin\\claude.cmd", "win32")).toBe(
+ "C:\\bin\\claude.cmd",
+ );
+ });
+
+ it("hands the bare name to spawn on posix", () => {
+ expect(resolveCliBinary("claude", undefined, "darwin")).toBe("claude");
+ expect(resolveCliBinary("codex", undefined, "linux")).toBe("codex");
+ });
+
+ it("finds the .cmd shim on windows, where spawn with shell:false would not", () => {
+ const present = new Set(["C:\\npm\\claude.cmd"]);
+ expect(
+ resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, (p) =>
+ present.has(p),
+ ),
+ ).toBe("C:\\npm\\claude.cmd");
+ });
+
+ it("falls back to the bare name on windows so ENOENT still surfaces", () => {
+ expect(
+ resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, () => false),
+ ).toBe("claude");
+ });
+});
diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.ts
new file mode 100644
index 00000000..c89b6454
--- /dev/null
+++ b/src/llm/provider/subscription-cli/resolve-cli-binary.ts
@@ -0,0 +1,38 @@
+import { existsSync } from "node:fs";
+import { win32 } from "node:path";
+
+/**
+ * Pick the command to spawn for a vendor CLI.
+ *
+ * A configured `binPath` always wins — that is the escape hatch for a
+ * binary outside `PATH`. Otherwise we hand the bare name to `spawn`,
+ * which resolves it through `PATH` itself, except on Windows: with
+ * `shell: false` Node will not try the `PATHEXT` suffixes, so
+ * `spawn("claude")` misses the `claude.cmd` shim npm installs. There we
+ * walk `PATH` ourselves and return the first suffixed hit.
+ */
+export function resolveCliBinary(
+ defaultBinary: string,
+ binPath?: string,
+ platform: NodeJS.Platform = process.platform,
+ env: NodeJS.ProcessEnv = process.env,
+ fileExists: (path: string) => boolean = existsSync,
+): string {
+ if (binPath && binPath.length > 0) return binPath;
+ if (platform !== "win32") return defaultBinary;
+ if (win32.isAbsolute(defaultBinary)) return defaultBinary;
+
+ const suffixes = [".cmd", ".exe", ".bat", ""];
+ // Windows path semantics regardless of the host we are running on,
+ // so the branch is testable from macOS/Linux.
+ for (const dir of (env.PATH ?? "").split(";")) {
+ if (dir.length === 0) continue;
+ for (const suffix of suffixes) {
+ const candidate = win32.join(dir, `${defaultBinary}${suffix}`);
+ if (fileExists(candidate)) return candidate;
+ }
+ }
+ // Nothing on PATH — hand back the bare name so the ENOENT surfaces
+ // from spawn with the standard not-installed message.
+ return defaultBinary;
+}
diff --git a/src/llm/provider/subscription-cli/run-cli-completion.ts b/src/llm/provider/subscription-cli/run-cli-completion.ts
new file mode 100644
index 00000000..32267416
--- /dev/null
+++ b/src/llm/provider/subscription-cli/run-cli-completion.ts
@@ -0,0 +1,87 @@
+import { runCommand } from "../../../sandbox/command-runner.js";
+import {
+ isEnoent,
+ mapCliFailure,
+ SubscriptionCliNotInstalledError,
+} from "./subscription-cli-errors.js";
+
+export interface CliRunOptions {
+ binary: string;
+ args: readonly string[];
+ /** Prompt text, written to stdin. Never placed on argv — see the provider. */
+ input?: string;
+ cwd: string;
+ timeoutMs: number;
+ maxOutputBytes: number;
+ signal?: AbortSignal;
+ installHint: string;
+ authHint: string;
+}
+
+export interface CliRunOutcome {
+ stdout: string;
+ stderr: string;
+ exitCode: number | null;
+ durationMs: number;
+}
+
+/**
+ * Injection seam. Tests substitute their own runner so no test ever
+ * spawns a real CLI; mirrors `OpenAiProviderOptions.fetchImpl`.
+ */
+export type CliRunner = (options: CliRunOptions) => Promise;
+
+/**
+ * Run a vendor CLI to completion and hand back its stdout, or throw a
+ * typed error. Builds on `runCommand`, which already provides
+ * shell-free spawn, stdin injection, timeout, an output cap and Windows
+ * tree-kill; this adds the failure taxonomy on top, the same way
+ * `git-runner.ts` wraps it for git.
+ */
+export const runCliCommand: CliRunner = async (options) => {
+ let result;
+ try {
+ result = await runCommand(options.binary, [...options.args], {
+ cwd: options.cwd,
+ timeoutMs: options.timeoutMs,
+ maxOutputBytes: options.maxOutputBytes,
+ shell: false,
+ // Inherit the environment untouched. We deliberately neither set
+ // nor clear ANTHROPIC_API_KEY: setting it would silently move the
+ // user onto API billing, clearing it would break anyone who wants
+ // exactly that.
+ ...(options.input === undefined ? {} : { input: options.input }),
+ ...(options.signal ? { signal: options.signal } : {}),
+ });
+ } catch (err) {
+ if (isEnoent(err)) {
+ throw new SubscriptionCliNotInstalledError(
+ options.binary,
+ options.installHint,
+ );
+ }
+ throw err;
+ }
+
+ if (result.exitCode !== 0 || result.timedOut || result.truncated) {
+ throw mapCliFailure({
+ binary: options.binary,
+ installHint: options.installHint,
+ authHint: options.authHint,
+ exitCode: result.exitCode,
+ stdout: result.stdout,
+ stderr: result.stderr,
+ timedOut: result.timedOut,
+ truncated: result.truncated,
+ timeoutMs: options.timeoutMs,
+ maxOutputBytes: options.maxOutputBytes,
+ });
+ }
+
+ return {
+ stdout: result.stdout,
+ stderr: result.stderr,
+ exitCode: result.exitCode,
+ durationMs: result.durationMs,
+ };
+};
diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.test.ts b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts
new file mode 100644
index 00000000..e49af1a0
--- /dev/null
+++ b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest";
+
+import type { CliRunOptions } from "./run-cli-completion.js";
+import { streamCliCommand } from "./stream-cli-completion.js";
+import { SubscriptionCliNotInstalledError } from "./subscription-cli-errors.js";
+
+/**
+ * These exercise the real spawn/line-splitting path against a scripted
+ * node child — never against a vendor CLI. Mocking `child_process`
+ * instead would test the mock, not the buffering behaviour that the
+ * NDJSON reader actually has to get right.
+ */
+function options(script: string, extra: Partial = {}): CliRunOptions {
+ return {
+ binary: process.execPath,
+ args: ["-e", script],
+ cwd: process.cwd(),
+ timeoutMs: 15_000,
+ maxOutputBytes: 1024 * 1024,
+ installHint: "install it",
+ authHint: "log in",
+ ...extra,
+ };
+}
+
+async function collect(opts: CliRunOptions): Promise {
+ const lines: string[] = [];
+ for await (const line of streamCliCommand(opts)) lines.push(line);
+ return lines;
+}
+
+describe("streamCliCommand", () => {
+ it("reassembles lines split across chunk boundaries", async () => {
+ // Deliberately writes half a JSON object, pauses, then the rest.
+ const script = `
+ process.stdout.write('{"type":"a"}\\n{"ty');
+ setTimeout(() => {
+ process.stdout.write('pe":"b"}\\n{"type":"c"}\\n');
+ }, 20);
+ `;
+ expect(await collect(options(script))).toEqual([
+ '{"type":"a"}',
+ '{"type":"b"}',
+ '{"type":"c"}',
+ ]);
+ });
+
+ it("yields a final line that has no trailing newline", async () => {
+ const script = `process.stdout.write('one\\ntwo');`;
+ expect(await collect(options(script))).toEqual(["one", "two"]);
+ });
+
+ it("delivers the prompt on stdin", async () => {
+ const script = `
+ let buf = "";
+ process.stdin.on("data", (c) => { buf += c; });
+ process.stdin.on("end", () => process.stdout.write(buf.length + "\\n"));
+ `;
+ expect(await collect(options(script, { input: "x".repeat(5000) }))).toEqual([
+ "5000",
+ ]);
+ });
+
+ it("raises a typed error when the binary does not exist", async () => {
+ await expect(
+ collect(options("", { binary: "definitely-not-a-real-binary-xyz" })),
+ ).rejects.toBeInstanceOf(SubscriptionCliNotInstalledError);
+ });
+
+ it("surfaces stderr when the child exits non-zero", async () => {
+ const script = `
+ process.stderr.write("weekly limit reached");
+ process.exit(3);
+ `;
+ await expect(collect(options(script))).rejects.toThrow(
+ /exited with code 3[\s\S]*weekly limit reached/,
+ );
+ });
+
+ it("maps a signed-out message to an auth error", async () => {
+ const script = `
+ process.stderr.write("Please run /login to authenticate");
+ process.exit(1);
+ `;
+ // Classified as an auth failure (not a generic non-zero exit), and
+ // the descriptor's own hint is what reaches the user.
+ await expect(collect(options(script))).rejects.toThrow(
+ /is not signed in\. log in/,
+ );
+ });
+
+ it("stops the child when the caller aborts", async () => {
+ const controller = new AbortController();
+ // Emits one line, then would hang for a minute.
+ const script = `
+ process.stdout.write('{"type":"a"}\\n');
+ setTimeout(() => {}, 60000);
+ `;
+ const lines: string[] = [];
+ const started = Date.now();
+ await expect(
+ (async () => {
+ for await (const line of streamCliCommand(
+ options(script, { signal: controller.signal }),
+ )) {
+ lines.push(line);
+ controller.abort();
+ }
+ })(),
+ ).rejects.toThrow();
+ expect(lines).toEqual(['{"type":"a"}']);
+ // SIGTERM must land well before the child's own 60s timer.
+ expect(Date.now() - started).toBeLessThan(10_000);
+ });
+
+ it("does not leak the child when the consumer abandons the iterator", async () => {
+ const script = `
+ process.stdout.write('{"type":"a"}\\n');
+ setTimeout(() => {}, 60000);
+ `;
+ const iterator = streamCliCommand(options(script));
+ const first = await iterator.next();
+ expect(first.value).toBe('{"type":"a"}');
+ // The generator's finally block is responsible for the kill.
+ await iterator.return();
+ });
+});
diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.ts b/src/llm/provider/subscription-cli/stream-cli-completion.ts
new file mode 100644
index 00000000..0098f502
--- /dev/null
+++ b/src/llm/provider/subscription-cli/stream-cli-completion.ts
@@ -0,0 +1,140 @@
+import { spawn } from "node:child_process";
+import type { CliRunOptions } from "./run-cli-completion.js";
+import {
+ isEnoent,
+ mapCliFailure,
+ SubscriptionCliNotInstalledError,
+} from "./subscription-cli-errors.js";
+
+/** Grace period between asking a child to stop and killing it. */
+const SIGKILL_DELAY_MS = 2_000;
+/** A single NDJSON line larger than this means the stream went wrong. */
+const MAX_LINE_BYTES = 4 * 1024 * 1024;
+
+export type CliStreamRunner = (
+ options: CliRunOptions,
+) => AsyncGenerator;
+
+/**
+ * Spawn a CLI and yield its stdout one line at a time.
+ *
+ * Separate from `runCliCommand` because the buffered runner resolves
+ * only once the process exits, which is exactly what streaming must
+ * avoid. The generator's `finally` always kills the child, so a consumer
+ * that abandons the iterator cannot leak a process.
+ */
+export const streamCliCommand: CliStreamRunner = async function* (options) {
+ const child = spawn(options.binary, [...options.args], {
+ cwd: options.cwd,
+ env: process.env,
+ shell: false,
+ stdio: ["pipe", "pipe", "pipe"],
+ ...(process.platform === "win32" ? { windowsHide: true } : {}),
+ });
+
+ let stderr = "";
+ let timedOut = false;
+ let killTimer: NodeJS.Timeout | null = null;
+ let settled = false;
+
+ const stop = (reason: "timeout" | "abort" | "done") => {
+ if (settled) return;
+ if (reason === "timeout") timedOut = true;
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // already gone
+ }
+ // Escalate only if SIGTERM was not enough.
+ killTimer = setTimeout(() => {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+ }, SIGKILL_DELAY_MS);
+ killTimer.unref?.();
+ };
+
+ const timer =
+ options.timeoutMs > 0 && Number.isFinite(options.timeoutMs)
+ ? setTimeout(() => stop("timeout"), options.timeoutMs)
+ : null;
+ const onAbort = () => stop("abort");
+ options.signal?.addEventListener("abort", onAbort, { once: true });
+
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk: string) => {
+ if (stderr.length < options.maxOutputBytes) stderr += chunk;
+ });
+
+ const exited = new Promise<{ code: number | null }>((resolve, reject) => {
+ child.on("error", (err) => {
+ settled = true;
+ reject(
+ isEnoent(err)
+ ? new SubscriptionCliNotInstalledError(
+ options.binary,
+ options.installHint,
+ )
+ : err,
+ );
+ });
+ child.on("close", (code) => {
+ settled = true;
+ resolve({ code });
+ });
+ });
+
+ // The exit promise is awaited only after stdout drains, so attach a
+ // no-op handler now: a spawn error (ENOENT) rejects immediately and
+ // would otherwise be reported as an unhandled rejection before the
+ // real await picks it up. Other awaiters still see the rejection.
+ exited.catch(() => {});
+
+ if (options.input !== undefined) child.stdin.write(options.input);
+ child.stdin.end();
+
+ child.stdout.setEncoding("utf8");
+ let buffer = "";
+ try {
+ for await (const chunk of child.stdout as AsyncIterable) {
+ buffer += chunk;
+ if (buffer.length > MAX_LINE_BYTES) {
+ throw new Error(
+ `${options.binary} emitted a line larger than ${MAX_LINE_BYTES} bytes`,
+ );
+ }
+ let newline = buffer.indexOf("\n");
+ while (newline !== -1) {
+ const line = buffer.slice(0, newline);
+ buffer = buffer.slice(newline + 1);
+ yield line;
+ newline = buffer.indexOf("\n");
+ }
+ }
+ // A stream that ends without a trailing newline still has a line.
+ if (buffer.length > 0) yield buffer;
+
+ const { code } = await exited;
+ if (code !== 0 || timedOut) {
+ throw mapCliFailure({
+ binary: options.binary,
+ installHint: options.installHint,
+ authHint: options.authHint,
+ exitCode: code,
+ stdout: "",
+ stderr,
+ timedOut,
+ truncated: false,
+ timeoutMs: options.timeoutMs,
+ maxOutputBytes: options.maxOutputBytes,
+ });
+ }
+ } finally {
+ if (timer) clearTimeout(timer);
+ options.signal?.removeEventListener("abort", onAbort);
+ if (!settled) stop("done");
+ if (killTimer) clearTimeout(killTimer);
+ }
+};
diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts
new file mode 100644
index 00000000..d412794f
--- /dev/null
+++ b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ isEnoent,
+ looksLikeAuthFailure,
+ mapCliFailure,
+ SubscriptionCliAuthError,
+ SubscriptionCliInvocationError,
+ SubscriptionCliNotInstalledError,
+} from "./subscription-cli-errors.js";
+
+const base = {
+ binary: "claude",
+ installHint: "Install Claude Code.",
+ authHint: "Run `claude` and complete /login.",
+ exitCode: 1,
+ stdout: "",
+ stderr: "",
+ timedOut: false,
+ truncated: false,
+ timeoutMs: 1000,
+ maxOutputBytes: 4096,
+};
+
+describe("isEnoent", () => {
+ it("detects the spawn error for a missing binary", () => {
+ expect(isEnoent(Object.assign(new Error("x"), { code: "ENOENT" }))).toBe(true);
+ expect(isEnoent(new Error("x"))).toBe(false);
+ expect(isEnoent(null)).toBe(false);
+ });
+});
+
+describe("looksLikeAuthFailure", () => {
+ it("matches the signed-out phrasings", () => {
+ for (const text of [
+ "Please run /login to authenticate",
+ "You are not logged in",
+ "Authentication required",
+ "Invalid API key",
+ "401 Unauthorized",
+ "credentials expired",
+ ]) {
+ expect(looksLikeAuthFailure(text)).toBe(true);
+ }
+ });
+
+ it("does not claim an auth problem for ordinary failures", () => {
+ // A false positive would send the user to /login for a rate limit.
+ for (const text of [
+ "5-hour limit reached; resets at 14:00",
+ "network error: ECONNRESET",
+ "model not found",
+ "Overloaded",
+ ]) {
+ expect(looksLikeAuthFailure(text)).toBe(false);
+ }
+ });
+});
+
+describe("mapCliFailure", () => {
+ it("reports a timeout with the budget that was exceeded", () => {
+ const err = mapCliFailure({ ...base, timedOut: true });
+ expect(err).toBeInstanceOf(SubscriptionCliInvocationError);
+ expect(err.message).toMatch(/timed out after 1000ms/);
+ });
+
+ it("refuses to parse truncated output rather than failing later", () => {
+ const err = mapCliFailure({ ...base, truncated: true });
+ expect(err.message).toMatch(/refusing to parse a truncated response/);
+ });
+
+ it("maps a signed-out CLI to an auth error carrying the hint", () => {
+ const err = mapCliFailure({
+ ...base,
+ stderr: "Error: not logged in. Please run /login",
+ });
+ expect(err).toBeInstanceOf(SubscriptionCliAuthError);
+ expect(err.message).toMatch(/complete \/login/);
+ });
+
+ it("passes an unexplained failure through verbatim", () => {
+ // Subscription rate limits have no structured form; swallowing the
+ // text would leave the user with an exit code and nothing else.
+ const err = mapCliFailure({
+ ...base,
+ exitCode: 2,
+ stderr: "weekly limit reached, resets Monday",
+ });
+ expect(err).toBeInstanceOf(SubscriptionCliInvocationError);
+ expect(err.message).toMatch(/exited with code 2/);
+ expect(err.message).toMatch(/weekly limit reached, resets Monday/);
+ });
+
+ it("truncates a huge stderr instead of pasting megabytes into the message", () => {
+ const err = mapCliFailure({ ...base, stderr: "e".repeat(10_000) });
+ expect(err.message.length).toBeLessThan(3000);
+ });
+});
+
+describe("error messages", () => {
+ it("tells the user how to fix a missing binary", () => {
+ const err = new SubscriptionCliNotInstalledError("claude", "Install it.");
+ expect(err.message).toMatch(/"claude" was not found on PATH/);
+ expect(err.message).toMatch(/Install it\./);
+ });
+});
diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.ts
new file mode 100644
index 00000000..6ab74805
--- /dev/null
+++ b/src/llm/provider/subscription-cli/subscription-cli-errors.ts
@@ -0,0 +1,115 @@
+/**
+ * Failure taxonomy for CLI-backed providers. The three cases the user
+ * can actually act on are kept apart from each other: the binary is
+ * missing, the CLI is signed out, or the invocation itself failed.
+ */
+
+export class SubscriptionCliNotInstalledError extends Error {
+ constructor(binary: string, installHint: string) {
+ super(`"${binary}" was not found on PATH. ${installHint}`);
+ this.name = "SubscriptionCliNotInstalledError";
+ }
+}
+
+export class SubscriptionCliAuthError extends Error {
+ constructor(binary: string, authHint: string, detail?: string) {
+ super(
+ `"${binary}" is not signed in. ${authHint}${detail ? ` (${detail})` : ""}`,
+ );
+ this.name = "SubscriptionCliAuthError";
+ }
+}
+
+export class SubscriptionCliInvocationError extends Error {
+ readonly exitCode: number | null;
+ constructor(message: string, exitCode: number | null = null) {
+ super(message);
+ this.name = "SubscriptionCliInvocationError";
+ this.exitCode = exitCode;
+ }
+}
+
+/**
+ * Signed-out CLIs do not use a stable exit code, so the text is the only
+ * signal. Kept deliberately narrow: a false positive here would relabel
+ * a real API error as "run /login" and send the user down a dead end.
+ */
+const AUTH_PATTERNS = [
+ /\bplease run\s+\/login\b/i,
+ /\brun\s+`?\/login`?\b/i,
+ /\bnot (?:logged in|authenticated|signed in)\b/i,
+ /\bauthentication (?:required|failed|error)\b/i,
+ /\binvalid api key\b/i,
+ /\bunauthorized\b/i,
+ /\bcredentials (?:are )?(?:missing|expired|invalid)\b/i,
+];
+
+export function looksLikeAuthFailure(text: string): boolean {
+ return AUTH_PATTERNS.some((re) => re.test(text));
+}
+
+/** `spawn` reports a missing binary as an ENOENT on the error event. */
+export function isEnoent(err: unknown): boolean {
+ return (
+ typeof err === "object" &&
+ err !== null &&
+ (err as { code?: unknown }).code === "ENOENT"
+ );
+}
+
+export interface CliFailureInput {
+ binary: string;
+ installHint: string;
+ authHint: string;
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+ timedOut: boolean;
+ truncated: boolean;
+ timeoutMs: number;
+ maxOutputBytes: number;
+}
+
+const DETAIL_CHARS = 2048;
+
+/**
+ * Turn a finished-but-unhappy CLI run into a typed error. Callers hand
+ * the raw streams over verbatim: subscription rate-limit messages have
+ * no documented structured form, so swallowing the text would leave the
+ * user with an exit code and no explanation.
+ */
+export function mapCliFailure(input: CliFailureInput): Error {
+ if (input.timedOut) {
+ return new SubscriptionCliInvocationError(
+ `"${input.binary}" timed out after ${input.timeoutMs}ms`,
+ input.exitCode,
+ );
+ }
+ if (input.truncated) {
+ return new SubscriptionCliInvocationError(
+ `"${input.binary}" produced more than ${input.maxOutputBytes} bytes; refusing to parse a truncated response`,
+ input.exitCode,
+ );
+ }
+ const combined = `${input.stderr}\n${input.stdout}`;
+ if (looksLikeAuthFailure(combined)) {
+ return new SubscriptionCliAuthError(
+ input.binary,
+ input.authHint,
+ tail(input.stderr || input.stdout),
+ );
+ }
+ return new SubscriptionCliInvocationError(
+ `"${input.binary}" exited with code ${input.exitCode ?? "null"}: ${
+ tail(input.stderr || input.stdout) || "no output"
+ }`,
+ input.exitCode,
+ );
+}
+
+function tail(text: string): string {
+ const trimmed = text.trim();
+ return trimmed.length > DETAIL_CHARS
+ ? `…${trimmed.slice(-DETAIL_CHARS)}`
+ : trimmed;
+}
diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts
new file mode 100644
index 00000000..571b8137
--- /dev/null
+++ b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts
@@ -0,0 +1,283 @@
+import { describe, expect, it } from "vitest";
+
+import type { AtomicAgentConfig } from "../../../config/index.js";
+import { getConfig } from "../../../config/index.js";
+import { VisionUnsupportedError } from "../llm-provider.js";
+import {
+ getProviderFactory,
+ type LlmProviderConfigEntry,
+} from "../registry/provider-types.js";
+import { registerBuiltInProviderKinds } from "../registry/register-built-in-providers.js";
+import { claudeCliAdapter } from "./claude-cli-adapter.js";
+import type { CliRunOptions, CliRunOutcome } from "./run-cli-completion.js";
+import { SubscriptionCliProvider } from "./subscription-cli-provider.js";
+import { SubscriptionCliNotInstalledError } from "./subscription-cli-errors.js";
+
+const SUCCESS = JSON.stringify({
+ subtype: "success",
+ is_error: false,
+ result: "hello",
+ stop_reason: "end_turn",
+ usage: { input_tokens: 10, output_tokens: 3 },
+});
+
+function stubRunner(stdout: string, calls: CliRunOptions[] = []) {
+ return async (options: CliRunOptions): Promise => {
+ calls.push(options);
+ return { stdout, stderr: "", exitCode: 0, durationMs: 1 };
+ };
+}
+
+function makeProvider(overrides: Partial[0]> = {}) {
+ return new SubscriptionCliProvider(buildOptions(overrides));
+}
+
+function buildOptions(overrides: Record = {}) {
+ return {
+ id: "claude-cli",
+ descriptor: claudeCliAdapter,
+ cwd: "/tmp",
+ runCliImpl: stubRunner(SUCCESS),
+ ...overrides,
+ } as ConstructorParameters[0];
+}
+
+describe("SubscriptionCliProvider capabilities", () => {
+ it("declares the native transport with no vision and no slot affinity", () => {
+ const provider = makeProvider();
+ // native_tools, despite never returning tool_calls: it routes
+ // step-executor down its guarded recovery ladder instead of the
+ // repair path, which would cost a second CLI invocation.
+ expect(provider.capabilities.toolTransport).toBe("native_tools");
+ expect(provider.toolCallAdapter).not.toBeNull();
+ expect(provider.streamConsumer).toBeNull();
+ expect(provider.capabilities.vision).toBe(false);
+ expect(provider.capabilities.supportsSlotAffinity).toBe(false);
+ expect(provider.capabilities.supportsPromptCache).toBe(true);
+ expect(provider.capabilities.contextWindow).toBeGreaterThan(0);
+ });
+
+ it("rejects vision instead of pretending", async () => {
+ await expect(
+ makeProvider().describeImage({ prompt: "x", images: [] }),
+ ).rejects.toBeInstanceOf(VisionUnsupportedError);
+ });
+
+ it("lists models without spawning anything", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) });
+ expect(await provider.listModels()).toContain("sonnet");
+ expect(calls).toHaveLength(0);
+ });
+
+ it("closes without error", async () => {
+ await expect(makeProvider().close()).resolves.toBeUndefined();
+ });
+});
+
+describe("SubscriptionCliProvider.complete", () => {
+ it("sends the prompt on stdin and never on argv", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) });
+ const prompt = "P".repeat(200_000);
+ const result = await provider.complete({ prompt });
+
+ expect(result.content).toBe("hello");
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.input).toBe(prompt);
+ expect(calls[0]?.args.some((arg) => arg.includes("PPPP"))).toBe(false);
+ });
+
+ it("uses the configured model and appends extraArgs", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({
+ model: "opus",
+ extraArgs: ["--effort", "high"],
+ runCliImpl: stubRunner(SUCCESS, calls),
+ });
+ await provider.complete({ prompt: "x" });
+ const args = calls[0]?.args ?? [];
+ expect(args[args.indexOf("--model") + 1]).toBe("opus");
+ expect(args.slice(-2)).toEqual(["--effort", "high"]);
+ });
+
+ it("forwards the abort signal to the child", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) });
+ const controller = new AbortController();
+ await provider.complete({ prompt: "x", signal: controller.signal });
+ expect(calls[0]?.signal).toBe(controller.signal);
+ });
+
+ it("passes responseFormat through as --json-schema", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) });
+ await provider.complete({
+ prompt: "x",
+ responseFormat: { name: "vote", schema: { type: "object" } },
+ });
+ expect(calls[0]?.args).toContain("--json-schema");
+ });
+});
+
+describe("SubscriptionCliProvider.completeStream", () => {
+ it("falls back to one buffered chunk when streaming is disabled", async () => {
+ const provider = makeProvider({ streaming: false });
+ const deltas: string[] = [];
+ const iterator = provider.completeStream({ prompt: "x" });
+ let next = await iterator.next();
+ while (!next.done) {
+ if (next.value.delta) deltas.push(next.value.delta);
+ next = await iterator.next();
+ }
+ expect(deltas).toEqual(["hello"]);
+ expect(next.value.content).toBe("hello");
+ });
+
+ it("streams deltas and returns the parsed final envelope", async () => {
+ const lines = [
+ JSON.stringify({ type: "system", subtype: "init" }),
+ JSON.stringify({
+ type: "stream_event",
+ event: {
+ type: "content_block_delta",
+ delta: { type: "text_delta", text: "he" },
+ },
+ }),
+ JSON.stringify({
+ type: "stream_event",
+ event: {
+ type: "content_block_delta",
+ delta: { type: "text_delta", text: "llo" },
+ },
+ }),
+ SUCCESS.replace('"subtype"', '"type":"result","subtype"'),
+ ];
+ const provider = makeProvider({
+ streamCliImpl: async function* () {
+ for (const line of lines) yield line;
+ },
+ });
+ const deltas: string[] = [];
+ const iterator = provider.completeStream({ prompt: "x" });
+ let next = await iterator.next();
+ while (!next.done) {
+ if (next.value.delta) deltas.push(next.value.delta);
+ next = await iterator.next();
+ }
+ expect(deltas).toEqual(["he", "llo"]);
+ expect(next.value.content).toBe("hello");
+ });
+
+ it("emits the final text once when no delta was recognised", async () => {
+ // Safety net for a stream schema we do not control: a mismatch must
+ // degrade to buffered behaviour, never to an empty turn.
+ const provider = makeProvider({
+ streamCliImpl: async function* () {
+ yield JSON.stringify({ type: "stream_event", event: { type: "unknown" } });
+ yield SUCCESS.replace('"subtype"', '"type":"result","subtype"');
+ },
+ });
+ const deltas: string[] = [];
+ const iterator = provider.completeStream({ prompt: "x" });
+ let next = await iterator.next();
+ while (!next.done) {
+ if (next.value.delta) deltas.push(next.value.delta);
+ next = await iterator.next();
+ }
+ expect(deltas).toEqual(["hello"]);
+ });
+
+ it("fails loudly when the stream ends with no result envelope", async () => {
+ const provider = makeProvider({
+ streamCliImpl: async function* () {
+ yield JSON.stringify({ type: "system" });
+ },
+ });
+ const iterator = provider.completeStream({ prompt: "x" });
+ await expect(
+ (async () => {
+ let next = await iterator.next();
+ while (!next.done) next = await iterator.next();
+ })(),
+ ).rejects.toThrow(/without a result envelope/);
+ });
+
+ it("routes rate-limit notices to onNotice instead of failing", async () => {
+ const notices: string[] = [];
+ const provider = makeProvider({
+ onNotice: (message: string) => notices.push(message),
+ streamCliImpl: async function* () {
+ yield JSON.stringify({
+ type: "rate_limit_event",
+ rate_limit_info: { status: "rejected", rateLimitType: "five_hour" },
+ });
+ yield SUCCESS.replace('"subtype"', '"type":"result","subtype"');
+ },
+ });
+ const iterator = provider.completeStream({ prompt: "x" });
+ let next = await iterator.next();
+ while (!next.done) next = await iterator.next();
+ expect(notices).toEqual(["claude rate limit rejected (five_hour)"]);
+ });
+});
+
+describe("SubscriptionCliProvider.health", () => {
+ it("is reachable when the version probe exits cleanly", async () => {
+ const calls: CliRunOptions[] = [];
+ const provider = makeProvider({
+ runCliImpl: stubRunner("2.1.220 (Claude Code)", calls),
+ });
+ const health = await provider.health();
+ expect(health.reachable).toBe(true);
+ expect(calls[0]?.args).toEqual(["--version"]);
+ // A health probe must never send a prompt or cost tokens.
+ expect(calls[0]?.input).toBeUndefined();
+ });
+
+ it("reports an actionable message when the binary is missing", async () => {
+ const provider = makeProvider({
+ runCliImpl: async () => {
+ throw new SubscriptionCliNotInstalledError("claude", "Install it.");
+ },
+ });
+ const health = await provider.health();
+ expect(health.reachable).toBe(false);
+ expect(health.error).toMatch(/not found on PATH/);
+ });
+});
+
+describe("registry factory", () => {
+ it("builds the provider from a subscription-cli entry", async () => {
+ registerBuiltInProviderKinds();
+ const factory = getProviderFactory("subscription-cli");
+ expect(factory).toBeDefined();
+ const entry: LlmProviderConfigEntry = {
+ id: "claude-cli",
+ kind: "subscription-cli",
+ defaultChatModel: "opus",
+ subscriptionCli: { cli: "claude" },
+ };
+ const provider = await factory!({
+ config: getConfig() as AtomicAgentConfig,
+ entry,
+ logger: { debug() {}, info() {}, warn() {}, error() {} } as never,
+ });
+ expect(provider).toBeInstanceOf(SubscriptionCliProvider);
+ expect(provider.id).toBe("claude-cli");
+ });
+
+ it("refuses an entry with no subscriptionCli block", () => {
+ registerBuiltInProviderKinds();
+ const factory = getProviderFactory("subscription-cli")!;
+ // Config parsing rejects this first; the factory guard is the
+ // backstop for an entry built in code rather than loaded from disk.
+ expect(() =>
+ factory({
+ config: getConfig() as AtomicAgentConfig,
+ entry: { id: "claude-cli", kind: "subscription-cli" },
+ logger: { debug() {}, info() {}, warn() {}, error() {} } as never,
+ }),
+ ).toThrow(/requires a subscriptionCli block/);
+ });
+});
diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.ts
new file mode 100644
index 00000000..b431cf87
--- /dev/null
+++ b/src/llm/provider/subscription-cli/subscription-cli-provider.ts
@@ -0,0 +1,287 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type {
+ CompletionRequest,
+ CompletionResult,
+ StreamChunk,
+} from "../completion-types.js";
+import type {
+ LlmProvider,
+ ProviderCapabilities,
+ ProviderHealthResult,
+ VisionRequest,
+ VisionResult,
+} from "../llm-provider.js";
+import { VisionUnsupportedError } from "../llm-provider.js";
+import type { ToolCallAdapter } from "../adapters/tool-call-adapter.js";
+import { openAiToolCallAdapter } from "../openai/openai-tool-call-adapter.js";
+import type { CliAdapterDescriptor } from "./cli-adapter-descriptor.js";
+import { resolveCliBinary } from "./resolve-cli-binary.js";
+import { runCliCommand, type CliRunner } from "./run-cli-completion.js";
+import {
+ streamCliCommand,
+ type CliStreamRunner,
+} from "./stream-cli-completion.js";
+
+/** Matches `OpenAiProvider`'s default; a CLI turn is never quick. */
+const DEFAULT_TIMEOUT_MS = 600_000;
+/**
+ * `runCommand` defaults to 256 KiB, which would silently truncate a long
+ * completion and hand `JSON.parse` a torn object.
+ */
+const MAX_COMPLETION_BYTES = 8 * 1024 * 1024;
+const HEALTH_TIMEOUT_MS = 5_000;
+const MAX_HEALTH_BYTES = 64 * 1024;
+
+export interface SubscriptionCliProviderOptions {
+ id: string;
+ descriptor: CliAdapterDescriptor;
+ /** Working directory for the child — the state dir, not the agent's cwd. */
+ cwd: string;
+ model?: string;
+ binPath?: string;
+ extraArgs?: readonly string[];
+ streaming?: boolean;
+ maxBudgetUsd?: number;
+ requestTimeoutMs?: number;
+ onNotice?: (message: string) => void;
+ /** Test seams; default to the real spawn-backed implementations. */
+ runCliImpl?: CliRunner;
+ streamCliImpl?: CliStreamRunner;
+}
+
+/**
+ * Drives an already-signed-in vendor CLI (`claude`, `codex`) as an LLM
+ * backend so a flat-rate subscription can power the agent with no API
+ * key. The CLI authenticates from its own session — this provider never
+ * reads, copies or replays OAuth tokens or keychain entries.
+ *
+ * Every CLI-specific decision lives in the descriptor; this class only
+ * knows how to run a process and shape the result.
+ */
+export class SubscriptionCliProvider implements LlmProvider {
+ readonly id: string;
+ readonly name: string;
+ readonly capabilities: ProviderCapabilities;
+ /**
+ * We never return `tool_calls`, yet the transport is `native_tools`
+ * and the adapter is present on purpose. On the grammar transport a
+ * format drift throws out of `parseToolCalls` and costs a second full
+ * CLI invocation on the repair path; on the native transport an empty
+ * `toolCalls` sends step-executor down its guarded recovery ladder,
+ * which parses the tool-call JSON out of `content` inside a
+ * try/catch and otherwise wraps the prose as a `reply`. Same result
+ * when the model complies, no extra process when it does not.
+ */
+ readonly toolCallAdapter: ToolCallAdapter = openAiToolCallAdapter;
+ /** Streaming is owned end to end here; that seam consumes SSE bytes. */
+ readonly streamConsumer = null;
+
+ private readonly descriptor: CliAdapterDescriptor;
+ private readonly binary: string;
+ private readonly cwd: string;
+ private readonly model: string;
+ private readonly extraArgs: readonly string[];
+ private readonly streamingEnabled: boolean;
+ private readonly maxBudgetUsd: number | undefined;
+ private readonly timeoutMs: number;
+ private readonly onNotice: ((message: string) => void) | undefined;
+ private readonly runCli: CliRunner;
+ private readonly streamCli: CliStreamRunner;
+
+ constructor(options: SubscriptionCliProviderOptions) {
+ const descriptor = options.descriptor;
+ this.id = options.id;
+ this.descriptor = descriptor;
+ this.name = descriptor.displayName;
+ this.binary = resolveCliBinary(descriptor.defaultBinary, options.binPath);
+ this.cwd = options.cwd;
+ // May be empty: Codex under a ChatGPT login rejects explicit model
+ // ids and resolves one itself, so the flag is then omitted.
+ this.model = options.model ?? descriptor.defaultChatModel;
+ this.extraArgs = options.extraArgs ?? [];
+ this.streamingEnabled =
+ descriptor.streamMode === "ndjson" && options.streaming !== false;
+ this.maxBudgetUsd = options.maxBudgetUsd;
+ this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
+ this.onNotice = options.onNotice;
+ this.runCli = options.runCliImpl ?? runCliCommand;
+ this.streamCli = options.streamCliImpl ?? streamCliCommand;
+ this.capabilities = {
+ vision: false,
+ visionSource: "config-disabled",
+ toolTransport: "native_tools",
+ contextWindow: descriptor.contextWindow,
+ supportsParallelTools: false,
+ // Every completion is a fresh process; there is no slot to pin.
+ supportsSlotAffinity: false,
+ // Verified: server-side prompt caching survives across separate
+ // invocations, so the KV-stable two-zone prompt still pays off.
+ supportsPromptCache: true,
+ reasoningFormat: "none",
+ };
+ }
+
+ async complete(request: CompletionRequest): Promise {
+ const staged = await this.stageSchema(request);
+ try {
+ const args = this.descriptor.completeArgs(
+ this.argsInput(request, staged.path),
+ );
+ const outcome = await this.runCli(this.runOptions(args, request));
+ return this.descriptor.parseResult(outcome.stdout, this.model);
+ } finally {
+ await staged.cleanup();
+ }
+ }
+
+ /**
+ * Some CLIs take the structured-output schema inline on argv, others
+ * only as a path. Writing that file is a side effect, so it lives here
+ * rather than inside the argv builders, which stay pure and testable.
+ */
+ private async stageSchema(
+ request: CompletionRequest,
+ ): Promise<{ path?: string; cleanup: () => Promise }> {
+ const noop = { cleanup: async () => {} };
+ if (
+ this.descriptor.schemaDelivery !== "file" ||
+ !request.responseFormat
+ ) {
+ return noop;
+ }
+ const dir = await mkdtemp(join(tmpdir(), "atomic-cli-schema-"));
+ const path = join(dir, "schema.json");
+ await writeFile(path, JSON.stringify(request.responseFormat.schema), "utf8");
+ return {
+ path,
+ cleanup: async () => {
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
+ },
+ };
+ }
+
+ async *completeStream(
+ request: CompletionRequest,
+ ): AsyncGenerator {
+ if (!this.streamingEnabled) {
+ const result = await this.complete(request);
+ if (result.content.length > 0) {
+ yield { delta: result.content, reasoningDelta: "", done: false };
+ }
+ yield { delta: "", reasoningDelta: "", done: true };
+ return result;
+ }
+
+ const args = this.descriptor.streamArgs(this.argsInput(request));
+ const lines = this.streamCli(this.runOptions(args, request));
+ let final: string | null = null;
+ let sawDelta = false;
+
+ for await (const line of lines) {
+ const event = this.descriptor.parseStreamEvent(line);
+ if (event.kind === "delta") {
+ sawDelta = true;
+ yield { delta: event.text, reasoningDelta: "", done: false };
+ } else if (event.kind === "final") {
+ final = event.raw;
+ } else if (event.kind === "notice") {
+ this.onNotice?.(event.message);
+ }
+ }
+
+ if (final === null) {
+ throw new Error(
+ `${this.binary} stream ended without a result envelope`,
+ );
+ }
+ const result = this.descriptor.parseResult(final, this.model);
+ // Safety net for a stream schema we do not control: if no delta was
+ // recognised, emit the authoritative text once so a mismatch
+ // degrades to buffered behaviour instead of an empty turn.
+ if (!sawDelta && result.content.length > 0) {
+ yield { delta: result.content, reasoningDelta: "", done: false };
+ }
+ yield { delta: "", reasoningDelta: "", done: true };
+ return result;
+ }
+
+ async describeImage(_request: VisionRequest): Promise {
+ throw new VisionUnsupportedError(this.id);
+ }
+
+ async health(): Promise {
+ const started = Date.now();
+ try {
+ await this.runCli({
+ binary: this.binary,
+ args: this.descriptor.healthArgs(),
+ cwd: this.cwd,
+ timeoutMs: HEALTH_TIMEOUT_MS,
+ maxOutputBytes: MAX_HEALTH_BYTES,
+ installHint: this.descriptor.installHint,
+ authHint: this.descriptor.authHint,
+ });
+ return {
+ reachable: true,
+ status: null,
+ error: null,
+ latencyMs: Date.now() - started,
+ };
+ } catch (err) {
+ return {
+ reachable: false,
+ status: null,
+ error: err instanceof Error ? err.message : String(err),
+ latencyMs: Date.now() - started,
+ };
+ }
+ }
+
+ async listModels(): Promise {
+ // Curated list, no probe: the CLI exposes no model-list command.
+ return this.descriptor.staticModels;
+ }
+
+ async close(): Promise {
+ // Nothing to release — every invocation is its own short-lived process.
+ }
+
+ private argsInput(request: CompletionRequest, schemaPath?: string) {
+ const delivery = this.descriptor.schemaDelivery;
+ return {
+ model: this.model,
+ systemPrompt: this.descriptor.systemPrompt,
+ ...(request.responseFormat && delivery === "inline"
+ ? { responseSchema: request.responseFormat.schema }
+ : {}),
+ ...(schemaPath ? { responseSchemaPath: schemaPath } : {}),
+ ...(this.maxBudgetUsd === undefined
+ ? {}
+ : { maxBudgetUsd: this.maxBudgetUsd }),
+ extraArgs: this.extraArgs,
+ };
+ }
+
+ private runOptions(args: readonly string[], request: CompletionRequest) {
+ return {
+ binary: this.binary,
+ args,
+ // The prompt goes on stdin, never argv: a two-zone prompt routinely
+ // exceeds the 128 KiB single-argument limit once the conversation
+ // zone fills, and argv delivery would fail with E2BIG on exactly
+ // the long sessions that matter most.
+ input: this.descriptor.buildStdin(
+ request.prompt,
+ this.descriptor.systemPrompt,
+ ),
+ cwd: this.cwd,
+ timeoutMs: this.timeoutMs,
+ maxOutputBytes: MAX_COMPLETION_BYTES,
+ installHint: this.descriptor.installHint,
+ authHint: this.descriptor.authHint,
+ ...(request.signal ? { signal: request.signal } : {}),
+ };
+ }
+}
diff --git a/src/llm/provider/verify/classify-verify-response.test.ts b/src/llm/provider/verify/classify-verify-response.test.ts
new file mode 100644
index 00000000..b7eb3608
--- /dev/null
+++ b/src/llm/provider/verify/classify-verify-response.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+
+import { OpenAiHttpError } from "../openai/openai-http.js";
+import {
+ classifyVerifyResponse,
+ classifyVerifyTransportError,
+} from "./classify-verify-response.js";
+
+describe("classifyVerifyResponse", () => {
+ it("treats any 2xx as proof the key is live and funded", () => {
+ expect(classifyVerifyResponse(200, "{}")).toEqual({
+ kind: "status",
+ status: "ok",
+ });
+ });
+
+ it("reads 402 as an empty account", () => {
+ expect(classifyVerifyResponse(402, "Payment Required")).toEqual({
+ kind: "status",
+ status: "no_balance",
+ });
+ });
+
+ it("separates a dead key from a drained one on 401/403", () => {
+ expect(classifyVerifyResponse(401, "No auth credentials found")).toEqual({
+ kind: "status",
+ status: "invalid_key",
+ });
+ // Prepaid services answer 403 with a perfectly valid key once the
+ // credit is gone; refusing it as "wrong key" would send the operator
+ // hunting for a new one.
+ expect(
+ classifyVerifyResponse(403, '{"error":"insufficient credits"}'),
+ ).toEqual({ kind: "status", status: "no_balance" });
+ });
+
+ it("keeps a bare 429 soft and a quota 429 hard", () => {
+ expect(classifyVerifyResponse(429, "slow down")).toEqual({
+ kind: "status",
+ status: "rate_limited",
+ });
+ expect(
+ classifyVerifyResponse(429, '{"error":{"code":"insufficient_quota"}}'),
+ ).toEqual({ kind: "status", status: "no_balance" });
+ });
+
+ it("reads Gemini's 400 for a bad key as a bad key", () => {
+ // The OpenAI-compatible Gemini surface answers 400 INVALID_ARGUMENT
+ // where every other service answers 401.
+ expect(
+ classifyVerifyResponse(
+ 400,
+ '{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}',
+ ),
+ ).toEqual({ kind: "status", status: "invalid_key" });
+ });
+
+ it("asks for the other token field instead of blaming the key", () => {
+ expect(
+ classifyVerifyResponse(
+ 400,
+ "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.",
+ ),
+ ).toEqual({ kind: "retry_token_field" });
+ });
+
+ it("moves to the next candidate when the model is the problem", () => {
+ expect(classifyVerifyResponse(404, "no such model")).toEqual({
+ kind: "retry_next_model",
+ });
+ expect(
+ classifyVerifyResponse(400, '{"error":"The model `x` does not exist"}'),
+ ).toEqual({ kind: "retry_next_model" });
+ });
+
+ it("falls back to a provider fault for anything else", () => {
+ expect(classifyVerifyResponse(503, "upstream unavailable")).toEqual({
+ kind: "status",
+ status: "provider_error",
+ });
+ });
+});
+
+describe("classifyVerifyTransportError", () => {
+ it("tells our own deadline apart from an unreachable host", () => {
+ const timedOut = new OpenAiHttpError("t", null, "u", true, null, "p");
+ expect(classifyVerifyTransportError(timedOut)).toBe("timeout");
+
+ const network = new OpenAiHttpError("n", null, "u", false, null, "p");
+ expect(classifyVerifyTransportError(network)).toBe("unreachable");
+ });
+
+ it("reports an abort as a cancellation", () => {
+ const abort = new Error("aborted");
+ abort.name = "AbortError";
+ expect(classifyVerifyTransportError(abort)).toBe("cancelled");
+ });
+});
diff --git a/src/llm/provider/verify/classify-verify-response.ts b/src/llm/provider/verify/classify-verify-response.ts
new file mode 100644
index 00000000..9e08abc1
--- /dev/null
+++ b/src/llm/provider/verify/classify-verify-response.ts
@@ -0,0 +1,91 @@
+/**
+ * Turning one HTTP answer into a verdict about the key.
+ *
+ * Providers disagree on how they say "no money" and "wrong key": OpenAI
+ * sends 429 `insufficient_quota`, OpenRouter 402, Anthropic-style
+ * gateways 403 with billing wording, and Gemini answers a 400 for a bad
+ * key rather than a 401. The status code alone is therefore not enough,
+ * so the body is consulted for wording before falling back to the code.
+ */
+
+import { OpenAiHttpError } from "../openai/openai-http.js";
+import type { ProviderVerifyStatus } from "./verify-types.js";
+
+export type VerifyResponseVerdict =
+ | { readonly kind: "status"; readonly status: ProviderVerifyStatus }
+ /** Same model, resend with the other max-tokens field. */
+ | { readonly kind: "retry_token_field" }
+ /** This model is unusable for this key; try the next candidate. */
+ | { readonly kind: "retry_next_model" };
+
+const BILLING_WORDING =
+ /insufficient|quota|credit|billing|payment|balance|top ?up|out of funds|resource[_ ]exhausted/;
+const KEY_WORDING =
+ /api[_ ]?key|unauthenticated|unauthorized|invalid authentication|permission denied/;
+const MISSING_MODEL_WORDING =
+ /model.{0,40}(not found|does not exist|is not available|unknown|unsupported|invalid)|(not found|unknown|unsupported).{0,20}model/;
+const TOKEN_FIELD_WORDING = /max_tokens|max_completion_tokens/;
+
+export function classifyVerifyResponse(
+ httpStatus: number,
+ body: string,
+): VerifyResponseVerdict {
+ if (httpStatus >= 200 && httpStatus < 300) {
+ // A completion came back, so the account could pay for the token it
+ // just spent. That is the whole point of probing with a paid model.
+ return { kind: "status", status: "ok" };
+ }
+ const text = body.toLowerCase();
+
+ if (httpStatus === 402) return verdict("no_balance");
+
+ if (httpStatus === 401 || httpStatus === 403) {
+ // Services that bill by prepaid credit answer 401/403 once the
+ // balance is gone, with a key that is otherwise perfectly valid.
+ return verdict(BILLING_WORDING.test(text) ? "no_balance" : "invalid_key");
+ }
+
+ if (httpStatus === 429) {
+ // Only a quota/credit refusal is a money problem. A bare 429 is the
+ // provider asking us to slow down, which proves the key works.
+ return verdict(BILLING_WORDING.test(text) ? "no_balance" : "rate_limited");
+ }
+
+ if (httpStatus === 404) return { kind: "retry_next_model" };
+
+ if (httpStatus === 400) {
+ // Gemini's OpenAI-compatible surface answers 400 INVALID_ARGUMENT
+ // for a bad key instead of 401.
+ if (KEY_WORDING.test(text)) return verdict("invalid_key");
+ if (BILLING_WORDING.test(text)) return verdict("no_balance");
+ if (MISSING_MODEL_WORDING.test(text)) return { kind: "retry_next_model" };
+ // Newer OpenAI models reject `max_tokens` and want
+ // `max_completion_tokens`; that is our request being wrong, not the
+ // key, so the same model gets one more chance with the other field.
+ if (TOKEN_FIELD_WORDING.test(text)) return { kind: "retry_token_field" };
+ }
+
+ return verdict("provider_error");
+}
+
+/** A thrown transport failure, which says nothing about the key itself. */
+export function classifyVerifyTransportError(err: unknown): ProviderVerifyStatus {
+ if (err instanceof OpenAiHttpError) {
+ if (err.timedOut) return "timeout";
+ if (err.status === null) return "unreachable";
+ return "provider_error";
+ }
+ if (isAbortError(err)) return "cancelled";
+ return "unreachable";
+}
+
+export function isAbortError(err: unknown): boolean {
+ return (
+ err instanceof Error &&
+ (err.name === "AbortError" || err.name === "TimeoutError")
+ );
+}
+
+function verdict(status: ProviderVerifyStatus): VerifyResponseVerdict {
+ return { kind: "status", status };
+}
diff --git a/src/llm/provider/verify/index.ts b/src/llm/provider/verify/index.ts
new file mode 100644
index 00000000..a100e465
--- /dev/null
+++ b/src/llm/provider/verify/index.ts
@@ -0,0 +1,20 @@
+export {
+ classifyVerifyResponse,
+ classifyVerifyTransportError,
+ type VerifyResponseVerdict,
+} from "./classify-verify-response.js";
+export {
+ cheapestPaidOpenRouterModel,
+ pickProbeModels,
+} from "./pick-probe-models.js";
+export {
+ PROVIDER_VERIFY_TIMEOUT_MS,
+ verifyProviderKey,
+} from "./verify-provider-key.js";
+export {
+ isBlockingVerifyStatus,
+ type ProviderVerifyKind,
+ type ProviderVerifyResult,
+ type ProviderVerifyStatus,
+ type ProviderVerifyTarget,
+} from "./verify-types.js";
diff --git a/src/llm/provider/verify/pick-probe-models.test.ts b/src/llm/provider/verify/pick-probe-models.test.ts
new file mode 100644
index 00000000..b5f7a63b
--- /dev/null
+++ b/src/llm/provider/verify/pick-probe-models.test.ts
@@ -0,0 +1,109 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+async function importFresh(): Promise<
+ typeof import("./pick-probe-models.js")
+> {
+ // The OpenRouter catalog caches at module scope, so a test that primes
+ // it would otherwise leak into the next one.
+ vi.resetModules();
+ return import("./pick-probe-models.js");
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("pickProbeModels", () => {
+ it("never probes OpenRouter with a free model", async () => {
+ // A zero-cost model answers 200 on a key with no credit at all,
+ // which is exactly the case the check exists to catch.
+ const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh();
+ const cheapest = cheapestPaidOpenRouterModel();
+ expect(cheapest).not.toBeNull();
+ expect(cheapest).not.toBe("openrouter/auto");
+ expect(cheapest).not.toContain(":free");
+
+ const picks = pickProbeModels({ kind: "openrouter" });
+ expect(picks[0]).toBe(cheapest);
+ });
+
+ it("keeps the free rows of a live catalog out of the choice", async () => {
+ const { refreshOpenRouterChatCatalogFromApi } = await import(
+ "../openrouter/fetch-openrouter-chat-catalog.js"
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ id: "vendor/free-model:free",
+ name: "Free",
+ context_length: 128_000,
+ pricing: { prompt: "0", completion: "0" },
+ supported_parameters: ["tools"],
+ },
+ {
+ id: "vendor/cheap-model",
+ name: "Cheap",
+ context_length: 128_000,
+ pricing: { prompt: "0.0000001", completion: "0.0000002" },
+ supported_parameters: ["tools"],
+ },
+ ],
+ }),
+ })),
+ );
+ await refreshOpenRouterChatCatalogFromApi();
+
+ const { cheapestPaidOpenRouterModel } = await import(
+ "./pick-probe-models.js"
+ );
+ expect(cheapestPaidOpenRouterModel()).toBe("vendor/cheap-model");
+ });
+
+ it("adds the operator's own pick as the fallback candidate", async () => {
+ const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh();
+ const picks = pickProbeModels({
+ kind: "openrouter",
+ selectedModelId: "vendor/picked",
+ });
+ expect(picks).toEqual([cheapestPaidOpenRouterModel(), "vendor/picked"]);
+ });
+
+ it("probes the chosen model where the catalog has no prices", async () => {
+ const { pickProbeModels } = await importFresh();
+ const { AIMLAPI_DEFAULT_CHAT_MODEL } = await import(
+ "../aimlapi/aimlapi-models-catalog.js"
+ );
+ const { GEMINI_DEFAULT_CHAT_MODEL } = await import(
+ "../gemini/gemini-provider.js"
+ );
+
+ expect(
+ pickProbeModels({ kind: "aimlapi", selectedModelId: "openai/gpt-5-nano" }),
+ ).toEqual(["openai/gpt-5-nano", AIMLAPI_DEFAULT_CHAT_MODEL]);
+ expect(pickProbeModels({ kind: "gemini" })).toEqual([
+ GEMINI_DEFAULT_CHAT_MODEL,
+ ]);
+ });
+
+ it("uses the discovered list for an arbitrary compatible endpoint", async () => {
+ const { pickProbeModels } = await importFresh();
+ expect(
+ pickProbeModels({
+ kind: "openai-compatible",
+ selectedModelId: " ",
+ listedModelIds: ["local-a", "local-b"],
+ }),
+ ).toEqual(["local-a"]);
+ expect(
+ pickProbeModels({
+ kind: "openai-compatible",
+ selectedModelId: "typed-id",
+ listedModelIds: ["local-a"],
+ }),
+ ).toEqual(["typed-id", "local-a"]);
+ });
+});
diff --git a/src/llm/provider/verify/pick-probe-models.ts b/src/llm/provider/verify/pick-probe-models.ts
new file mode 100644
index 00000000..349c8f38
--- /dev/null
+++ b/src/llm/provider/verify/pick-probe-models.ts
@@ -0,0 +1,84 @@
+/**
+ * Which model the credential check should spend a token on.
+ *
+ * The check has to prove the account can actually pay, so a free model
+ * is the wrong instrument: `openrouter/auto` and every `:free` slug
+ * answer 200 on a key with zero credit, which would turn the balance
+ * check into a formality. Where the catalog carries prices we take the
+ * cheapest *paid* model; where it does not, the model the operator just
+ * chose is the honest probe — it is the one they are about to use.
+ */
+
+import { AIMLAPI_DEFAULT_CHAT_MODEL } from "../aimlapi/aimlapi-models-catalog.js";
+import { GEMINI_DEFAULT_CHAT_MODEL } from "../gemini/gemini-provider.js";
+import { listOpenRouterChatPicks } from "../openrouter/fetch-openrouter-chat-catalog.js";
+import type { ProviderVerifyKind } from "./verify-types.js";
+
+/** More than two candidates would turn a check into a shopping trip. */
+const MAX_PROBE_MODELS = 2;
+
+export function pickProbeModels(input: {
+ kind: ProviderVerifyKind;
+ /** The model the wizard is about to save, when it knows one. */
+ selectedModelId?: string | null;
+ /** Ids already listed from `/v1/models`, when that call was made. */
+ listedModelIds?: readonly string[];
+}): readonly string[] {
+ const selected = input.selectedModelId?.trim() || null;
+ const listed = input.listedModelIds?.filter((id) => id.length > 0) ?? [];
+
+ if (input.kind === "openrouter") {
+ return dedupe([cheapestPaidOpenRouterModel(), selected]);
+ }
+ if (input.kind === "aimlapi") {
+ // The AI/ML API catalog carries no prices, so there is nothing to
+ // rank; the operator's own pick is the closest thing to a known cost.
+ return dedupe([selected, AIMLAPI_DEFAULT_CHAT_MODEL]);
+ }
+ if (input.kind === "gemini") {
+ return dedupe([selected, GEMINI_DEFAULT_CHAT_MODEL]);
+ }
+ // An arbitrary OpenAI-compatible endpoint has no catalog we can price,
+ // and its `/v1/models` list is already on hand from the model step.
+ return dedupe([selected, listed[0] ?? null]);
+}
+
+/**
+ * Cheapest OpenRouter chat model with a non-zero input price, from the
+ * live catalog when it has been fetched and the static one otherwise.
+ * Ties break on output price, then id, so the choice is stable across
+ * runs rather than dependent on catalog order.
+ */
+export function cheapestPaidOpenRouterModel(): string | null {
+ let best: { id: string; input: number; output: number } | null = null;
+ for (const pick of listOpenRouterChatPicks()) {
+ const pricing = pick.entry.pricing;
+ if (!pricing || !(pricing.input > 0)) continue;
+ const candidate = {
+ id: pick.id,
+ input: pricing.input,
+ output: pricing.output ?? 0,
+ };
+ if (!best || isCheaper(candidate, best)) best = candidate;
+ }
+ return best?.id ?? null;
+}
+
+function isCheaper(
+ a: { id: string; input: number; output: number },
+ b: { id: string; input: number; output: number },
+): boolean {
+ if (a.input !== b.input) return a.input < b.input;
+ if (a.output !== b.output) return a.output < b.output;
+ return a.id.localeCompare(b.id) < 0;
+}
+
+function dedupe(ids: readonly (string | null)[]): readonly string[] {
+ const out: string[] = [];
+ for (const id of ids) {
+ if (!id || out.includes(id)) continue;
+ out.push(id);
+ if (out.length === MAX_PROBE_MODELS) break;
+ }
+ return out;
+}
diff --git a/src/llm/provider/verify/verify-provider-key.test.ts b/src/llm/provider/verify/verify-provider-key.test.ts
new file mode 100644
index 00000000..a35f80ef
--- /dev/null
+++ b/src/llm/provider/verify/verify-provider-key.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { verifyProviderKey } from "./verify-provider-key.js";
+import type { ProviderVerifyTarget } from "./verify-types.js";
+
+function target(
+ overrides: Partial = {},
+): ProviderVerifyTarget {
+ return {
+ label: "testprov",
+ baseUrl: "https://api.example.com",
+ apiPathPrefix: "/v1",
+ apiKey: "sk-secret-key",
+ probeModels: ["cheap-model"],
+ ...overrides,
+ };
+}
+
+function response(body: unknown, status = 200): Response {
+ return new Response(typeof body === "string" ? body : JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+function bodyOf(call: Parameters[]): Record {
+ return JSON.parse(String((call[1] as RequestInit).body)) as Record<
+ string,
+ unknown
+ >;
+}
+
+describe("verifyProviderKey", () => {
+ it("spends one token on the cheapest model and reports ok", async () => {
+ const fetchImpl = vi.fn(async () => response({ choices: [] }));
+ const result = await verifyProviderKey(target(), { fetchImpl });
+
+ expect(result).toMatchObject({ status: "ok", probedModel: "cheap-model" });
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe("https://api.example.com/v1/chat/completions");
+ expect(
+ (init.headers as Record).authorization,
+ ).toBe("Bearer sk-secret-key");
+ expect(bodyOf(fetchImpl.mock.calls[0] as never)).toMatchObject({
+ model: "cheap-model",
+ max_tokens: 1,
+ stream: false,
+ });
+ });
+
+ it("does not retry a refused key", async () => {
+ // The shared HTTP client retries three times with backoff; a key
+ // check must answer at the first no.
+ const fetchImpl = vi.fn(async () =>
+ response({ error: "No auth credentials found" }, 401),
+ );
+ const result = await verifyProviderKey(target(), { fetchImpl });
+
+ expect(result.status).toBe("invalid_key");
+ expect(result.httpStatus).toBe(401);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports an empty account", async () => {
+ const fetchImpl = vi.fn(async () => response("Insufficient credits", 402));
+ const result = await verifyProviderKey(target(), { fetchImpl });
+ expect(result.status).toBe("no_balance");
+ });
+
+ it("falls back to the second candidate when the first is gone", async () => {
+ const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
+ const body = JSON.parse(String(init?.body)) as { model: string };
+ return body.model === "gone-model"
+ ? response({ error: "no such model" }, 404)
+ : response({ choices: [] });
+ });
+ const result = await verifyProviderKey(
+ target({ probeModels: ["gone-model", "live-model"] }),
+ { fetchImpl },
+ );
+
+ expect(result).toMatchObject({ status: "ok", probedModel: "live-model" });
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it("resends with max_completion_tokens when the model demands it", async () => {
+ const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
+ const body = JSON.parse(String(init?.body)) as Record;
+ return "max_tokens" in body
+ ? response(
+ { error: "Unsupported parameter: 'max_tokens'. Use 'max_completion_tokens'." },
+ 400,
+ )
+ : response({ choices: [] });
+ });
+ const result = await verifyProviderKey(target(), { fetchImpl });
+
+ expect(result.status).toBe("ok");
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ expect(bodyOf(fetchImpl.mock.calls[1] as never)).toMatchObject({
+ max_completion_tokens: 1,
+ });
+ });
+
+ it("gives up after three requests", async () => {
+ const fetchImpl = vi.fn(async () => response({ error: "not found" }, 404));
+ const result = await verifyProviderKey(
+ target({ probeModels: ["a", "b"] }),
+ { fetchImpl },
+ );
+
+ expect(result.status).toBe("model_unavailable");
+ expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(3);
+ });
+
+ it("reports our own deadline as a timeout, not a bad key", async () => {
+ const fetchImpl = vi.fn(
+ (_url: unknown, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ const err = new Error("aborted");
+ err.name = "AbortError";
+ reject(err);
+ });
+ }),
+ );
+ const result = await verifyProviderKey(target(), {
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ timeoutMs: 10,
+ });
+ expect(result.status).toBe("timeout");
+ });
+
+ it("reports a caller abort as a cancellation", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const fetchImpl = vi.fn(async () => response({ choices: [] }));
+ const result = await verifyProviderKey(target(), {
+ fetchImpl,
+ signal: controller.signal,
+ });
+ expect(result.status).toBe("cancelled");
+ });
+
+ it("reports an unreachable host without blaming the key", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await verifyProviderKey(target(), { fetchImpl });
+ expect(result.status).toBe("unreachable");
+ });
+
+ it("never puts the key in the reported detail", async () => {
+ const fetchImpl = vi.fn(async () =>
+ response("Bearer sk-secret-key rejected", 403),
+ );
+ const result = await verifyProviderKey(
+ target({ apiKey: "sk-secret-key" }),
+ { fetchImpl },
+ );
+ expect(result.detail).not.toContain("sk-secret-key");
+ });
+});
diff --git a/src/llm/provider/verify/verify-provider-key.ts b/src/llm/provider/verify/verify-provider-key.ts
new file mode 100644
index 00000000..8a9f8f10
--- /dev/null
+++ b/src/llm/provider/verify/verify-provider-key.ts
@@ -0,0 +1,198 @@
+/**
+ * Prove a cloud API key is usable before anything is written to disk.
+ *
+ * A key can be well-formed, present in `.env` and completely dead: wrong
+ * service, revoked, or attached to an account with no credit. `/v1/models`
+ * does not settle it — plenty of endpoints list models for an
+ * unauthenticated caller, and none of them charge for the listing. The
+ * only answer that proves both authentication and funds is a real
+ * completion, so this asks for exactly one token from the cheapest model
+ * available (see `pick-probe-models`).
+ */
+
+import {
+ openAiFetch,
+ type OpenAiHttpDeps,
+} from "../openai/openai-http.js";
+import {
+ classifyVerifyResponse,
+ classifyVerifyTransportError,
+ isAbortError,
+} from "./classify-verify-response.js";
+import type {
+ ProviderVerifyResult,
+ ProviderVerifyStatus,
+ ProviderVerifyTarget,
+} from "./verify-types.js";
+
+/**
+ * Short on purpose. This runs while the operator watches a wizard, and
+ * a slow provider is a reason to save with a warning, not to freeze the
+ * screen for the 600s a normal completion is allowed.
+ */
+export const PROVIDER_VERIFY_TIMEOUT_MS = 8_000;
+
+/** model → other token field → next model. Never more than that. */
+const MAX_VERIFY_REQUESTS = 3;
+
+/** Provider error bodies are quoted back bounded, same cap as the HTTP layer. */
+const VERIFY_DETAIL_MAX_LEN = 300;
+
+export async function verifyProviderKey(
+ target: ProviderVerifyTarget,
+ opts: {
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ fetchImpl?: typeof fetch;
+ } = {},
+): Promise {
+ const startedAt = Date.now();
+ const models = target.probeModels.filter((id) => id.length > 0);
+ if (models.length === 0) {
+ return result("model_unavailable", null, null, "no model to test with", startedAt);
+ }
+
+ const deps: OpenAiHttpDeps = {
+ baseUrl: target.baseUrl,
+ apiKey: target.apiKey,
+ extraHeaders: target.extraHeaders ?? {},
+ requestTimeoutMs: opts.timeoutMs ?? PROVIDER_VERIFY_TIMEOUT_MS,
+ fetchImpl: opts.fetchImpl ?? fetch,
+ label: target.label,
+ };
+ const path = `${target.apiPathPrefix}/chat/completions`;
+
+ let requests = 0;
+ let tokenField: "max_tokens" | "max_completion_tokens" = "max_tokens";
+ let lastVerdict: {
+ status: ProviderVerifyStatus;
+ model: string;
+ httpStatus: number;
+ detail: string;
+ } | null = null;
+
+ for (const model of models) {
+ // The token-field retry is per model: an endpoint that wants
+ // `max_completion_tokens` wants it for the next candidate too.
+ for (;;) {
+ if (requests >= MAX_VERIFY_REQUESTS) {
+ return lastVerdict
+ ? result(
+ lastVerdict.status,
+ lastVerdict.model,
+ lastVerdict.httpStatus,
+ lastVerdict.detail,
+ startedAt,
+ target.apiKey,
+ )
+ : result("model_unavailable", model, null, "no usable model", startedAt);
+ }
+ if (opts.signal?.aborted) {
+ return result("cancelled", model, null, "check cancelled", startedAt);
+ }
+ requests += 1;
+
+ let res: Response;
+ try {
+ res = await openAiFetch(
+ deps,
+ path,
+ probeBody(model, tokenField),
+ { ...(opts.signal ? { signal: opts.signal } : {}) },
+ false,
+ "POST",
+ );
+ } catch (err) {
+ if (opts.signal?.aborted || isAbortError(err)) {
+ return result("cancelled", model, null, "check cancelled", startedAt);
+ }
+ const status = classifyVerifyTransportError(err);
+ return result(
+ status,
+ model,
+ null,
+ err instanceof Error ? err.message : String(err),
+ startedAt,
+ target.apiKey,
+ );
+ }
+
+ const body = res.ok ? "" : await readBounded(res);
+ const verdict = classifyVerifyResponse(res.status, body);
+ if (verdict.kind === "retry_token_field" && tokenField === "max_tokens") {
+ tokenField = "max_completion_tokens";
+ continue;
+ }
+ if (verdict.kind === "retry_next_model" || verdict.kind === "retry_token_field") {
+ lastVerdict = {
+ status: "model_unavailable",
+ model,
+ httpStatus: res.status,
+ detail: body,
+ };
+ break;
+ }
+ return result(verdict.status, model, res.status, body, startedAt, target.apiKey);
+ }
+ }
+
+ return lastVerdict
+ ? result(
+ lastVerdict.status,
+ lastVerdict.model,
+ lastVerdict.httpStatus,
+ lastVerdict.detail,
+ startedAt,
+ target.apiKey,
+ )
+ : result("model_unavailable", models[0] ?? null, null, "no usable model", startedAt);
+}
+
+/**
+ * One token, no sampling, no tools. Hand-built rather than reusing
+ * `buildOpenAiChatBody`, which pulls token limits out of the config and
+ * adds tool plumbing a probe has no use for.
+ */
+function probeBody(
+ model: string,
+ tokenField: "max_tokens" | "max_completion_tokens",
+): Record {
+ return {
+ model,
+ messages: [{ role: "user", content: "ping" }],
+ [tokenField]: 1,
+ temperature: 0,
+ stream: false,
+ };
+}
+
+async function readBounded(res: Response): Promise {
+ const text = await res.text().catch(() => "");
+ return text.slice(0, VERIFY_DETAIL_MAX_LEN);
+}
+
+function result(
+ status: ProviderVerifyStatus,
+ probedModel: string | null,
+ httpStatus: number | null,
+ detail: string,
+ startedAt: number,
+ apiKey = "",
+): ProviderVerifyResult {
+ return {
+ status,
+ probedModel,
+ httpStatus,
+ detail: redactKey(detail, apiKey).slice(0, VERIFY_DETAIL_MAX_LEN),
+ latencyMs: Date.now() - startedAt,
+ };
+}
+
+/**
+ * Some providers echo the offending credential back in the error body,
+ * and this detail is headed for a status line and the log file.
+ */
+function redactKey(detail: string, apiKey: string): string {
+ if (apiKey.length < 8) return detail;
+ return detail.split(apiKey).join("***");
+}
diff --git a/src/llm/provider/verify/verify-types.ts b/src/llm/provider/verify/verify-types.ts
new file mode 100644
index 00000000..17ab313e
--- /dev/null
+++ b/src/llm/provider/verify/verify-types.ts
@@ -0,0 +1,67 @@
+/**
+ * Shapes for the pre-save credential check: what to probe, and what the
+ * probe concluded. Kept free of config and UI imports so the check can
+ * run from the wizard, from onboarding, or from a future "test key"
+ * action without dragging any of them along.
+ */
+
+/** The cloud kinds a key can be checked for. Local servers never carry one. */
+export type ProviderVerifyKind =
+ | "openrouter"
+ | "aimlapi"
+ | "gemini"
+ | "openai-compatible";
+
+export type ProviderVerifyStatus =
+ /** The provider answered a real completion: the key is live and funded. */
+ | "ok"
+ /** The provider does not recognize this key, or refuses it outright. */
+ | "invalid_key"
+ /** The key authenticates but the account cannot pay for a token. */
+ | "no_balance"
+ /** None of the probe models exist for this key; auth stays unproven. */
+ | "model_unavailable"
+ /** Throttled right now — which itself proves the key authenticated. */
+ | "rate_limited"
+ /** No HTTP response at all: DNS, refused connection, TLS, offline. */
+ | "unreachable"
+ /** Our own deadline fired before the provider answered. */
+ | "timeout"
+ /** The provider failed in a way that says nothing about the key. */
+ | "provider_error"
+ /** The operator (or the caller) aborted the check. */
+ | "cancelled";
+
+export interface ProviderVerifyTarget {
+ /** Service name for user-facing wording ("OpenRouter", "Groq"). */
+ readonly label: string;
+ /** API root without the version prefix, already normalized. */
+ readonly baseUrl: string;
+ /** Version prefix the service uses: `/v1`, Gemini's `/v1beta/openai`. */
+ readonly apiPathPrefix: string;
+ /** Trimmed key. A target is never built without one. */
+ readonly apiKey: string;
+ /** Ordered candidates; at most the first two are tried. */
+ readonly probeModels: readonly string[];
+ readonly extraHeaders?: Record;
+}
+
+export interface ProviderVerifyResult {
+ readonly status: ProviderVerifyStatus;
+ /** The model the verdict came from, `null` when nothing was answered. */
+ readonly probedModel: string | null;
+ readonly httpStatus: number | null;
+ /** Bounded provider text for the status line and logs; never the key. */
+ readonly detail: string;
+ readonly latencyMs: number;
+}
+
+/**
+ * The two verdicts that must stop a save. Everything else is a report:
+ * a machine behind a proxy, an offline laptop or a throttled key still
+ * has to be configurable, and refusing there would strand the operator
+ * with no way to enter a key at all.
+ */
+export function isBlockingVerifyStatus(status: ProviderVerifyStatus): boolean {
+ return status === "invalid_key" || status === "no_balance";
+}
diff --git a/src/llm/run-mode/index.ts b/src/llm/run-mode/index.ts
new file mode 100644
index 00000000..bb3fa636
--- /dev/null
+++ b/src/llm/run-mode/index.ts
@@ -0,0 +1,7 @@
+export { resolveRunMode } from "./resolve-run-mode.js";
+export type {
+ ResolvedRunMode,
+ RunModeDegradation,
+ RunModeDegradationReason,
+} from "./resolve-run-mode.js";
+export { describeRunModeDegradation } from "./run-mode-degradation.js";
diff --git a/src/llm/run-mode/resolve-run-mode.test.ts b/src/llm/run-mode/resolve-run-mode.test.ts
new file mode 100644
index 00000000..8c3bcee9
--- /dev/null
+++ b/src/llm/run-mode/resolve-run-mode.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+
+import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js";
+import { resolveRunMode } from "./resolve-run-mode.js";
+
+const LOCAL = { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:8080" };
+const CLOUD = { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" };
+
+function config(over: Partial = {}): ResolvedLlmConfig {
+ return {
+ activeTextProvider: "local-llama",
+ activeEmbeddingProvider: "local-llama",
+ providers: [{ ...LOCAL }, { ...CLOUD }],
+ toolTransport: "auto",
+ ...over,
+ };
+}
+
+describe("resolveRunMode", () => {
+ it("derives local from the active provider when no runMode block exists", () => {
+ const r = resolveRunMode(config());
+ expect(r.stored).toBeNull();
+ expect(r.effective).toBe("local");
+ expect(r.primaryProviderId).toBe("local-llama");
+ expect(r.degraded).toBeNull();
+ });
+
+ it("derives cloud from a cloud active provider", () => {
+ const r = resolveRunMode(config({ activeTextProvider: "openrouter" }));
+ expect(r.effective).toBe("cloud");
+ expect(r.primaryProviderId).toBe("openrouter");
+ });
+
+ it("discovers both legs by provider kind", () => {
+ const r = resolveRunMode(config());
+ expect(r.localProviderId).toBe("local-llama");
+ expect(r.cloudProviderId).toBe("openrouter");
+ });
+
+ it("honours explicitly pinned legs over kind discovery", () => {
+ const r = resolveRunMode(
+ config({
+ providers: [{ ...LOCAL }, { ...CLOUD }, { id: "aimlapi", kind: "aimlapi" }],
+ runMode: { cloudProvider: "aimlapi" },
+ }),
+ );
+ expect(r.cloudProviderId).toBe("aimlapi");
+ });
+
+ it("resolves fusion when both legs exist and the cloud leg is active", () => {
+ const r = resolveRunMode(
+ config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }),
+ );
+ expect(r.effective).toBe("fusion");
+ // Fusion pins the cloud leg as primary, which is what makes it the
+ // fallback chain's head and the local leg its `appendLocal` tail.
+ expect(r.primaryProviderId).toBe("openrouter");
+ expect(r.degraded).toBeNull();
+ });
+
+ it("defaults the fusion dial and sub-runner target", () => {
+ const r = resolveRunMode(
+ config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }),
+ );
+ expect(r.fusion).toEqual({ cloudShare: 40, subRunners: "local" });
+ });
+
+ it("carries an explicit fusion dial through", () => {
+ const r = resolveRunMode(
+ config({
+ activeTextProvider: "openrouter",
+ runMode: { mode: "fusion", fusion: { cloudShare: 0, subRunners: "cloud" } },
+ }),
+ );
+ expect(r.fusion).toEqual({ cloudShare: 0, subRunners: "cloud" });
+ });
+
+ // The non-contradiction rule: `activeTextProvider` is authoritative.
+ it("drops stored fusion back to derived when the operator switched provider by hand", () => {
+ const r = resolveRunMode(
+ config({ activeTextProvider: "local-llama", runMode: { mode: "fusion" } }),
+ );
+ expect(r.effective).toBe("local");
+ // Not a degradation — nothing is broken, the operator simply moved.
+ expect(r.degraded).toBeNull();
+ });
+
+ it("drops stored cloud back to local when the local provider is active", () => {
+ const r = resolveRunMode(
+ config({ activeTextProvider: "local-llama", runMode: { mode: "cloud" } }),
+ );
+ expect(r.effective).toBe("local");
+ expect(r.degraded).toBeNull();
+ });
+
+ it("degrades cloud to local when no cloud provider is configured", () => {
+ const r = resolveRunMode(
+ config({ providers: [{ ...LOCAL }], runMode: { mode: "cloud" } }),
+ );
+ expect(r.effective).toBe("local");
+ expect(r.cloudProviderId).toBeNull();
+ expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "cloud" });
+ });
+
+ it("degrades fusion to local when no cloud provider is configured", () => {
+ const r = resolveRunMode(
+ config({ providers: [{ ...LOCAL }], runMode: { mode: "fusion" } }),
+ );
+ expect(r.effective).toBe("local");
+ expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "fusion" });
+ });
+
+ it("degrades fusion to cloud when no local provider is configured", () => {
+ const r = resolveRunMode(
+ config({
+ providers: [{ ...CLOUD }],
+ activeTextProvider: "openrouter",
+ activeEmbeddingProvider: "openrouter",
+ runMode: { mode: "fusion" },
+ }),
+ );
+ expect(r.effective).toBe("cloud");
+ expect(r.localProviderId).toBeNull();
+ expect(r.degraded).toEqual({ reason: "no-local-provider", requested: "fusion" });
+ });
+
+ it("warns but still runs fusion when the tool transport is pinned", () => {
+ const r = resolveRunMode(
+ config({
+ activeTextProvider: "openrouter",
+ toolTransport: "grammar",
+ runMode: { mode: "fusion" },
+ }),
+ );
+ expect(r.effective).toBe("fusion");
+ expect(r.degraded).toEqual({
+ reason: "tool-transport-pinned",
+ requested: "fusion",
+ });
+ });
+
+ it("assumes local when the active provider id resolves to nothing", () => {
+ // A broken file must never silently start spending cloud tokens.
+ const r = resolveRunMode(config({ activeTextProvider: "ghost" }));
+ expect(r.effective).toBe("local");
+ });
+
+ it("never returns an empty primaryProviderId", () => {
+ const r = resolveRunMode(config({ providers: [], activeTextProvider: "ghost" }));
+ expect(r.primaryProviderId).toBe("ghost");
+ });
+});
diff --git a/src/llm/run-mode/resolve-run-mode.ts b/src/llm/run-mode/resolve-run-mode.ts
new file mode 100644
index 00000000..95aac11a
--- /dev/null
+++ b/src/llm/run-mode/resolve-run-mode.ts
@@ -0,0 +1,131 @@
+import type {
+ RunModeName,
+ RunModeSubRunners,
+} from "../../config/llm-run-mode-config.js";
+import { DEFAULT_FUSION_CLOUD_SHARE } from "../../config/llm-run-mode-config.js";
+import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js";
+
+/** Provider kind that identifies the local leg. */
+const LOCAL_PROVIDER_KIND = "llama-server";
+
+export type RunModeDegradationReason =
+ | "no-cloud-provider"
+ | "no-local-provider"
+ | "tool-transport-pinned";
+
+export type RunModeDegradation = {
+ reason: RunModeDegradationReason;
+ /** The mode the operator asked for, before degradation. */
+ requested: RunModeName;
+};
+
+export type ResolvedRunMode = {
+ /** What the config file says, or `null` when the block is absent. */
+ stored: RunModeName | null;
+ /** What the runtime will actually do. */
+ effective: RunModeName;
+ localProviderId: string | null;
+ cloudProviderId: string | null;
+ /**
+ * The provider that must be `llm.activeTextProvider` for `effective`
+ * to hold. Never empty — falls back to the configured active provider
+ * when neither leg resolves.
+ */
+ primaryProviderId: string;
+ fusion: { cloudShare: number; subRunners: RunModeSubRunners };
+ degraded: RunModeDegradation | null;
+};
+
+/**
+ * Project the `llm.runMode` block onto the providers that actually
+ * exist.
+ *
+ * `llm.activeTextProvider` stays AUTHORITATIVE — `runMode.mode` is
+ * purely additive. The effective mode is derived from which provider is
+ * active, and a stored `fusion` is only honoured when the cloud leg is
+ * the active one:
+ *
+ * ```
+ * derived = kindOf(activeTextProvider) === "llama-server" ? "local" : "cloud"
+ * effective = stored === "fusion" && bothLegsExist && active === cloudId
+ * ? "fusion" : derived
+ * ```
+ *
+ * That rule is what keeps the two keys from ever contradicting each
+ * other: an operator who switches provider by hand in Manage → LLM
+ * simply drops out of fusion on the next read, with no reconciliation
+ * step and no state that lies about what is running. It also means
+ * fusion pins the CLOUD provider as the fallback chain's primary, so
+ * `resolveFallbackChain` hoists it to the head and appends local at the
+ * tail with no changes of its own.
+ */
+export function resolveRunMode(
+ resolved: ResolvedLlmConfig,
+): ResolvedRunMode {
+ const runMode = resolved.runMode;
+ const stored = runMode?.mode ?? null;
+
+ const localProviderId =
+ runMode?.localProvider ??
+ resolved.providers.find((p) => p.kind === LOCAL_PROVIDER_KIND)?.id ??
+ null;
+ const cloudProviderId =
+ runMode?.cloudProvider ??
+ resolved.providers.find((p) => p.kind !== LOCAL_PROVIDER_KIND)?.id ??
+ null;
+
+ const activeKind = resolved.providers.find(
+ (p) => p.id === resolved.activeTextProvider,
+ )?.kind;
+ // An unresolvable active provider means a broken config; assume local
+ // so a broken file can never silently start spending cloud tokens.
+ const derived: RunModeName =
+ activeKind === undefined || activeKind === LOCAL_PROVIDER_KIND
+ ? "local"
+ : "cloud";
+
+ const fusion = {
+ cloudShare: runMode?.fusion?.cloudShare ?? DEFAULT_FUSION_CLOUD_SHARE,
+ subRunners: runMode?.fusion?.subRunners ?? ("local" as RunModeSubRunners),
+ };
+
+ let effective: RunModeName = derived;
+ let degraded: RunModeDegradation | null = null;
+
+ if (stored === "fusion") {
+ if (cloudProviderId === null) {
+ degraded = { reason: "no-cloud-provider", requested: stored };
+ } else if (localProviderId === null) {
+ degraded = { reason: "no-local-provider", requested: stored };
+ } else if (resolved.activeTextProvider === cloudProviderId) {
+ // Both legs exist AND the cloud leg is the active provider, which
+ // is what makes the cloud provider the fallback chain's primary.
+ effective = "fusion";
+ if (resolved.toolTransport !== "auto") {
+ // Not a downgrade: fusion still runs, but a pinned transport
+ // sends one leg the wrong wire shape (grammar to a native-tools
+ // provider or vice versa), so the operator has to know.
+ degraded = { reason: "tool-transport-pinned", requested: stored };
+ }
+ }
+ // else: the operator switched the active provider by hand, so we
+ // simply report `derived`. That is the non-contradiction rule
+ // working, not a degradation — nothing to warn about.
+ } else if (stored === "cloud" && cloudProviderId === null) {
+ degraded = { reason: "no-cloud-provider", requested: stored };
+ }
+
+ const primaryProviderId =
+ (effective === "local" ? localProviderId : cloudProviderId) ??
+ resolved.activeTextProvider;
+
+ return {
+ stored,
+ effective,
+ localProviderId,
+ cloudProviderId,
+ primaryProviderId,
+ fusion,
+ degraded,
+ };
+}
diff --git a/src/llm/run-mode/run-mode-degradation.test.ts b/src/llm/run-mode/run-mode-degradation.test.ts
new file mode 100644
index 00000000..ff0f4ceb
--- /dev/null
+++ b/src/llm/run-mode/run-mode-degradation.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import { describeRunModeDegradation } from "./run-mode-degradation.js";
+
+describe("describeRunModeDegradation", () => {
+ it("names the orchestrator when fusion has no cloud leg", () => {
+ const msg = describeRunModeDegradation({
+ reason: "no-cloud-provider",
+ requested: "fusion",
+ });
+ expect(msg).toContain("Fusion needs a cloud orchestrator");
+ expect(msg).toContain("Staying on local");
+ });
+
+ it("uses the plain cloud wording when cloud mode has no cloud leg", () => {
+ const msg = describeRunModeDegradation({
+ reason: "no-cloud-provider",
+ requested: "cloud",
+ });
+ expect(msg).toContain("Cloud mode needs a cloud provider");
+ expect(msg).not.toContain("Fusion");
+ });
+
+ it("names the executor when fusion has no local leg", () => {
+ expect(
+ describeRunModeDegradation({ reason: "no-local-provider", requested: "fusion" }),
+ ).toContain("Fusion needs a local executor");
+ });
+
+ it("explains a pinned tool transport as a warning, not a downgrade", () => {
+ const msg = describeRunModeDegradation({
+ reason: "tool-transport-pinned",
+ requested: "fusion",
+ });
+ expect(msg).toContain("llm.toolTransport");
+ expect(msg).not.toContain("Staying on local");
+ });
+
+ it("points every degradation at a way to fix it", () => {
+ expect(
+ describeRunModeDegradation({ reason: "no-cloud-provider", requested: "cloud" }),
+ ).toContain("/llm");
+ });
+});
diff --git a/src/llm/run-mode/run-mode-degradation.ts b/src/llm/run-mode/run-mode-degradation.ts
new file mode 100644
index 00000000..acdbdb99
--- /dev/null
+++ b/src/llm/run-mode/run-mode-degradation.ts
@@ -0,0 +1,23 @@
+import type { RunModeDegradation } from "./resolve-run-mode.js";
+
+/**
+ * Operator-visible sentence for a run-mode degradation.
+ *
+ * Kept out of `resolveRunMode` so the resolver stays a pure projection
+ * and the wording can be asserted on its own — and so the TUI, the CLI
+ * and the HTTP surface all say exactly the same thing.
+ */
+export function describeRunModeDegradation(
+ degraded: RunModeDegradation,
+): string {
+ switch (degraded.reason) {
+ case "no-cloud-provider":
+ return degraded.requested === "fusion"
+ ? "Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)."
+ : "Cloud mode needs a cloud provider — none is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm).";
+ case "no-local-provider":
+ return "Fusion needs a local executor — no llama-server provider is configured. Running cloud-only.";
+ case "tool-transport-pinned":
+ return 'Fusion works best with llm.toolTransport "auto" — it is pinned, so one leg will get the wrong wire format.';
+ }
+}
diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts
index 66f28fe1..c0fbeb8c 100644
--- a/src/runtime/bootstrap.test.ts
+++ b/src/runtime/bootstrap.test.ts
@@ -918,3 +918,88 @@ describe("createAgentRuntime", () => {
}
});
});
+
+describe("createAgentRuntime steering", () => {
+ let stateDir: string;
+ let workingDir: string;
+
+ beforeEach(() => {
+ stateDir = mkdtempSync(join(tmpdir(), "atomic-runtime-steer-"));
+ workingDir = mkdtempSync(join(tmpdir(), "atomic-cwd-steer-"));
+ mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true });
+ process.env.ATOMIC_AGENT_STATE_DIR = stateDir;
+ process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars");
+ resetConfigCache();
+ });
+
+ afterEach(() => {
+ rmSync(stateDir, { recursive: true, force: true });
+ rmSync(workingDir, { recursive: true, force: true });
+ delete process.env.ATOMIC_AGENT_STATE_DIR;
+ delete process.env.ATOMIC_AGENT_GRAMMARS_DIR;
+ resetConfigCache();
+ });
+
+ it("refuses to steer a session with no turn in flight", async () => {
+ const runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true },
+ });
+ try {
+ const session = runtime.createSession();
+ // Nothing is running: steering would silently vanish, so the
+ // caller is told "no" and can fall back to a normal turn.
+ expect(runtime.steer(session.id, "hello?")).toBe(false);
+ expect(runtime.steeringInbox.peek(session.id)).toEqual([]);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("accepts a steer while a turn holds the session lock", async () => {
+ const runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true },
+ });
+ try {
+ const session = runtime.createSession();
+ let release!: () => void;
+ const held = new Promise((res) => {
+ release = res;
+ });
+ const inFlight = runtime.turnController.enqueue({
+ sessionId: session.id,
+ origin: "tui",
+ run: async () => {
+ expect(runtime.steer(session.id, "change course")).toBe(true);
+ expect(runtime.steeringInbox.peek(session.id)).toEqual([
+ "change course",
+ ]);
+ await held;
+ return null;
+ },
+ });
+ release();
+ await inFlight;
+ // Still pending: only the agent loop drains it.
+ expect(runtime.steeringInbox.drain(session.id)).toEqual(["change course"]);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("drops pending steers on shutdown", async () => {
+ const runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true },
+ });
+ const session = runtime.createSession();
+ runtime.steeringInbox.push(session.id, "stale");
+ await runtime.shutdown();
+ expect(runtime.steeringInbox.peek(session.id)).toEqual([]);
+ });
+});
+
diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts
index 68000664..17e4af6c 100644
--- a/src/runtime/bootstrap.ts
+++ b/src/runtime/bootstrap.ts
@@ -10,7 +10,10 @@ import {
} from "../config/index.js";
import type { LlmStreamParams } from "../agent/step-executor.js";
+import { StepRouter } from "../agent/routing/index.js";
+import { resolveRunMode } from "../llm/run-mode/index.js";
import { TurnController } from "./turn-controller.js";
+import { SteeringInbox } from "./steering-inbox.js";
import type { TurnEventHook, TurnOrigin } from "./turn-controller.js";
import type { ChannelStatus } from "./channel-status.js";
@@ -293,6 +296,25 @@ export interface AgentRuntime {
* funnels through this controller internally.
*/
readonly turnController: TurnController;
+ /**
+ * Out-of-band channel for messages sent to a session whose turn is
+ * already running. `TurnController` is strictly FIFO by design, so a
+ * mid-turn message would otherwise have to wait for the turn to
+ * close; the inbox lets it reach the model at the next step boundary
+ * instead. Prefer {@link AgentRuntime.steer} over touching this
+ * directly — it checks that a turn is actually in flight.
+ */
+ readonly steeringInbox: SteeringInbox;
+ /**
+ * Fold `text` into the turn currently running on `sessionId`.
+ *
+ * Returns `false` — and queues nothing — when the session has no turn
+ * in flight, or when the inbox for that session is full. A `false`
+ * return means "not steered": the caller is expected to fall back to
+ * a normal `runTurn`, or to its own message queue. Never starts a
+ * turn on its own.
+ */
+ steer(sessionId: string, text: string): boolean;
/**
* Durable user-profile store. Present even when
* `memory.profile.enabled` is `false`, because the store owns the
@@ -659,6 +681,7 @@ export async function createAgentRuntime(
* pointer.
*/
const turnContext = new AsyncLocalStorage<{ sessionId: string }>();
+ const steeringInbox = new SteeringInbox();
const turnController = new TurnController({
onHookError: (err, ctxInfo) => {
logger.warn("turn event hook threw", {
@@ -1188,11 +1211,16 @@ export async function createAgentRuntime(
*/
const resolveModelPricing = (
modelId: string | null,
+ servedProviderId?: string,
): ResolvedModel | undefined => {
if (!modelId) return undefined;
const resolved = resolveLlmConfig(getConfig());
+ // Price against the provider that actually SERVED the completion,
+ // not the active one. They differ after any fallover, and routinely
+ // under fusion — pricing a local completion against the cloud
+ // provider's catalog reports a cost that was never incurred.
const entry = resolved.providers.find(
- (p) => p.id === resolved.activeTextProvider,
+ (p) => p.id === (servedProviderId ?? resolved.activeTextProvider),
);
if (!entry) return undefined;
return resolveModel(entry, modelId, catalogForProvider(entry));
@@ -1343,9 +1371,10 @@ export async function createAgentRuntime(
const recordUnaryUsage = (
params: LlmStreamParams,
result: CompletionResult,
+ servedProviderId?: string,
): void => {
if (!result.usage) return;
- const model = resolveModelPricing(result.modelId);
+ const model = resolveModelPricing(result.modelId, servedProviderId);
if (costAccumulator) {
costAccumulator.recordTurn({
modelId: result.modelId,
@@ -1367,7 +1396,7 @@ export async function createAgentRuntime(
result: CompletionResult,
): void => {
if (!result.usage || !sessionId) return;
- const model = resolveModelPricing(result.modelId);
+ const model = resolveModelPricing(result.modelId, result.servedProviderId);
turnUsageMeter.record({
sessionId,
usage: result.usage,
@@ -1375,6 +1404,40 @@ export async function createAgentRuntime(
});
};
+ // Keep both fusion legs open across an active-provider swap. Without
+ // this, switching into fusion closes the provider it routes to.
+ providerRegistry.setPinnedProviderIds(() => {
+ const runMode = resolveRunMode(resolveLlmConfig(getConfig()));
+ if (runMode.effective !== "fusion") return new Set();
+ return new Set(
+ [runMode.cloudProviderId, runMode.localProviderId].filter(
+ (id): id is string => id !== null,
+ ),
+ );
+ });
+
+ /**
+ * Fusion step router. Always constructed; it resolves the live config
+ * on every step and returns `null` unless fusion is the effective run
+ * mode, so a non-fusion install pays one config read and nothing else.
+ */
+ const stepRouter = new StepRouter({
+ resolveFusion: () => {
+ const live = getConfig();
+ const runMode = resolveRunMode(resolveLlmConfig(live));
+ if (runMode.effective !== "fusion") return null;
+ if (!runMode.cloudProviderId || !runMode.localProviderId) return null;
+ return {
+ cloudProviderId: runMode.cloudProviderId,
+ localProviderId: runMode.localProviderId,
+ cloudShare: runMode.fusion.cloudShare,
+ subRunners: runMode.fusion.subRunners,
+ maxSteps: live.agent.maxSteps,
+ conversationMaxTokens: live.agent.conversationMaxTokens,
+ };
+ },
+ });
+
const fallbackSeamDeps: FallbackSeamDeps = {
fallbackChain,
resolveSlice: (providerId) => {
@@ -1396,6 +1459,26 @@ export async function createAgentRuntime(
? undefined
: createFallbackStreamer(fallbackSeamDeps));
+ /**
+ * Completion seam for the memory sub-runners (reflection, link
+ * generation, curation votes, query rewriting, distillation).
+ *
+ * Under fusion these default to the LOCAL leg: they are cold-path,
+ * fire-and-forget structured-JSON jobs that ride the reserved
+ * reflection slot and are already KV-warm on the local server, so
+ * sending them to the cloud multiplies per-turn cost with no
+ * user-visible latency win. `llm.runMode.fusion.subRunners` overrides
+ * it. Outside fusion this is `llmComplete` with no added behaviour.
+ */
+ const subRunnerLlmComplete = (
+ params: LlmStreamParams,
+ ): Promise => {
+ const providerId = stepRouter.subRunnerProviderId(params.sessionId);
+ return llmComplete(
+ providerId ? { ...params, preferredProviderId: providerId } : params,
+ );
+ };
+
const taskStore = new TaskStore({ dbFile: config.paths.tasksDbFile });
const webhookSessionStore = new WebhookSessionStore(
resolve(config.paths.stateDir, "webhook-sessions.json"),
@@ -1426,7 +1509,7 @@ export async function createAgentRuntime(
const baseReflectionRunner = buildReflectionRunner({
config,
slotManager,
- llmComplete,
+ llmComplete: subRunnerLlmComplete,
toolTransport: bootstrapLlmSlice.transport,
profileStore,
notesStore,
@@ -1483,7 +1566,7 @@ export async function createAgentRuntime(
{ once: true },
);
});
- const completionPromise = llmComplete({
+ const completionPromise = subRunnerLlmComplete({
prompt: params.prompt,
grammar: params.grammar,
slotId: params.slotId,
@@ -1552,7 +1635,7 @@ export async function createAgentRuntime(
{ once: true },
);
});
- const completionPromise = llmComplete({
+ const completionPromise = subRunnerLlmComplete({
prompt: params.prompt,
grammar: params.grammar,
slotId: params.slotId,
@@ -1688,7 +1771,7 @@ export async function createAgentRuntime(
{ once: true },
);
});
- const completionPromise = llmComplete({
+ const completionPromise = subRunnerLlmComplete({
prompt: params.prompt,
grammar: params.grammar,
slotId: params.slotId,
@@ -1752,7 +1835,10 @@ export async function createAgentRuntime(
registry: toolRegistry,
slotManager,
grammar,
+ stepRouter,
llmComplete,
+ // Mid-turn steering: the loop drains this at every step boundary.
+ steeringInbox,
...(llmCompleteStream ? { llmCompleteStream } : {}),
toolDescriptors: effectiveToolDescriptors,
capabilities,
@@ -1833,6 +1919,15 @@ export async function createAgentRuntime(
enumerable: true,
get: () => resolveActiveLlmSlice().slotAffinity,
});
+ // Slot affinity for a specific routed provider. Without this, fusion
+ // would read affinity off the active (cloud) provider and run every
+ // locally-routed step with slotId -1 — no prompt cache at all on the
+ // local leg.
+ Object.defineProperty(loopDeps, "resolveSlotAffinity", {
+ enumerable: true,
+ get: () => (providerId: string) =>
+ resolveActiveLlmSlice(providerId).slotAffinity,
+ });
const loop = new AgentLoop(
loopDeps as typeof loopDeps & {
skillCatalog: readonly SkillCatalogEntry[];
@@ -1851,6 +1946,9 @@ export async function createAgentRuntime(
const shutdown = async (): Promise => {
if (shutdownCalled) return;
shutdownCalled = true;
+ // Nothing will drain the inbox after this point; drop pending
+ // steers so a message cannot resurface in a later process.
+ steeringInbox.clearAll();
// Cancel any in-flight reflection before tearing down the profile
// store — otherwise a late-arriving completion could try to write
// into a closed SQLite connection.
@@ -2071,6 +2169,17 @@ export async function createAgentRuntime(
});
};
+ /**
+ * Public entry point for mid-turn steering. Deliberately does NOT
+ * enqueue: the whole point is to reach the turn that is already
+ * running, and going through `turnController` would put the message
+ * behind it.
+ */
+ const steer = (sessionId: string, text: string): boolean => {
+ if (!turnController.isBusy(sessionId)) return false;
+ return steeringInbox.push(sessionId, text);
+ };
+
const runTurn = async (
session: SessionState,
userMessage: string,
@@ -2224,7 +2333,7 @@ export async function createAgentRuntime(
{ once: true },
);
});
- const completionPromise = llmComplete({
+ const completionPromise = subRunnerLlmComplete({
prompt: params.prompt,
grammar: params.grammar,
slotId: params.slotId,
@@ -2404,6 +2513,8 @@ export async function createAgentRuntime(
slotManager,
sessionStore,
turnController,
+ steeringInbox,
+ steer,
profileStore,
notesStore,
lessonStore,
diff --git a/src/runtime/llm-fallback-seam.test.ts b/src/runtime/llm-fallback-seam.test.ts
index a2a8197a..32ac7719 100644
--- a/src/runtime/llm-fallback-seam.test.ts
+++ b/src/runtime/llm-fallback-seam.test.ts
@@ -184,3 +184,73 @@ describe("createFallbackStreamer (real bootstrap seam)", () => {
expect(result.servedTransport).toBe("native_tools");
});
});
+
+describe("fusion routing through the real seam", () => {
+ const providers = () =>
+ new Map([
+ ["cloud", fakeProvider("cloud", "native_tools", async () => answer("cloud"))],
+ ["local", fakeProvider("local", "grammar", async () => answer("local"))],
+ ]);
+
+ it("stamps servedProviderId with the link that answered", async () => {
+ const complete = createFallbackCompleter(seamDeps(providers()));
+ const result = await complete(baseParams);
+ expect(result.servedProviderId).toBe("cloud");
+ });
+
+ it("starts at preferredProviderId instead of the chain primary", async () => {
+ const complete = createFallbackCompleter(seamDeps(providers()));
+ const result = await complete({
+ ...baseParams,
+ preferredProviderId: "local",
+ });
+ // Load-bearing: "local" is the chain TAIL, so without the
+ // preference plumbing this would answer from "cloud".
+ expect(result.servedProviderId).toBe("local");
+ expect(result.modelId).toBe("local-model");
+ // And the transport stamp must follow the routed leg, not the primary.
+ expect(result.servedTransport).toBe("grammar");
+ });
+
+ it("still falls over on health when the preferred leg fails", async () => {
+ const map = new Map([
+ ["cloud", fakeProvider("cloud", "native_tools", async () => answer("cloud"))],
+ [
+ "local",
+ fakeProvider("local", "grammar", async () => {
+ throw new OpenAiHttpError("boom", 503, "http://local", false, null, "local");
+ }),
+ ],
+ ]);
+ const complete = createFallbackCompleter(seamDeps(map));
+ const result = await complete({
+ ...baseParams,
+ preferredProviderId: "local",
+ });
+ expect(result.servedProviderId).toBe("cloud");
+ });
+
+ it("prices against the served leg, not the active one", async () => {
+ // Guards the fusion cost-attribution fix in bootstrap: the recorder
+ // is handed the id of the link that answered.
+ const seen: string[] = [];
+ const deps = seamDeps(providers());
+ const complete = createFallbackCompleter({
+ ...deps,
+ recordUnaryUsage: (_params, _result, servedProviderId) => {
+ seen.push(servedProviderId);
+ },
+ });
+ await complete({ ...baseParams, preferredProviderId: "local" });
+ expect(seen).toEqual(["local"]);
+ });
+
+ it("routes the stream seam by preference and stamps the served id", async () => {
+ const stream = createFallbackStreamer(seamDeps(providers()));
+ const gen = stream({ ...baseParams, preferredProviderId: "local" });
+ let next = await gen.next();
+ while (next.done !== true) next = await gen.next();
+ expect(next.value.servedProviderId).toBe("local");
+ expect(next.value.servedTransport).toBe("grammar");
+ });
+});
diff --git a/src/runtime/llm-fallback-seam.ts b/src/runtime/llm-fallback-seam.ts
index d77f7e87..ed54348d 100644
--- a/src/runtime/llm-fallback-seam.ts
+++ b/src/runtime/llm-fallback-seam.ts
@@ -34,8 +34,17 @@ export interface FallbackSeamDeps {
fallbackChain: ProviderFallbackChain;
/** Resolve the served link's provider + transport for `providerId`. */
resolveSlice: (providerId: string) => ResolvedLinkSlice;
- /** Fold a unary completion's usage into cost + meter (no-op when absent). */
- recordUnaryUsage: (params: LlmStreamParams, result: CompletionResult) => void;
+ /**
+ * Fold a unary completion's usage into cost + meter (no-op when
+ * absent). `servedProviderId` names the link that answered, which is
+ * what pricing must be looked up against — under fusion it routinely
+ * differs from `llm.activeTextProvider`.
+ */
+ recordUnaryUsage: (
+ params: LlmStreamParams,
+ result: CompletionResult,
+ servedProviderId: string,
+ ) => void;
/** Fold a streamed completion's usage into the meter. */
recordStreamUsage: (
sessionId: string | undefined,
@@ -92,10 +101,13 @@ export function createFallbackCompleter(
slotId: params.slotId,
cachePrompt: params.slotId >= 0,
});
- deps.recordUnaryUsage(params, result);
- return { ...result, servedTransport: transport };
+ deps.recordUnaryUsage(params, result, providerId);
+ return { ...result, servedTransport: transport, servedProviderId: providerId };
},
params.sessionId,
+ { ...(params.preferredProviderId
+ ? { preferredProviderId: params.preferredProviderId }
+ : {}) },
);
}
@@ -117,6 +129,7 @@ export function createFallbackStreamer(
): Promise<{
primed: PrimedStream;
transport: ToolCallTransport;
+ providerId: string;
}> => {
const { provider, transport } = deps.resolveSlice(providerId);
const base = {
@@ -142,18 +155,21 @@ export function createFallbackStreamer(
slotId: params.slotId,
cachePrompt: params.slotId >= 0,
});
- return { primed: await primeStream(stream), transport };
+ return { primed: await primeStream(stream), transport, providerId };
};
return (params) => {
async function* run(): AsyncGenerator {
- const { primed, transport } = await runWithFallback(
+ const { primed, transport, providerId } = await runWithFallback(
deps.fallbackChain,
(id) => openStreamPrimed(id, params),
params.sessionId,
+ { ...(params.preferredProviderId
+ ? { preferredProviderId: params.preferredProviderId }
+ : {}) },
);
const result = yield* replayPrimedStream(primed);
- return { ...result, servedTransport: transport };
+ return { ...result, servedTransport: transport, servedProviderId: providerId };
}
return meterStream(deps, params.sessionId, run());
};
diff --git a/src/runtime/steering-inbox.test.ts b/src/runtime/steering-inbox.test.ts
new file mode 100644
index 00000000..dbabb62e
--- /dev/null
+++ b/src/runtime/steering-inbox.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { MAX_PENDING_STEERS, SteeringInbox } from "./steering-inbox.js";
+
+describe("SteeringInbox", () => {
+ it("drains what was pushed, in order", () => {
+ const inbox = new SteeringInbox();
+ expect(inbox.push("s1", "first")).toBe(true);
+ expect(inbox.push("s1", "second")).toBe(true);
+ expect(inbox.drain("s1")).toEqual(["first", "second"]);
+ });
+
+ it("empties the slot on drain so one message is delivered once", () => {
+ const inbox = new SteeringInbox();
+ inbox.push("s1", "only");
+ expect(inbox.drain("s1")).toEqual(["only"]);
+ expect(inbox.drain("s1")).toEqual([]);
+ });
+
+ it("returns an empty array for a session that was never pushed to", () => {
+ expect(new SteeringInbox().drain("nobody")).toEqual([]);
+ });
+
+ it("keeps sessions isolated", () => {
+ const inbox = new SteeringInbox();
+ inbox.push("a", "for-a");
+ inbox.push("b", "for-b");
+ expect(inbox.drain("a")).toEqual(["for-a"]);
+ expect(inbox.drain("b")).toEqual(["for-b"]);
+ });
+
+ it("trims and rejects blank text", () => {
+ const inbox = new SteeringInbox();
+ expect(inbox.push("s1", " ")).toBe(false);
+ expect(inbox.push("s1", "\n\t")).toBe(false);
+ expect(inbox.push("s1", " padded ")).toBe(true);
+ expect(inbox.drain("s1")).toEqual(["padded"]);
+ });
+
+ it("refuses past the per-session cap instead of dropping the oldest", () => {
+ const inbox = new SteeringInbox();
+ for (let i = 0; i < MAX_PENDING_STEERS; i += 1) {
+ expect(inbox.push("s1", `m${i}`)).toBe(true);
+ }
+ // A refusal is the signal the caller needs to park the message
+ // somewhere else; silently evicting m0 would lose it.
+ expect(inbox.push("s1", "overflow")).toBe(false);
+ const drained = inbox.drain("s1");
+ expect(drained).toHaveLength(MAX_PENDING_STEERS);
+ expect(drained[0]).toBe("m0");
+ expect(drained).not.toContain("overflow");
+ });
+
+ it("accepts again once the cap is drained", () => {
+ const inbox = new SteeringInbox();
+ for (let i = 0; i < MAX_PENDING_STEERS; i += 1) inbox.push("s1", `m${i}`);
+ expect(inbox.push("s1", "nope")).toBe(false);
+ inbox.drain("s1");
+ expect(inbox.push("s1", "yes")).toBe(true);
+ });
+
+ it("peek does not consume", () => {
+ const inbox = new SteeringInbox();
+ inbox.push("s1", "held");
+ expect(inbox.peek("s1")).toEqual(["held"]);
+ expect(inbox.peek("s1")).toEqual(["held"]);
+ expect(inbox.drain("s1")).toEqual(["held"]);
+ });
+
+ it("clear drops one session, clearAll drops every session", () => {
+ const inbox = new SteeringInbox();
+ inbox.push("a", "x");
+ inbox.push("b", "y");
+ inbox.clear("a");
+ expect(inbox.peek("a")).toEqual([]);
+ expect(inbox.peek("b")).toEqual(["y"]);
+ inbox.clearAll();
+ expect(inbox.peek("b")).toEqual([]);
+ });
+});
diff --git a/src/runtime/steering-inbox.ts b/src/runtime/steering-inbox.ts
new file mode 100644
index 00000000..fca2dd2c
--- /dev/null
+++ b/src/runtime/steering-inbox.ts
@@ -0,0 +1,85 @@
+/**
+ * Per-session mailbox for user messages that arrive **while a turn is
+ * already running**.
+ *
+ * The runtime has exactly one ordered path into `AgentLoop.runTurn`
+ * (`TurnController`, per-session FIFO), and that is deliberate: two
+ * concurrent turns on one session would race the browser, the slot
+ * manager and the transcript. But FIFO also means a message sent
+ * mid-turn cannot reach the model until the current turn closes, which
+ * is the wrong answer when the operator is watching the agent walk off
+ * a cliff and wants to redirect it *now*.
+ *
+ * This inbox is the out-of-band channel for exactly that. It does not
+ * start turns and it does not touch the queue: `AgentLoop` drains it at
+ * the top of every step and folds the text into that step's `### notice`
+ * block. The effect lands at the next **step** boundary — never
+ * mid-inference, and never mid-tool-call.
+ *
+ * Ownership mirrors `TurnController`: one instance per runtime, keyed by
+ * session id, and cross-session isolated by construction.
+ */
+
+/**
+ * Maximum messages held for one session before `push` starts refusing.
+ * A turn stuck in a long tool call can be steered a handful of times
+ * before the model gets a chance to read any of them; past that the
+ * caller should queue instead of piling more onto one prompt. Refusing
+ * is safer than dropping the oldest — the caller learns the message did
+ * not land and can park it.
+ */
+export const MAX_PENDING_STEERS = 16;
+
+/** Narrow read side, so `AgentLoop` never sees the mutating surface. */
+export interface SteeringDrain {
+ drain(sessionId: string): readonly string[];
+}
+
+export class SteeringInbox implements SteeringDrain {
+ private readonly bySession = new Map();
+
+ /**
+ * Queue a message for the turn currently running on `sessionId`.
+ * Returns `false` when the text is blank or the per-session cap is
+ * reached — callers treat that as "not steered, park it instead".
+ */
+ push(sessionId: string, text: string): boolean {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return false;
+ const pending = this.bySession.get(sessionId);
+ if (pending === undefined) {
+ this.bySession.set(sessionId, [trimmed]);
+ return true;
+ }
+ if (pending.length >= MAX_PENDING_STEERS) return false;
+ pending.push(trimmed);
+ return true;
+ }
+
+ /**
+ * Take everything pending for `sessionId` and empty the slot. Always
+ * returns an array (possibly empty) so callers never branch on
+ * `undefined`.
+ */
+ drain(sessionId: string): readonly string[] {
+ const pending = this.bySession.get(sessionId);
+ if (pending === undefined || pending.length === 0) return [];
+ this.bySession.delete(sessionId);
+ return pending;
+ }
+
+ /** Non-destructive read, for UI badges and tests. */
+ peek(sessionId: string): readonly string[] {
+ return this.bySession.get(sessionId) ?? [];
+ }
+
+ /** Discard pending messages for one session (session switch / abort). */
+ clear(sessionId: string): void {
+ this.bySession.delete(sessionId);
+ }
+
+ /** Discard everything (runtime shutdown). */
+ clearAll(): void {
+ this.bySession.clear();
+ }
+}
diff --git a/src/sidecar/index.ts b/src/sidecar/index.ts
index ef08bfa8..a1ab0e96 100644
--- a/src/sidecar/index.ts
+++ b/src/sidecar/index.ts
@@ -20,6 +20,7 @@ export type {
StartSessionPayload,
RunStepPayload,
SendMessagePayload,
+ SteerMessagePayload,
CancelPayload,
ApprovalResponsePayload,
GetSessionPayload,
diff --git a/src/sidecar/main.ts b/src/sidecar/main.ts
index f3429d2b..30d54dda 100644
--- a/src/sidecar/main.ts
+++ b/src/sidecar/main.ts
@@ -13,6 +13,7 @@ import type {
CancelPayload,
GetSessionPayload,
SendMessagePayload,
+ SteerMessagePayload,
SkillInstallPayload,
SkillUninstallPayload,
StartSessionPayload,
@@ -144,6 +145,13 @@ export async function bootstrapSidecar(): Promise<{
text: event.text,
});
break;
+ case "steer_applied":
+ protocol.emitEvent("steer_applied", {
+ sessionId,
+ text: event.text,
+ stepIndex: event.stepIndex,
+ });
+ break;
case "turn_started":
protocol.emitEvent("turn_started", {
sessionId,
@@ -310,6 +318,12 @@ export async function bootstrapSidecar(): Promise<{
},
});
active = { ...active, session: result.session };
+ // A steer that arrived too late to be drained must not vanish. The
+ // sidecar has no queue of its own, so surface it to the host, which
+ // can decide to re-send it as a normal message.
+ for (const text of result.undelivered ?? []) {
+ protocol.emitEvent("steer_undelivered", { sessionId, text });
+ }
return {
reason: result.reason,
turnCount: result.session.turnCount,
@@ -332,6 +346,20 @@ export async function bootstrapSidecar(): Promise<{
},
);
+ router.register(
+ "steer_message",
+ (request) => {
+ const { sessionId, text } = request.payload;
+ if (!active || active.session.id !== sessionId) return { steered: false };
+ // Deliberately NOT routed through `turnController.enqueue`: the
+ // point of steering is to reach the turn that already holds the
+ // session lock, and enqueueing would put it behind that turn.
+ // `runtime.steer` returns false when nothing is running, which is
+ // the host's cue to call `send_message` instead.
+ return { steered: active.runtime.steer(sessionId, text) };
+ },
+ );
+
router.register(
"cancel",
(request) => {
diff --git a/src/sidecar/sidecar-events.ts b/src/sidecar/sidecar-events.ts
index ad9e4f8f..38f7b1c5 100644
--- a/src/sidecar/sidecar-events.ts
+++ b/src/sidecar/sidecar-events.ts
@@ -10,6 +10,7 @@ export type HostRequestType =
| "start_session"
| "run_step"
| "send_message"
+ | "steer_message"
| "cancel"
| "approval_response"
| "get_session"
@@ -26,6 +27,8 @@ export type SidecarEventType =
| "tool_call_started"
| "tool_call_result"
| "user_message"
+ | "steer_applied"
+ | "steer_undelivered"
| "assistant_reply"
| "assistant_delta"
| "reasoning_delta"
@@ -91,6 +94,18 @@ export interface SendMessagePayload {
maxSteps?: number;
}
+/**
+ * Fold a message into the turn already running on `sessionId`. Unlike
+ * {@link SendMessagePayload} this never starts a turn and never queues
+ * behind one — see §"Mid-turn steering" in AGENTS.md. The response's
+ * `steered: false` means the session was idle (or the inbox was full)
+ * and the host should fall back to `send_message`.
+ */
+export interface SteerMessagePayload {
+ sessionId: string;
+ text: string;
+}
+
export interface CancelPayload {
sessionId: string;
}
@@ -176,6 +191,29 @@ export interface UserMessagePayload {
text: string;
}
+/**
+ * A mid-turn message reached the model at `stepIndex`. Distinct from
+ * `user_message`, which marks the message that opened the turn — hosts
+ * render this one inline inside the running turn.
+ */
+export interface SteerAppliedPayload {
+ sessionId: string;
+ text: string;
+ stepIndex: number;
+}
+
+/**
+ * A steer was accepted but the turn ended before the loop could drain
+ * it (it landed during the final inference, or the turn was cancelled).
+ * The host owns it now — re-send it as a `send_message` if it still
+ * makes sense. Emitted rather than silently dropped so "the message you
+ * sent always goes somewhere" holds on this surface too.
+ */
+export interface SteerUndeliveredPayload {
+ sessionId: string;
+ text: string;
+}
+
export interface AssistantReplyPayload {
sessionId: string;
text: string;
diff --git a/src/sidecar/steer-message.test.ts b/src/sidecar/steer-message.test.ts
new file mode 100644
index 00000000..caaf817f
--- /dev/null
+++ b/src/sidecar/steer-message.test.ts
@@ -0,0 +1,157 @@
+import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import type { CompletionResult } from "../llm/llama-server-client.js";
+import { resetConfigCache } from "../config/index.js";
+import { createAgentRuntime } from "../runtime/bootstrap.js";
+import type { AgentRuntime } from "../runtime/bootstrap.js";
+import { FakeBrowserBackend } from "../http/test-harness.js";
+
+/**
+ * Mirrors the production `steer_message` handler in
+ * `src/sidecar/main.ts` — same convention as
+ * `send-message-concurrency.test.ts`, which mirrors `send_message`
+ * rather than driving the stdin protocol.
+ *
+ * The property under test is the one that makes steering different from
+ * every other host request: it must NOT go through
+ * `turnController.enqueue`. Enqueueing would park the message behind
+ * the very turn it is meant to redirect, which is the bug this whole
+ * feature exists to avoid.
+ */
+function makeSteerHandler(runtime: AgentRuntime, activeSessionId: string) {
+ return (sessionId: string, text: string): { steered: boolean } => {
+ if (activeSessionId !== sessionId) return { steered: false };
+ return { steered: runtime.steer(sessionId, text) };
+ };
+}
+
+describe("sidecar steer_message", () => {
+ let stateDir: string;
+ let workingDir: string;
+ let runtime: AgentRuntime;
+
+ beforeEach(() => {
+ stateDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-state-"));
+ workingDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-cwd-"));
+ mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true });
+ process.env.ATOMIC_AGENT_STATE_DIR = stateDir;
+ process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars");
+ resetConfigCache();
+ });
+
+ afterEach(async () => {
+ if (runtime) await runtime.shutdown();
+ rmSync(stateDir, { recursive: true, force: true });
+ rmSync(workingDir, { recursive: true, force: true });
+ delete process.env.ATOMIC_AGENT_STATE_DIR;
+ delete process.env.ATOMIC_AGENT_GRAMMARS_DIR;
+ resetConfigCache();
+ });
+
+ it("resolves immediately while a turn holds the session, and lands in that turn", async () => {
+ const enters: string[] = [];
+ let releaseFirst: (() => void) | null = null;
+ const llamaComplete = async (params: {
+ sessionId: string;
+ }): Promise => {
+ if (params.sessionId.startsWith("reflection:")) return reply("ignored");
+ enters.push("user-turn");
+ if (enters.length === 1) {
+ await new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ }
+ return reply("done");
+ };
+
+ runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: {
+ browserBackend: new FakeBrowserBackend(),
+ skipLlamaHealthCheck: true,
+ llamaComplete,
+ },
+ });
+
+ const session = runtime.createSession({ metadata: { source: "steer-test" } });
+ const steer = makeSteerHandler(runtime, session.id);
+
+ const turn = runtime.runTurn(session, "start working", {
+ origin: "sidecar",
+ });
+
+ const deadline = Date.now() + 5_000;
+ while (enters.length < 1 && Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ expect(enters).toEqual(["user-turn"]);
+
+ // The turn is blocked inside its inference. A queued handler would
+ // hang here; steering must answer now.
+ expect(steer(session.id, "actually, do it differently")).toEqual({
+ steered: true,
+ });
+ expect(runtime.steeringInbox.peek(session.id)).toEqual([
+ "actually, do it differently",
+ ]);
+
+ releaseFirst?.();
+ await turn;
+ });
+
+ it("refuses when the session is idle so the host falls back to send_message", async () => {
+ runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: {
+ browserBackend: new FakeBrowserBackend(),
+ skipLlamaHealthCheck: true,
+ llamaComplete: async () => reply("done"),
+ },
+ });
+ const session = runtime.createSession();
+ const steer = makeSteerHandler(runtime, session.id);
+ expect(steer(session.id, "hello?")).toEqual({ steered: false });
+ expect(runtime.steeringInbox.peek(session.id)).toEqual([]);
+ });
+
+ it("refuses for a session that is not the active one", async () => {
+ runtime = await createAgentRuntime({
+ workingDir,
+ approvalLevel: 5,
+ overrides: {
+ browserBackend: new FakeBrowserBackend(),
+ skipLlamaHealthCheck: true,
+ llamaComplete: async () => reply("done"),
+ },
+ });
+ const active = runtime.createSession();
+ const other = runtime.createSession();
+ const steer = makeSteerHandler(runtime, active.id);
+ expect(steer(other.id, "wrong session")).toEqual({ steered: false });
+ expect(runtime.steeringInbox.peek(other.id)).toEqual([]);
+ });
+});
+
+function reply(text: string): CompletionResult {
+ return {
+ content: JSON.stringify({ tool: "reply", args: { text } }),
+ reasoningContent: "",
+ stop: true,
+ truncated: false,
+ timing: {
+ promptMs: 0,
+ predictedMs: 0,
+ promptTokens: 1,
+ predictedTokens: 1,
+ },
+ cacheHitTokens: 0,
+ slotId: 0,
+ modelId: null,
+ };
+}
diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts
index b72d64b9..1c410399 100644
--- a/src/tui/agent-event-reducer.ts
+++ b/src/tui/agent-event-reducer.ts
@@ -27,6 +27,7 @@ import { reduceLlmPanelAction } from "./llm-panel/llm-panel-reducer.js";
import { reduceFallbackPanelAction } from "./llm-panel/fallback/fallback-panel-reducer.js";
import { reduceTelegramAction } from "./telegram/telegram-panel-reducer.js";
import { reducePrivacyAction } from "./privacy/privacy-panel-reducer.js";
+import { reduceRunModeAction } from "./run-mode/run-mode-reducer.js";
import type { TuiAction } from "./tui-action.js";
import type { RunOutcome, StreamingToolCall, TuiState } from "./tui-state.js";
@@ -55,6 +56,8 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState {
if (telegramHandled !== null) return telegramHandled;
const privacyHandled = reducePrivacyAction(state, action);
if (privacyHandled !== null) return privacyHandled;
+ const runModeHandled = reduceRunModeAction(state, action);
+ if (runModeHandled !== null) return runModeHandled;
const uiHandled = reduceUiAction(state, action);
if (uiHandled !== null) return uiHandled;
switch (action.type) {
@@ -209,6 +212,13 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState {
switch (event.type) {
case "user_message":
return appendUserMessage(state, event.text);
+ case "steer_applied":
+ // Same bubble shape as any other user message: it *is* one, and
+ // the agent loop recorded it as a real `user` turn. Rendering it
+ // here (rather than optimistically at submit time) means a steer
+ // that missed the turn and fell back to the queue appears exactly
+ // once, when it actually reaches the model.
+ return appendUserMessage(state, event.text);
case "turn_started":
return {
...state,
@@ -375,6 +385,21 @@ function reduceStepEvent(
},
};
}
+ case "step_routed": {
+ return appendFeed(state, {
+ kind: "runtime_info",
+ stepIndex: event.stepIndex,
+ line: formatFeedLine({
+ type: "step_routed",
+ stepIndex: event.stepIndex,
+ role: event.role,
+ providerId: event.providerId,
+ complexity: event.complexity,
+ cloudShare: event.cloudShare,
+ }),
+ color: "blue",
+ });
+ }
case "parse_retry": {
const withFeed = appendFeed(state, {
kind: "runtime_info",
diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts
index 3152f3f3..10efe7ca 100644
--- a/src/tui/app-key-bindings.test.ts
+++ b/src/tui/app-key-bindings.test.ts
@@ -1,8 +1,17 @@
import { describe, it, expect, vi } from "vitest";
import type { Key } from "ink";
-import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js";
-import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js";
+import {
+ escapeHasNothingToCancel,
+ escapeOpensMenu,
+ handleAppKey,
+ handlePanelEscape,
+} from "./app-key-bindings.js";
+import {
+ createInitialTuiState,
+ type TuiSessionInfo,
+ type TuiState,
+} from "./tui-state.js";
import type { ApprovalRequest } from "../approval/approval-gate.js";
function pendingRequest(
@@ -596,3 +605,235 @@ describe("handlePanelEscape", () => {
expect(dispatch).not.toHaveBeenCalled();
});
});
+
+describe("handleAppKey — ctrl+n opens a new terminal window", () => {
+ function ctx(
+ state: ReturnType,
+ onNewWindowRequested: () => void,
+ ) {
+ return {
+ state,
+ dispatch: vi.fn(),
+ callbacks: {
+ onApprovalDecision: vi.fn(),
+ onAbort: vi.fn(),
+ onQuit: vi.fn(),
+ onNewWindowRequested,
+ },
+ ctrlCArmed: false,
+ setCtrlCArmed: vi.fn(),
+ sidebarVisible: false,
+ };
+ }
+
+ it("fires the callback and claims the key in chat mode", () => {
+ const onNewWindowRequested = vi.fn();
+ const state = createInitialTuiState(stubSession());
+ const handled = handleAppKey(
+ "n",
+ emptyKey({ ctrl: true }),
+ ctx(state, onNewWindowRequested),
+ );
+ expect(handled).toBe(true);
+ expect(onNewWindowRequested).toHaveBeenCalledTimes(1);
+ });
+
+ it("stays silent while an approval is pending", () => {
+ // The approval layer owns every key — y/n/esc must not compete with
+ // a window spawn.
+ const onNewWindowRequested = vi.fn();
+ const state = {
+ ...createInitialTuiState(stubSession()),
+ pendingApproval: pendingRequest(),
+ };
+ handleAppKey("n", emptyKey({ ctrl: true }), ctx(state, onNewWindowRequested));
+ expect(onNewWindowRequested).not.toHaveBeenCalled();
+ });
+
+ it("stays silent while the slash palette is open", () => {
+ const onNewWindowRequested = vi.fn();
+ const state = {
+ ...createInitialTuiState(stubSession()),
+ slashPaletteOpen: true,
+ };
+ const handled = handleAppKey(
+ "n",
+ emptyKey({ ctrl: true }),
+ ctx(state, onNewWindowRequested),
+ );
+ expect(handled).toBe(false);
+ expect(onNewWindowRequested).not.toHaveBeenCalled();
+ });
+
+ it("ignores a plain `n` and shift/meta variants", () => {
+ const onNewWindowRequested = vi.fn();
+ const state = createInitialTuiState(stubSession());
+ handleAppKey("n", emptyKey(), ctx(state, onNewWindowRequested));
+ handleAppKey(
+ "n",
+ emptyKey({ ctrl: true, shift: true }),
+ ctx(state, onNewWindowRequested),
+ );
+ handleAppKey(
+ "n",
+ emptyKey({ ctrl: true, meta: true }),
+ ctx(state, onNewWindowRequested),
+ );
+ expect(onNewWindowRequested).not.toHaveBeenCalled();
+ });
+
+ it("does not throw when no handler is wired", () => {
+ const state = createInitialTuiState(stubSession());
+ const handled = handleAppKey("n", emptyKey({ ctrl: true }), {
+ state,
+ dispatch: vi.fn(),
+ callbacks: {
+ onApprovalDecision: vi.fn(),
+ onAbort: vi.fn(),
+ onQuit: vi.fn(),
+ },
+ ctrlCArmed: false,
+ setCtrlCArmed: vi.fn(),
+ sidebarVisible: false,
+ });
+ expect(handled).toBe(true);
+ });
+});
+
+describe("Ctrl+T — Enter-while-busy mode", () => {
+ function ctx(state: ReturnType, extra = {}) {
+ return {
+ state,
+ dispatch: vi.fn(),
+ callbacks: {
+ onApprovalDecision: vi.fn(),
+ onAbort: vi.fn(),
+ onQuit: vi.fn(),
+ onWhileBusyModePersistRequested: vi.fn(),
+ },
+ ctrlCArmed: false,
+ setCtrlCArmed: vi.fn(),
+ sidebarVisible: false,
+ ...extra,
+ };
+ }
+
+ it("toggles the mode and asks for it to be persisted", () => {
+ const state = createInitialTuiState(stubSession());
+ expect(state.whileBusyMode).toBe("steer");
+ const c = ctx(state);
+ const handled = handleAppKey("t", emptyKey({ ctrl: true }), c);
+ expect(handled).toBe(true);
+ expect(c.dispatch).toHaveBeenCalledWith({
+ type: "while_busy_mode_changed",
+ });
+ expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith(
+ "queue",
+ );
+ });
+
+ it("persists the opposite direction from queue mode", () => {
+ const state = { ...createInitialTuiState(stubSession()), whileBusyMode: "queue" as const };
+ const c = ctx(state);
+ handleAppKey("t", emptyKey({ ctrl: true }), c);
+ expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith(
+ "steer",
+ );
+ });
+
+ it("leaves a pending approval alone — y/n/esc own the keyboard there", () => {
+ const state = {
+ ...createInitialTuiState(stubSession()),
+ pendingApproval: pendingRequest(),
+ };
+ const c = ctx(state);
+ const handled = handleAppKey("t", emptyKey({ ctrl: true }), c);
+ expect(handled).toBe(false);
+ expect(c.dispatch).not.toHaveBeenCalledWith({
+ type: "while_busy_mode_changed",
+ });
+ });
+
+ it("ignores a plain t", () => {
+ const c = ctx(createInitialTuiState(stubSession()));
+ handleAppKey("t", emptyKey(), c);
+ expect(c.dispatch).not.toHaveBeenCalledWith({
+ type: "while_busy_mode_changed",
+ });
+ });
+});
+
+/**
+ * The Esc ladder, rung by rung. Each case is a state in which the
+ * operator pressing Esc means something specific and *not* "show me the
+ * menu" — the whole risk of giving the key a new meaning is that one of
+ * these quietly loses it.
+ */
+describe("escapeOpensMenu", () => {
+ function runScreen(patch: Partial = {}): TuiState {
+ return { ...createInitialTuiState(stubSession()), uiMode: "chat", ...patch };
+ }
+
+ it("opens on an idle, empty, unscrolled Run screen", () => {
+ expect(escapeOpensMenu(runScreen())).toBe(true);
+ });
+
+ it("declines while the menu is already up, so Esc cannot toggle it", () => {
+ expect(escapeOpensMenu(runScreen({ menuOpen: true }))).toBe(false);
+ // ...but the surface underneath is still an idle Run screen, which is
+ // what the hint strip reads and why the two predicates are separate.
+ expect(escapeHasNothingToCancel(runScreen({ menuOpen: true }))).toBe(true);
+ });
+
+ it("declines with a draft in the buffer — Esc clears it first", () => {
+ expect(escapeOpensMenu(runScreen({ inputValue: "half a thought" }))).toBe(
+ false,
+ );
+ });
+
+ it("declines while a turn is running — Esc is the abort", () => {
+ expect(escapeOpensMenu(runScreen({ status: "running" }))).toBe(false);
+ });
+
+ it("declines while the transcript is scrolled back — Esc snaps it down", () => {
+ expect(escapeOpensMenu(runScreen({ chatScrollOffset: 4 }))).toBe(false);
+ });
+
+ it("declines under every overlay that owns Esc in its own layer", () => {
+ const overlays: Partial[] = [
+ { slashPaletteOpen: true },
+ { sessionPickerOpen: true },
+ { themePickerOpen: true },
+ { pendingApproval: pendingRequest() },
+ { updatePrompt: { current: "0.3.0", latest: "0.4.0" } },
+ { updateStatus: "done" },
+ ];
+ for (const overlay of overlays) {
+ expect(escapeOpensMenu(runScreen(overlay))).toBe(false);
+ }
+ });
+
+ it("declines while the run-mode dial is up", () => {
+ const base = createInitialTuiState(stubSession());
+ const state = runScreen({
+ runModePanel: {
+ ...base.runModePanel,
+ picker: {
+ cursor: 0,
+ draftMode: "local",
+ draftCloudShare: 0,
+ digitBuffer: "",
+ },
+ },
+ });
+ expect(escapeOpensMenu(state)).toBe(false);
+ });
+
+ it("declines with the sidebar focused — Esc hands focus back first", () => {
+ expect(escapeOpensMenu(runScreen({ chatFocus: "sidebar" }))).toBe(false);
+ });
+
+ it("declines inside a debug panel — Esc is the way home to Run", () => {
+ expect(escapeOpensMenu(runScreen({ uiMode: "debug" }))).toBe(false);
+ });
+});
diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts
index 61d88729..fe6eae53 100644
--- a/src/tui/app-key-bindings.ts
+++ b/src/tui/app-key-bindings.ts
@@ -6,10 +6,21 @@ import {
type ApprovalRequest,
} from "../approval/approval-gate.js";
import { formatApprovalCategory } from "../approval/approval-level.js";
+import type { WhileBusySubmitMode } from "../config/index.js";
+import {
+ handleMenuKey,
+ isMenuLeaderKey,
+ isMenuOpenKey,
+ resolveLeaderChord,
+} from "./menu/menu-keys.js";
+import type { MenuNode } from "./menu/menu-registry.js";
import { cycleNavSlot, type NavSlot } from "./section.js";
import { selectSidebarTasks } from "./sidebar-tasks-selector.js";
import type { TuiAction } from "./tui-action.js";
-import type { TuiState } from "./tui-state.js";
+import { handleRunModePickerKey } from "./run-mode/run-mode-key-bindings.js";
+import { cycleRunMode } from "./run-mode/run-mode-nav.js";
+import type { RunModeName } from "../config/index.js";
+import { canAcceptMessage, type TuiState } from "./tui-state.js";
/**
* Number of **terminal rows** a single PageUp / PageDown keypress
@@ -34,7 +45,11 @@ export interface AppKeyCallbacks {
grant?: ApprovalGrantScope,
): void;
onAbort(): void;
+ /** Persist the Enter-while-busy mode after a Ctrl+T flip. */
+ onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void;
onQuit(): void;
+ /** Optional — Ctrl+R cycles Local → Cloud → Fusion. */
+ onRunModeChangeRequested?(mode: RunModeName, cloudShare?: number): void;
/** Optional — called when Enter is pressed on the focused sidebar row. */
onSessionSwitchRequested?(sessionId: string): void;
/**
@@ -52,6 +67,12 @@ export interface AppKeyCallbacks {
* key binding additionally dispatches `quit_requested` so Ink unmounts.
*/
onUpdateRestart?(): void;
+ /**
+ * Optional — Ctrl+N: open a new OS terminal window running a fresh
+ * `atomic-agent tui` in the same working directory. The handler owns
+ * the spawn and reports success / failure into the chat log.
+ */
+ onNewWindowRequested?(): void;
}
export interface AppKeyContext {
@@ -72,6 +93,11 @@ export interface AppKeyContext {
* the sidebar steals plain Tab.
*/
sidebarVisible: boolean;
+ /** True while a `ctrl+g` leader is waiting for its chord key. */
+ menuLeaderArmed: boolean;
+ setMenuLeaderArmed: (armed: boolean) => void;
+ /** Navigate to a place, or run an action's slash command. */
+ activateMenuNode: (node: MenuNode) => void;
}
/**
@@ -86,6 +112,13 @@ export function handleAppKey(
ctx: AppKeyContext,
): boolean {
const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx;
+ // The dial overlay opens over the chat surface, where the editor holds
+ // focus and would eat ←/→ and digits. Claim keys here — same place the
+ // approval and update prompts claim theirs — and swallow everything
+ // until it closes.
+ if (handleRunModePickerKey(input, key, { state, dispatch, callbacks })) {
+ return true;
+ }
if (state.pendingApproval) {
return handleApprovalKey(input, key, state.pendingApproval, ctx);
}
@@ -102,6 +135,27 @@ export function handleAppKey(
if (state.updatePrompt && handleUpdateKey(input, key, ctx)) {
return true;
}
+ // The menu and its leader sit above every panel guard on purpose: they are
+ // the way out of a panel, so a panel must never be able to swallow them.
+ if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) {
+ return true;
+ }
+ if (ctx.menuLeaderArmed) {
+ ctx.setMenuLeaderArmed(false);
+ const node = resolveLeaderChord(input, key);
+ if (node) ctx.activateMenuNode(node);
+ // An unclaimed chord is swallowed rather than passed on: a mistyped
+ // leader must not leak a letter into the prompt or fire a panel hotkey.
+ return true;
+ }
+ if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) {
+ ctx.setMenuLeaderArmed(true);
+ return true;
+ }
+ if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) {
+ dispatch({ type: "menu_opened" });
+ return true;
+ }
if (
ctx.sidebarVisible &&
state.uiMode === "chat" &&
@@ -109,6 +163,23 @@ export function handleAppKey(
) {
if (handleSidebarKey(input, key, ctx)) return true;
}
+ // Ctrl+T flips what Enter does while a turn is running (steer <-> queue).
+ // Alt/Shift/Ctrl+Enter are all already "insert newline" in
+ // `multi-line-editor.tsx`, so the mode cannot live on a Return
+ // modifier; an explicit, visible toggle is the honest alternative.
+ if (
+ key.ctrl &&
+ !key.shift &&
+ !key.meta &&
+ input === "t" &&
+ !state.pendingApproval
+ ) {
+ dispatch({ type: "while_busy_mode_changed" });
+ callbacks.onWhileBusyModePersistRequested?.(
+ state.whileBusyMode === "steer" ? "queue" : "steer",
+ );
+ return true;
+ }
if (key.ctrl && input === "c") {
if (ctrlCArmed) {
callbacks.onAbort();
@@ -124,6 +195,23 @@ export function handleAppKey(
return true;
}
setCtrlCArmed(false);
+ // Esc aborts a turn in flight — the binding the hint strip advertises
+ // for the whole time `status === "running"`. It has to be claimed here
+ // rather than in the editor's own Esc handler because the editor is
+ // `disabled` while a turn runs, which switches its `useInput` off and
+ // makes the abort branch over there unreachable. Overlays that own Esc
+ // themselves keep it; a pending approval already returned above.
+ if (
+ key.escape &&
+ state.status === "running" &&
+ !state.slashPaletteOpen &&
+ !state.themePickerOpen &&
+ !state.sessionPickerOpen
+ ) {
+ callbacks.onAbort();
+ dispatch({ type: "abort_requested" });
+ return true;
+ }
if (
state.uiMode === "chat" &&
!state.slashPaletteOpen &&
@@ -145,6 +233,108 @@ export function handleAppKey(
return true;
}
}
+ const debugTabBusy = isPanelModalOpen(state);
+ // Ctrl+N opens a second agent in a new OS terminal window. Guarded
+ // like Ctrl+B so it cannot fire from inside a modal, the slash
+ // palette, or a pending approval. The editor never claims Ctrl+N
+ // (it handles only ctrl+a/e/u/k/w/c), so no keystroke is stolen.
+ if (
+ !debugTabBusy &&
+ !state.slashPaletteOpen &&
+ !state.pendingApproval &&
+ key.ctrl &&
+ !key.shift &&
+ !key.meta &&
+ input === "n"
+ ) {
+ callbacks.onNewWindowRequested?.();
+ return true;
+ }
+ // Ctrl+R cycles the run mode from anywhere — a run mode is global, not
+ // a property of the chat surface. Ctrl+R is free: this file binds only
+ // Ctrl+C and Ctrl+B, and `MultiLineEditor` ignores every ctrl chord
+ // outside a/e/u/k/w/c/o.
+ if (
+ !debugTabBusy &&
+ !state.slashPaletteOpen &&
+ !state.pendingApproval &&
+ key.ctrl &&
+ !key.shift &&
+ !key.meta &&
+ input === "r"
+ ) {
+ callbacks.onRunModeChangeRequested?.(
+ cycleRunMode(state.runModePanel.effective, 1),
+ );
+ return true;
+ }
+ // Ctrl+B is the dedicated nav-cycle escape valve: it always advances
+ // one nav slot forward regardless of where focus currently is. This
+ // is the key power users press when they want to reach Observe /
+ // Manage without first clearing sidebar focus or re-pressing Tab to
+ // walk through both sidebar panes.
+ if (
+ !debugTabBusy &&
+ !state.slashPaletteOpen &&
+ !state.pendingApproval &&
+ key.ctrl &&
+ !key.shift &&
+ !key.meta &&
+ input === "b"
+ ) {
+ const next = cycleNavSlot(state, 1);
+ applyNavSlot(dispatch, next);
+ return true;
+ }
+ // Tab / Shift+Tab routing:
+ // - In chat mode with the sidebar visible, plain Tab cycles
+ // editor → sidebar(sessions) → sidebar(tasks) → editor so the
+ // operator can reach the rail with a single key. The
+ // in-sidebar transition (sessions ↔ tasks) is handled in
+ // `handleSidebarKey`; the path here covers the "land into the
+ // sidebar from the editor" leg.
+ // - Shift+Tab always cycles nav slots backward — same key surface
+ // as before so muscle memory survives.
+ // - Outside chat (debug mode) or with sidebar collapsed, plain
+ // Tab cycles nav slots forward as a fallback so power users on
+ // narrow terminals are not stranded.
+ if (
+ !debugTabBusy &&
+ !state.slashPaletteOpen &&
+ key.tab &&
+ !state.pendingApproval
+ ) {
+ if (key.shift) {
+ const prev = cycleNavSlot(state, -1);
+ applyNavSlot(dispatch, prev);
+ return true;
+ }
+ if (
+ ctx.sidebarVisible &&
+ state.uiMode === "chat" &&
+ state.chatFocus === "editor"
+ ) {
+ // Land in the sidebar at the section the operator left last.
+ dispatch({ type: "chat_focus_set", focus: "sidebar" });
+ return true;
+ }
+ const next = cycleNavSlot(state, 1);
+ applyNavSlot(dispatch, next);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * True while a debug panel has a modal, confirm or text-entry surface
+ * open — the state in which Tab / Ctrl+B / letter keys belong to that
+ * surface instead of the global nav cycler.
+ *
+ * Extracted from `handleAppKey` so the mouse layer can gate clicks on
+ * exactly the same condition the keyboard gates on: one predicate, no
+ * chance of the two drifting apart.
+ */
+export function isPanelModalOpen(state: TuiState): boolean {
const tasksTabBusy =
state.uiMode === "debug" &&
state.activeTab === "tasks" &&
@@ -205,7 +395,7 @@ export function handleAppKey(
// must not cycle the nav away mid-typing.
(state.llmPanel.mode === "cloud" &&
state.llmPanel.cloudModelFilterFocused));
- const debugTabBusy =
+ return (
tasksTabBusy ||
skillsTabBusy ||
memoryTabBusy ||
@@ -213,62 +403,8 @@ export function handleAppKey(
telegramTabBusy ||
mcpTabBusy ||
providersTabBusy ||
- llmTabBusy;
- // Ctrl+B is the dedicated nav-cycle escape valve: it always advances
- // one nav slot forward regardless of where focus currently is. This
- // is the key power users press when they want to reach Observe /
- // Manage without first clearing sidebar focus or re-pressing Tab to
- // walk through both sidebar panes.
- if (
- !debugTabBusy &&
- !state.slashPaletteOpen &&
- !state.pendingApproval &&
- key.ctrl &&
- !key.shift &&
- !key.meta &&
- input === "b"
- ) {
- const next = cycleNavSlot(state, 1);
- applyNavSlot(dispatch, next);
- return true;
- }
- // Tab / Shift+Tab routing:
- // - In chat mode with the sidebar visible, plain Tab cycles
- // editor → sidebar(sessions) → sidebar(tasks) → editor so the
- // operator can reach the rail with a single key. The
- // in-sidebar transition (sessions ↔ tasks) is handled in
- // `handleSidebarKey`; the path here covers the "land into the
- // sidebar from the editor" leg.
- // - Shift+Tab always cycles nav slots backward — same key surface
- // as before so muscle memory survives.
- // - Outside chat (debug mode) or with sidebar collapsed, plain
- // Tab cycles nav slots forward as a fallback so power users on
- // narrow terminals are not stranded.
- if (
- !debugTabBusy &&
- !state.slashPaletteOpen &&
- key.tab &&
- !state.pendingApproval
- ) {
- if (key.shift) {
- const prev = cycleNavSlot(state, -1);
- applyNavSlot(dispatch, prev);
- return true;
- }
- if (
- ctx.sidebarVisible &&
- state.uiMode === "chat" &&
- state.chatFocus === "editor"
- ) {
- // Land in the sidebar at the section the operator left last.
- dispatch({ type: "chat_focus_set", focus: "sidebar" });
- return true;
- }
- const next = cycleNavSlot(state, 1);
- applyNavSlot(dispatch, next);
- return true;
- }
- return false;
+ llmTabBusy
+ );
}
/**
@@ -299,6 +435,64 @@ export function handlePanelEscape(
return true;
}
+/**
+ * True when a press of Esc on the Run screen would find nothing to
+ * cancel — the state in which it opens the operator menu instead.
+ *
+ * Esc is the key an operator presses when they want *out* of whatever
+ * they are in, and four PRs went into making it mean exactly "cancel /
+ * back one level" everywhere (it used to quit the agent on the first
+ * press, unannounced). Opening the menu is the natural bottom of that
+ * ladder: once there is nothing left to back out of, "get me out of
+ * here" becomes "show me where I can go". It is additive — every rung
+ * above it keeps the key.
+ *
+ * The rungs, in the order Esc already resolves them, each of which is a
+ * cancel the operator meant and must not be swapped for a menu:
+ *
+ * 1. an open overlay — approval prompt, update offer, run-mode dial,
+ * slash palette, session picker, theme picker — closes. These own
+ * Esc in their own layer; the dial and the approval prompt also
+ * take focus off the editor, which is the only way to keep a key
+ * out of the prompt at all.
+ * 2. a focused sidebar hands focus back to the editor.
+ * 3. a debug panel goes home to Run.
+ * 4. a transcript scrolled up snaps back to the latest reply.
+ * 5. a running turn aborts.
+ * 6. a half-typed draft is cleared.
+ *
+ * Only when all six decline is the operator pressing Esc against an
+ * idle, empty, unscrolled Run screen — a press that used to do nothing
+ * at all, which is the whole reason it is free to mean something now.
+ *
+ * Says nothing about the menu already being open; that is
+ * {@link escapeOpensMenu}'s business. The hint strip wants this one: it
+ * describes the chat surface, which does not stop being an idle Run
+ * screen just because a popup is floating over it.
+ */
+export function escapeHasNothingToCancel(state: TuiState): boolean {
+ if (state.pendingApproval) return false;
+ if (state.updatePrompt || state.updateStatus === "done") return false;
+ if (state.runModePanel.picker !== null) return false;
+ if (state.slashPaletteOpen) return false;
+ if (state.sessionPickerOpen || state.themePickerOpen) return false;
+ if (state.chatFocus !== "editor") return false;
+ if (state.uiMode !== "chat") return false;
+ if (state.chatScrollOffset > 0) return false;
+ if (!canAcceptMessage(state)) return false;
+ return state.inputValue.length === 0;
+}
+
+/**
+ * The binding itself: {@link escapeHasNothingToCancel} plus the one rung
+ * that only the key cares about — an open menu, which Esc closes
+ * (`handleMenuKey`). Without this the same press would close and reopen
+ * the popup and Esc would look inert.
+ */
+export function escapeOpensMenu(state: TuiState): boolean {
+ return !state.menuOpen && escapeHasNothingToCancel(state);
+}
+
function shouldTreatArrowAsChatScroll(
input: string,
key: Key,
@@ -389,7 +583,12 @@ function handleSidebarKey(
return false;
}
-function applyNavSlot(
+/**
+ * Apply a nav slot — the one place that knows "run" means chat mode and
+ * every other slot is a debug tab. Exported so a click on a status-bar
+ * pill lands the operator in exactly the same state Tab would.
+ */
+export function applyNavSlot(
dispatch: (action: TuiAction) => void,
slot: NavSlot,
): void {
@@ -435,6 +634,42 @@ function grantConfirmation(
return `granted: ${formatApprovalCategory(request.category)} for this session`;
}
+/**
+ * Resolve a pending approval: tell the runtime, then fold the decision
+ * into the reducer (and, for a grant, print the confirmation line).
+ * Shared by the key handler and the approval modal's clickable
+ * buttons — one implementation, so the two can never disagree about
+ * what "approve" means.
+ */
+export function decideApproval(
+ request: ApprovalRequest,
+ approved: boolean,
+ ctx: {
+ dispatch: (action: TuiAction) => void;
+ callbacks: Pick;
+ },
+ grant?: ApprovalGrantScope,
+): void {
+ // Call through without a trailing `undefined`: the callback's arity
+ // is observable (tests spy on it, hosts may inspect `arguments`).
+ if (grant) {
+ ctx.callbacks.onApprovalDecision(request.approvalId, approved, grant);
+ } else {
+ ctx.callbacks.onApprovalDecision(request.approvalId, approved);
+ }
+ ctx.dispatch({
+ type: "approval_resolved",
+ approvalId: request.approvalId,
+ approved,
+ });
+ if (approved && grant) {
+ ctx.dispatch({
+ type: "system_message",
+ text: grantConfirmation(request, grant),
+ });
+ }
+}
+
function handleApprovalKey(
input: string,
key: Key,
@@ -443,57 +678,24 @@ function handleApprovalKey(
): boolean {
const lower = input.toLowerCase();
if (lower === "y") {
- ctx.callbacks.onApprovalDecision(request.approvalId, true);
- ctx.dispatch({
- type: "approval_resolved",
- approvalId: request.approvalId,
- approved: true,
- });
+ decideApproval(request, true, ctx);
return true;
}
if (lower === "s" && canGrantCategory(request)) {
- ctx.callbacks.onApprovalDecision(request.approvalId, true, "category");
- ctx.dispatch({
- type: "approval_resolved",
- approvalId: request.approvalId,
- approved: true,
- });
- ctx.dispatch({
- type: "system_message",
- text: grantConfirmation(request, "category"),
- });
+ decideApproval(request, true, ctx, "category");
return true;
}
if (lower === "a" && canGrantShape(request)) {
- ctx.callbacks.onApprovalDecision(request.approvalId, true, "shape");
- ctx.dispatch({
- type: "approval_resolved",
- approvalId: request.approvalId,
- approved: true,
- });
- ctx.dispatch({
- type: "system_message",
- text: grantConfirmation(request, "shape"),
- });
+ decideApproval(request, true, ctx, "shape");
return true;
}
if (lower === "n") {
- ctx.callbacks.onApprovalDecision(request.approvalId, false);
- ctx.dispatch({
- type: "approval_resolved",
- approvalId: request.approvalId,
- approved: false,
- });
+ decideApproval(request, false, ctx);
return true;
}
if (key.escape || (key.ctrl && input === "c")) {
- ctx.callbacks.onApprovalDecision(request.approvalId, false);
+ decideApproval(request, false, ctx);
ctx.callbacks.onAbort();
- ctx.dispatch({
- type: "approval_resolved",
- approvalId: request.approvalId,
- approved: false,
- });
ctx.dispatch({ type: "abort_requested" });
return true;
}
diff --git a/src/tui/approval-modal.tsx b/src/tui/approval-modal.tsx
index f12896fe..7899650e 100644
--- a/src/tui/approval-modal.tsx
+++ b/src/tui/approval-modal.tsx
@@ -1,11 +1,16 @@
import { Box, Text } from "ink";
-import type { ReactElement } from "react";
+import type { ReactElement, ReactNode } from "react";
import {
canGrantCategory,
canGrantShape,
+ type ApprovalGrantScope,
type ApprovalRequest,
} from "../approval/approval-gate.js";
import { formatApprovalCategory } from "../approval/approval-level.js";
+import { decideApproval } from "./app-key-bindings.js";
+import { MouseTarget, useMouseCommands } from "./mouse/mouse-context.js";
+import { isPrimaryPress } from "./mouse/mouse-event.js";
+import { MOUSE_LAYER_MODAL } from "./mouse/mouse-registry.js";
interface ApprovalModalProps {
request: ApprovalRequest;
@@ -14,7 +19,9 @@ interface ApprovalModalProps {
/**
* Displayed as an in-place banner rather than a floating window to keep
* rendering predictable across terminals. Hotkey handling lives at the
- * app root (`tui-app.tsx`) via ink's `useInput`.
+ * app root (`tui-app.tsx`) via ink's `useInput`; the `[y]` / `[s]` /
+ * `[a]` / `[n]` markers are also click targets, routed through the same
+ * `decideApproval` the keys use.
*/
export function ApprovalModal({ request }: ApprovalModalProps): ReactElement {
const categoryLabel = formatApprovalCategory(request.category);
@@ -59,22 +66,39 @@ export function ApprovalModal({ request }: ApprovalModalProps): ReactElement {
) : null}
-
-
- [y] approve{" "}
- {grantCategory ? (
- <>
- [s] allow {categoryLabel} this session{" "}
- >
- ) : null}
- {grantShape ? (
- <>
- [a] allow all {request.commandShape}{" "}
- commands this session{" "}
- >
- ) : null}
- [n] deny [esc] abort run
-
+
+
+
+ [y]
+
+ approve
+
+ {grantCategory ? (
+
+
+ [s]
+
+ allow {categoryLabel} this session
+
+ ) : null}
+ {grantShape ? (
+
+
+ [a]
+
+ allow all {request.commandShape} commands this session
+
+ ) : null}
+
+
+ [n]
+
+ deny
+
+
+ [esc]
+ abort run
+
{footerHint(grantCategory)}
@@ -92,3 +116,38 @@ function clip(value: string, limit: number): string {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}…`;
}
+
+interface ApprovalButtonProps {
+ request: ApprovalRequest;
+ approved: boolean;
+ grant?: ApprovalGrantScope;
+ children: ReactNode;
+}
+
+/**
+ * A clickable decision marker. Renders as plain text when the mouse
+ * layer is absent, so the modal looks identical with `--no-mouse` and
+ * under the test renderer.
+ */
+function ApprovalButton({
+ request,
+ approved,
+ grant,
+ children,
+}: ApprovalButtonProps): ReactElement {
+ const mouse = useMouseCommands();
+ if (!mouse) return <>{children}>;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ decideApproval(request, approved, mouse, grant);
+ return true;
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/src/tui/build-terminal-launch.test.ts b/src/tui/build-terminal-launch.test.ts
new file mode 100644
index 00000000..b30032bf
--- /dev/null
+++ b/src/tui/build-terminal-launch.test.ts
@@ -0,0 +1,214 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ agentArgv,
+ buildTerminalLaunch,
+ type TerminalLaunchInput,
+} from "./build-terminal-launch.js";
+
+function input(overrides: Partial = {}): TerminalLaunchInput {
+ return {
+ platform: "darwin",
+ execPath: "/usr/local/bin/node",
+ argv: ["/usr/local/bin/node", "/opt/atomic/dist/cli/index.js", "tui"],
+ isSea: false,
+ cwd: "/home/val/work",
+ env: {},
+ hasBinary: () => false,
+ ...overrides,
+ };
+}
+
+describe("agentArgv", () => {
+ it("keeps the script path under plain node", () => {
+ expect(agentArgv(input())).toEqual([
+ "/usr/local/bin/node",
+ "/opt/atomic/dist/cli/index.js",
+ "tui",
+ ]);
+ });
+
+ it("drops the script slot for a SEA binary", () => {
+ // A SEA binary is its own entry point; re-injecting argv[1] makes the
+ // child read the invoke path as a command name ("unknown command").
+ expect(
+ agentArgv(
+ input({
+ isSea: true,
+ execPath: "/usr/local/bin/atomic-agent",
+ argv: ["/usr/local/bin/atomic-agent", "/usr/local/bin/atomic-agent"],
+ }),
+ ),
+ ).toEqual(["/usr/local/bin/atomic-agent", "tui"]);
+ });
+
+ it("always asks for the tui explicitly", () => {
+ // The parent may have been started as `atomic-agent` with no args.
+ expect(agentArgv(input({ argv: ["/usr/local/bin/node", "/opt/a.js"] }))).toContain(
+ "tui",
+ );
+ });
+});
+
+describe("buildTerminalLaunch — macOS", () => {
+ it("drives Terminal.app through osascript, cd'ing into the working dir", () => {
+ const launch = buildTerminalLaunch(input());
+ expect(launch).not.toBeNull();
+ expect(launch?.cmd).toBe("osascript");
+ expect(launch?.label).toBe("Terminal");
+ const script = launch?.args[1] ?? "";
+ expect(script).toContain('tell application "Terminal" to do script');
+ expect(script).toContain("cd '/home/val/work'");
+ expect(script).toContain("/opt/atomic/dist/cli/index.js");
+ expect(script).toContain("tui");
+ expect(launch?.args[3]).toContain("activate");
+ });
+
+ it("uses iTerm when the operator already lives in iTerm", () => {
+ const launch = buildTerminalLaunch(
+ input({ env: { TERM_PROGRAM: "iTerm.app" } }),
+ );
+ expect(launch?.label).toBe("iTerm");
+ expect(launch?.args[1]).toContain('tell application "iTerm"');
+ });
+
+ it("carries a non-default state dir into the new window", () => {
+ // The spawned terminal starts a login shell and inherits nothing —
+ // without this the second window would use a different state dir.
+ const launch = buildTerminalLaunch(
+ input({ env: { ATOMIC_AGENT_STATE_DIR: "/tmp/state dir" } }),
+ );
+ expect(launch?.args[1]).toContain(
+ "ATOMIC_AGENT_STATE_DIR='/tmp/state dir'",
+ );
+ });
+
+ it("carries the asset dir overrides alongside the state dir", () => {
+ // Same login-shell problem: if the parent was pointed at a
+ // non-default `grammars/` or `starter-skills/` and the child is not
+ // told, the new window quietly resolves different assets.
+ const launch = buildTerminalLaunch(
+ input({
+ env: {
+ ATOMIC_AGENT_GRAMMARS_DIR: "/opt/atomic/grammars",
+ ATOMIC_AGENT_STARTER_SKILLS_DIR: "/opt/atomic/starter skills",
+ },
+ }),
+ );
+ expect(launch?.args[1]).toContain(
+ "ATOMIC_AGENT_GRAMMARS_DIR='/opt/atomic/grammars'",
+ );
+ expect(launch?.args[1]).toContain(
+ "ATOMIC_AGENT_STARTER_SKILLS_DIR='/opt/atomic/starter skills'",
+ );
+ });
+
+ it("emits no assignments when nothing is overridden", () => {
+ // The common case must stay a bare `cd … && node …` line.
+ const launch = buildTerminalLaunch(input());
+ expect(launch?.args[1]).not.toContain("ATOMIC_AGENT_");
+ });
+
+ it("escapes quotes in paths for both the shell and AppleScript layers", () => {
+ const launch = buildTerminalLaunch(input({ cwd: `/home/o'brien/work` }));
+ const script = launch?.args[1] ?? "";
+ // POSIX single-quote escaping, with its backslash doubled by the
+ // AppleScript escaper so the shell still sees exactly one.
+ expect(script).toContain(`cd '/home/o'\\\\''brien/work'`);
+ // And nothing unescaped can close the AppleScript string literal.
+ const body = script.slice(script.indexOf("do script ") + "do script ".length);
+ expect(body.slice(1, -1)).not.toMatch(/(^|[^\\])"/);
+ });
+});
+
+describe("buildTerminalLaunch — Linux", () => {
+ it("returns null when no emulator is installed", () => {
+ // Headless box: report it, never throw into the render loop.
+ expect(buildTerminalLaunch(input({ platform: "linux" }))).toBeNull();
+ });
+
+ it("prefers gnome-terminal's `--` argv shape", () => {
+ const launch = buildTerminalLaunch(
+ input({ platform: "linux", hasBinary: (n) => n === "gnome-terminal" }),
+ );
+ expect(launch?.cmd).toBe("gnome-terminal");
+ expect(launch?.args[0]).toBe("--");
+ expect(launch?.args[1]).toBe("sh");
+ });
+
+ it("falls back to xterm when nothing better exists", () => {
+ const launch = buildTerminalLaunch(
+ input({ platform: "linux", hasBinary: (n) => n === "xterm" }),
+ );
+ expect(launch?.cmd).toBe("xterm");
+ expect(launch?.args[0]).toBe("-e");
+ });
+
+ it("honours $ATOMIC_AGENT_TERMINAL over the probe order", () => {
+ const launch = buildTerminalLaunch(
+ input({
+ platform: "linux",
+ env: { ATOMIC_AGENT_TERMINAL: "foot", TERMINAL: "xterm" },
+ hasBinary: () => true,
+ }),
+ );
+ expect(launch?.cmd).toBe("foot");
+ expect(launch?.args).toEqual(["-e", "sh", "-c", expect.any(String)]);
+ });
+
+ it("keeps the window alive after the agent exits", () => {
+ // `-e` closes the window the moment the command returns, which would
+ // eat a startup error before anyone could read it.
+ const launch = buildTerminalLaunch(
+ input({ platform: "linux", hasBinary: (n) => n === "xterm" }),
+ );
+ expect(launch?.args.at(-1)).toContain('exec "${SHELL:-sh}"');
+ });
+});
+
+describe("buildTerminalLaunch — Windows", () => {
+ it("opens a new Windows Terminal window when wt.exe is present", () => {
+ const launch = buildTerminalLaunch(
+ input({
+ platform: "win32",
+ hasBinary: (n) => n === "wt.exe",
+ cwd: "C:\\work",
+ }),
+ );
+ expect(launch?.cmd).toBe("wt.exe");
+ expect(launch?.args.slice(0, 5)).toEqual(["-w", "-1", "nt", "-d", "C:\\work"]);
+ expect(launch?.args).toContain("tui");
+ });
+
+ it("falls back to a `start`-ed cmd.exe that stays open", () => {
+ const launch = buildTerminalLaunch(
+ input({ platform: "win32", cwd: "C:\\work" }),
+ );
+ expect(launch?.cmd).toBe("cmd.exe");
+ expect(launch?.args.slice(0, 5)).toEqual([
+ "/c",
+ "start",
+ "atomic-agent",
+ "cmd",
+ "/k",
+ ]);
+ });
+
+ it("forwards the state and asset dirs through `set` on the cmd.exe path", () => {
+ const launch = buildTerminalLaunch(
+ input({
+ platform: "win32",
+ cwd: "C:\\work",
+ env: {
+ ATOMIC_AGENT_STATE_DIR: "C:\\state",
+ ATOMIC_AGENT_GRAMMARS_DIR: "C:\\opt\\grammars",
+ },
+ }),
+ );
+ const command = String(launch?.args.at(-1));
+ expect(command).toContain(`set "ATOMIC_AGENT_STATE_DIR=C:\\state" && `);
+ expect(command).toContain(
+ `set "ATOMIC_AGENT_GRAMMARS_DIR=C:\\opt\\grammars" && `,
+ );
+ });
+});
diff --git a/src/tui/build-terminal-launch.ts b/src/tui/build-terminal-launch.ts
new file mode 100644
index 00000000..a4673774
--- /dev/null
+++ b/src/tui/build-terminal-launch.ts
@@ -0,0 +1,221 @@
+/**
+ * Resolves "open a new OS terminal window running atomic-agent" into a
+ * concrete `{cmd, args}` for the current platform. Pure on purpose: the
+ * PATH probe and the spawn both arrive as inputs, so every branch is
+ * unit-reachable without touching the machine.
+ */
+
+export interface TerminalLaunch {
+ readonly cmd: string;
+ readonly args: readonly string[];
+ /** Human name of the terminal being opened, for the chat confirmation. */
+ readonly label: string;
+}
+
+export interface TerminalLaunchInput {
+ readonly platform: NodeJS.Platform;
+ /** `process.execPath` of the running agent. */
+ readonly execPath: string;
+ /** `process.argv` of the running agent. */
+ readonly argv: readonly string[];
+ /** `isSea()` — a SEA build has no script path in argv. */
+ readonly isSea: boolean;
+ /** Working directory the new window should start in. */
+ readonly cwd: string;
+ readonly env: Readonly>;
+ /** `true` when `name` resolves to an executable on PATH. */
+ readonly hasBinary: (name: string) => boolean;
+}
+
+interface LinuxTerminal {
+ readonly bin: string;
+ readonly label: string;
+ /** Wraps a `sh -c`-able command line into this emulator's argv shape. */
+ readonly args: (command: string) => readonly string[];
+}
+
+/**
+ * Probed in order. `-e` is the near-universal spelling; gnome-terminal
+ * deprecated it in favour of `--`, and kitty takes the command bare.
+ */
+const LINUX_TERMINALS: readonly LinuxTerminal[] = [
+ {
+ bin: "gnome-terminal",
+ label: "gnome-terminal",
+ args: (command) => ["--", "sh", "-c", command],
+ },
+ {
+ bin: "konsole",
+ label: "konsole",
+ args: (command) => ["-e", "sh", "-c", command],
+ },
+ {
+ bin: "xfce4-terminal",
+ label: "xfce4-terminal",
+ args: (command) => ["-e", `sh -c ${shellQuote(command)}`],
+ },
+ { bin: "kitty", label: "kitty", args: (command) => ["sh", "-c", command] },
+ {
+ bin: "alacritty",
+ label: "alacritty",
+ args: (command) => ["-e", "sh", "-c", command],
+ },
+ {
+ bin: "wezterm",
+ label: "wezterm",
+ args: (command) => ["start", "--", "sh", "-c", command],
+ },
+ {
+ bin: "x-terminal-emulator",
+ label: "x-terminal-emulator",
+ args: (command) => ["-e", "sh", "-c", command],
+ },
+ { bin: "xterm", label: "xterm", args: (command) => ["-e", "sh", "-c", command] },
+];
+
+/**
+ * Returns `null` — never throws — when the platform offers nothing we
+ * know how to drive (a headless Linux box with no emulator installed is
+ * the realistic case). The caller turns that into one warn line.
+ */
+export function buildTerminalLaunch(
+ input: TerminalLaunchInput,
+): TerminalLaunch | null {
+ switch (input.platform) {
+ case "darwin":
+ return darwinLaunch(input);
+ case "win32":
+ return win32Launch(input);
+ default:
+ return posixLaunch(input);
+ }
+}
+
+/**
+ * The argv the child needs to re-enter the TUI. Mirrors the SEA
+ * reasoning in `tui-command.ts`'s self-update relaunch: a SEA binary is
+ * its own entry point, plain node needs the script path back. `tui` is
+ * always explicit so the new window lands in the UI regardless of how
+ * the parent process was invoked.
+ */
+export function agentArgv(input: TerminalLaunchInput): readonly string[] {
+ const scriptPath = input.isSea ? undefined : input.argv[1];
+ return scriptPath
+ ? [input.execPath, scriptPath, "tui"]
+ : [input.execPath, "tui"];
+}
+
+/**
+ * Env vars the child must be told about explicitly, in the order they
+ * are emitted. A freshly spawned terminal starts a login shell and does
+ * **not** inherit our environment, so anything that steers where the
+ * agent reads its state or its packaged assets has to travel inside the
+ * command line — otherwise the second window silently talks to a
+ * different `~/.atomic-agent`, or resolves `grammars/` and
+ * `starter-skills/` from its own cwd and disagrees with the parent about
+ * which copy is authoritative.
+ */
+const FORWARDED_ENV: readonly string[] = [
+ "ATOMIC_AGENT_STATE_DIR",
+ "ATOMIC_AGENT_GRAMMARS_DIR",
+ "ATOMIC_AGENT_STARTER_SKILLS_DIR",
+];
+
+/** `NAME='value' ` for every forwarded var that is actually set. */
+function forwardedEnv(
+ input: TerminalLaunchInput,
+ format: (name: string, value: string) => string,
+): string {
+ return FORWARDED_ENV.map((name) => {
+ const value = input.env[name];
+ return value ? format(name, value) : "";
+ }).join("");
+}
+
+function posixCommandLine(input: TerminalLaunchInput): string {
+ const prefix = forwardedEnv(
+ input,
+ (name, value) => `${name}=${shellQuote(value)} `,
+ );
+ const agent = agentArgv(input).map(shellQuote).join(" ");
+ return `cd ${shellQuote(input.cwd)} && ${prefix}${agent}`;
+}
+
+function darwinLaunch(input: TerminalLaunchInput): TerminalLaunch {
+ // Terminal.app is always installed; iTerm only when the operator is
+ // already living in it. Both keep the shell alive after the agent
+ // exits, so errors stay on screen.
+ const app = input.env.TERM_PROGRAM === "iTerm.app" ? "iTerm" : "Terminal";
+ const script = escapeAppleScript(posixCommandLine(input));
+ return {
+ cmd: "osascript",
+ args: [
+ "-e",
+ `tell application "${app}" to do script "${script}"`,
+ "-e",
+ `tell application "${app}" to activate`,
+ ],
+ label: app === "iTerm" ? "iTerm" : "Terminal",
+ };
+}
+
+function posixLaunch(input: TerminalLaunchInput): TerminalLaunch | null {
+ // `-e` closes the window the moment the agent exits, which would eat
+ // a startup error before anyone could read it; drop into a shell in
+ // the same directory instead.
+ const command = `${posixCommandLine(input)}; exec "\${SHELL:-sh}"`;
+ const preferred =
+ input.env.ATOMIC_AGENT_TERMINAL ?? input.env.TERMINAL ?? null;
+ if (preferred && input.hasBinary(preferred)) {
+ const known = LINUX_TERMINALS.find((t) => t.bin === preferred);
+ return {
+ cmd: preferred,
+ args: known ? known.args(command) : ["-e", "sh", "-c", command],
+ label: preferred,
+ };
+ }
+ const found = LINUX_TERMINALS.find((t) => input.hasBinary(t.bin));
+ if (!found) return null;
+ return { cmd: found.bin, args: found.args(command), label: found.label };
+}
+
+function win32Launch(input: TerminalLaunchInput): TerminalLaunch {
+ const agent = agentArgv(input);
+ if (input.hasBinary("wt.exe")) {
+ // `-w -1` opens a new window rather than a tab in the existing one.
+ return {
+ cmd: "wt.exe",
+ args: ["-w", "-1", "nt", "-d", input.cwd, ...agent],
+ label: "Windows Terminal",
+ };
+ }
+ const prefix = forwardedEnv(
+ input,
+ (name, value) => `set "${name}=${value}" && `,
+ );
+ const command = `${prefix}${agent.map(cmdQuote).join(" ")}`;
+ return {
+ cmd: "cmd.exe",
+ // `/k` keeps the console open after the agent exits, matching the
+ // POSIX branches. The empty title argument is required by `start`.
+ args: ["/c", "start", "atomic-agent", "cmd", "/k", command],
+ label: "Command Prompt",
+ };
+}
+
+/** POSIX single-quote quoting — safe for every byte except NUL. */
+function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, `'\\''`)}'`;
+}
+
+function cmdQuote(value: string): string {
+ return /[\s&|<>^]/.test(value) ? `"${value}"` : value;
+}
+
+/**
+ * AppleScript string literal escaping. Backslash first, then the quote —
+ * reversing the order would double-escape the backslashes we just added.
+ */
+function escapeAppleScript(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
diff --git a/src/tui/chat-loop-reducer.test.ts b/src/tui/chat-loop-reducer.test.ts
index a645c57c..3ebbe33f 100644
--- a/src/tui/chat-loop-reducer.test.ts
+++ b/src/tui/chat-loop-reducer.test.ts
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { reduceTuiState } from "./agent-event-reducer.js";
import { apply, fakeSession } from "./test-fixtures.js";
import type { TuiAction } from "./tui-action.js";
-import { canAcceptMessage, createInitialTuiState } from "./tui-state.js";
+import {
+ canAcceptMessage,
+ canTypeMessage,
+ createInitialTuiState,
+} from "./tui-state.js";
describe("chat loop", () => {
it("should update inputValue on input_changed", () => {
@@ -303,3 +307,37 @@ describe("chat loop", () => {
expect(next.runHistory[0]?.durationMs).toBeGreaterThan(0);
});
});
+
+describe("queued submissions", () => {
+ it("may be typed while a turn is running", () => {
+ const running = reduceTuiState(createInitialTuiState(fakeSession()), {
+ type: "message_submitted",
+ });
+ expect(canAcceptMessage(running)).toBe(false);
+ expect(canTypeMessage(running)).toBe(true);
+ });
+
+ it("does not wipe the live turn's feed the way message_submitted does", () => {
+ // This is the regression the separate action exists for: reusing
+ // `message_submitted` for a mid-run send called startNewRun and
+ // blanked the screen the operator was reading.
+ const initial = createInitialTuiState(fakeSession());
+ const running = apply(initial, [
+ { type: "message_submitted" },
+ { type: "agent_event", event: { type: "step_started", stepIndex: 0 } },
+ { type: "assistant_delta", text: "partial answer" },
+ ]);
+ expect(running.feed.length).toBeGreaterThan(0);
+
+ const afterQueue = reduceTuiState(running, {
+ type: "message_queued",
+ text: "one more thing",
+ } as TuiAction);
+
+ expect(afterQueue.feed).toEqual(running.feed);
+ expect(afterQueue.streamingAssistantText).toBe("partial answer");
+ expect(afterQueue.status).toBe("running");
+ expect(afterQueue.queuedMessages).toEqual(["one more thing"]);
+ expect(afterQueue.inputValue).toBe("");
+ });
+});
diff --git a/src/tui/chat-orchestrator.test.ts b/src/tui/chat-orchestrator.test.ts
new file mode 100644
index 00000000..b0de4028
--- /dev/null
+++ b/src/tui/chat-orchestrator.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createEmptySessionState } from "../session/session-state.js";
+import type { AgentRuntime } from "../runtime/bootstrap.js";
+import { ChatOrchestrator } from "./chat-orchestrator.js";
+import { makeTuiEventBus } from "./make-event-bus.js";
+import type { TuiAction } from "./tui-action.js";
+
+interface Deferred {
+ promise: Promise<{ session: ReturnType; reason: string; stepCount: number }>;
+ resolve: () => void;
+}
+
+function session(id = "s1") {
+ return createEmptySessionState({ id, workingDir: "/tmp" });
+}
+
+function deferred(id: string): Deferred {
+ let resolve!: () => void;
+ const promise = new Promise<{
+ session: ReturnType;
+ reason: string;
+ stepCount: number;
+ }>((res) => {
+ resolve = () => res({ session: session(id), reason: "reply", stepCount: 1 });
+ });
+ return { promise, resolve };
+}
+
+/**
+ * Minimal `AgentRuntime` stand-in. Every sub-orchestrator the
+ * `ChatOrchestrator` constructor builds only stores references and
+ * subscribes to the bus, so nothing here needs to do I/O.
+ */
+function stubRuntime(runTurn: (text: string) => Promise): AgentRuntime {
+ return {
+ createSession: () => session(),
+ runTurn: (_s: unknown, text: string) => runTurn(text),
+ sessionStore: { listRecent: () => [], load: () => null },
+ approvals: { clearSessionGrants: () => undefined },
+ config: { update: { checkOnStartup: false, repo: "x/y" }, tracing: { trace: { dir: "/tmp", enabled: false } } },
+ profileStore: { list: () => [] },
+ skillCatalog: [],
+ } as unknown as AgentRuntime;
+}
+
+describe("ChatOrchestrator message queue", () => {
+ it("runs the first message and parks the second until the first settles", async () => {
+ const first = deferred("s1");
+ const second = deferred("s1");
+ const seen: string[] = [];
+ const runTurn = vi.fn((text: string) => {
+ seen.push(text);
+ return (seen.length === 1 ? first : second).promise;
+ });
+ const bus = makeTuiEventBus();
+ const actions: TuiAction[] = [];
+ bus.subscribe((a) => actions.push(a));
+ const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, {
+ maxSteps: 5,
+ llamaUrl: "http://127.0.0.1:8080",
+ });
+
+ orchestrator.sendMessage("first");
+ orchestrator.sendMessage("second");
+ expect(seen).toEqual(["first"]);
+ expect(queueSnapshots(actions).at(-1)).toEqual(["second"]);
+
+ first.resolve();
+ await first.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(seen).toEqual(["first", "second"]);
+ expect(queueSnapshots(actions).at(-1)).toEqual([]);
+ second.resolve();
+ await second.promise;
+ });
+
+ it("clearQueue drops parked messages without touching the running turn", async () => {
+ const first = deferred("s1");
+ const runTurn = vi.fn(() => first.promise);
+ const bus = makeTuiEventBus();
+ const actions: TuiAction[] = [];
+ bus.subscribe((a) => actions.push(a));
+ const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, {
+ maxSteps: 5,
+ llamaUrl: "http://127.0.0.1:8080",
+ });
+
+ orchestrator.sendMessage("running");
+ orchestrator.sendMessage("parked-a");
+ orchestrator.sendMessage("parked-b");
+ expect(queueSnapshots(actions).at(-1)).toEqual(["parked-a", "parked-b"]);
+
+ orchestrator.clearQueue();
+ expect(queueSnapshots(actions).at(-1)).toEqual([]);
+ expect(runTurn).toHaveBeenCalledTimes(1);
+
+ first.resolve();
+ await first.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+ // Nothing left to drain — the cleared queue really is empty.
+ expect(runTurn).toHaveBeenCalledTimes(1);
+ });
+
+ it("clearQueue on an empty queue does not spam the bus", () => {
+ const bus = makeTuiEventBus();
+ const actions: TuiAction[] = [];
+ bus.subscribe((a) => actions.push(a));
+ const orchestrator = new ChatOrchestrator(
+ stubRuntime(() => new Promise(() => undefined)),
+ bus,
+ { maxSteps: 5, llamaUrl: "http://127.0.0.1:8080" },
+ );
+ orchestrator.clearQueue();
+ expect(queueSnapshots(actions)).toHaveLength(0);
+ });
+});
+
+function queueSnapshots(actions: readonly TuiAction[]): readonly string[][] {
+ return actions
+ .filter((a): a is Extract =>
+ a.type === "queue_changed",
+ )
+ .map((a) => [...a.queued]);
+}
diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts
index 959af6fb..54543ca5 100644
--- a/src/tui/chat-orchestrator.ts
+++ b/src/tui/chat-orchestrator.ts
@@ -22,6 +22,7 @@ import { ProvidersOrchestrator } from "./providers/providers-orchestrator.js";
import { FallbackOrchestrator } from "./llm-panel/fallback/fallback-orchestrator.js";
import { TuiTelegramOrchestrator } from "./telegram/tui-telegram-orchestrator.js";
import { PrivacyOrchestrator } from "./privacy/privacy-orchestrator.js";
+import { RunModeOrchestrator } from "./run-mode/run-mode-orchestrator.js";
import type { TuiEventBus } from "./tui-app.js";
import { formatAgentErrorForChat } from "./format-agent-error-for-chat.js";
import { turnsToMessages } from "./turns-to-messages.js";
@@ -93,6 +94,7 @@ export class ChatOrchestrator {
public readonly llmHealth: LlmHealthPoller;
public readonly telegram: TuiTelegramOrchestrator;
public readonly privacy: PrivacyOrchestrator;
+ public readonly runMode: RunModeOrchestrator;
constructor(
private readonly runtime: AgentRuntime,
@@ -122,6 +124,7 @@ export class ChatOrchestrator {
});
this.telegram = new TuiTelegramOrchestrator(runtime, bus);
this.privacy = new PrivacyOrchestrator(runtime, bus);
+ this.runMode = new RunModeOrchestrator(runtime, bus);
}
/**
@@ -139,6 +142,7 @@ export class ChatOrchestrator {
this.llmHealth.start();
this.telegram.start();
this.privacy.refresh();
+ this.runMode.refresh();
// Boot the tasks orchestrator on TUI mount so the always-on
// sidebar's Tasks pane has fresh data without waiting for the
// operator to open the Tasks debug tab. Idempotent — opening the
@@ -259,6 +263,7 @@ export class ChatOrchestrator {
}
this.session = loaded;
this.queue.length = 0;
+ this.emitQueue();
// Session grants are point exceptions scoped to the session that
// granted them; a switch must not carry them into the next one.
this.runtime.approvals.clearSessionGrants();
@@ -349,6 +354,7 @@ export class ChatOrchestrator {
}
this.session = this.runtime.createSession();
this.queue.length = 0;
+ this.emitQueue();
// A fresh session starts with no point exceptions: grants never
// outlive the session that created them.
this.runtime.approvals.clearSessionGrants();
@@ -371,11 +377,54 @@ export class ChatOrchestrator {
this.ensureSession();
if (this.currentController) {
this.queue.push(text);
+ this.emitQueue();
return;
}
void this.runOneTurn(text);
}
+ /**
+ * Fold a message into the turn already running on this session.
+ *
+ * Falls back to the normal queue whenever the runtime refuses: the
+ * turn may have finished between the operator's keypress and this
+ * call, or the steering inbox may be full. Either way the message
+ * goes somewhere — the one outcome this must never have is silence.
+ */
+ steerMessage(text: string): void {
+ if (this.quitting) return;
+ const session = this.ensureSession();
+ if (this.runtime.steer(session.id, text)) {
+ this.bus.emit({
+ type: "runtime_info",
+ line: "steering: will reach the model at the next step",
+ });
+ return;
+ }
+ this.sendMessage(text);
+ }
+
+ /**
+ * Drop every parked message without touching the running turn
+ * (`/queue clear`). No-op on an empty queue so the TUI is not spammed
+ * with redundant `queue_changed` frames.
+ */
+ clearQueue(): void {
+ if (this.queue.length === 0) return;
+ this.queue.length = 0;
+ this.emitQueue();
+ }
+
+ /**
+ * Re-publish the pending-message queue to the TUI. The orchestrator is
+ * the source of truth — the reducer mirrors this list rather than
+ * tracking pushes and drains on its own, so an optimistic UI insert can
+ * never drift from what will actually run.
+ */
+ private emitQueue(): void {
+ this.bus.emit({ type: "queue_changed", queued: [...this.queue] });
+ }
+
private async runOneTurn(text: string): Promise {
if (!this.session) return;
const controller = new AbortController();
@@ -387,6 +436,17 @@ export class ChatOrchestrator {
origin: "tui",
});
this.session = result.session;
+ // A steer that landed too late to be drained (final inference, or
+ // a cancelled turn) comes back here. Park it so it runs as its own
+ // turn rather than evaporating.
+ for (const undelivered of result.undelivered ?? []) {
+ this.queue.push(undelivered);
+ this.bus.emit({
+ type: "runtime_info",
+ line: `steering: turn ended first — queued "${undelivered}"`,
+ });
+ }
+ if ((result.undelivered ?? []).length > 0) this.emitQueue();
if (isFailedSessionStatus(this.session.status)) this.exitCode = 1;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -401,6 +461,7 @@ export class ChatOrchestrator {
if (this.currentController === controller) this.currentController = null;
}
const next = this.queue.shift();
+ if (next !== undefined) this.emitQueue();
if (next !== undefined && !this.quitting) {
void this.runOneTurn(next);
}
@@ -479,6 +540,7 @@ export class ChatOrchestrator {
if (this.quitting) return;
this.quitting = true;
this.queue.length = 0;
+ this.emitQueue();
this.currentController?.abort();
}
diff --git a/src/tui/clipboard/clipboard-context.tsx b/src/tui/clipboard/clipboard-context.tsx
new file mode 100644
index 00000000..2bcf83b0
--- /dev/null
+++ b/src/tui/clipboard/clipboard-context.tsx
@@ -0,0 +1,59 @@
+/**
+ * React access to the clipboard writer.
+ *
+ * Shaped like `mouse-context.tsx` and for the same reason: the chat
+ * bubbles are presentational and prop-drilling a writer down through
+ * `ChatLog` → `FinalisedMessage` → every bubble would be a bigger change
+ * than the feature earns.
+ *
+ * Unlike the mouse context there is a **default** when no provider is
+ * mounted, because a copy button with no clipboard is not a degraded
+ * button, it is a broken one. The default is created lazily and shared,
+ * so the common case — the real TUI, which mounts no provider — needs no
+ * wiring at all. `createClipboardWriter` refuses to act on a non-TTY
+ * stdout, which is what keeps that default from touching a real human's
+ * clipboard when a component test happens to render a copy button.
+ *
+ * Tests that want to *observe* a copy mount `ClipboardProvider` with a
+ * fake and get an exact record of what was copied.
+ */
+import { createContext, useContext, type ReactElement, type ReactNode } from "react";
+import {
+ createClipboardWriter,
+ type ClipboardWriter,
+} from "./copy-to-clipboard.js";
+
+const ClipboardContext = createContext(null);
+
+let defaultWriter: ClipboardWriter | null = null;
+
+/**
+ * The process-wide writer used when no provider is mounted. Lazy so that
+ * merely importing a chat component does not read `process.platform` or
+ * capture a `process.stdout` that a harness may still replace.
+ */
+export function getDefaultClipboardWriter(): ClipboardWriter {
+ defaultWriter ??= createClipboardWriter();
+ return defaultWriter;
+}
+
+export interface ClipboardProviderProps {
+ readonly writer: ClipboardWriter;
+ readonly children: ReactNode;
+}
+
+export function ClipboardProvider({
+ writer,
+ children,
+}: ClipboardProviderProps): ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
+/** The active clipboard writer — the provider's, or the shared default. */
+export function useClipboard(): ClipboardWriter {
+ return useContext(ClipboardContext) ?? getDefaultClipboardWriter();
+}
diff --git a/src/tui/clipboard/copy-to-clipboard.test.ts b/src/tui/clipboard/copy-to-clipboard.test.ts
new file mode 100644
index 00000000..1c5fa595
--- /dev/null
+++ b/src/tui/clipboard/copy-to-clipboard.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it } from "vitest";
+import {
+ createClipboardWriter,
+ createNullClipboardWriter,
+ fitsInOsc52,
+ osc52Sequence,
+ platformClipboardCommand,
+ OSC52_MAX_BASE64_CHARS,
+ type ClipboardCommandRunner,
+} from "./copy-to-clipboard.js";
+
+interface FakeStdout {
+ isTTY: boolean;
+ writes: string[];
+ write(chunk: string): boolean;
+}
+
+function makeStdout(isTty: boolean): FakeStdout {
+ const writes: string[] = [];
+ return {
+ isTTY: isTty,
+ writes,
+ write(chunk: string): boolean {
+ writes.push(chunk);
+ return true;
+ },
+ };
+}
+
+interface RunLog {
+ runner: ClipboardCommandRunner;
+ calls: Array<{ command: string; args: readonly string[]; text: string }>;
+}
+
+function makeRunner(result: boolean): RunLog {
+ const calls: RunLog["calls"] = [];
+ return {
+ calls,
+ runner: async (command, args, text) => {
+ calls.push({ command, args, text });
+ return result;
+ },
+ };
+}
+
+describe("osc52Sequence", () => {
+ it("wraps base64 in the OSC 52 clipboard sequence", () => {
+ expect(osc52Sequence("hi")).toBe("\u001B]52;c;aGk=\u0007");
+ });
+
+ it("encodes non-ASCII as UTF-8 bytes, not UTF-16 units", () => {
+ // A terminal decodes the payload as bytes; encoding "é" as its
+ // UTF-16 code unit would paste a replacement character.
+ expect(osc52Sequence("é")).toBe(
+ `\u001B]52;c;${Buffer.from("é", "utf8").toString("base64")}\u0007`,
+ );
+ });
+
+ it("carries newlines through untouched", () => {
+ const decoded = Buffer.from(
+ osc52Sequence("a\nb").slice("\u001B]52;c;".length, -1),
+ "base64",
+ ).toString("utf8");
+ expect(decoded).toBe("a\nb");
+ });
+});
+
+describe("fitsInOsc52", () => {
+ it("accepts an ordinary chat message", () => {
+ expect(fitsInOsc52("a normal reply")).toBe(true);
+ });
+
+ it("rejects a payload past the terminal-safe ceiling", () => {
+ const tooBig = "x".repeat(OSC52_MAX_BASE64_CHARS);
+ expect(fitsInOsc52(tooBig)).toBe(false);
+ });
+});
+
+describe("platformClipboardCommand", () => {
+ it("uses pbcopy on macOS", () => {
+ expect(platformClipboardCommand("darwin", {})).toEqual({
+ command: "pbcopy",
+ args: [],
+ });
+ });
+
+ it("uses clip on Windows", () => {
+ expect(platformClipboardCommand("win32", {})?.command).toBe("clip");
+ });
+
+ it("prefers wl-copy over xclip when both sessions advertise themselves", () => {
+ const command = platformClipboardCommand("linux", {
+ WAYLAND_DISPLAY: "wayland-0",
+ DISPLAY: ":0",
+ });
+ expect(command?.command).toBe("wl-copy");
+ });
+
+ it("falls back to xclip under X11", () => {
+ expect(platformClipboardCommand("linux", { DISPLAY: ":0" })).toEqual({
+ command: "xclip",
+ args: ["-selection", "clipboard"],
+ });
+ });
+
+ it("has nothing to offer on a headless box", () => {
+ // Not a failure: OSC 52 is the correct — and only — route back to
+ // the clipboard of whoever is on the other end of the ssh pipe.
+ expect(platformClipboardCommand("linux", {})).toBeNull();
+ });
+});
+
+describe("createClipboardWriter", () => {
+ it("emits OSC 52 and runs the platform command for one copy", () => {
+ const stdout = makeStdout(true);
+ const run = makeRunner(true);
+ const writer = createClipboardWriter({
+ stdout,
+ runCommand: run.runner,
+ platform: "darwin",
+ env: {},
+ });
+ return writer.copy("hello").then((ok) => {
+ expect(ok).toBe(true);
+ expect(stdout.writes).toEqual([osc52Sequence("hello")]);
+ expect(run.calls).toEqual([
+ { command: "pbcopy", args: [], text: "hello" },
+ ]);
+ });
+ });
+
+ it("still reports success when the platform command fails but OSC 52 went out", () => {
+ // The SSH case: pbcopy would target the wrong machine anyway, and a
+ // terminal that honoured OSC 52 has the text.
+ const stdout = makeStdout(true);
+ const run = makeRunner(false);
+ const writer = createClipboardWriter({
+ stdout,
+ runCommand: run.runner,
+ platform: "darwin",
+ env: {},
+ });
+ return writer.copy("hello").then((ok) => expect(ok).toBe(true));
+ });
+
+ it("reports success from the platform command alone when the payload is too big for OSC 52", async () => {
+ const stdout = makeStdout(true);
+ const run = makeRunner(true);
+ const writer = createClipboardWriter({
+ stdout,
+ runCommand: run.runner,
+ platform: "darwin",
+ env: {},
+ });
+ const huge = "x".repeat(OSC52_MAX_BASE64_CHARS);
+ expect(await writer.copy(huge)).toBe(true);
+ expect(stdout.writes).toEqual([]);
+ expect(run.calls[0]?.text).toBe(huge);
+ });
+
+ it("reports failure when there is no platform command and the payload is too big", async () => {
+ const stdout = makeStdout(true);
+ const run = makeRunner(true);
+ const writer = createClipboardWriter({
+ stdout,
+ runCommand: run.runner,
+ platform: "linux",
+ env: {},
+ });
+ expect(await writer.copy("x".repeat(OSC52_MAX_BASE64_CHARS))).toBe(false);
+ expect(run.calls).toEqual([]);
+ });
+
+ it("does nothing at all when stdout is not a TTY", async () => {
+ // This guard is what keeps `npx vitest` from overwriting the
+ // clipboard of whoever is running the suite.
+ const stdout = makeStdout(false);
+ const run = makeRunner(true);
+ const writer = createClipboardWriter({
+ stdout,
+ runCommand: run.runner,
+ platform: "darwin",
+ env: {},
+ });
+ expect(await writer.copy("hello")).toBe(false);
+ expect(stdout.writes).toEqual([]);
+ expect(run.calls).toEqual([]);
+ });
+
+ it("falls back to the platform command when the stdout write throws", async () => {
+ const run = makeRunner(true);
+ const writer = createClipboardWriter({
+ stdout: {
+ isTTY: true,
+ write: () => {
+ throw new Error("EIO");
+ },
+ },
+ runCommand: run.runner,
+ platform: "darwin",
+ env: {},
+ });
+ expect(await writer.copy("hello")).toBe(true);
+ expect(run.calls).toHaveLength(1);
+ });
+});
+
+describe("createNullClipboardWriter", () => {
+ it("always reports failure", async () => {
+ expect(await createNullClipboardWriter().copy("hello")).toBe(false);
+ });
+});
diff --git a/src/tui/clipboard/copy-to-clipboard.ts b/src/tui/clipboard/copy-to-clipboard.ts
new file mode 100644
index 00000000..fe2fdd62
--- /dev/null
+++ b/src/tui/clipboard/copy-to-clipboard.ts
@@ -0,0 +1,211 @@
+/**
+ * Writing to the *user's* clipboard from a TUI.
+ *
+ * There is no single mechanism that works everywhere, and the two that
+ * exist fail in exactly opposite situations — so this module runs both
+ * and reports success if either one landed.
+ *
+ * - **OSC 52** (`ESC ] 52 ; c ; BEL`) asks the terminal
+ * emulator itself to set the clipboard. It is the only mechanism
+ * that survives SSH: the bytes travel back up the same pty the
+ * frames come down, so the text lands on the machine the human is
+ * sitting at rather than on the box the agent happens to run on.
+ * Its weakness is that it is advisory — the terminal may ignore it
+ * (Apple Terminal does), gate it behind a preference (iTerm2's
+ * "Applications in terminal may access clipboard"), or swallow it
+ * in a multiplexer (tmux needs `set -g set-clipboard on`; GNU screen
+ * needs DCS wrapping we do not emit). Crucially, **there is no
+ * reply**: a terminal that ignores 52 is indistinguishable from one
+ * that honoured it, so we can never report "OSC 52 worked".
+ * - **The platform clipboard command** (`pbcopy`, `wl-copy`, `xclip`,
+ * `clip.exe`) is authoritative — it either exits 0 or it does not —
+ * but it writes to the clipboard of the machine the *process* runs
+ * on, which is the wrong machine over SSH, and it does not exist at
+ * all on a headless box.
+ *
+ * Doing both is not belt-and-braces sloppiness; it is the only way to
+ * cover Apple Terminal (native only) and a remote session (OSC 52 only)
+ * with one code path. Writing the same string twice is harmless: the
+ * clipboard ends up holding that string either way.
+ *
+ * Safety of interleaving OSC 52 with Ink's frames: the sequence moves no
+ * cursor, sets no mode, and paints no cell, so a terminal that
+ * understands it consumes it invisibly wherever it lands between Ink's
+ * writes, and one that does not silently drops an unknown OSC. That is
+ * why it can be written straight to the same stdout Ink is rendering to
+ * without coordinating with the renderer or leaving the alt screen.
+ *
+ * Everything the writer touches — stdout, process spawning, platform,
+ * env — is injected, so tests exercise the real decision logic without
+ * going anywhere near the developer's actual clipboard.
+ */
+import { spawn } from "node:child_process";
+
+export interface ClipboardWriter {
+ /**
+ * Copies `text`. Resolves `true` when at least one mechanism is
+ * believed to have worked — see {@link createClipboardWriter} for what
+ * "believed" can and cannot mean.
+ */
+ copy(text: string): Promise;
+}
+
+/** Minimal shape of the stream OSC 52 is written to. */
+export interface ClipboardStdout {
+ write(chunk: string): unknown;
+ readonly isTTY?: boolean;
+}
+
+/** Runs a clipboard command with `text` on stdin; resolves `true` on exit 0. */
+export type ClipboardCommandRunner = (
+ command: string,
+ args: readonly string[],
+ text: string,
+) => Promise;
+
+export interface ClipboardWriterOptions {
+ readonly stdout?: ClipboardStdout;
+ readonly runCommand?: ClipboardCommandRunner;
+ readonly platform?: NodeJS.Platform;
+ readonly env?: Readonly>;
+}
+
+export interface ClipboardCommand {
+ readonly command: string;
+ readonly args: readonly string[];
+}
+
+/**
+ * Terminals differ on how much base64 they will accept in one OSC 52,
+ * and the ones that dislike a long payload tend to drop it *silently*
+ * rather than truncate — which would leave the user with a stale
+ * clipboard and a cheerful "copied!". Past this size we skip OSC 52 and
+ * let the platform command carry the copy alone; a paste that big is
+ * overwhelmingly a local one anyway.
+ */
+export const OSC52_MAX_BASE64_CHARS = 100_000;
+
+/** BEL terminator: accepted everywhere `ESC \` is, and by a few terminals that mis-parse ST. */
+const BEL = "\u0007";
+
+/** The OSC 52 sequence that sets the system clipboard (`c`) to `text`. */
+export function osc52Sequence(text: string): string {
+ const payload = Buffer.from(text, "utf8").toString("base64");
+ return `\u001B]52;c;${payload}${BEL}`;
+}
+
+/** `true` when `text` is small enough to be worth sending as OSC 52. */
+export function fitsInOsc52(text: string): boolean {
+ // 4 base64 chars per 3 input bytes, rounded up — cheaper than encoding
+ // a megabyte of transcript just to find out it is too big.
+ const bytes = Buffer.byteLength(text, "utf8");
+ return Math.ceil(bytes / 3) * 4 <= OSC52_MAX_BASE64_CHARS;
+}
+
+/**
+ * The platform's clipboard command, or `null` when there is none worth
+ * trying. On Linux the answer depends on the *session*, not the OS:
+ * `wl-copy` under Wayland, `xclip` under X11, and nothing at all on a
+ * headless box — where returning `null` is the honest answer and OSC 52
+ * is the only route back to the human's clipboard.
+ */
+export function platformClipboardCommand(
+ platform: NodeJS.Platform,
+ env: Readonly>,
+): ClipboardCommand | null {
+ if (platform === "darwin") return { command: "pbcopy", args: [] };
+ if (platform === "win32") return { command: "clip", args: [] };
+ if (env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] };
+ if (env.DISPLAY) {
+ return { command: "xclip", args: ["-selection", "clipboard"] };
+ }
+ return null;
+}
+
+/**
+ * Default runner: spawns the command, feeds `text` on stdin, resolves on
+ * the exit code. A missing binary surfaces as an `error` event rather
+ * than a non-zero exit, so both collapse to `false` — the caller only
+ * ever needs "did the clipboard change".
+ */
+const spawnClipboardCommand: ClipboardCommandRunner = (
+ command,
+ args,
+ text,
+) =>
+ new Promise((resolve) => {
+ let settled = false;
+ const done = (ok: boolean): void => {
+ if (settled) return;
+ settled = true;
+ resolve(ok);
+ };
+ try {
+ const child = spawn(command, [...args], {
+ stdio: ["pipe", "ignore", "ignore"],
+ });
+ child.on("error", () => done(false));
+ child.on("close", (code) => done(code === 0));
+ // EPIPE here means the child died before reading — `close` already
+ // has that case covered, so the write error is not interesting.
+ child.stdin?.on("error", () => {});
+ child.stdin?.end(text);
+ } catch {
+ done(false);
+ }
+ });
+
+/**
+ * Builds the clipboard writer used by the TUI.
+ *
+ * `copy` resolves `true` if the platform command succeeded, **or** if we
+ * emitted OSC 52 to a TTY. The second half is optimism, and deliberately
+ * so: OSC 52 never answers, so the alternative is to report failure on
+ * every terminal that only supports OSC 52 (i.e. every SSH session),
+ * which would be wrong far more often than the optimism is. A stale
+ * clipboard is recoverable; a "copy failed" badge on a copy that worked
+ * teaches the user the button is broken.
+ *
+ * When stdout is not a TTY nothing is attempted at all. There is no
+ * terminal to talk to, and — the reason this guard matters in practice —
+ * it keeps every non-interactive run, the test suite included, from
+ * reaching out and overwriting a real human's clipboard.
+ */
+export function createClipboardWriter(
+ options: ClipboardWriterOptions = {},
+): ClipboardWriter {
+ const stdout = options.stdout ?? process.stdout;
+ const runCommand = options.runCommand ?? spawnClipboardCommand;
+ const platform = options.platform ?? process.platform;
+ const env = options.env ?? process.env;
+ return {
+ async copy(text: string): Promise {
+ if (stdout.isTTY !== true) return false;
+ let claimed = false;
+ if (fitsInOsc52(text)) {
+ try {
+ stdout.write(osc52Sequence(text));
+ claimed = true;
+ } catch {
+ // A stdout that rejects a write is a dead terminal; the
+ // platform command may still be able to do the job.
+ }
+ }
+ const command = platformClipboardCommand(platform, env);
+ if (command) {
+ const ok = await runCommand(command.command, command.args, text);
+ claimed = claimed || ok;
+ }
+ return claimed;
+ },
+ };
+}
+
+/**
+ * A writer that does nothing and reports failure. Used where a clipboard
+ * is structurally unavailable, and as the explicit stand-in in tests
+ * that must not touch a real one.
+ */
+export function createNullClipboardWriter(): ClipboardWriter {
+ return { copy: async () => false };
+}
diff --git a/src/tui/clipboard/index.ts b/src/tui/clipboard/index.ts
new file mode 100644
index 00000000..cf8f8122
--- /dev/null
+++ b/src/tui/clipboard/index.ts
@@ -0,0 +1,19 @@
+export {
+ createClipboardWriter,
+ createNullClipboardWriter,
+ fitsInOsc52,
+ osc52Sequence,
+ platformClipboardCommand,
+ OSC52_MAX_BASE64_CHARS,
+ type ClipboardCommand,
+ type ClipboardCommandRunner,
+ type ClipboardStdout,
+ type ClipboardWriter,
+ type ClipboardWriterOptions,
+} from "./copy-to-clipboard.js";
+export {
+ ClipboardProvider,
+ getDefaultClipboardWriter,
+ useClipboard,
+ type ClipboardProviderProps,
+} from "./clipboard-context.js";
diff --git a/src/tui/commands/dispatch-run-mode.ts b/src/tui/commands/dispatch-run-mode.ts
new file mode 100644
index 00000000..203d20d5
--- /dev/null
+++ b/src/tui/commands/dispatch-run-mode.ts
@@ -0,0 +1,64 @@
+import type { RunModeName } from "../../config/index.js";
+import { RUN_MODES } from "../run-mode/run-mode-nav.js";
+
+export interface RunModeCommand {
+ /** Return to the Run section (what `/run` always did as a `/chat` alias). */
+ returnToRun: boolean;
+ /** Open the dial overlay (bare `/run`). */
+ openPicker: boolean;
+ mode?: RunModeName;
+ cloudShare?: number;
+ /** Usage line to echo instead of acting. */
+ error?: string;
+}
+
+const USAGE = "usage: /run [local|cloud|fusion] [0-100]";
+
+/**
+ * Parse `/run [mode] [share]`.
+ *
+ * Bare `/run` keeps its historical behaviour — returning to the Run
+ * section, which it had as an alias of `/chat` — and additionally opens
+ * the mode picker. Keeping both means the alias's muscle memory still
+ * works while the name now also owns the thing it is named after.
+ *
+ * Lives in its own module because `slash-command-handler.ts` is already
+ * far past the 300-line budget.
+ */
+export function parseRunModeCommand(rawArgs: string): RunModeCommand {
+ const [rawMode, rawShare, ...rest] = rawArgs
+ .trim()
+ .split(/\s+/)
+ .filter((token) => token.length > 0);
+ if (rawMode === undefined) {
+ return { returnToRun: true, openPicker: true };
+ }
+ if (rest.length > 0) {
+ return { returnToRun: false, openPicker: false, error: USAGE };
+ }
+ const mode = rawMode.toLowerCase();
+ if (!RUN_MODES.includes(mode as RunModeName)) {
+ return {
+ returnToRun: false,
+ openPicker: false,
+ error: `unknown run mode ${JSON.stringify(rawMode)} — ${USAGE}`,
+ };
+ }
+ if (rawShare === undefined) {
+ return { returnToRun: true, openPicker: false, mode: mode as RunModeName };
+ }
+ const share = Number.parseInt(rawShare.replace(/%$/, ""), 10);
+ if (!Number.isInteger(share) || share < 0 || share > 100) {
+ return {
+ returnToRun: false,
+ openPicker: false,
+ error: `cloud share must be an integer 0-100 — ${USAGE}`,
+ };
+ }
+ return {
+ returnToRun: true,
+ openPicker: false,
+ mode: mode as RunModeName,
+ cloudShare: share,
+ };
+}
diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts
index 98563a41..9053ba35 100644
--- a/src/tui/commands/slash-command-handler.test.ts
+++ b/src/tui/commands/slash-command-handler.test.ts
@@ -54,11 +54,50 @@ describe("dispatchSlashCommand", () => {
]);
});
- it("returns to the Run section for /run (alias of /chat)", () => {
+ it("still returns to the Run section for a bare /run, and opens the mode picker", () => {
+ // `/run` used to be a plain alias of `/chat`. It now owns the run
+ // MODE too, so the historical behaviour is preserved and the picker
+ // is opened on top of it — muscle memory keeps working.
const result = dispatchSlashCommand("/run");
+ expect(result.actions).toEqual([
+ { type: "ui_mode_set", mode: "chat" },
+ { type: "run_mode_picker_opened" },
+ ]);
+ expect(result.runModeSet).toBeUndefined();
+ });
+
+ it("switches run mode directly for /run ", () => {
+ const result = dispatchSlashCommand("/run fusion");
+ expect(result.runModeSet).toBe("fusion");
+ expect(result.runModeCloudShare).toBeUndefined();
expect(result.actions).toEqual([{ type: "ui_mode_set", mode: "chat" }]);
});
+ it("accepts a dial value, with or without a percent sign", () => {
+ expect(dispatchSlashCommand("/run fusion 75").runModeCloudShare).toBe(75);
+ expect(dispatchSlashCommand("/run fusion 75%").runModeCloudShare).toBe(75);
+ });
+
+ it("accepts the inclusive dial bounds", () => {
+ expect(dispatchSlashCommand("/run fusion 0").runModeCloudShare).toBe(0);
+ expect(dispatchSlashCommand("/run fusion 100").runModeCloudShare).toBe(100);
+ });
+
+ it("rejects an unknown mode without touching state", () => {
+ const result = dispatchSlashCommand("/run hybrid");
+ expect(result.runModeSet).toBeUndefined();
+ expect(result.actions).toEqual([]);
+ expect(result.systemMessage).toMatch(/unknown run mode/);
+ // Never forwarded to the model as a chat message.
+ expect(result.forwardAsMessage).toBe(false);
+ });
+
+ it("rejects an out-of-range dial value", () => {
+ const result = dispatchSlashCommand("/run fusion 101");
+ expect(result.runModeSet).toBeUndefined();
+ expect(result.systemMessage).toMatch(/0-100/);
+ });
+
it("switches to debug mode and tab for /logs", () => {
const result = dispatchSlashCommand("/logs");
expect(result.actions).toEqual([
@@ -86,6 +125,18 @@ describe("dispatchSlashCommand", () => {
expect(result.triggerSessionPicker).toBe(false);
});
+ it("signals triggerNewWindow for /window and its alias", () => {
+ // `/new` restarts the session in place; `/window` is the OS-level
+ // sibling of Ctrl+N — the two must never be confused.
+ for (const buffer of ["/window", "/newwindow"]) {
+ const result = dispatchSlashCommand(buffer);
+ expect(result.triggerNewWindow).toBe(true);
+ expect(result.triggerSessionNew).toBe(false);
+ expect(result.forwardAsMessage).toBe(false);
+ }
+ expect(dispatchSlashCommand("/new").triggerNewWindow).toBe(false);
+ });
+
it("opens the Memory tab for bare /memory", () => {
const result = dispatchSlashCommand("/memory");
expect(result.triggerMemoryDump).toBe(false);
diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts
index 7fa51b55..d678d6b9 100644
--- a/src/tui/commands/slash-command-handler.ts
+++ b/src/tui/commands/slash-command-handler.ts
@@ -1,4 +1,7 @@
+import type { WhileBusySubmitMode } from "../../config/index.js";
import type { TuiAction } from "../tui-action.js";
+import type { RunModeName } from "../../config/index.js";
+import { parseRunModeCommand } from "./dispatch-run-mode.js";
import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js";
import { isThemeName, THEME_NAMES } from "../theme/theme.js";
import { parseSlashCommand } from "./slash-command-parser.js";
@@ -28,6 +31,8 @@ export interface SlashDispatchResult {
readonly triggerSessionPicker: boolean;
/** When true the caller should ask the orchestrator to start a fresh session. */
readonly triggerSessionNew: boolean;
+ /** When true the caller should open a new OS terminal window (`/window`). */
+ readonly triggerNewWindow: boolean;
/** When true the caller should ask the orchestrator to dump the user profile. */
readonly triggerMemoryDump: boolean;
/** When true the caller should ask the orchestrator to list the skill catalog in chat. */
@@ -36,6 +41,10 @@ export interface SlashDispatchResult {
readonly triggerDebugBundleDump: boolean;
/** When true the caller should forward the raw buffer as a normal message. */
readonly forwardAsMessage: boolean;
+ /** When set, caller should persist this run mode and swap the provider. */
+ readonly runModeSet?: RunModeName;
+ /** Optional dial value accompanying `runModeSet`. */
+ readonly runModeCloudShare?: number;
/** When set, caller should probe this URL, persist on success, then refresh UI. */
readonly persistLlamaUrl?: string;
/** Task id to cancel via the orchestrator (`/task cancel `). */
@@ -82,6 +91,19 @@ export interface SlashDispatchResult {
* the privacy orchestrator.
*/
readonly analyticsVerb?: "enable" | "disable" | "status";
+ /**
+ * `/queue` side-effect. `list` renders the parked messages into chat —
+ * the listing needs `TuiState`, which this pure dispatcher does not
+ * have, so the caller formats it. `clear` additionally asks the
+ * orchestrator to drop its own copy of the queue.
+ */
+ readonly queueVerb?: "list" | "clear";
+ /**
+ * `/steer ` or `/queue `: land this one message in the given
+ * mode without touching the persisted default. Ignored when no turn is
+ * running (the caller submits it normally instead).
+ */
+ readonly submitWhileBusy?: { mode: WhileBusySubmitMode; text: string };
/**
* `/privacy level <1..5>` side-effect (with `/privacy approve on|off`
* kept as aliases for 5 and 1): move the approval ladder to an
@@ -89,6 +111,13 @@ export interface SlashDispatchResult {
* `PrivacyOrchestrator.setApprovalLevel`.
*/
readonly approvalLevelSet?: number;
+ /**
+ * `/mouse [on|off]` — flip terminal mouse reporting at runtime, or
+ * report the current state with no argument. The caller owns the
+ * escape sequences and the config write, because both live outside
+ * React (see `tui-command.ts`).
+ */
+ readonly mouseVerb?: "on" | "off" | "status";
}
/**
@@ -107,6 +136,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult {
triggerQuit: false,
triggerSessionPicker: false,
triggerSessionNew: false,
+ triggerNewWindow: false,
triggerMemoryDump: false,
triggerSkillCatalogDump: false,
triggerDebugBundleDump: false,
@@ -124,6 +154,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult {
triggerQuit: false,
triggerSessionPicker: false,
triggerSessionNew: false,
+ triggerNewWindow: false,
triggerMemoryDump: false,
triggerSkillCatalogDump: false,
triggerDebugBundleDump: false,
@@ -142,12 +173,18 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult {
return pureActions([], {
systemMessage: formatSlashCommandHelp(),
});
+ case "mouse":
+ return dispatchMouseSub(parsed.args);
case "theme":
return dispatchThemeSub(parsed.args);
case "clear":
return pureActions([{ type: "chat_cleared" }], {
systemMessage: "chat cleared",
});
+ case "queue":
+ return dispatchQueueSub(parsed.args);
+ case "steer":
+ return dispatchSteerSub(parsed.args);
case "abort":
return pureActions([{ type: "abort_requested" }], {
triggerAbort: true,
@@ -162,6 +199,22 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult {
return pureActions([{ type: "ui_mode_toggled" }]);
case "chat":
return pureActions([{ type: "ui_mode_set", mode: "chat" }]);
+ case "run": {
+ const runCmd = parseRunModeCommand(parsed.args);
+ if (runCmd.error) {
+ return pureActions([], { systemMessage: runCmd.error });
+ }
+ const actions: TuiAction[] = [];
+ // `/run` was an alias of `/chat`; keep that behaviour exactly.
+ if (runCmd.returnToRun) actions.push({ type: "ui_mode_set", mode: "chat" });
+ if (runCmd.openPicker) actions.push({ type: "run_mode_picker_opened" });
+ return pureActions(actions, {
+ ...(runCmd.mode ? { runModeSet: runCmd.mode } : {}),
+ ...(runCmd.cloudShare === undefined
+ ? {}
+ : { runModeCloudShare: runCmd.cloudShare }),
+ });
+ }
case "observe":
return pureActions([
{ type: "ui_mode_set", mode: "debug" },
@@ -204,6 +257,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult {
return pureActions([], { triggerSessionPicker: true });
case "new":
return pureActions([], { triggerSessionNew: true });
+ case "window":
+ return pureActions([], { triggerNewWindow: true });
case "tools":
return dispatchToolsSub(parsed.args);
case "skills":
@@ -255,6 +310,50 @@ function formatSlashCommandHelp(): string {
return ["slash commands:", ...lines].join("\n");
}
+/**
+ * `/queue` — bare switches the Enter-while-busy mode to `queue` and
+ * lists what is currently parked; `clear` (alias `drop`) empties it;
+ * anything else is a one-off message to park without changing the mode.
+ * The `queue_changed` action is dispatched optimistically so the strip
+ * above the prompt disappears immediately; `ChatOrchestrator.clearQueue`
+ * then re-publishes the authoritative empty queue.
+ */
+function dispatchQueueSub(args: string): SlashDispatchResult {
+ const raw = args.trim();
+ const verb = raw.toLowerCase();
+ if (verb === "clear" || verb === "drop") {
+ return pureActions([{ type: "queue_changed", queued: [] }], {
+ queueVerb: "clear",
+ });
+ }
+ if (raw.length > 0) {
+ return pureActions([], {
+ submitWhileBusy: { mode: "queue", text: raw },
+ });
+ }
+ return pureActions([{ type: "while_busy_mode_changed", mode: "queue" }], {
+ queueVerb: "list",
+ });
+}
+
+/**
+ * `/steer` — bare switches the Enter-while-busy mode to `steer`;
+ * `/steer ` lands one message in the running turn without
+ * changing the persisted default.
+ */
+function dispatchSteerSub(args: string): SlashDispatchResult {
+ const raw = args.trim();
+ if (raw.length > 0) {
+ return pureActions([], {
+ submitWhileBusy: { mode: "steer", text: raw },
+ });
+ }
+ return pureActions([{ type: "while_busy_mode_changed", mode: "steer" }], {
+ systemMessage:
+ "Enter now steers the running turn (Ctrl+T or /queue switches back)",
+ });
+}
+
function pureActions(
actions: readonly TuiAction[],
overrides: Partial<
@@ -268,10 +367,13 @@ function pureActions(
triggerQuit: false,
triggerSessionPicker: false,
triggerSessionNew: false,
+ triggerNewWindow: false,
triggerMemoryDump: false,
triggerSkillCatalogDump: false,
triggerDebugBundleDump: false,
forwardAsMessage: false,
+ runModeSet: undefined,
+ runModeCloudShare: undefined,
persistLlamaUrl: undefined,
taskCancelId: undefined,
taskRunId: undefined,
@@ -283,6 +385,8 @@ function pureActions(
setThemeName: undefined,
telegramVerb: undefined,
analyticsVerb: undefined,
+ queueVerb: undefined,
+ submitWhileBusy: undefined,
approvalLevelSet: undefined,
...overrides,
};
@@ -295,6 +399,25 @@ function pureActions(
* the registry and, on success, asks the caller to swap + persist + re-render.
* Unknown names surface a usage hint instead of switching.
*/
+/**
+ * `/mouse` with no argument reports state; `on` / `off` set it. Any
+ * other word is rejected rather than guessed at — a typo'd `/mouse ff`
+ * silently disabling clicks would be a maddening bug to chase.
+ */
+function dispatchMouseSub(rawArgs: string): SlashDispatchResult {
+ const verb = rawArgs.trim().toLowerCase();
+ if (verb.length === 0) return pureActions([], { mouseVerb: "status" });
+ if (verb === "on" || verb === "enable") {
+ return pureActions([], { mouseVerb: "on" });
+ }
+ if (verb === "off" || verb === "disable") {
+ return pureActions([], { mouseVerb: "off" });
+ }
+ return pureActions([], {
+ systemMessage: `usage: /mouse [on|off] (got "${rawArgs.trim()}")`,
+ });
+}
+
function dispatchThemeSub(rawArgs: string): SlashDispatchResult {
const arg = rawArgs.trim().toLowerCase();
if (arg.length === 0) {
diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts
index 779235c2..5b0fb080 100644
--- a/src/tui/commands/slash-commands.ts
+++ b/src/tui/commands/slash-commands.ts
@@ -1,5 +1,7 @@
import fuzzysort from "fuzzysort";
+import { toSlashCommands } from "../menu/menu-registry.js";
+
export interface SlashCommandDef {
/** Canonical command name (without leading `/`). */
readonly name: string;
@@ -10,108 +12,22 @@ export interface SlashCommandDef {
}
/**
- * Atomic-agent's slash command registry. Intentionally small: the
- * handler-side dispatch in `slash-command-handler.ts` knows how to
- * action each name. Additions live here so the palette + parser stay
- * in sync by construction.
+ * Atomic-agent's slash command registry — a **projection** of the
+ * operator menu (`src/tui/menu/menu-registry.ts`), not a list of its
+ * own. Every command is one menu node carrying a `slash` field, so the
+ * palette and the menu cannot describe the same command differently.
+ *
+ * Order is the historical palette order, carried on `MenuSlash.rank`:
+ * an empty query lists the registry as-is, and fuzzy-search ties break
+ * by index, so both are user-visible.
+ *
+ * To add a command, add the node to `MENU`. The handler-side dispatch in
+ * `slash-command-handler.ts` still knows how to action each name.
*/
-export const SLASH_COMMANDS: readonly SlashCommandDef[] = [
- {
- name: "dump",
- description:
- "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug",
- },
- { name: "help", description: "list available slash commands" },
- {
- name: "tools",
- description:
- "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `",
- },
- {
- name: "theme",
- description:
- "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)",
- },
- { name: "clear", description: "clear chat transcript (keeps session)" },
- { name: "abort", description: "abort the running turn" },
- { name: "quit", description: "exit atomic-agent", aliases: ["exit"] },
- { name: "debug", description: "toggle debug pane (feed / logs / world …)" },
- { name: "chat", description: "return to single-view chat mode", aliases: ["run"] },
- {
- name: "observe",
- description:
- "switch to the Observe section (feed / world / reasoning / logs / llm-logs)",
- },
- {
- name: "manage",
- description:
- "switch to the Manage section (tasks / skills / LLM / telegram)",
- },
- { name: "feed", description: "jump to the Observe → Feed tab" },
- { name: "logs", description: "jump to the Observe → Logs tab" },
- { name: "reasoning", description: "jump to the Observe → Reasoning tab" },
- { name: "world", description: "jump to the Observe → World tab" },
- { name: "expand", description: "expand every tool card in the chat log" },
- { name: "collapse", description: "collapse every tool card in the chat log" },
- { name: "session", description: "show current session id" },
- { name: "sessions", description: "open session picker to switch threads" },
- { name: "new", description: "start a fresh session (keeps warm runtime)" },
- {
- name: "skills",
- description:
- "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat",
- },
- {
- name: "skill",
- description:
- "skill subcommand: `/skill enable ` | `/skill disable `",
- },
- {
- name: "memory",
- description:
- "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat",
- },
- {
- name: "llm",
- description:
- "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider",
- },
- {
- name: "mcp",
- description:
- "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm",
- },
- {
- name: "model",
- description:
- "open chat model picker · subcommands: pull | use | status | ",
- aliases: ["models", "local"],
- },
- { name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)" },
- {
- name: "task",
- description:
- "task subcommand: `/task new` | `/task cancel ` | `/task run `",
- },
- {
- name: "telegram",
- description:
- "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token",
- },
- {
- name: "import",
- description: "open the Import tab (one-shot Hermes -> atomic-agent migration)",
- },
- {
- name: "privacy",
- description:
- "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`",
- },
- {
- name: "analytics",
- description: "toggle anonymous analytics: `/analytics on|off|status`",
- },
-];
+export const SLASH_COMMANDS: readonly SlashCommandDef[] = toSlashCommands().map(
+ ({ name, description, aliases }) =>
+ aliases ? { name, description, aliases } : { name, description },
+);
/**
* Filter the registry by a slash query (the characters typed after `/`).
diff --git a/src/tui/components/chat-copy-button.test.tsx b/src/tui/components/chat-copy-button.test.tsx
new file mode 100644
index 00000000..cc088a9a
--- /dev/null
+++ b/src/tui/components/chat-copy-button.test.tsx
@@ -0,0 +1,294 @@
+import { render } from "ink-testing-library";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ReactElement, ReactNode } from "react";
+import { ClipboardProvider } from "../clipboard/clipboard-context.js";
+import type { ClipboardWriter } from "../clipboard/copy-to-clipboard.js";
+import type { TuiMouseEvent } from "../mouse/mouse-event.js";
+import { MouseProvider } from "../mouse/mouse-context.js";
+import { MouseTargetRegistry } from "../mouse/mouse-registry.js";
+import type { TuiAppCallbacks } from "../tui-app.js";
+import { createInitialTuiState, type TuiSessionInfo } from "../tui-state.js";
+import { ChatCopyButton } from "./chat-copy-button.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: "copy",
+ workingDir: "/tmp/copy",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+function strip(value: string): string {
+ return value.replace(/\[[0-9;]*m/g, "");
+}
+
+/**
+ * Screen position of `needle`. Stripping SGR leaves the visual grid
+ * intact, so these are the cells a terminal would report for a click —
+ * the same trick `mouse-app.test.tsx` uses.
+ */
+function locate(frame: string, needle: string): { x: number; y: number } {
+ for (const [y, line] of frame.split("\n").entries()) {
+ const x = line.indexOf(needle);
+ if (x !== -1) return { x, y };
+ }
+ throw new Error(`"${needle}" is not on screen:\n${frame}`);
+}
+
+function click(x: number, y: number): TuiMouseEvent {
+ return {
+ kind: "press",
+ button: "left",
+ wheel: null,
+ x,
+ y,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ };
+}
+
+/**
+ * Captured before any `vi.useFakeTimers()` call so the polling below
+ * keeps running on real time. The fake-timer tests here deliberately
+ * fake **only** `setTimeout`/`clearTimeout` — the component's badge
+ * window and nothing else. Faking the whole clock would also freeze
+ * React's scheduler and Ink's own bookkeeping, and the frame under
+ * assertion would simply never repaint.
+ */
+const realSetTimeout = globalThis.setTimeout;
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => realSetTimeout(resolve, ms));
+
+/** Fakes the badge window only. See {@link realSetTimeout}. */
+function fakeBadgeTimerOnly(): void {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+}
+
+/**
+ * Ink commits a frame and React flushes the effect that registers the
+ * click target on its own schedule, so a freshly rendered button is not
+ * clickable for a tick or two. Everything here polls rather than
+ * sleeping a fixed interval.
+ */
+async function waitUntil(
+ condition: () => boolean,
+ what: string,
+ timeoutMs = 10_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (condition()) return;
+ await delay(25);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+interface Harness {
+ frame: () => string;
+ clickAt: (needle: string) => void;
+ clickCell: (x: number, y: number) => void;
+ unmount: () => void;
+}
+
+function noopCallbacks(): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {},
+ onQuit: () => {},
+ onMessageSubmitted: () => {},
+ };
+}
+
+function mount(
+ writer: ClipboardWriter,
+ children: ReactNode,
+ { withMouse = true }: { withMouse?: boolean } = {},
+): Harness {
+ const registry = new MouseTargetRegistry();
+ const state = createInitialTuiState(SESSION);
+ const tree: ReactElement = withMouse ? (
+ {}}
+ callbacks={noopCallbacks()}
+ getState={() => state}
+ >
+ {children}
+
+ ) : (
+ <>{children}>
+ );
+ const { lastFrame, unmount } = render(
+ {tree},
+ );
+ const frame = (): string => strip(lastFrame() ?? "");
+ return {
+ frame,
+ clickAt: (needle) => {
+ const at = locate(frame(), needle);
+ registry.dispatch(click(at.x, at.y));
+ },
+ clickCell: (x, y) => registry.dispatch(click(x, y)),
+ unmount,
+ };
+}
+
+/**
+ * Clicks `[copy]` until the click actually lands. The target is
+ * registered by an effect that runs after the frame the label first
+ * appears in, so the first click can fall on a cell nothing owns yet —
+ * the same reason `mouse-app.test.tsx` re-sends its clicks.
+ */
+async function clickCopy(app: Harness, copied: readonly string[]): Promise {
+ await waitUntil(() => app.frame().includes("[copy]"), "the idle label");
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ if (copied.length > 0) return;
+ app.clickAt("[copy]");
+ await delay(25);
+ }
+ throw new Error("click never took effect on the copy button");
+}
+
+function recordingWriter(result = true): {
+ writer: ClipboardWriter;
+ copied: string[];
+} {
+ const copied: string[] = [];
+ return {
+ copied,
+ writer: {
+ copy: async (text: string) => {
+ copied.push(text);
+ return result;
+ },
+ },
+ };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("ChatCopyButton", () => {
+ it("renders the quiet idle label", () => {
+ const app = mount(recordingWriter().writer, );
+ expect(app.frame()).toContain("[copy]");
+ app.unmount();
+ });
+
+ it("still renders without a mouse provider", () => {
+ // `useMouseCommands()` is null under `--no-mouse` and in every
+ // component test; the button must degrade to a label, not vanish.
+ const app = mount(recordingWriter().writer, , {
+ withMouse: false,
+ });
+ expect(app.frame()).toContain("[copy]");
+ app.unmount();
+ });
+
+ it("copies the message text and flips the label when clicked", async () => {
+ const { writer, copied } = recordingWriter();
+ const app = mount(writer, );
+ await clickCopy(app, copied);
+ expect(copied).toEqual(["the exact reply"]);
+ await waitUntil(
+ () => app.frame().includes("[copied!]"),
+ "the copied badge",
+ );
+ app.unmount();
+ });
+
+ it("reports a refused copy instead of claiming success", async () => {
+ const { writer, copied } = recordingWriter(false);
+ const app = mount(writer, );
+ await clickCopy(app, copied);
+ await waitUntil(
+ () => app.frame().includes("[copy failed]"),
+ "the failure badge",
+ );
+ app.unmount();
+ });
+
+ it("copies the message its own button belongs to, not a neighbour's", async () => {
+ const { writer, copied } = recordingWriter();
+ const app = mount(
+ writer,
+ <>
+
+
+ >,
+ );
+ await waitUntil(() => app.frame().split("[copy]").length === 3, "both buttons");
+ // The second button is the second `[copy]` on screen — one row down.
+ const first = locate(app.frame(), "[copy]");
+ for (let attempt = 0; attempt < 40 && copied.length === 0; attempt += 1) {
+ app.clickCell(first.x, first.y + 1);
+ await delay(25);
+ }
+ expect(copied).toEqual(["second message"]);
+ app.unmount();
+ });
+
+ it("does not leave a timer behind when unmounted mid-badge", async () => {
+ fakeBadgeTimerOnly();
+ const { writer, copied } = recordingWriter();
+ const app = mount(writer, );
+ await clickCopy(app, copied);
+ await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge");
+ // Only the badge window is faked, so this count is the component's
+ // pending revert and nothing else.
+ expect(vi.getTimerCount()).toBe(1);
+ app.unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
+describe("ChatCopyButton label timer", () => {
+ it("reverts to the idle label once the badge window elapses", async () => {
+ fakeBadgeTimerOnly();
+ const { writer, copied } = recordingWriter();
+ const app = mount(
+ writer,
+ ,
+ );
+ await clickCopy(app, copied);
+ await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge");
+ vi.advanceTimersByTime(4_999);
+ expect(app.frame()).toContain("[copied!]");
+ vi.advanceTimersByTime(1);
+ await waitUntil(
+ () => app.frame().includes("[copy]") && !app.frame().includes("[copied!]"),
+ "the label reverting on its own",
+ );
+ app.unmount();
+ });
+
+ it("a second click restarts the window instead of letting the first timer clear it", async () => {
+ fakeBadgeTimerOnly();
+ const { writer, copied } = recordingWriter();
+ const app = mount(
+ writer,
+ ,
+ );
+ await clickCopy(app, copied);
+ await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge");
+ vi.advanceTimersByTime(4_000);
+ const seen = copied.length;
+ app.clickAt("[copied!]");
+ await waitUntil(() => copied.length > seen, "the second copy");
+ // `copied` grows inside `copy()`; the badge timer is only restarted
+ // in the `.then` after it. Give that microtask a real tick.
+ await delay(25);
+ // The first click's timeout is due 1s from here. If it had not been
+ // cleared, the badge would blink off a second after the re-click.
+ vi.advanceTimersByTime(2_000);
+ expect(vi.getTimerCount()).toBe(1);
+ expect(app.frame()).toContain("[copied!]");
+ app.unmount();
+ });
+});
diff --git a/src/tui/components/chat-copy-button.tsx b/src/tui/components/chat-copy-button.tsx
new file mode 100644
index 00000000..208ccbb9
--- /dev/null
+++ b/src/tui/components/chat-copy-button.tsx
@@ -0,0 +1,95 @@
+import { Box, Text } from "ink";
+import { useCallback, type ReactElement } from "react";
+import { useClipboard } from "../clipboard/clipboard-context.js";
+import { useTransientStatus } from "../hooks/use-transient-status.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { theme } from "../theme/theme.js";
+
+interface ChatCopyButtonProps {
+ /** Exactly the text that lands on the clipboard — no markdown, no borders. */
+ readonly text: string;
+ /** How long `copied!` stays up before the label reverts. */
+ readonly revertAfterMs?: number;
+}
+
+/** Idle / just-copied / copy-was-refused. Drives the label and nothing else. */
+type CopyStatus = "idle" | "copied" | "failed";
+
+const DEFAULT_REVERT_MS = 2_000;
+
+const LABELS: Readonly> = {
+ idle: "[copy]",
+ copied: "[copied!]",
+ failed: "[copy failed]",
+};
+
+/**
+ * The per-message copy affordance in the chat log.
+ *
+ * **Why a button at all.** Mouse reporting takes the terminal's own
+ * drag-to-select away (see `mouse-tracking.ts`), and "I want that reply
+ * on my clipboard" is overwhelmingly the reason anyone selects text in a
+ * chat TUI. A button answers that intent directly and, unlike a
+ * selection, copies the message *source* — the raw text, not the
+ * markdown-rendered, border-decorated, hard-wrapped thing on screen,
+ * which is what a drag would have given you.
+ *
+ * **Why brackets and no colour.** `[copy]` in the palette's `muted`
+ * grey, dimmed, is the quietest thing that still reads as a control.
+ * There is one of these under every message; anything with hue would
+ * turn the transcript into a column of badges. "Dark grey" is expressed
+ * as a theme token rather than a literal because the four light palettes
+ * would swallow a literal `#555` whole — `muted` + `dimColor` is the
+ * darkest grey each palette actually has.
+ *
+ * **Without a mouse provider** (component tests, `--no-mouse`) the
+ * button still renders — it is a legible hint that the message has a
+ * copy affordance when the mouse is on — but registers no target.
+ */
+export function ChatCopyButton({
+ text,
+ revertAfterMs = DEFAULT_REVERT_MS,
+}: ChatCopyButtonProps): ReactElement {
+ const clipboard = useClipboard();
+ const mouse = useMouseCommands();
+ const [status, flash] = useTransientStatus("idle", revertAfterMs);
+
+ const copy = useCallback(() => {
+ // Fire-and-forget: the click handler runs outside React's render
+ // pass and the clipboard write can outlive the frame. `flash` is the
+ // only thing that touches state, and it no-ops after unmount.
+ void clipboard
+ .copy(text)
+ .then((ok) => flash(ok ? "copied" : "failed"))
+ .catch(() => flash("failed"));
+ }, [clipboard, text, flash]);
+
+ const label = (
+
+ {LABELS[status]}
+
+ );
+
+ // A row wrapper, not a column child: in a column Yoga stretches the
+ // target to the full chat width and every click on the line would
+ // copy. In a row it hugs the six cells the label actually occupies.
+ return (
+
+ {mouse ? (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ copy();
+ return true;
+ }}
+ >
+ {label}
+
+ ) : (
+ label
+ )}
+
+ );
+}
diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx
index 8cd25f51..7dfcd6e2 100644
--- a/src/tui/components/chat-log.test.tsx
+++ b/src/tui/components/chat-log.test.tsx
@@ -25,7 +25,11 @@ describe("ChatLog", () => {
const state = createInitialTuiState(BASE_SESSION);
const { lastFrame } = render();
const text = strip(lastFrame() ?? "");
- expect(text).toContain("Local-First AI Agent");
+ // The mark shrinks with the surface — shaded art at full size, half
+ // block art below it — so assert that *some* mark is drawn rather
+ // than on a wordmark only a tall terminal earns. See
+ // `splash-fit.render.test.tsx`.
+ expect(text).toMatch(/:::|[█▀▄]/u);
expect(text).toContain("/help");
});
@@ -57,6 +61,35 @@ describe("ChatLog", () => {
expect(text).toContain("hi there");
});
+ it("offers [try again] beside [copy] on a user message, sharing one row", () => {
+ const state: TuiState = {
+ ...createInitialTuiState(BASE_SESSION),
+ messages: [{ id: "m1", role: "user", text: "list the files", timestamp: 1 }],
+ };
+ const { lastFrame } = render();
+ const text = strip(lastFrame() ?? "");
+ const footer = text.split("\n").find((line) => line.includes("[copy]"));
+ // Same row, not merely present: `estimateMessageHeight` charges one
+ // footer row per message, and Ink 7 paints an over-tall frame's later
+ // lines over its earlier ones instead of clipping it.
+ expect(footer).toContain("[try again]");
+ });
+
+ it("keeps [try again] off assistant and system messages", () => {
+ const state: TuiState = {
+ ...createInitialTuiState(BASE_SESSION),
+ messages: [
+ { id: "m1", role: "assistant", text: "hi there", toolSteps: 0, timestamp: 1 },
+ { id: "m2", role: "system", text: "turn finished", timestamp: 2 },
+ ],
+ };
+ const { lastFrame } = render();
+ const text = strip(lastFrame() ?? "");
+ // Both still copyable; neither carries a command to re-run.
+ expect(text.split("[copy]").length - 1).toBe(2);
+ expect(text).not.toContain("[try again]");
+ });
+
it("hides the `reply` tool card so it does not duplicate the assistant bubble", () => {
const state: TuiState = {
...createInitialTuiState(BASE_SESSION),
diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx
index 09f0e081..3cbdd5fb 100644
--- a/src/tui/components/chat-log.tsx
+++ b/src/tui/components/chat-log.tsx
@@ -1,14 +1,17 @@
import { Box, Text, measureElement, type DOMElement } from "ink";
import { useEffect, useRef, useState, type ReactElement } from "react";
import { useTerminalSize } from "../hooks/use-terminal-size.js";
+import { computeChatViewportRows } from "../layout.js";
import type { TuiAction } from "../tui-action.js";
import type { ChatMessage, TuiState } from "../tui-state.js";
import { theme } from "../theme/theme.js";
import { AssistantBubble } from "./assistant-bubble.js";
+import { ChatCopyButton } from "./chat-copy-button.js";
import {
estimateMessageHeight,
estimateStreamingTailHeight,
} from "./chat-message-height.js";
+import { ChatTryAgainButton } from "./chat-try-again-button.js";
import { ReasoningBubble } from "./reasoning-bubble.js";
import { SplashBanner } from "./splash-banner.js";
import { SystemBubble } from "./system-bubble.js";
@@ -16,15 +19,6 @@ import { ThinkingIndicator } from "./thinking-indicator.js";
import { ToolCard } from "./tool-card.js";
import { UserBubble } from "./user-bubble.js";
-/**
- * Rows of "chrome" outside the chat surface: status bar + prompt
- * meta-row + prompt input + prompt tail-cap + hotkey hint + a small
- * safety pad. Used to convert `terminal.rows` into the chat-area
- * viewport height. Slightly conservative — better to leave one empty
- * row than to clip the prompt.
- */
-const CHROME_ROWS = 8;
-
interface ChatLogProps {
state: TuiState;
/**
@@ -89,7 +83,10 @@ export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement {
// All hooks must run unconditionally — only the JSX branches on
// `isEmpty`. Compute viewport / measured-K / clamp regardless,
// even when the early return for the splash branch fires below.
- const viewport = Math.max(5, terminalSize.rows - CHROME_ROWS);
+ const viewport = computeChatViewportRows(
+ terminalSize.rows,
+ terminalSize.columns,
+ );
// First-frame fallback for `K` until the post-mount `measureElement`
// call returns the truth. Estimates are unreliable (text wraps, Yoga
// collapses some margins, reasoning blocks expand mid-turn) so we
@@ -170,12 +167,36 @@ interface FinalisedMessageProps {
toolsExpandedById: Readonly>;
}
+/**
+ * One finalised message plus its footer of affordances.
+ *
+ * The buttons hang below the bubble rather than inside it so the bubble
+ * components stay purely presentational, and so the text they act on is
+ * the message's own `text` — the raw source, before markdown rendering,
+ * borders and wrapping. They are attached only to **finalised**
+ * messages: the streaming tail is by definition half a message, and a
+ * copy taken mid-stream would silently truncate.
+ *
+ * `[try again]` joins `[copy]` on user messages only — the roles differ
+ * in whether re-sending their text means anything, and the argument is
+ * written out in `chat-try-again-button.tsx`. Both buttons share one
+ * row, so the footer costs the same single row for every role and
+ * `estimateMessageHeight` stays role-blind.
+ */
function FinalisedMessage({
message,
toolsExpandedById,
}: FinalisedMessageProps): ReactElement {
if (message.role === "user") {
- return ;
+ return (
+
+
+
+
+
+
+
+ );
}
if (message.role === "assistant") {
return (
@@ -207,14 +228,18 @@ function FinalisedMessage({
text={message.text}
toolSteps={message.toolSteps ?? 0}
/>
+
);
}
return (
-
+
+
+
+
);
}
diff --git a/src/tui/components/chat-message-height.test.ts b/src/tui/components/chat-message-height.test.ts
index 218fe73a..1e2b5f0a 100644
--- a/src/tui/components/chat-message-height.test.ts
+++ b/src/tui/components/chat-message-height.test.ts
@@ -28,8 +28,8 @@ function assistantMsg(
describe("estimateMessageHeight", () => {
it("counts a single-line user message as body + bubble overhead", () => {
const h = estimateMessageHeight(userMsg("u1", "hello"));
- // 1 body + 3 overhead (margin + 2 padding)
- expect(h).toBe(4);
+ // 1 body + 3 overhead (margin + 2 padding) + 1 copy-button row
+ expect(h).toBe(5);
});
it("counts assistant footer when toolSteps > 0", () => {
@@ -70,9 +70,9 @@ describe("selectVisibleMessages", () => {
userMsg("u3", "c"),
userMsg("u4", "d"),
];
- // Each 1-line user message costs 4 rows. Budget for 2 messages
- // exactly: 8 rows.
- const slice = selectVisibleMessages(msgs, 0, 8);
+ // Each 1-line user message costs 5 rows (4 of bubble + the copy
+ // button under it). Budget for 2 messages exactly: 10 rows.
+ const slice = selectVisibleMessages(msgs, 0, 10);
expect(slice.visible.map((m) => m.id)).toEqual(["u3", "u4"]);
expect(slice.hiddenAbove).toBe(2);
});
@@ -92,8 +92,8 @@ describe("selectVisibleMessages", () => {
it("respects multiline body length", () => {
const longMsg = userMsg("u1", "line1\nline2\nline3\nline4\nline5");
- // 5 body + 3 overhead = 8 rows.
- expect(estimateMessageHeight(longMsg)).toBe(8);
+ // 5 body + 3 overhead + 1 copy button = 9 rows.
+ expect(estimateMessageHeight(longMsg)).toBe(9);
const slice = selectVisibleMessages(
[longMsg, userMsg("u2", "tail")],
0,
diff --git a/src/tui/components/chat-message-height.ts b/src/tui/components/chat-message-height.ts
index d9236814..e837a9c0 100644
--- a/src/tui/components/chat-message-height.ts
+++ b/src/tui/components/chat-message-height.ts
@@ -25,6 +25,17 @@ const BUBBLE_OVERHEAD_ROWS = 3; // marginTop + paddingTop + paddingBottom
const REASONING_BUBBLE_OVERHEAD_ROWS = 5; // marginTop + paddingTop + 1-line header + paddingBottom + safety
const TOOL_CARD_BASE_ROWS = 2;
const ASSISTANT_FOOTER_ROWS = 1;
+/**
+ * `FinalisedMessage` hangs a button footer under every finalised bubble,
+ * whatever the role: `[copy]` everywhere, `[try again]` beside it on
+ * user messages. The two share a row, so this stays one row and
+ * unconditional — the day a role earns a second footer line this
+ * estimate has to learn about roles, and an under-count is not cosmetic:
+ * Ink 7 paints an over-tall frame's later lines over its earlier ones
+ * instead of clipping. The streaming tail has no footer at all, which is
+ * why the row is charged here and not in `estimateStreamingTailHeight`.
+ */
+const MESSAGE_FOOTER_ROWS = 1;
function bodyLines(text: string): number {
if (text.length === 0) return 1;
@@ -33,7 +44,7 @@ function bodyLines(text: string): number {
export function estimateMessageHeight(message: ChatMessage): number {
const bodyRows = bodyLines(message.text);
- let total = bodyRows + BUBBLE_OVERHEAD_ROWS;
+ let total = bodyRows + BUBBLE_OVERHEAD_ROWS + MESSAGE_FOOTER_ROWS;
if (message.role === "assistant") {
if (message.reasoningBlocks && message.reasoningBlocks.length > 0) {
total += REASONING_BUBBLE_OVERHEAD_ROWS + 1;
diff --git a/src/tui/components/chat-try-again-button.test.tsx b/src/tui/components/chat-try-again-button.test.tsx
new file mode 100644
index 00000000..3462644a
--- /dev/null
+++ b/src/tui/components/chat-try-again-button.test.tsx
@@ -0,0 +1,237 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import type { ReactElement, ReactNode } from "react";
+import { reduceTuiState } from "../agent-event-reducer.js";
+import type { TuiMouseEvent } from "../mouse/mouse-event.js";
+import { MouseProvider } from "../mouse/mouse-context.js";
+import { MouseTargetRegistry } from "../mouse/mouse-registry.js";
+import type { TuiAction } from "../tui-action.js";
+import type { TuiAppCallbacks } from "../tui-app.js";
+import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "../tui-state.js";
+import { ChatTryAgainButton } from "./chat-try-again-button.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: "again",
+ workingDir: "/tmp/again",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+function strip(value: string): string {
+ return value.replace(/\[[0-9;]*m/g, "");
+}
+
+/** Screen cell of `needle` — the position a terminal reports for a click. */
+function locate(frame: string, needle: string): { x: number; y: number } {
+ for (const [y, line] of frame.split("\n").entries()) {
+ const x = line.indexOf(needle);
+ if (x !== -1) return { x, y };
+ }
+ throw new Error(`"${needle}" is not on screen:\n${frame}`);
+}
+
+function click(x: number, y: number): TuiMouseEvent {
+ return {
+ kind: "press",
+ button: "left",
+ wheel: null,
+ x,
+ y,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ };
+}
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Ink commits frames on a throttle and the effect that registers a click
+ * target runs after the frame the label first appears in, so nothing
+ * here sleeps a fixed interval — it polls. Same reason
+ * `chat-copy-button.test.tsx` and `mouse-app.test.tsx` re-send clicks.
+ */
+async function waitUntil(
+ condition: () => boolean,
+ what: string,
+ timeoutMs = 10_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (condition()) return;
+ await delay(25);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+interface Harness {
+ frame: () => string;
+ /** Clicks the label until the registry actually owns those cells. */
+ clickUntil: (needle: string, landed: () => boolean) => Promise;
+ clickOnce: (needle: string) => void;
+ state: () => TuiState;
+ actions: TuiAction[];
+ submitted: string[];
+ steered: string[];
+ unmount: () => void;
+}
+
+function mount(
+ children: ReactNode,
+ { withMouse = true, initial }: { withMouse?: boolean; initial?: TuiState } = {},
+): Harness {
+ const registry = new MouseTargetRegistry();
+ // A real reducer behind the provider: the point of these tests is what
+ // the submit path does to `TuiState`, and a stub dispatch would assert
+ // only that the component called something.
+ let state = initial ?? createInitialTuiState(SESSION);
+ const actions: TuiAction[] = [];
+ const dispatch = (action: TuiAction): void => {
+ actions.push(action);
+ state = reduceTuiState(state, action);
+ };
+ const submitted: string[] = [];
+ const steered: string[] = [];
+ const callbacks: TuiAppCallbacks = {
+ onApprovalDecision: () => {},
+ onAbort: () => {},
+ onQuit: () => {},
+ onMessageSubmitted: (text) => submitted.push(text),
+ onMessageSteered: (text) => steered.push(text),
+ };
+ const tree: ReactElement = withMouse ? (
+ state}
+ >
+ {children}
+
+ ) : (
+ <>{children}>
+ );
+ const { lastFrame, unmount } = render(tree);
+ const frame = (): string => strip(lastFrame() ?? "");
+ const clickOnce = (needle: string): void => {
+ const at = locate(frame(), needle);
+ registry.dispatch(click(at.x, at.y));
+ };
+ return {
+ frame,
+ clickOnce,
+ clickUntil: async (needle, landed) => {
+ await waitUntil(() => frame().includes(needle), `the ${needle} label`);
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ if (landed()) return;
+ clickOnce(needle);
+ await delay(25);
+ }
+ throw new Error(`click never took effect on ${needle}`);
+ },
+ state: () => state,
+ actions,
+ submitted,
+ steered,
+ unmount,
+ };
+}
+
+describe("ChatTryAgainButton", () => {
+ it("renders the quiet idle label", () => {
+ const app = mount();
+ expect(app.frame()).toContain("[try again]");
+ app.unmount();
+ });
+
+ it("still renders without a mouse provider", () => {
+ const app = mount(, { withMouse: false });
+ expect(app.frame()).toContain("[try again]");
+ app.unmount();
+ });
+
+ it("re-sends the message through the normal submit path", async () => {
+ const app = mount();
+ await app.clickUntil("[try again]", () => app.submitted.length > 0);
+ expect(app.submitted).toEqual(["list the files"]);
+ // `message_submitted` is what Enter dispatches — the re-run starts a
+ // real turn rather than poking the orchestrator behind the reducer.
+ expect(app.actions.map((a) => a.type)).toContain("message_submitted");
+ await waitUntil(() => app.frame().includes("[sent]"), "the sent badge");
+ app.unmount();
+ });
+
+ it("keeps an unsent draft in the composer", async () => {
+ const initial: TuiState = {
+ ...createInitialTuiState(SESSION),
+ inputValue: "half-written thought",
+ };
+ const app = mount(, { initial });
+ await app.clickUntil("[try again]", () => app.submitted.length > 0);
+ expect(app.submitted).toEqual(["run that again"]);
+ // Submitting blanks `inputValue` (`startNewRun`); the draft is put
+ // back afterwards, so the re-run costs a turn and not the operator's
+ // half-typed message.
+ expect(app.state().inputValue).toBe("half-written thought");
+ app.unmount();
+ });
+
+ it("steers into the running turn when that is what Enter would do", async () => {
+ const initial: TuiState = {
+ ...createInitialTuiState(SESSION),
+ status: "running",
+ whileBusyMode: "steer",
+ };
+ const app = mount(, { initial });
+ await app.clickUntil("[try again]", () => app.steered.length > 0);
+ expect(app.steered).toEqual(["try that again"]);
+ // Not a second turn: the routing is `handleEditorSubmit`'s, not ours.
+ expect(app.submitted).toEqual([]);
+ expect(app.state().status).toBe("running");
+ app.unmount();
+ });
+
+ it("queues into the running turn when that is what Enter would do", async () => {
+ const initial: TuiState = {
+ ...createInitialTuiState(SESSION),
+ status: "running",
+ whileBusyMode: "queue",
+ };
+ const app = mount(, { initial });
+ await app.clickUntil("[try again]", () => app.submitted.length > 0);
+ expect(app.steered).toEqual([]);
+ expect(app.state().queuedMessages).toEqual(["and again"]);
+ app.unmount();
+ });
+
+ it("ignores the second press of a double-click, then re-arms", async () => {
+ const app = mount(
+ ,
+ );
+ // The first send starts a turn, so the second one steers into it —
+ // count both landings, since which one fires is the submit path's
+ // decision and this test is about how many times it was asked.
+ const sends = (): number => app.submitted.length + app.steered.length;
+ await app.clickUntil("[try again]", () => sends() > 0);
+ await waitUntil(() => app.frame().includes("[sent]"), "the sent badge");
+ // A terminal reports a double-click as two presses; a turn is not
+ // free, so the badge window swallows the second one.
+ app.clickOnce("[sent]");
+ await delay(50);
+ expect(sends()).toBe(1);
+ // The guard is a window, not a latch.
+ await waitUntil(
+ () => app.frame().includes("[try again]"),
+ "the label re-arming",
+ );
+ await app.clickUntil("[try again]", () => sends() > 1);
+ expect(app.submitted).toEqual(["expensive turn"]);
+ expect(app.steered).toEqual(["expensive turn"]);
+ app.unmount();
+ });
+});
diff --git a/src/tui/components/chat-try-again-button.tsx b/src/tui/components/chat-try-again-button.tsx
new file mode 100644
index 00000000..20855b96
--- /dev/null
+++ b/src/tui/components/chat-try-again-button.tsx
@@ -0,0 +1,141 @@
+import { Box, Text } from "ink";
+import { type ReactElement } from "react";
+import { useTransientStatus } from "../hooks/use-transient-status.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import {
+ MouseTarget,
+ useMouseCommands,
+ type MouseContextValue,
+} from "../mouse/mouse-context.js";
+import { handleEditorSubmit } from "../submit-handler.js";
+import { theme } from "../theme/theme.js";
+
+interface ChatTryAgainButtonProps {
+ /** The message source, resent verbatim — byte for byte what was sent before. */
+ readonly text: string;
+ /** How long `sent` stays up before the label reverts. */
+ readonly revertAfterMs?: number;
+}
+
+/** Idle / just-resent. The badge is also the double-click guard. */
+type TryAgainStatus = "idle" | "sent";
+
+const DEFAULT_REVERT_MS = 2_000;
+
+const LABELS: Readonly> = {
+ idle: "[try again]",
+ sent: "[sent]",
+};
+
+/**
+ * Re-run `text` exactly as if it had been typed into the composer and
+ * submitted with Enter.
+ *
+ * **One submit path.** Everything goes through `handleEditorSubmit`, the
+ * function Enter calls, so a re-run inherits whatever routing the
+ * operator has configured instead of inventing a third behaviour: idle
+ * starts a turn; while a turn is running `tui.whileBusySubmit` (Ctrl+T)
+ * decides between steering the text into the turn in flight and parking
+ * it in the queue. The same rule covers the odd cases for free — a
+ * message that happens to read as a slash command runs as one, because
+ * that is what typing it would do, and a second interpretation of the
+ * same text is exactly how two submit paths drift apart.
+ *
+ * **The composer draft survives.** Every landing that path dispatches
+ * blanks `inputValue` — `startNewRun`, `message_queued` and
+ * `message_steered` all do — which would silently eat a half-written
+ * message the operator had not sent yet. The draft is snapshotted before
+ * the submit and written back after it, so a re-run costs a turn and
+ * nothing else. Restoring the buffer alone is enough: a draft that would
+ * also need slash-palette state restored cannot reach this handler at
+ * all, because `TuiApp` raises the mouse floor to `MOUSE_LAYER_MODAL`
+ * while the palette is open and this button sits on the base layer.
+ */
+export function resubmitChatMessage(
+ text: string,
+ mouse: MouseContextValue,
+): void {
+ // Read state at click time, not render time: the handler fires outside
+ // React's render pass and the turn may have started or finished since
+ // the frame that painted the button.
+ const state = mouse.getState();
+ const draft = state.inputValue;
+ handleEditorSubmit(text, state, mouse.dispatch, mouse.callbacks);
+ if (draft.length > 0) {
+ mouse.dispatch({ type: "input_changed", value: draft });
+ }
+}
+
+/**
+ * The per-message "run that again" affordance, beside `[copy]`.
+ *
+ * **Only user messages get one**, which is `chat-log.tsx`'s call to
+ * make, not this component's — but the reasoning belongs next to the
+ * code it explains. A user message is a command someone gave the agent,
+ * so re-running it is a real intent: the model wandered off, a file
+ * changed, a tool was down. An assistant message is the agent's own
+ * prose; sending it back would open a turn whose prompt is the previous
+ * answer, which is not "try again" in any sense an operator means. A
+ * system message is TUI runtime output — queue listings, turn-failed
+ * lines — and re-sending one as a prompt is worse than nonsense. Asking
+ * the model to have another go at the *same* question is a different
+ * feature (it has to drop the last turn, not append one) and it is not
+ * this button.
+ *
+ * **Why a badge when the click already changes the screen.** Often it
+ * does not. A steered message is not rendered until the loop applies it
+ * at the next step boundary (`steer_applied`), which can be seconds
+ * away, so a click with no feedback reads as a dead button and gets
+ * clicked again. `[sent]` closes that gap and doubles as the guard:
+ * clicks are ignored while it is up, so the double-click a terminal
+ * reports as two presses cannot open two turns.
+ *
+ * **Without a mouse provider** (component tests, `--no-mouse`) it still
+ * renders, exactly like `[copy]` — a legible hint that the affordance is
+ * there when the mouse is on — but registers no target.
+ */
+export function ChatTryAgainButton({
+ text,
+ revertAfterMs = DEFAULT_REVERT_MS,
+}: ChatTryAgainButtonProps): ReactElement {
+ const mouse = useMouseCommands();
+ const [status, flash] = useTransientStatus(
+ "idle",
+ revertAfterMs,
+ );
+
+ const label = (
+
+ {LABELS[status]}
+
+ );
+
+ // One space off `[copy]`, on the same row: the footer stays a single
+ // line whatever the role, so `estimateMessageHeight` does not have to
+ // branch — and an under-counted row is not a cosmetic bug in Ink 7,
+ // which paints an over-tall frame's later lines over its earlier ones
+ // rather than clipping.
+ return (
+
+ {mouse ? (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ // Claim the press either way — the click landed on this
+ // button, and letting it fall through would hand it to the
+ // viewport wheel target behind the chat log.
+ if (status !== "idle") return true;
+ resubmitChatMessage(text, mouse);
+ flash("sent");
+ return true;
+ }}
+ >
+ {label}
+
+ ) : (
+ label
+ )}
+
+ );
+}
diff --git a/src/tui/components/cloud-provider-onboarding.tsx b/src/tui/components/cloud-provider-onboarding.tsx
index fbb57fb3..dec58a9c 100644
--- a/src/tui/components/cloud-provider-onboarding.tsx
+++ b/src/tui/components/cloud-provider-onboarding.tsx
@@ -1,34 +1,60 @@
import { Box, Text, useInput } from "ink";
-import { useCallback, useState, type ReactElement } from "react";
+import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
import { handleProvidersWizardKey } from "../providers/providers-wizard-key-bindings.js";
import { createProvidersWizardState } from "../providers/providers-wizard-state.js";
import type { ProvidersWizardState } from "../providers/providers-wizard-state.js";
import { saveProviderWizardToConfig } from "../providers/save-provider-wizard.js";
+import { verifyWizardBeforeSave } from "../providers/verify-wizard-before-save.js";
import { theme } from "../theme/theme.js";
import { ProvidersWizard } from "./providers-wizard.js";
export type CloudProviderOnboardingOutcome = "saved_cloud" | "aborted";
export function CloudProviderOnboarding(props: {
- onFinished(outcome: CloudProviderOnboardingOutcome): void;
+ /** `notice` carries a key that was saved without a completed check. */
+ onFinished(outcome: CloudProviderOnboardingOutcome, notice?: string): void;
onBack(): void;
}): ReactElement {
const [wizard, setWizard] = useState(() =>
createProvidersWizardState("add"),
);
const [submitting, setSubmitting] = useState(false);
+ const verifyAbort = useRef(null);
+ const alive = useRef(true);
+ useEffect(() => {
+ return () => {
+ alive.current = false;
+ verifyAbort.current?.abort();
+ };
+ }, []);
const submit = useCallback(
- (nextWizard: ProvidersWizardState) => {
+ async (nextWizard: ProvidersWizardState) => {
if (submitting) return;
setSubmitting(true);
+ const abort = new AbortController();
+ verifyAbort.current = abort;
try {
+ // First run goes through the same gate as the Providers tab, so
+ // a dead key cannot be the one the agent starts life with.
+ const gate = await verifyWizardBeforeSave(nextWizard, {
+ signal: abort.signal,
+ });
+ if (!alive.current) return;
+ if (!gate.proceed) {
+ setWizard({ ...nextWizard, error: gate.error, submitting: false });
+ setSubmitting(false);
+ return;
+ }
saveProviderWizardToConfig(nextWizard);
- props.onFinished("saved_cloud");
+ props.onFinished("saved_cloud", gate.warning ?? undefined);
} catch (err) {
+ if (!alive.current) return;
const message = err instanceof Error ? err.message : String(err);
setWizard({ ...nextWizard, error: message, submitting: false });
setSubmitting(false);
+ } finally {
+ if (verifyAbort.current === abort) verifyAbort.current = null;
}
},
[props, submitting],
@@ -36,6 +62,7 @@ export function CloudProviderOnboarding(props: {
useInput((input, key) => {
if (key.ctrl && input === "c") {
+ verifyAbort.current?.abort();
props.onFinished("aborted");
return;
}
@@ -46,8 +73,19 @@ export function CloudProviderOnboarding(props: {
props.onBack();
return;
}
- if (result.submit) {
- submit(result.wizard);
+ if ("cancelSubmit" in result && result.cancelSubmit) {
+ verifyAbort.current?.abort();
+ verifyAbort.current = null;
+ setSubmitting(false);
+ setWizard({
+ ...wizard,
+ submitting: false,
+ error: "Key check cancelled — press Enter to try again.",
+ });
+ return;
+ }
+ if ("submit" in result && result.submit) {
+ void submit(result.wizard);
return;
}
setWizard(result.wizard);
diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx
index 0946055c..0945b713 100644
--- a/src/tui/components/debug-pane.tsx
+++ b/src/tui/components/debug-pane.tsx
@@ -1,6 +1,9 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { useTerminalSize } from "../hooks/use-terminal-size.js";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js";
import { EventFeed } from "../event-feed.js";
import { LogsTab } from "../logs-tab.js";
import { ReasoningTab } from "../reasoning-tab.js";
@@ -76,32 +79,60 @@ function SubTabBar({ state, section }: SubTabBarProps): ReactElement | null {
const tabs =
section === "manage" ? buildManageTabs(state) : buildObserveTabs(state);
return (
-
- {tabs.map((tab, idx) => {
- const active = tab.id === state.activeTab;
- return (
-
-
- {active ? `${theme.glyphs.chevronRight} ` : " "}
- {tab.label}
+
+ {tabs.map((tab, idx) => (
+
+
+ {idx < tabs.length - 1 ? (
+
+ {" "}
+ {theme.glyphs.pipeSeparator}
+ {" "}
- {idx < tabs.length - 1 ? (
-
- {" "}
- {theme.glyphs.pipeSeparator}
- {" "}
-
- ) : null}
-
- );
- })}
+ ) : null}
+
+ ))}
);
}
+/**
+ * One sub-tab. Split out of the strip so each label owns a measurable
+ * box the mouse layer can hit — clicking a tab performs the same
+ * dispatch Tab-cycling does.
+ */
+function SubTabLabel({
+ tab,
+ active,
+}: {
+ tab: SubTab;
+ active: boolean;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {active ? `${theme.glyphs.chevronRight} ` : " "}
+ {tab.label}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ if (!active) mouse.dispatch({ type: "tab_changed", tab: tab.id });
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
interface SubTab {
id: TuiTab;
label: string;
@@ -138,7 +169,7 @@ function buildManageTabs(state: TuiState): SubTab[] {
* terminal — it overlaps/garbles earlier lines instead (verified) — so
* the per-tab budget must subtract this accurately and err generous.
*/
-const APP_CHROME_ROWS = 9;
+export const APP_CHROME_ROWS = 9;
/**
* Height consumed INSIDE the debug pane above the active tab: the
* `SubTabBar` (1 row) + the `DebugDiagnosticsLine`. The diagnostics line
diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx
index 8611ee66..9a170711 100644
--- a/src/tui/components/hotkey-hint.test.tsx
+++ b/src/tui/components/hotkey-hint.test.tsx
@@ -116,3 +116,90 @@ describe("HotkeyHint scroll key spelling per platform", () => {
expect(out).not.toContain(MAC_SCROLL_KEY);
});
});
+
+describe("HotkeyHint esc chip", () => {
+ it("names the menu when that is what Esc will actually do", () => {
+ const out = renderHint(chatState());
+ expect(out).toMatch(/esc\]\s*menu/);
+ });
+
+ it("names the cancel while there is a draft to cancel", () => {
+ // The whole point of the chip is that Esc changed meaning; a fixed
+ // label would be a lie in one state or the other.
+ const out = renderHint(chatState({ inputValue: "half a thought" }));
+ expect(out).toMatch(/esc\]\s*cancel/);
+ expect(out).not.toMatch(/esc\]\s*menu/);
+ });
+
+ it("names the cancel while the transcript is scrolled back", () => {
+ const out = renderHint(chatState({ chatScrollOffset: 4 }));
+ expect(out).toMatch(/esc\]\s*cancel/);
+ });
+
+ it("does not flinch when a popup floats over the chat", () => {
+ // The row describes the surface behind the menu, not the menu — and a
+ // strip that rewrites itself when a floating window opens would make
+ // the frame below the popup move, which is exactly what the popup is
+ // built not to do.
+ const out = renderHint(chatState({ menuOpen: true }));
+ expect(out).toMatch(/esc\]\s*menu/);
+ });
+
+ it("leaves the abort chip alone while a turn is running", () => {
+ const out = renderHint(chatState({ status: "running" }));
+ expect(out).toMatch(/esc\]\s*abort/);
+ });
+});
+
+describe("HotkeyHint queue affordances", () => {
+ it("advertises what Enter does now that the editor stays live mid-run", () => {
+ const steering = renderHint(chatState({ status: "running" }));
+ expect(steering).toContain("⏎");
+ expect(steering).toContain("steer");
+ const queueing = renderHint(
+ chatState({ status: "running", whileBusyMode: "queue" }),
+ );
+ expect(queueing).toContain("queue");
+ });
+
+ it("offers ctrl+t as the way to flip to the other mode", () => {
+ const steering = renderHint(chatState({ status: "running" }));
+ expect(steering).toMatch(/ctrl\+t\]\s*queue/);
+ const queueing = renderHint(
+ chatState({ status: "running", whileBusyMode: "queue" }),
+ );
+ expect(queueing).toMatch(/ctrl\+t\]\s*steer/);
+ });
+
+ it("shows how many messages are parked behind the turn", () => {
+ const out = renderHint(
+ chatState({ status: "running", queuedMessages: ["a", "b"] }),
+ );
+ expect(out).toContain("queued");
+ expect(out).toContain("2");
+ });
+
+ it("hides the parked chip when the queue is empty", () => {
+ const out = renderHint(chatState({ status: "running" }));
+ expect(out).not.toContain("queued");
+ });
+
+ it("stays on one row at 80 columns while running with a full queue", () => {
+ // The strip is a single-row affordance; wrapping pushes the prompt
+ // down and reads as a layout bug.
+ const out = renderHint(
+ chatState({ status: "running", queuedMessages: ["a", "b", "c"] }),
+ );
+ expect(out.split("\n").filter((l) => l.trim().length > 0)).toHaveLength(1);
+ });
+
+ it("gives an armed ctrl+c the whole row", () => {
+ const { lastFrame, unmount } = render(
+ ,
+ );
+ const out = (lastFrame() ?? "").replace(ANSI, "");
+ unmount();
+ expect(out).toContain("press again to quit");
+ expect(out).not.toContain("ctrl+t");
+ });
+});
diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx
index 03f093ea..eda66a6c 100644
--- a/src/tui/components/hotkey-hint.tsx
+++ b/src/tui/components/hotkey-hint.tsx
@@ -1,5 +1,17 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import {
+ applyNavSlot,
+ decideApproval,
+ escapeHasNothingToCancel,
+} from "../app-key-bindings.js";
+import {
+ MouseTarget,
+ useMouseCommands,
+ type MouseContextValue,
+} from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { cycleNavSlot } from "../section.js";
import { theme } from "../theme/theme.js";
import type { TuiState } from "../tui-state.js";
@@ -7,11 +19,24 @@ interface HotkeyHintProps {
state: TuiState;
/** Whether a Ctrl+C was recently pressed and is armed for exit. */
ctrlCArmed?: boolean;
+ /**
+ * Whether the left rail is on screen. Below its width threshold there
+ * is no rail, and a `tab sidebar` chip would name a surface the
+ * operator cannot see.
+ */
+ sidebarVisible?: boolean;
}
interface HotkeyChip {
readonly key: string;
readonly label: string;
+ /**
+ * What a click on this chip does. Only chips with one unambiguous
+ * meaning get one — "ctrl+j newline" or "↑↓ select" describe a
+ * gesture, not a command, so they stay plain text rather than
+ * pretending to be buttons.
+ */
+ readonly onClick?: (mouse: MouseContextValue) => void;
}
/**
@@ -27,16 +52,17 @@ const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgd
* to fit one terminal row and let slash commands take care of the long
* tail.
*/
-export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement {
- const chips = resolveChips(state, ctrlCArmed ?? false);
+export function HotkeyHint({
+ state,
+ ctrlCArmed,
+ sidebarVisible = true,
+}: HotkeyHintProps): ReactElement {
+ const chips = resolveChips(state, ctrlCArmed ?? false, sidebarVisible);
return (
-
+
{chips.map((chip, idx) => (
-
-
- [{chip.key}]
-
- {chip.label}
+
+
{idx < chips.length - 1 ? (
{" "}
@@ -44,20 +70,73 @@ export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement
{" "}
) : null}
-
+
))}
);
}
-function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
+function Chip({ chip }: { chip: HotkeyChip }): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+
+ [{chip.key}]
+
+ {chip.label}
+
+ );
+ if (!mouse || !chip.onClick) return label;
+ const onClick = chip.onClick;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ onClick(mouse);
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
+/** Opens the slash palette exactly the way typing `/` does. */
+/** Click target for the `ctrl+p` chip — the operator menu. */
+function openOperatorMenu(mouse: MouseContextValue): void {
+ mouse.dispatch({ type: "menu_opened" });
+}
+
+function resolveChips(
+ state: TuiState,
+ ctrlCArmed: boolean,
+ sidebarVisible: boolean,
+): HotkeyChip[] {
if (state.pendingApproval) {
+ const approval = state.pendingApproval;
return [
- { key: "y", label: "approve" },
- { key: "n", label: "deny" },
+ {
+ key: "y",
+ label: "approve",
+ onClick: (mouse) => decideApproval(approval, true, mouse),
+ },
+ {
+ key: "n",
+ label: "deny",
+ onClick: (mouse) => decideApproval(approval, false, mouse),
+ },
{ key: "esc", label: "abort run" },
];
}
+ if (state.runModePanel.picker) {
+ return [
+ { key: "↑↓", label: "mode" },
+ { key: "←→", label: "share" },
+ { key: "0-9", label: "set" },
+ { key: "enter", label: "apply" },
+ { key: "esc", label: "cancel" },
+ ];
+ }
if (state.slashPaletteOpen) {
return [
{ key: "↑↓", label: "select" },
@@ -67,25 +146,56 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
}
if (state.status === "running") {
// A long streaming answer is exactly when the operator wants to
- // scroll back, so the hint rides along with abort.
- return [
+ // scroll back, so the hint rides along with abort. The editor stays
+ // live during a run, so this row also has to say what Enter will do
+ // to whatever is being typed, and how to flip it.
+ //
+ // Labels are terse on purpose: the strip must stay on ONE terminal
+ // row at 80 columns. An armed Ctrl+C takes the whole row for itself
+ // — at that moment nothing else matters.
+ if (ctrlCArmed) {
+ return [
+ { key: "ctrl+c", label: "press again to quit" },
+ { key: "esc", label: "abort" },
+ ];
+ }
+ const chips: HotkeyChip[] = [
{ key: SCROLL_KEY, label: "scroll" },
- { key: "esc", label: "abort" },
+ { key: "\u23ce", label: state.whileBusyMode },
{
- key: "ctrl+c",
- label: ctrlCArmed ? "press again to quit" : "abort",
+ key: "ctrl+t",
+ label: state.whileBusyMode === "steer" ? "queue" : "steer",
},
+ { key: "esc", label: "abort" },
];
+ if (state.queuedMessages.length > 0) {
+ chips.push({ key: "queued", label: `${state.queuedMessages.length}` });
+ }
+ return chips;
}
if (state.uiMode === "debug") {
// Ctrl+B still cycles panels but is unadvertised: it duplicated the
// Tab chip word-for-word, and the freed slot pays for the one hint
// panels actually lacked — the way back to Run.
return [
- { key: "tab", label: "next panel" },
- { key: "shift+tab", label: "prev panel" },
- { key: "esc", label: "back to Run" },
- { key: "/", label: "commands" },
+ {
+ key: "tab",
+ label: "next panel",
+ onClick: (mouse) =>
+ applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), 1)),
+ },
+ {
+ key: "shift+tab",
+ label: "prev panel",
+ onClick: (mouse) =>
+ applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), -1)),
+ },
+ {
+ key: "esc",
+ label: "back to Run",
+ onClick: (mouse) => mouse.dispatch({ type: "ui_mode_set", mode: "chat" }),
+ },
+ { key: "ctrl+p", label: "menu", onClick: openOperatorMenu },
{
key: "ctrl+c",
label: ctrlCArmed ? "press again to quit" : "quit",
@@ -104,15 +214,52 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
},
];
}
- // Six chips is the cap for one row on narrow terminals. The scroll
- // hint replaces ctrl+b: Observe stays reachable via /observe, while
- // scrolling had no visible entry point at all.
+ // Six chips is the cap for one row on narrow terminals. `ctrl+p` takes
+ // the slot `/` used to hold: the menu contains every slash command as
+ // well as every destination, so advertising the superset costs nothing
+ // and `/` keeps working for anyone who already reaches for it. ctrl+r
+ // (cycle run mode) stays unadvertised for the same reason ctrl+b was —
+ // the mode strip above the chat is its visible entry point, and the
+ // menu now lists Local / Cloud / Fusion outright.
+ const escapeMenu = escapeHasNothingToCancel(state);
return [
{ key: "enter", label: "send" },
- { key: "alt+enter", label: "newline" },
- { key: "tab", label: "sidebar" },
+ { key: "ctrl+j", label: "newline" },
+ // Tab only reaches the rail when there is one; below its width
+ // threshold the chip would name a surface that is not on screen.
+ ...(sidebarVisible
+ ? [
+ {
+ key: "tab",
+ label: "sidebar",
+ onClick: (mouse: MouseContextValue) =>
+ mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }),
+ },
+ ]
+ : []),
{ key: SCROLL_KEY, label: "scroll" },
- { key: "/", label: "commands" },
+ { key: "ctrl+p", label: "menu", onClick: openOperatorMenu },
+ // Esc means something here and nothing said so. It is the one key an
+ // operator reaches for expecting "get me out of this", and leaving it
+ // off the strip is how it ended up feeling like the quit key. (The
+ // running branch above already advertises it as `abort`.)
+ //
+ // What it means now depends on what is left to cancel, so the chip has
+ // to follow the state instead of naming one fixed action: `cancel`
+ // covers the two things Esc still backs out of on this surface — a
+ // half-typed draft and a transcript scrolled up — and once neither is
+ // there it opens the menu. It shares its body with the binding, so the
+ // label cannot claim one thing while the key does another.
+ //
+ // Deliberately blind to the menu already being open (`escapeOpensMenu`
+ // is not): this whole row describes the chat surface *behind* any
+ // popup — `enter send` and `tab sidebar` are no truer while the menu
+ // has the keyboard — and the popup carries its own `esc close` footer.
+ // Flipping this one word when a floating surface opens would also make
+ // the strip a moving part of a frame that is supposed to sit still.
+ escapeMenu
+ ? { key: "esc", label: "menu", onClick: openOperatorMenu }
+ : { key: "esc", label: "cancel" },
{
key: "ctrl+c",
label: ctrlCArmed ? "press again to quit" : "quit",
diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx
index d58699a5..d0a06221 100644
--- a/src/tui/components/llm-mode-rows.tsx
+++ b/src/tui/components/llm-mode-rows.tsx
@@ -4,9 +4,17 @@ import { selectCloudModelSection } from "../llm-panel/llm-panel-row-builders.js"
import { activeCursor, selectLlmPanelRows, type LlmPanelRow } from "../llm-panel/llm-panel-selectors.js";
import { classifyRamFit, classifyVramFit } from "../local-models/local-models-panel-state.js";
import { computeRowWindow } from "../row-window.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import {
+ MOUSE_LAYER_MODAL,
+ MOUSE_LAYER_PANEL,
+} from "../mouse/mouse-registry.js";
+import { isPanelModalOpen } from "../app-key-bindings.js";
+import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js";
import { theme } from "../theme/theme.js";
import type { TuiState } from "../tui-state.js";
import { FallbackRows } from "./llm-fallback-rows.js";
+import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js";
export function LlmModeRows({
rows,
@@ -350,15 +358,34 @@ function Row({ row, state }: { row: LlmPanelRow; state: TuiState }): ReactElemen
// see `LlmModeRows` — but never guarded the horizontal axis), which is
// what garbles adjacent rows and drags rendering on a narrow window.
return (
-
- {mark} {renderRowText(row, state)}
- {insufficient ? (
- Not enough VRAM
- ) : ramFit === "tight" ? (
- RAM tight
- ) : null}
- · {row.enterEffect}
-
+
+ mouse.dispatch({ type: "llm_cursor_set", cursor: idx })
+ }
+ onActivate={pressEnter(handleLlmPanelKey)}
+ >
+
+ {mark} {renderRowText(row, state)}
+ {insufficient ? (
+ Not enough VRAM
+ ) : ramFit === "tight" ? (
+ RAM tight
+ ) : null}
+ · {row.enterEffect}
+
+
);
}
@@ -373,7 +400,13 @@ function renderRowText(row: LlmPanelRow, state: TuiState): string {
case "localBackend":
return `llama.cpp backend [${state.localModelsPanel.backend.currentTag ?? "not installed"}]`;
case "cloudProvider":
- return `${row.provider.id} [${row.provider.kind}] ${row.provider.hasApiKey ? "key ok" : "missing key"}`;
+ return `${row.provider.id} [${row.provider.kind}] ${
+ row.provider.kind === SUBSCRIPTION_CLI_KIND
+ ? "cli auth"
+ : row.provider.hasApiKey
+ ? "key ok"
+ : "missing key"
+ }`;
case "cloudChatModel":
return `${row.providerId}/${row.modelId} [text]`;
case "cloudEmbeddingModel":
diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx
index be15965d..652d97ea 100644
--- a/src/tui/components/llm-panel-modals.tsx
+++ b/src/tui/components/llm-panel-modals.tsx
@@ -3,7 +3,12 @@ import type { ReactElement, ReactNode } from "react";
import { theme } from "../theme/theme.js";
import type { TuiState } from "../tui-state.js";
import { ProvidersWizard } from "./providers-wizard.js";
-import { parseExternalUrl } from "../llm-panel/llm-panel-modal-key-bindings.js";
+import {
+ handleLlmModalKey,
+ parseExternalUrl,
+} from "../llm-panel/llm-panel-modal-key-bindings.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
import { filteredPickerModels } from "../providers/providers-panel-state.js";
/** Upper bound for the picker's list window (roomy terminals). */
@@ -36,6 +41,30 @@ function blankRows(count: number): ReactElement[] {
));
}
+/**
+ * True when one of the boxes below owns the screen.
+ *
+ * `handleLlmModalKey` returns non-null for exactly these states, i.e.
+ * the panel behind a modal cannot be driven while one is open. It must
+ * therefore not be DRAWN either: the panel already spends the whole tab
+ * budget, so drawing a modal on top of it is a frame taller than the
+ * terminal, and Ink 7 resolves that by overwriting earlier lines rather
+ * than clipping. Callers use this to hand the modal the full budget and
+ * render nothing else.
+ */
+export function hasLlmModal(state: TuiState): boolean {
+ return (
+ state.providersPanel.wizard !== null ||
+ state.providersPanel.removeConfirm !== null ||
+ state.localModelsPanel.embeddingOnboardingPrompt !== null ||
+ state.localModelsPanel.removeConfirmId !== null ||
+ state.localModelsPanel.embeddingRemoveConfirmId !== null ||
+ state.providersPanel.chatModelPicker !== null ||
+ state.llmPanel.externalUrlDraft !== null ||
+ state.llmPanel.stopLocalDaemonsPrompt !== null
+ );
+}
+
export function LlmPanelModals({
state,
maxRows,
@@ -44,7 +73,12 @@ export function LlmPanelModals({
maxRows?: number;
}): ReactElement | null {
if (state.providersPanel.wizard) {
- return ;
+ return (
+
+ );
}
if (state.providersPanel.removeConfirm) {
return (
@@ -151,15 +185,31 @@ export function LlmPanelModals({
// the fixed-height note above. truncate-end keeps a long id
// from wrapping into a second line and changing the height.
return (
-
+ mouse.dispatch({
+ type: "providers_chat_model_picker_cursor_set",
+ cursor: idx,
+ })
+ }
+ onActivate={pressEnter(handleLlmModalKey)}
>
- {selected ? "› " : " "}
- {id}
- {isCurrent ? current : null}
-
+
+ {selected ? "› " : " "}
+ {id}
+ {isCurrent ? current : null}
+
+
);
})}
{blankRows(blanks)}
diff --git a/src/tui/components/llm-panel.test.tsx b/src/tui/components/llm-panel.test.tsx
index 22b4584a..cf6002de 100644
--- a/src/tui/components/llm-panel.test.tsx
+++ b/src/tui/components/llm-panel.test.tsx
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import { createInitialTuiState, type TuiState } from "../tui-state.js";
import { fakeSession } from "../test-fixtures.js";
import type { ProvidersChatModelPickerState } from "../providers/providers-panel-state.js";
+import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js";
+import { createProvidersWizardState } from "../providers/providers-wizard-state.js";
import { LlmPanel } from "./llm-panel.js";
function stateWithPicker(
@@ -115,6 +117,63 @@ describe("LlmPanel", () => {
});
});
+/**
+ * Reported as "there is only aimlapi in the provider list" and "I don't
+ * see OpenRouter on some screen sizes".
+ *
+ * Neither was a missing row: `KIND_ROW_ORDER` has always had all of
+ * them. The wizard was drawn ON TOP of the whole LLM panel, so the frame
+ * ran ~16 rows past the tab budget, and Ink 7 answers an over-tall frame
+ * by painting later lines over earlier ones instead of clipping. Half
+ * the provider rows arrived on screen wearing the tail of the row below
+ * them. The budgets below are what `tabContentBudget` hands the tab at
+ * 120x40, 100x30 and 80x24 — the three sizes the reports came from.
+ */
+describe("the add-provider wizard fits the terminal", () => {
+ function stateWithWizard(): TuiState {
+ const base = createInitialTuiState(fakeSession());
+ return {
+ ...base,
+ uiMode: "debug" as const,
+ activeTab: "llm" as const,
+ llmPanel: { ...base.llmPanel, mode: "cloud" as const },
+ providersPanel: {
+ ...base.providersPanel,
+ wizard: createProvidersWizardState("add"),
+ },
+ };
+ }
+
+ for (const budget of [27, 17, 11]) {
+ it(`never exceeds a ${budget}-row budget`, () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect((lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(budget);
+ });
+ }
+
+ it("still shows OpenRouter and the full-list counter on a short terminal", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const text = stripAnsi(lastFrame() ?? "");
+ expect(text).toContain("OpenRouter");
+ expect(text).toContain(`(1/${KIND_ROW_ORDER.length})`);
+ });
+
+ it("draws the modal alone, not stacked over the panel it covers", () => {
+ // The panel is unreachable while the wizard owns the keyboard, and
+ // drawing it was what spent the row budget twice.
+ const { lastFrame } = render(
+ ,
+ );
+ const text = stripAnsi(lastFrame() ?? "");
+ expect(text).not.toContain("Active chat route");
+ expect(text).not.toContain("n add provider");
+ });
+});
+
describe("model picker fixed height", () => {
const MAX_ROWS = 20;
diff --git a/src/tui/components/llm-panel.tsx b/src/tui/components/llm-panel.tsx
index b0f80a5e..f65809de 100644
--- a/src/tui/components/llm-panel.tsx
+++ b/src/tui/components/llm-panel.tsx
@@ -1,5 +1,7 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
import { theme } from "../theme/theme.js";
import type { TuiState } from "../tui-state.js";
import {
@@ -9,7 +11,7 @@ import {
import type { LocalModelsPanelState } from "../local-models/local-models-panel-state.js";
import { LLM_PANEL_MODES, type LlmPanelMode } from "../llm-panel/llm-panel-state.js";
import { LlmModeRows } from "./llm-mode-rows.js";
-import { LlmPanelModals } from "./llm-panel-modals.js";
+import { hasLlmModal, LlmPanelModals } from "./llm-panel-modals.js";
/**
* Rows consumed by the full fixed chrome: RouteCard (~7) + ModeHeader (3)
@@ -47,9 +49,23 @@ export function LlmPanel({
const useFull = maxRows >= FULL_HEADER_ROWS + FULL_HEADER_MIN_LIST;
const headerRows = useFull ? FULL_HEADER_ROWS : COMPACT_HEADER_ROWS;
const listBudget = Math.max(1, maxRows - headerRows);
+ // A modal takes the whole budget and the panel behind it is not drawn.
+ // The two used to be stacked, which spent the budget twice over: Ink 7
+ // does not clip an over-tall frame, it paints later lines over earlier
+ // ones, so the add-provider list arrived on screen with most of its
+ // rows overwritten by the panel underneath (reports #1 and #2). The
+ // panel is unreachable while a modal is open anyway —
+ // `handleLlmModalKey` claims every key — so nothing is lost by hiding
+ // it, and the modal finally gets a height it can size itself against.
+ if (hasLlmModal(state)) {
+ return (
+
+
+
+ );
+ }
return (
-
{/* The starting banner and active-download banners are important
feedback — keep them visible regardless of the compact/full
header decision. */}
@@ -166,30 +182,65 @@ function footerHint(mode: LlmPanelMode, useFull: boolean): string {
: "j/k · Enter · ←/→ mode · f filter · r";
}
+/**
+ * The Local / Cloud / External / Fallback switcher.
+ *
+ * The active pane used to be marked by colour alone. On a screen this
+ * busy that is not a marker — pressing ←/→ visibly changed the rows
+ * underneath and still read as "nothing happened", which is exactly how
+ * it was reported. It carries the same `▸` every other selected thing in
+ * this TUI carries, and each label is its own clickable box.
+ */
function ModeHeader({ mode }: { mode: LlmPanelMode }): ReactElement {
return (
-
- Mode:{" "}
+
+ Mode:
{LLM_PANEL_MODES.map((candidate, index) => (
-
+
{index > 0 ? | : null}
-
- {MODE_LABELS[candidate]}
-
-
+
+
))}
+
+
+ ←/→ switches pane · click a name to jump straight to it
- Press ←/→ to switch mode
);
}
+function ModeTab({
+ candidate,
+ active,
+}: {
+ candidate: LlmPanelMode;
+ active: boolean;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {active ? `${theme.glyphs.chevronRight} ` : " "}
+ {MODE_LABELS[candidate]}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ mouse.dispatch({ type: "llm_mode_set", mode: candidate });
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
function StatusLines({
state,
compact = false,
diff --git a/src/tui/components/local-models-config-wizard.tsx b/src/tui/components/local-models-config-wizard.tsx
index 3c8678cc..161fc140 100644
--- a/src/tui/components/local-models-config-wizard.tsx
+++ b/src/tui/components/local-models-config-wizard.tsx
@@ -29,7 +29,8 @@ export interface LocalModelsConfigWizardProps {
* configured server really did stop answering.
*/
hadConfiguredBackend?: boolean;
- onFinished(outcome: LocalModelsWizardOutcome): void;
+ /** `notice` carries a warning the caller should print after teardown. */
+ onFinished(outcome: LocalModelsWizardOutcome, notice?: string): void;
}
type WizardPhase = "pick" | "remote-chat-url" | "remote-embedding-url" | "cloud";
@@ -70,8 +71,8 @@ export function LocalModelsConfigWizard({
const [hint, setHint] = useState(null);
const finish = useCallback(
- (outcome: LocalModelsWizardOutcome) => {
- onFinished(outcome);
+ (outcome: LocalModelsWizardOutcome, notice?: string) => {
+ onFinished(outcome, notice);
app.exit();
},
[app, onFinished],
diff --git a/src/tui/components/local-models-panel.tsx b/src/tui/components/local-models-panel.tsx
index 607b15f9..358237ba 100644
--- a/src/tui/components/local-models-panel.tsx
+++ b/src/tui/components/local-models-panel.tsx
@@ -1,5 +1,7 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleLocalModelsTabKey } from "../local-models/local-models-key-bindings.js";
import { theme } from "../theme/theme.js";
import { computeRowWindow } from "../row-window.js";
import {
@@ -595,7 +597,15 @@ function renderChatRow(
// their individual colors; the badges that fall off the edge are
// informational and reappear once the window is widened.
return (
-
+
+ mouse.dispatch({ type: "local_models_cursor_set", row: index })
+ }
+ onActivate={pressEnter(handleLocalModelsTabKey)}
+ >
+
) : null}
+
);
}
@@ -677,7 +688,18 @@ function renderEmbeddingRow(
// See renderChatRow: nowrap + per-fragment truncate-end so a narrow
// window clips the row instead of wrapping and overlapping the next.
return (
-
+
+ mouse.dispatch({
+ type: "local_models_cursor_set",
+ row: embOffset + index,
+ })
+ }
+ onActivate={pressEnter(handleLocalModelsTabKey)}
+ >
+
{isCursor ? "> " : " "}
{r.active ? "* " : ""}
@@ -698,6 +720,7 @@ function renderEmbeddingRow(
) : null}
+
);
}
diff --git a/src/tui/components/logo-fit.test.ts b/src/tui/components/logo-fit.test.ts
new file mode 100644
index 00000000..d44767b8
--- /dev/null
+++ b/src/tui/components/logo-fit.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { LOGO_ART, TAGLINE, WORDMARK_ROWS } from "./logo.js";
+import { LOGO_METRICS, WORDMARK_WIDTH, type LogoVariant } from "./splash-fit.js";
+
+function measure(rows: readonly string[]): { width: number; height: number } {
+ return {
+ width: rows.reduce((acc, row) => Math.max(acc, row.length), 0),
+ height: rows.length,
+ };
+}
+
+/**
+ * `splash-fit.ts` picks a mark from numbers it keeps in `LOGO_METRICS`;
+ * the artwork itself lives in `logo.tsx`. If the two ever drift the
+ * breakpoints silently start lying, so measure the real rows here.
+ */
+describe("logo artwork", () => {
+ const variants: readonly LogoVariant[] = ["full", "small", "mini"];
+
+ it.each(variants)("matches the declared metrics for %s", (variant) => {
+ expect(measure(LOGO_ART[variant])).toEqual(LOGO_METRICS[variant]);
+ });
+
+ it("orders the variants strictly smallest-last", () => {
+ expect(LOGO_METRICS.full.width).toBeGreaterThan(LOGO_METRICS.small.width);
+ expect(LOGO_METRICS.small.width).toBeGreaterThan(LOGO_METRICS.mini.width);
+ expect(LOGO_METRICS.full.height).toBeGreaterThan(LOGO_METRICS.small.height);
+ expect(LOGO_METRICS.small.height).toBeGreaterThan(LOGO_METRICS.mini.height);
+ });
+
+ it("matches the declared wordmark width and keeps the tagline narrower", () => {
+ expect(measure(WORDMARK_ROWS).width).toBe(WORDMARK_WIDTH);
+ expect(TAGLINE.length).toBeLessThanOrEqual(WORDMARK_WIDTH);
+ });
+});
diff --git a/src/tui/components/logo-raster.test.ts b/src/tui/components/logo-raster.test.ts
new file mode 100644
index 00000000..22409edb
--- /dev/null
+++ b/src/tui/components/logo-raster.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+
+import { LOGO_ART } from "./logo.js";
+import { rasteriseMark, toInkMask } from "./logo-raster.js";
+import { LOGO_METRICS } from "./splash-fit.js";
+
+const source = toInkMask(LOGO_ART.full);
+
+/** Ink coverage as a fraction of the box — a crude "does it look like the mark". */
+function density(rows: readonly string[]): number {
+ const total = rows.reduce((acc, row) => acc + row.length, 0);
+ if (total === 0) return 0;
+ const ink = rows.reduce(
+ (acc, row) => acc + [...row].filter((ch) => ch !== " ").length,
+ 0,
+ );
+ return ink / total;
+}
+
+describe("rasteriseMark", () => {
+ it("returns exactly the box it was asked for", () => {
+ for (const [columns, rows] of [
+ [20, 12],
+ [13, 8],
+ [7, 4],
+ ] as const) {
+ const art = rasteriseMark(source, { columns, rows });
+ expect(art).toHaveLength(rows);
+ for (const line of art) expect(line).toHaveLength(columns);
+ }
+ });
+
+ it("keeps the mark's aspect ratio instead of stretching it", () => {
+ // The source is 34 cells wide and 20 tall = 34x40 half-block pixels.
+ // Asked for a box twice as wide as that shape needs, the drawing must
+ // stay its own shape and sit centred, not stretch to the edges.
+ const art = rasteriseMark(source, { columns: 60, rows: 12 });
+ const drawn = art.filter((row) => row.trim().length > 0);
+ const leading = Math.min(
+ ...drawn.map((row) => row.length - row.trimStart().length),
+ );
+ const trailing = Math.min(
+ ...drawn.map((row) => row.length - row.trimEnd().length),
+ );
+ // 12 cell rows = 24 pixels tall, so a 34x40 source scales to 0.6 and
+ // draws ~20 columns wide — nowhere near the 60 it was offered.
+ const inkWidth = 60 - leading - trailing;
+ expect(inkWidth).toBeLessThanOrEqual(22);
+ expect(leading).toBeGreaterThan(0);
+ });
+
+ it("still draws something recognisable at the smallest size", () => {
+ // Regression on the reason this module exists: the hand-drawn
+ // half-size mark had lost its arms and read as a solid blob.
+ // A blob is ~100% ink; empty is 0. The real mark sits in between.
+ const mini = rasteriseMark(source, {
+ columns: LOGO_METRICS.mini.width,
+ rows: LOGO_METRICS.mini.height,
+ });
+ expect(mini).toHaveLength(LOGO_METRICS.mini.height);
+ expect(density(mini)).toBeGreaterThan(0.25);
+ expect(density(mini)).toBeLessThan(0.85);
+ });
+
+ it("never scales the drawing up past its natural size", () => {
+ const art = rasteriseMark(source, { columns: 200, rows: 60 });
+ const widest = art.reduce(
+ (acc, row) => Math.max(acc, row.trimEnd().length),
+ 0,
+ );
+ expect(widest).toBeLessThanOrEqual(200);
+ const drawn = art.filter((row) => row.trim().length > 0);
+ expect(drawn.length).toBeLessThanOrEqual(20);
+ });
+
+ it("degrades to nothing rather than throwing on a zero-sized box", () => {
+ expect(rasteriseMark(source, { columns: 0, rows: 0 })).toEqual([]);
+ expect(rasteriseMark([], { columns: 10, rows: 4 })).toEqual([]);
+ });
+});
diff --git a/src/tui/components/logo-raster.ts b/src/tui/components/logo-raster.ts
new file mode 100644
index 00000000..f998f963
--- /dev/null
+++ b/src/tui/components/logo-raster.ts
@@ -0,0 +1,174 @@
+/**
+ * Scales the brand mark to any size from one drawing.
+ *
+ * The mark used to ship as three hand-drawn copies — 34×20, 17×10 and a
+ * one-line text fallback. Hand copies drift: the half-size one had lost
+ * the taper of the lower-right tail and read as a blob, and every new
+ * breakpoint meant drawing the shape again by eye.
+ *
+ * So there is one drawing now, and every smaller size is measured off it.
+ * Two details make that work in a terminal:
+ *
+ * - **Half blocks.** `▀` `▄` `█` split a cell into an upper and a lower
+ * pixel, so a cell grid of W×H carries a pixel grid of W×2H. Vertical
+ * resolution doubles, which is what stops a downscaled mark turning
+ * into a staircase.
+ * - **Cell aspect.** A terminal cell is about twice as tall as it is
+ * wide, so one half-block pixel is roughly square. Scaling in that
+ * pixel space — rather than in cells — is what keeps the mark from
+ * being squashed, and it is why the source is measured as 34×40 rather
+ * than 34×20.
+ *
+ * Sampling is an area average with a coverage threshold, not
+ * nearest-neighbour: at small sizes a thin arm covers only part of a
+ * destination pixel, and nearest-neighbour drops exactly those arms —
+ * which is what made the old half-size copy look broken.
+ */
+
+/** Upper pixel set. */
+const UPPER = "▀";
+/** Lower pixel set. */
+const LOWER = "▄";
+/** Both set. */
+const BOTH = "█";
+
+/**
+ * Fraction of a destination pixel that must be covered by ink for it to
+ * be drawn. Below 0.5 the mark fattens and the counter-space between the
+ * arms fills in; above it, thin arms drop out at the smallest sizes.
+ */
+const COVERAGE_THRESHOLD = 0.38;
+
+export interface RasterSize {
+ /** Width in terminal cells. */
+ columns: number;
+ /** Height in terminal cells. */
+ rows: number;
+}
+
+/**
+ * A boolean ink mask. `true` is drawn, `false` is background — the
+ * source art's shading characters (`:`, `-`, `@`, `#`, …) all count as
+ * ink, because at any reduced size shading is noise.
+ */
+export type InkMask = readonly (readonly boolean[])[];
+
+/** Turn character rows into an ink mask, padded to the widest row. */
+export function toInkMask(rows: readonly string[]): InkMask {
+ const width = rows.reduce((acc, row) => Math.max(acc, row.length), 0);
+ return rows.map((row) => {
+ const cells: boolean[] = [];
+ for (let x = 0; x < width; x += 1) {
+ const ch = row[x] ?? " ";
+ cells.push(ch !== " ");
+ }
+ return cells;
+ });
+}
+
+/**
+ * Expand a cell mask into a pixel mask by doubling every row — one cell
+ * row is two half-block pixels tall. This is what puts the source into
+ * the square-pixel space the scaling maths assumes.
+ */
+function toPixels(mask: InkMask): InkMask {
+ return mask.flatMap((row) => [row, row]);
+}
+
+/**
+ * Fraction of the source rectangle `[x0,x1) × [y0,y1)` that is ink.
+ * Partial cells at the edges count partially, which is the whole point:
+ * it is what keeps a one-pixel arm visible when it lands between two
+ * destination pixels.
+ */
+function coverage(
+ pixels: InkMask,
+ x0: number,
+ x1: number,
+ y0: number,
+ y1: number,
+): number {
+ let ink = 0;
+ let total = 0;
+ const yStart = Math.floor(y0);
+ const yEnd = Math.ceil(y1);
+ const xStart = Math.floor(x0);
+ const xEnd = Math.ceil(x1);
+ for (let y = yStart; y < yEnd; y += 1) {
+ const row = pixels[y];
+ if (!row) continue;
+ const yWeight = Math.min(y + 1, y1) - Math.max(y, y0);
+ if (yWeight <= 0) continue;
+ for (let x = xStart; x < xEnd; x += 1) {
+ const xWeight = Math.min(x + 1, x1) - Math.max(x, x0);
+ if (xWeight <= 0) continue;
+ const weight = yWeight * xWeight;
+ total += weight;
+ if (row[x]) ink += weight;
+ }
+ }
+ return total === 0 ? 0 : ink / total;
+}
+
+/**
+ * Draw `source` at `size`, preserving its aspect ratio and centring the
+ * result in the requested box. Returns exactly `size.rows` strings, each
+ * exactly `size.columns` wide.
+ *
+ * The mark is never scaled **up** past its natural size — the source is
+ * a drawing, not a vector, and enlarging it only exposes the pixel grid.
+ * Callers that have room for the full mark should draw the source art
+ * directly.
+ */
+export function rasteriseMark(
+ source: InkMask,
+ size: RasterSize,
+): readonly string[] {
+ const columns = Math.max(0, Math.floor(size.columns));
+ const rows = Math.max(0, Math.floor(size.rows));
+ if (columns === 0 || rows === 0) return [];
+
+ const pixels = toPixels(source);
+ const srcWidth = pixels[0]?.length ?? 0;
+ const srcHeight = pixels.length;
+ if (srcWidth === 0 || srcHeight === 0) return [];
+
+ // Destination pixel grid: full width, two pixels per cell row.
+ const boxWidth = columns;
+ const boxHeight = rows * 2;
+ const scale = Math.min(boxWidth / srcWidth, boxHeight / srcHeight, 1);
+ const drawWidth = Math.max(1, Math.round(srcWidth * scale));
+ const drawHeight = Math.max(2, Math.round(srcHeight * scale));
+ const padX = Math.floor((boxWidth - drawWidth) / 2);
+ const padY = Math.floor((boxHeight - drawHeight) / 2);
+
+ const lit: boolean[][] = [];
+ for (let y = 0; y < boxHeight; y += 1) {
+ const row: boolean[] = new Array(boxWidth).fill(false);
+ const srcY0 = ((y - padY) * srcHeight) / drawHeight;
+ const srcY1 = ((y - padY + 1) * srcHeight) / drawHeight;
+ if (y >= padY && y < padY + drawHeight) {
+ for (let x = 0; x < boxWidth; x += 1) {
+ if (x < padX || x >= padX + drawWidth) continue;
+ const srcX0 = ((x - padX) * srcWidth) / drawWidth;
+ const srcX1 = ((x - padX + 1) * srcWidth) / drawWidth;
+ row[x] = coverage(pixels, srcX0, srcX1, srcY0, srcY1) >= COVERAGE_THRESHOLD;
+ }
+ }
+ lit.push(row);
+ }
+
+ const out: string[] = [];
+ for (let r = 0; r < rows; r += 1) {
+ const top = lit[r * 2] ?? [];
+ const bottom = lit[r * 2 + 1] ?? [];
+ let line = "";
+ for (let x = 0; x < boxWidth; x += 1) {
+ const t = top[x] === true;
+ const b = bottom[x] === true;
+ line += t && b ? BOTH : t ? UPPER : b ? LOWER : " ";
+ }
+ out.push(line.trimEnd().padEnd(boxWidth));
+ }
+ return out;
+}
diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx
index 42fd4015..a71abe02 100644
--- a/src/tui/components/logo.tsx
+++ b/src/tui/components/logo.tsx
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { rasteriseMark, toInkMask } from "./logo-raster.js";
+import type { LogoVariant } from "./splash-fit.js";
/**
* Atomic-plus mark + `ATOMIC AGENT` wordmark, rendered side-by-side and
@@ -8,60 +10,130 @@ import { theme } from "../theme/theme.js";
* can be reused in any centered "home" layout (e.g. the empty-chat
* landing surface) without copying the row data.
*
- * Rendered as plain Ink primitives — no animations, no alpha. Use the
- * `compact` variant in narrow layouts where the wordmark would wrap.
+ * Rendered as plain Ink primitives — no animations, no alpha. The mark
+ * comes in three sizes so the same component can serve a 200-column
+ * desktop terminal and a 40-column SSH window: `full` (34×20), `small`
+ * (20×12) and `mini` (9×5). `SplashBanner` picks one via
+ * `computeSplashFit`; callers that just want the classic artwork can
+ * keep using the defaults.
+ *
+ * Only `full` is drawn by hand. The smaller two are **measured off it**
+ * by `logo-raster.ts` — half-block glyphs at the terminal's ~2:1 cell
+ * aspect, so they are the same shape at a smaller scale rather than a
+ * second and third attempt at drawing it. The hand-drawn half-size copy
+ * they replace had lost the taper of the lower-right tail and read as a
+ * blob; a redrawn mark also drifts from the original every time either
+ * is touched, which is a maintenance cost with no upside.
*/
export interface LogoProps {
+ /** Which mark to draw. Defaults to the full 34×20 artwork. */
+ variant?: LogoVariant;
+ /**
+ * Legacy switch for "mark only, no wordmark". Still honoured so
+ * existing callers keep working; prefer `wordmark={false}`.
+ */
compact?: boolean;
+ /** Draw the `ATOMIC AGENT` wordmark beside the mark. */
+ wordmark?: boolean;
+ /** Draw the "Local AI-First Agent" tagline under the wordmark. */
+ tagline?: boolean;
}
-export function Logo({ compact = false }: LogoProps): ReactElement {
+/**
+ * Mark artwork keyed by variant. `full` is the original drawing; the
+ * others preserve its silhouette — upper-left flare, full-width cross
+ * bar, tapering lower-right tail — at roughly half scale and as a
+ * single line. `splash-fit.ts` mirrors these dimensions in
+ * `LOGO_METRICS`; `logo-fit.test.ts` fails if the two ever disagree.
+ */
+const FULL_ART: readonly string[] = [
+ // Leading padding has been uniformly trimmed so the middle bar sits
+ // at column 0 — keeps the art within ~34 columns for narrow terminals.
+ " -:::::::--",
+ " -::::::::-",
+ " -:::::::::-",
+ " -::::::::::-",
+ " -:::::::::::-",
+ " -:::::::::::::-",
+ " -::::::::::::::::-",
+ "-::::::::::::::::::::::::::::::::-",
+ "::::::::::::::::::::::::::::::::::",
+ "::::::::::::::::::::::::::::::::::",
+ "-:::::::::::::::::::::::::::::::::",
+ "=------------:::::::::::::::::---=",
+ " @@@@@@@@@@@*-::::::::::::-=+#%%@",
+ " -:::::::::::-+#@",
+ " -::::::::::=#@",
+ " -:::::::::=#",
+ " -::::::::-*",
+ " -::::::::=",
+ " +--------*",
+ " %%%%%%",
+];
+
+/**
+ * Mark artwork keyed by variant. `full` is the original drawing and the
+ * single source of truth; `small` and `mini` are scaled from it at load
+ * time, so all three are the same shape by construction. `splash-fit.ts`
+ * mirrors these dimensions in `LOGO_METRICS`; `logo-fit.test.ts` fails if
+ * the two ever disagree.
+ */
+export const LOGO_ART: Readonly> = {
+ full: FULL_ART,
+ small: rasteriseMark(toInkMask(FULL_ART), { columns: 20, rows: 12 }),
+ mini: rasteriseMark(toInkMask(FULL_ART), { columns: 7, rows: 4 }),
+};
+
+/**
+ * The rail's own mark: smaller than `mini`, because on the rail it sits
+ * beside the wordmark rather than above it and has to leave room for the
+ * text. 6x4 is the floor at which the silhouette still reads — 6x3 and
+ * 5x3 collapse the arms into a blob.
+ */
+export const RAIL_MARK: readonly string[] = rasteriseMark(
+ toInkMask(FULL_ART),
+ { columns: 6, rows: 4 },
+);
+
+export const WORDMARK_ROWS: readonly string[] = [
+ "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀",
+ "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ",
+];
+
+export const TAGLINE = "Local AI-First Agent";
+
+export function Logo({
+ variant = "full",
+ compact = false,
+ wordmark,
+ tagline,
+}: LogoProps): ReactElement {
+ const showWordmark = wordmark ?? !compact;
+ const showTagline = tagline ?? showWordmark;
return (
-
- {compact ? null : (
+
+ {showWordmark || showTagline ? (
-
-
-
- Local AI-First Agent
-
-
+ {showWordmark ? : null}
+ {showTagline ? (
+
+
+ {TAGLINE}
+
+
+ ) : null}
- )}
+ ) : null}
);
}
-function LogoMark(): ReactElement {
- // Leading padding has been uniformly trimmed so the middle bar sits
- // at column 0 — keeps the art within ~34 columns for narrow terminals.
- const rows: readonly string[] = [
- " -:::::::--",
- " -::::::::-",
- " -:::::::::-",
- " -::::::::::-",
- " -:::::::::::-",
- " -:::::::::::::-",
- " -::::::::::::::::-",
- "-::::::::::::::::::::::::::::::::-",
- "::::::::::::::::::::::::::::::::::",
- "::::::::::::::::::::::::::::::::::",
- "-:::::::::::::::::::::::::::::::::",
- "=------------:::::::::::::::::---=",
- " @@@@@@@@@@@*-::::::::::::-=+#%%@",
- " -:::::::::::-+#@",
- " -::::::::::=#@",
- " -:::::::::=#",
- " -::::::::-*",
- " -::::::::=",
- " +--------*",
- " %%%%%%",
- ];
+function LogoMark({ variant }: { variant: LogoVariant }): ReactElement {
return (
- {rows.map((row, idx) => (
-
+ {LOGO_ART[variant].map((row, idx) => (
+
{row}
))}
@@ -70,14 +142,10 @@ function LogoMark(): ReactElement {
}
function WordMark(): ReactElement {
- const rows: readonly string[] = [
- "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀",
- "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ",
- ];
return (
- {rows.map((row, idx) => (
-
+ {WORDMARK_ROWS.map((row, idx) => (
+
{row}
))}
diff --git a/src/tui/components/mcp-list.tsx b/src/tui/components/mcp-list.tsx
index e8fb3e05..2c09dbb0 100644
--- a/src/tui/components/mcp-list.tsx
+++ b/src/tui/components/mcp-list.tsx
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleMcpTabKey } from "../mcp/mcp-key-bindings.js";
import type {
McpPanelState,
McpServerRow,
@@ -30,11 +32,16 @@ export function McpList(props: McpListProps): ReactElement {
return (
{slice.map((row, idx) => (
-
+ selected={start + idx === panel.cursor}
+ onSelect={(mouse) =>
+ mouse.dispatch({ type: "mcp_cursor_set", row: start + idx })
+ }
+ onActivate={pressEnter(handleMcpTabKey)}
+ >
+
+
))}
);
diff --git a/src/tui/components/memory-list.tsx b/src/tui/components/memory-list.tsx
index 90d07f6f..3ac2380d 100644
--- a/src/tui/components/memory-list.tsx
+++ b/src/tui/components/memory-list.tsx
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleMemoryTabKey } from "../memory/memory-key-bindings.js";
import type { MemoryPanelState } from "../memory/memory-panel-state.js";
import type { MemorySummaryRow } from "../memory/memory-panel-state.js";
@@ -44,11 +46,16 @@ export function MemoryList(props: MemoryListProps): ReactElement {
↑ {hiddenBefore} above
) : null}
{pageRows.map((row, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({ type: "memory_cursor_set", row: idx + windowStart })
+ }
+ onActivate={pressEnter(handleMemoryTabKey)}
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/multi-line-editor-body.tsx b/src/tui/components/multi-line-editor-body.tsx
index d7d5f5a6..e1ed64f8 100644
--- a/src/tui/components/multi-line-editor-body.tsx
+++ b/src/tui/components/multi-line-editor-body.tsx
@@ -1,13 +1,24 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import { useMouseTarget } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
import { theme } from "../theme/theme.js";
import type { Cursor } from "./multi-line-editor-cursor.js";
+/** Width of the `❯ ` / ` ` gutter in front of every editor line. */
+const GUTTER_COLUMNS = 2;
+
export interface EditorBodyProps {
value: string;
cursor: Cursor;
placeholder: string;
focus: boolean;
+ /**
+ * Move the caret to a clicked cell. `row`/`col` are already relative
+ * to the text, gutter excluded; the owner clamps and converts them to
+ * a buffer offset.
+ */
+ onClickCursor?: (row: number, col: number) => void;
}
/**
@@ -21,10 +32,19 @@ export function EditorBody({
cursor,
placeholder,
focus,
+ onClickCursor,
}: EditorBodyProps): ReactElement {
+ // One target for the whole buffer: the click's local row is the line,
+ // its local column minus the gutter is the character. Lines are not
+ // soft-wrapped here, so the mapping is exact.
+ const bodyRef = useMouseTarget((hit) => {
+ if (!isPrimaryPress(hit.event) || !onClickCursor) return false;
+ onClickCursor(hit.localY, hit.localX - GUTTER_COLUMNS);
+ return true;
+ });
if (value.length === 0) {
return (
-
+
{theme.glyphs.promptCaret}
{focus ? : null}
{placeholder}
@@ -33,7 +53,7 @@ export function EditorBody({
}
const lines = value.split("\n");
return (
-
+
{lines.map((line, idx) => (
diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx
index 79c3d695..fe395e4b 100644
--- a/src/tui/components/multi-line-editor.tsx
+++ b/src/tui/components/multi-line-editor.tsx
@@ -21,7 +21,18 @@ export interface MultiLineEditorProps {
disabled?: boolean;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
- /** Esc pressed while editor has focus. */
+ /**
+ * Esc pressed while the editor has focus.
+ *
+ * The editor reports the press and decides nothing — not even "clear the
+ * buffer", which it could do locally. Esc is a whole-app ladder (cancel
+ * an overlay → leave the sidebar → back out of a panel → snap the
+ * transcript down → abort the turn → clear the draft → open the menu),
+ * and only the parent can see the rungs. A local shortcut here would be
+ * a second opinion on the same keystroke, which is exactly how Esc came
+ * to quit the agent in the first place. See `escapeOpensMenu` in
+ * `app-key-bindings.ts` for the order and the reasoning behind it.
+ */
onEscape?: () => void;
/** Ctrl+C while editor has focus (overrides the default ignore for Ctrl+C). */
onInterrupt?: () => void;
@@ -56,7 +67,20 @@ export interface MultiLineEditorProps {
*
* Key handling:
* - Enter submits the trimmed buffer (and emits empty-submit as no-op)
- * - Alt/Meta+Enter or Ctrl+J insert a newline
+ * - Ctrl+J inserts a newline, and it is what the hint strip names.
+ * Ctrl+J is the literal LF byte, so every terminal can send it with
+ * no negotiation at all.
+ *
+ * Shift+Enter and Alt+Enter also insert one *where the terminal can
+ * express them*, which is the catch that made "newline does not
+ * work" a real report: a bare terminal sends plain CR for
+ * Shift+Enter, indistinguishable from Enter, so it submits. Telling
+ * them apart needs the kitty keyboard protocol or modifyOtherKeys,
+ * which this app does not turn on — doing so changes how every key
+ * arrives, which is not a trade to make for one gesture.
+ *
+ * A trailing backslash before Enter also forces a newline, and works
+ * everywhere for the same reason Ctrl+J does.
* - Backslash at end-of-line before Enter also forces a newline
* - Up/Down trigger `onHistoryPrev` / `onHistoryNext` when the cursor
* is at the top/bottom of the buffer
@@ -142,6 +166,20 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement {
);
const cursor = cursorToRowCol(value, cursorPos);
+ /**
+ * Place the caret where the operator clicked. `rowColToCursor` does
+ * not clamp, so a click past the end of a short line would otherwise
+ * run the offset into the following line; clamping here keeps a click
+ * in the empty space to the right of a line meaning "end of this
+ * line", which is what every editor does.
+ */
+ const placeCursorAt = (row: number, col: number): void => {
+ if (disabled) return;
+ const lines = value.split("\n");
+ const safeRow = Math.max(0, Math.min(row, lines.length - 1));
+ const safeCol = Math.max(0, Math.min(col, (lines[safeRow] ?? "").length));
+ setCursorPos(rowColToCursor(lines, safeRow, safeCol));
+ };
if (bare) {
return (
);
}
@@ -159,7 +198,13 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement {
paddingX={1}
flexDirection="column"
>
-
+
);
}
@@ -202,6 +247,8 @@ function handleKey(ctx: KeyContext): void {
return;
}
if (key.return) {
+ // Any Return modifier means "newline" — see the note above on why
+ // this is wider than the gesture the hint strip advertises.
const newline = key.meta || key.shift || key.ctrl;
const trailingBackslash = value.endsWith("\\") && cursor === value.length;
if (newline) {
@@ -302,7 +349,7 @@ function handleKey(ctx: KeyContext): void {
}
function isGlobalHotkey(input: string, key: Key): boolean {
- if (key.ctrl && (input === "c" || input === "o")) return true;
+ if (key.ctrl && (input === "c" || input === "o" || input === "t")) return true;
// F-keys and other multi-byte escape sequences we don't handle locally.
if (input.startsWith("\u001b") && input.length > 1) return true;
return false;
diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx
new file mode 100644
index 00000000..24cd8ba5
--- /dev/null
+++ b/src/tui/components/prompt-meta-bar.test.tsx
@@ -0,0 +1,188 @@
+import { render } from "ink-testing-library";
+import type { ReactElement } from "react";
+import { describe, expect, it } from "vitest";
+import { MouseProvider } from "../mouse/mouse-context.js";
+import type { TuiMouseEvent } from "../mouse/mouse-event.js";
+import { MouseTargetRegistry } from "../mouse/mouse-registry.js";
+import type { TuiAppCallbacks } from "../tui-app.js";
+import type { TuiState } from "../tui-state.js";
+import { PromptShell } from "./prompt-shell.js";
+
+function strip(value: string): string {
+ return value
+ .replace(/\u001b\[[0-9;]*m/g, "")
+ .replace(/\u001b\]8;;[^\u0007]*\u0007/g, "");
+}
+
+/**
+ * Screen position of `needle`'s first cell. Stripping SGR codes leaves
+ * the visual grid intact, so the column/row returned here are the same
+ * cells a terminal would report for a click on that label.
+ */
+function locate(frame: string, needle: string): { x: number; y: number } {
+ const lines = strip(frame).split("\n");
+ for (const [y, line] of lines.entries()) {
+ const x = line.indexOf(needle);
+ if (x !== -1) return { x, y };
+ }
+ throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`);
+}
+
+function click(x: number, y: number): TuiMouseEvent {
+ return {
+ kind: "press",
+ button: "left",
+ wheel: null,
+ x,
+ y,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ };
+}
+
+const noopCallbacks = {} as TuiAppCallbacks;
+
+/**
+ * `PromptShell` inside a real registry. The buttons only need dispatch
+ * to exist — they act through their own props — but the registry is the
+ * real one so the click goes through genuine Yoga hit-testing rather
+ * than a hand-fed rectangle.
+ */
+async function mountWithMouse(node: ReactElement): Promise<{
+ registry: MouseTargetRegistry;
+ frame: () => string;
+ unmount: () => void;
+}> {
+ const registry = new MouseTargetRegistry();
+ const { lastFrame, unmount } = render(
+ {}}
+ callbacks={noopCallbacks}
+ getState={() => ({}) as TuiState}
+ >
+ {node}
+ ,
+ );
+ // Ink commits on its own throttle and React registers the click
+ // targets in the effect after that commit, so a freshly mounted
+ // button is not hit-testable on the very first tick.
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ return { registry, frame: () => lastFrame() ?? "", unmount };
+}
+
+describe("composer buttons", () => {
+ it("submits the live buffer when Send is clicked", async () => {
+ const sent: string[] = [];
+ const { registry, frame, unmount } = await mountWithMouse(
+ {}}
+ onSubmit={(value) => sent.push(value)}
+ />,
+ );
+ const { x, y } = locate(frame(), "send");
+ expect(registry.dispatch(click(x, y))).toBe(true);
+ expect(sent).toEqual(["ship it"]);
+ unmount();
+ });
+
+ it("stays inert while the buffer is blank", async () => {
+ const sent: string[] = [];
+ const { registry, frame, unmount } = await mountWithMouse(
+ {}}
+ onSubmit={(value) => sent.push(value)}
+ />,
+ );
+ const { x, y } = locate(frame(), "send");
+ expect(registry.dispatch(click(x, y))).toBe(false);
+ expect(sent).toEqual([]);
+ unmount();
+ });
+
+ it("stays inert while the editor is disabled", async () => {
+ const sent: string[] = [];
+ const { registry, frame, unmount } = await mountWithMouse(
+ {}}
+ onSubmit={(value) => sent.push(value)}
+ />,
+ );
+ const { x, y } = locate(frame(), "send");
+ expect(registry.dispatch(click(x, y))).toBe(false);
+ expect(sent).toEqual([]);
+ unmount();
+ });
+
+
+ it("ignores a right-button press on Send", async () => {
+ const sent: string[] = [];
+ const { registry, frame, unmount } = await mountWithMouse(
+ {}}
+ onSubmit={(value) => sent.push(value)}
+ />,
+ );
+ const { x, y } = locate(frame(), "send");
+ expect(
+ registry.dispatch({ ...click(x, y), button: "right" }),
+ ).toBe(false);
+ expect(sent).toEqual([]);
+ unmount();
+ });
+
+ it("renders without a mouse provider at all", () => {
+ const { lastFrame, unmount } = render(
+ {}} onSubmit={() => {}} />,
+ );
+ const frame = strip(lastFrame() ?? "");
+ expect(frame).toContain("send");
+ unmount();
+ });
+});
+
+describe("the model label", () => {
+ const renderModel = (model: string): string => {
+ const { lastFrame, unmount } = render(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ const frame = strip(lastFrame() ?? "");
+ unmount();
+ return frame;
+ };
+
+ /**
+ * Fusion names both legs. Spending the whole budget left-to-right ate
+ * the local half outright — "vendor/some-very-long-name ⇄ q…" — which
+ * hides the model that actually executes most of the steps.
+ */
+ it("keeps both fusion legs identifiable", () => {
+ const frame = renderModel(
+ "vendor/some-very-long-cloud-model ⇄ qwen3-4b-instruct-q4.gguf",
+ );
+ expect(frame).toContain("vendor/some-v…");
+ expect(frame).toContain("qwen3-4b-inst…");
+ });
+
+ it("still trims a single long name the way it always did", () => {
+ expect(renderModel("vendor/an-extremely-long-single-model-name")).toContain(
+ "vendor/an-extremely-long-single…",
+ );
+ });
+});
diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx
new file mode 100644
index 00000000..c5e24ee8
--- /dev/null
+++ b/src/tui/components/prompt-meta-bar.tsx
@@ -0,0 +1,218 @@
+import { Box, Text } from "ink";
+import type { ReactElement } from "react";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { theme } from "../theme/theme.js";
+
+/**
+ * The composer's action bar: what the model is on the left, the two
+ * buttons on the right, drawn on the same inverted ground as the rail.
+ *
+ * **Why inverted.** The bar is the composer's chrome, not its content.
+ * A terminal has no borders-and-shadows to say "this strip is a
+ * toolbar", so it borrows the one device the rail already established:
+ * its own ground, per-palette rather than a literal white, because
+ * `#fff` disappears on the four light themes. Reading the composer as
+ * "a field with a toolbar under it" instead of "two lines of text" is
+ * the whole point of the change.
+ *
+ * The ground is one `backgroundColor` on the bar container, which Ink 7
+ * paints across the empty space between the meta text and the buttons —
+ * no filler cells, and no risk of the row growing taller than it looks.
+ *
+ * **A caveat about the slots.** `leftSlot` / `rightSlot` arrive from the
+ * chat surface already coloured (the LLM health pill, the context-window
+ * counter), and those colours were chosen against the *normal* ground.
+ * On the rail ground they read as low-contrast secondary text — which is
+ * what they are — but on `github-dark` and `catppuccin-mocha` the muted
+ * tone is close enough to the light rail ground to be genuinely faint.
+ * Recolouring them would mean reaching into components outside this
+ * file; the glyph in each pill carries a saturated status colour and
+ * stays legible, so the signal survives even where the label dims.
+ */
+export interface PromptMetaBarProps {
+ /** Chat-surface content rendered first — normally the LLM health pill. */
+ leftSlot: ReactElement | null;
+ model: string | null;
+ provider: string | null;
+ /** Chat-surface content rendered just before the buttons. */
+ rightSlot: ReactElement | null;
+ /** Whether Send has something to send; drives the primary/ghost look. */
+ canSend: boolean;
+ onSend: () => void;
+}
+
+/** Labels carry their own padding so the chip's ground reads as a button. */
+const SEND_LABEL = " send → ";
+
+const MODEL_LABEL_MAX_LEN = 32;
+
+/**
+ * Separator `runModeModelSummary` puts between the two fusion legs.
+ * Matched here rather than imported as a run-mode concept: this file
+ * only needs to know that a label can be a pair, so that it can spend
+ * its budget on both halves instead of on the first one.
+ */
+const PAIR_SEPARATOR = " ⇄ ";
+
+export function PromptMetaBar({
+ leftSlot,
+ model,
+ provider,
+ rightSlot,
+ canSend,
+ onSend,
+}: PromptMetaBarProps): ReactElement {
+ return (
+
+ {/*
+ The meta group is the only thing allowed to give up columns: at
+ 60 the buttons must survive intact, because a half-drawn button
+ is worse than a truncated model name.
+ */}
+
+
+
+
+ {rightSlot ? (
+
+ {rightSlot}
+
+ ) : null}
+
+
+
+ );
+}
+
+interface ComposerButtonProps {
+ label: string;
+ /** Filled in the accent colour — the bar's one primary action. */
+ primary?: boolean;
+ /** A disabled button still renders: it says the affordance exists. */
+ enabled: boolean;
+ onPress: () => void;
+}
+
+/**
+ * One button chip.
+ *
+ * Every colour here is a *pair* taken from the theme rather than a
+ * literal, and each pair is one the palette already guarantees to be
+ * opposite: `border` against `railBackground`, `accent` against
+ * `railForeground`. That is what keeps the chips legible across all
+ * eleven palettes without a per-theme table — the tokens flip polarity
+ * with the theme, so the contrast holds on light and dark alike.
+ *
+ * A disabled Send drops its ground entirely and dims to `railMuted`,
+ * which is the terminal's version of a ghost button: still there, still
+ * labelled, visibly not pressable.
+ */
+function ComposerButton({
+ label,
+ primary = false,
+ enabled,
+ onPress,
+}: ComposerButtonProps): ReactElement {
+ const background = !enabled
+ ? theme.colors.railBackground
+ : primary
+ ? theme.colors.accent
+ : theme.colors.border;
+ const foreground = !enabled
+ ? theme.colors.railMuted
+ : primary
+ ? theme.colors.railForeground
+ : theme.colors.railBackground;
+ const chip = (
+
+ {label}
+
+ );
+ const mouse = useMouseCommands();
+ // No provider (component tests, the wizard's separate Ink tree) or
+ // nothing to do: render the label and stop. Registering a target that
+ // swallows the click without acting would be worse than no target.
+ if (!mouse || !enabled) return chip;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ onPress();
+ return true;
+ }}
+ >
+ {chip}
+
+ );
+}
+
+interface MetaLeftProps {
+ leftSlot: ReactElement | null;
+ model: string | null;
+ provider: string | null;
+}
+
+function MetaLeft({ leftSlot, model, provider }: MetaLeftProps): ReactElement {
+ if (!leftSlot && !model && !provider) {
+ return ;
+ }
+ const cleanModel = model ? formatModel(model) : null;
+ // Wrap the optional `leftSlot` in a `` so neighbouring spans
+ // (a leading dot separator before the model) stay on the same line
+ // without Yoga inserting an inline break between Box children.
+ // `truncate` rather than wrap: a second line here would push the
+ // frame's bottom border down and change the composer's height, which
+ // is exactly the kind of drift a bounded frame exists to prevent.
+ return (
+
+ {leftSlot ? {leftSlot} : null}
+ {leftSlot && (cleanModel || provider) ? (
+
+ {" "}
+ {theme.glyphs.dotSeparator}{" "}
+
+ ) : null}
+ {cleanModel ? (
+
+ {cleanModel}
+
+ ) : null}
+ {cleanModel && provider ? (
+
+ {" "}
+ {theme.glyphs.dotSeparator}{" "}
+
+ ) : null}
+ {provider ? (
+ {provider}
+ ) : null}
+
+ );
+}
+
+function formatModel(model: string): string {
+ // Fusion names both legs. Truncating the joined string would eat the
+ // local half whole and leave "anthropic/claude-sonnet-4.5 ⇄ q…", which
+ // says less than either name alone would: the reader can no longer
+ // tell which local model is executing. Each side gets half the budget
+ // so both stay identifiable at the width the row already had.
+ const [cloud, local] = model.split(PAIR_SEPARATOR);
+ if (cloud !== undefined && local !== undefined) {
+ const half = Math.floor((MODEL_LABEL_MAX_LEN - PAIR_SEPARATOR.length) / 2);
+ return `${shorten(cloud, half)}${PAIR_SEPARATOR}${shorten(local, half)}`;
+ }
+ return shorten(model, MODEL_LABEL_MAX_LEN);
+}
+
+function shorten(label: string, max: number): string {
+ const stripped = label.replace(/\.gguf$/i, "");
+ if (stripped.length <= max) return stripped;
+ return `${stripped.slice(0, max - 1)}…`;
+}
diff --git a/src/tui/components/prompt-shell.test.tsx b/src/tui/components/prompt-shell.test.tsx
index a354b95f..5efaa1d3 100644
--- a/src/tui/components/prompt-shell.test.tsx
+++ b/src/tui/components/prompt-shell.test.tsx
@@ -1,3 +1,4 @@
+import { Box, Text } from "ink";
import { render } from "ink-testing-library";
import { describe, expect, it } from "vitest";
import { PromptShell } from "./prompt-shell.js";
@@ -9,7 +10,7 @@ function strip(value: string): string {
}
describe("PromptShell", () => {
- it("renders the left tail cap (╹) below the editor", () => {
+ it("closes a frame around the editor and the action bar", () => {
const { lastFrame, unmount } = render(
{
/>,
);
const frame = strip(lastFrame() ?? "");
- expect(frame).toContain("╹");
+ expect(frame).toContain("╭");
+ expect(frame).toContain("╰");
expect(frame).toContain("hello");
+ // The tail cap the frame replaced.
+ expect(frame).not.toContain("╹");
+ unmount();
+ });
+
+ it("shows the send button", () => {
+ const { lastFrame, unmount } = render(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ const frame = strip(lastFrame() ?? "");
+ expect(frame).toContain("send");
+ unmount();
+ });
+
+ /**
+ * The composer's whole height budget: four rows of chrome plus the
+ * buffer. If this grows, the chat viewport shrinks — and Ink 7 will
+ * overlap the lines above rather than clip, so a drift here is not a
+ * cosmetic one.
+ */
+ it("spends four rows on chrome regardless of the buffer", () => {
+ const heightOf = (value: string): number => {
+ const { lastFrame, unmount } = render(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ const rows = strip(lastFrame() ?? "")
+ .split("\n")
+ .filter((line) => line.trim().length > 0).length;
+ unmount();
+ return rows;
+ };
+ expect(heightOf("one")).toBe(4);
+ expect(heightOf("one\ntwo\nthree")).toBe(6);
+ });
+
+ /**
+ * 60 columns is the narrowest terminal the composer has to survive:
+ * the chat column is 56 wide once the root padding is taken, and the
+ * rail is already hidden at that width. The meta group is the only
+ * thing allowed to give up columns — a clipped button reads as a
+ * rendering bug, a clipped model name reads as a long model name.
+ */
+ it("keeps the send button whole in a 56-column chat column", () => {
+ const { lastFrame, unmount } = render(
+ // A column, like the chat surface: the composer takes the
+ // column's full width rather than its own intrinsic one.
+
+ {"● healthy"}}
+ rightSlot={ctx 32768}
+ onChange={() => {}}
+ onSubmit={() => {}}
+ />
+ ,
+ );
+ const lines = strip(lastFrame() ?? "")
+ .split("\n")
+ .filter((line) => line.trim().length > 0);
+ expect(lines).toHaveLength(4);
+ for (const line of lines) {
+ expect(line.length).toBeLessThanOrEqual(56);
+ }
+ const bar = lines[2] ?? "";
+ expect(bar).toContain(" send → ");
unmount();
});
@@ -107,7 +188,12 @@ describe("PromptShell", () => {
unmount();
});
- it("omits the meta-row when neither model nor right-slot is set", () => {
+ /**
+ * The bar is unconditional now — it carries the buttons, so it cannot
+ * come and go with the model label the way the old meta-row did
+ * without the composer changing height mid-session.
+ */
+ it("keeps the action bar with no model and no slots", () => {
const { lastFrame, unmount } = render(
{
);
const frame = strip(lastFrame() ?? "");
expect(frame).not.toContain("llama.cpp");
+ expect(frame).toContain("send");
unmount();
});
});
diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx
index 18a31934..905143df 100644
--- a/src/tui/components/prompt-shell.tsx
+++ b/src/tui/components/prompt-shell.tsx
@@ -1,21 +1,32 @@
-import { Box, Text } from "ink";
+import { Box } from "ink";
import type { ReactElement } from "react";
import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js";
import { theme } from "../theme/theme.js";
import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js";
+import { PromptMetaBar } from "./prompt-meta-bar.js";
/**
- * Visual shell around `MultiLineEditor` modelled after the opencode
- * prompt: a left "tail" column terminated by a `╹` cap, optional
- * rotating placeholder, and a meta-row underneath that surfaces the
- * active model. The editor itself runs in `bare` mode so the chrome is
- * fully owned here.
+ * The composer: a framed input field with a toolbar under it.
+ *
+ * It used to be an opencode-style left "tail" — a single border column
+ * down the left of the editor, capped by a `╹`. That reads as a quote
+ * block, not as a place you type into, and it gave the two things the
+ * composer needs to advertise (send, reference a file) nowhere to live.
+ * A closed frame plus an action bar is the shape every operator already
+ * knows from every other message box they have used, and it costs one
+ * row *less* than the tail did: border, editor, bar, border — where the
+ * tail spent a top pad, a blank row above the meta and the cap glyph.
+ *
+ * The frame is deliberately the app's only fully-boxed surface besides
+ * modals. Bounded height matters: Ink 7 does not clip a frame taller
+ * than the terminal, it overlaps the lines above it (the hazard
+ * `splash-fit.ts` exists to document), so the composer grows only with
+ * the buffer the operator typed and never with its own chrome.
*
* Out-of-scope (deferred for parity with opencode):
* - bracketed paste with image bytes (Ink delivers cooked stdin)
- * - mouse interactions / hover (Ink has no mouse layer)
* - extmark "chips" inside the textarea (e.g. coloured `@file.ts`)
- * - alpha / fade-in animations on the meta-row
+ * - alpha / fade-in animations on the action bar
*
* The shell does **not** open the autocomplete popup — slash-palette
* stays where it lived before, rendered by the parent above the editor.
@@ -33,7 +44,7 @@ export interface PromptShellProps
rotatingPlaceholders?: readonly string[];
/** Rotation period in milliseconds. Defaults to 4000. */
placeholderRotationMs?: number;
- /** Active model alias rendered into the meta-row (e.g. `qwen3-30b`). */
+ /** Active model alias rendered into the action bar (e.g. `qwen3-30b`). */
model?: string | null;
/**
* Optional provider hint shown after the model (e.g. `llama.cpp`).
@@ -41,13 +52,13 @@ export interface PromptShellProps
*/
provider?: string | null;
/**
- * Optional content rendered at the start of the meta-row, before the
+ * Optional content rendered at the start of the action bar, before the
* model/provider labels. Used by the chat surface to show the live
* LLM health pill. Separated by a dot from the model when both are
* present.
*/
leftSlot?: ReactElement | null;
- /** Optional content rendered on the right-hand side of the meta-row. */
+ /** Optional content rendered just before the buttons on the right. */
rightSlot?: ReactElement | null;
}
@@ -63,6 +74,8 @@ export function PromptShell(props: PromptShellProps): ReactElement {
focus,
disabled,
value,
+ onChange,
+ onSubmit,
...editorProps
} = props;
const rotated = useRotatingPlaceholder(
@@ -72,102 +85,44 @@ export function PromptShell(props: PromptShellProps): ReactElement {
const effectivePlaceholder =
value.length === 0 ? (rotated ?? placeholder ?? "") : "";
const accent = focus && !disabled ? theme.colors.accent : theme.colors.border;
- // Render the meta-row whenever any slot is occupied. With the live
- // LLM-health pill being a permanent left-slot tenant, this means the
- // row is effectively always rendered after mount — keeping the
- // layout stable so the input does not jump up by one cell the moment
- // `/props` lands.
- const showMeta =
- Boolean(model) ||
- Boolean(provider) ||
- Boolean(leftSlot) ||
- Boolean(rightSlot);
+ // Send is live on exactly the condition Enter is: a non-blank buffer
+ // in an editor that is accepting input. `handleEditorSubmit` drops a
+ // blank buffer anyway, but a button that visibly does nothing when
+ // pressed is a bug report waiting to happen.
+ const canSend = !disabled && value.trim().length > 0;
return (
-
-
+ {/*
+ Padding lives on the editor row, not on the frame: the action
+ bar has to reach both borders for its ground to read as a
+ toolbar rather than as a floating stripe.
+ */}
+
+
+
+ onSubmit(value)}
/>
- {showMeta ? (
-
-
- {rightSlot ? {rightSlot} : null}
-
- ) : null}
- ╹
);
}
-
-interface MetaLeftProps {
- leftSlot: ReactElement | null;
- model: string | null;
- provider: string | null;
-}
-
-const MODEL_LABEL_MAX_LEN = 32;
-
-function MetaLeft({
- leftSlot,
- model,
- provider,
-}: MetaLeftProps): ReactElement {
- if (!leftSlot && !model && !provider) {
- return ;
- }
- const cleanModel = model ? formatModel(model) : null;
- // Wrap the optional `leftSlot` in a `` so neighbouring spans
- // (a leading dot separator before the model) stay on the same line
- // without Yoga inserting an inline break between Box children.
- return (
-
- {leftSlot ? {leftSlot} : null}
- {leftSlot && (cleanModel || provider) ? (
-
- {" "}
- {theme.glyphs.dotSeparator}{" "}
-
- ) : null}
- {cleanModel ? (
-
- {cleanModel}
-
- ) : null}
- {cleanModel && provider ? (
-
- {" "}
- {theme.glyphs.dotSeparator}{" "}
-
- ) : null}
- {provider ? (
- {provider}
- ) : null}
-
- );
-}
-
-function formatModel(model: string): string {
- const stripped = model.replace(/\.gguf$/i, "");
- if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped;
- return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`;
-}
diff --git a/src/tui/components/providers-panel.tsx b/src/tui/components/providers-panel.tsx
index ebea65e2..fa4a757c 100644
--- a/src/tui/components/providers-panel.tsx
+++ b/src/tui/components/providers-panel.tsx
@@ -1,8 +1,10 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import { MouseListRow } from "../mouse/mouse-list-row.js";
import { theme } from "../theme/theme.js";
import type { ProvidersPanelState } from "../providers/providers-panel-state.js";
import { ProvidersWizard } from "./providers-wizard.js";
+import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js";
export function ProvidersPanel(props: {
panel: ProvidersPanelState;
@@ -30,19 +32,31 @@ export function ProvidersPanel(props: {
);
}
- const lines: string[] = ["Providers (text LLM + embeddings)", ""];
+ // Each line is its own element rather than one joined string: the
+ // provider rows have to be individually measurable for the mouse
+ // layer, and a column of one-line Texts renders identically.
+ const lines: PanelLine[] = [
+ { text: "Providers (text LLM + embeddings)" },
+ { text: "" },
+ ];
if (props.panel.statusLine) {
- lines.push(props.panel.statusLine, "");
+ lines.push({ text: props.panel.statusLine }, { text: "" });
}
if (props.panel.rows.length === 0) {
- lines.push("(no providers — press n to add OpenRouter or OpenAI-compatible)");
+ lines.push({
+ text: "(no providers — press n to add OpenRouter or OpenAI-compatible)",
+ });
} else {
props.panel.rows.forEach((row, i) => {
const mark = i === props.panel.cursor ? ">" : " ";
const flags = [
row.isActiveText ? "TEXT*" : "",
row.isActiveEmbedding ? "EMB*" : "",
- row.hasApiKey ? "key" : "no-key",
+ row.kind === SUBSCRIPTION_CLI_KIND
+ ? "cli auth"
+ : row.hasApiKey
+ ? "key"
+ : "no-key",
]
.filter(Boolean)
.join(" ");
@@ -52,20 +66,44 @@ export function ProvidersPanel(props: {
]
.filter(Boolean)
.join(" ");
- lines.push(
- `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`,
- );
+ lines.push({
+ text: `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`,
+ rowIndex: i,
+ });
});
}
lines.push(
- "",
- "j/k move · n add · c configure cloud · d remove",
- "t active text · e active embedding · r refresh",
+ { text: "" },
+ { text: "j/k move · n add · c configure cloud · d remove" },
+ { text: "t active text · e active embedding · r refresh" },
);
return (
- {lines.join("\n")}
+ {lines.map((line, idx) =>
+ line.rowIndex === undefined ? (
+ {line.text}
+ ) : (
+
+ mouse.dispatch({
+ type: "providers_cursor_set",
+ row: line.rowIndex as number,
+ })
+ }
+ >
+ {line.text}
+
+ ),
+ )}
);
}
+
+/** One rendered line; `rowIndex` marks the clickable provider rows. */
+interface PanelLine {
+ text: string;
+ rowIndex?: number;
+}
diff --git a/src/tui/components/providers-wizard.test.tsx b/src/tui/components/providers-wizard.test.tsx
index 7406e08b..d3fb5028 100644
--- a/src/tui/components/providers-wizard.test.tsx
+++ b/src/tui/components/providers-wizard.test.tsx
@@ -1,11 +1,14 @@
import { render } from "ink-testing-library";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { refreshAimlapiChatCatalogFromApi } from "../../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js";
import { refreshOpenRouterChatCatalogFromApi } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js";
import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js";
import { createProvidersWizardState } from "../providers/providers-wizard-state.js";
-import type { ProvidersWizardKind } from "../providers/providers-wizard-state.js";
+import type {
+ ProvidersWizardKind,
+ ProvidersWizardState,
+} from "../providers/providers-wizard-state.js";
import { ProvidersWizard } from "./providers-wizard.js";
function stripAnsi(value: string): string {
@@ -287,3 +290,69 @@ describe("ProvidersWizard cloud model pickers", () => {
expect(text).not.toContain("vendor/model-000");
});
});
+
+/**
+ * Reported as "I added a random key and got stuck on embedding
+ * selection". The key check did fire and did refuse the save — nothing
+ * was written — but a list screen had nowhere to print `wizard.error`
+ * and nowhere to say a check was running, so Enter looked like a key
+ * that did nothing, forever.
+ */
+describe("ProvidersWizard surfaces the key check on list screens", () => {
+ // The chat-model case mounts `CatalogChatModelStep`, which fires a
+ // live catalog refresh on mount. Keep it offline: a real response
+ // would replace the module cache the windowing tests assert against.
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new Error("offline");
+ }),
+ );
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function embeddingStep(overrides: Partial) {
+ return {
+ ...createProvidersWizardState("add", { kind: "openrouter" }),
+ phase: "pick_embedding" as const,
+ cursor: 0,
+ ...overrides,
+ };
+ }
+
+ it("prints the refusal on the embedding screen", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const text = stripAnsi(lastFrame() ?? "");
+ expect(text).toContain("OpenRouter does not recognize this key");
+ });
+
+ it("says a check is in flight while the save waits on the provider", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const text = stripAnsi(lastFrame() ?? "");
+ expect(text).toContain("checking the key with the provider");
+ expect(text).toContain("Esc cancels");
+ });
+
+ it("prints the refusal on the chat-model screen too", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(stripAnsi(lastFrame() ?? "")).toContain("no balance");
+ });
+});
diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx
index c713409d..0947b6a9 100644
--- a/src/tui/components/providers-wizard.tsx
+++ b/src/tui/components/providers-wizard.tsx
@@ -10,11 +10,13 @@ import {
getCachedOpenRouterChatPicks,
refreshOpenRouterChatCatalogFromApi,
} from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js";
+import { listCompatChatModelPicks } from "../providers/providers-wizard-key-bindings.js";
import {
apiKeyForWizard,
baseUrlForWizard,
- listCompatChatModelPicks,
-} from "../providers/providers-wizard-key-bindings.js";
+ envHintForWizard,
+ wizardKeyIsOptional,
+} from "../providers/providers-wizard-target.js";
import { theme } from "../theme/theme.js";
import { findProviderPreset } from "../providers/provider-presets.js";
import {
@@ -34,8 +36,37 @@ import type {
ProvidersWizardState,
} from "../providers/providers-wizard-state.js";
import { renderPickList } from "./wizard-pick-list.js";
+import type { MouseContextValue } from "../mouse/mouse-context.js";
+import { pressEnter } from "../mouse/mouse-list-row.js";
+import { handleLlmModalKey } from "../llm-panel/llm-panel-modal-key-bindings.js";
+
+/**
+ * Click wiring shared by every pick list the wizard shows, so the four
+ * screens cannot drift apart. A click on an unselected row only moves
+ * the wizard's own cursor; a click on the selected row is replayed as
+ * Enter through `handleLlmModalKey`, the same entry point the keyboard
+ * uses, so whatever Enter advances or saves today a second click does
+ * too.
+ */
+const PICK_LIST_MOUSE = {
+ onRowSelect: (index: number, mouse: MouseContextValue): void => {
+ const wizard = mouse.getState().providersPanel.wizard;
+ // The wizard can close between the paint and the click; there is
+ // nothing to move a cursor in then.
+ if (!wizard) return;
+ mouse.dispatch({
+ type: "providers_wizard_updated",
+ wizard: { ...wizard, cursor: index },
+ });
+ },
+ onRowActivate: pressEnter(handleLlmModalKey),
+} as const;
const KIND_LABELS: Record = {
+ "claude-cli":
+ "Claude Code subscription (drives your signed-in `claude` CLI — no API key)",
+ "codex-cli":
+ "OpenAI Codex subscription (drives your signed-in `codex` CLI — no API key)",
openrouter: "OpenRouter (cloud chat + optional cloud embed)",
aimlapi: "AI/ML API (aimlapi.com — 500+ models, OpenAI-compatible)",
gemini: "Gemini (Google AI)",
@@ -60,20 +91,6 @@ const KIND_OPTIONS = KIND_ROW_ORDER.map((row) => ({
label: labelForKindRow(row),
}));
-/**
- * Env var named on the key screen. A preset names its own variable;
- * naming the shared compat one there would promise Groq's key a home it
- * does not use.
- */
-function envHintForWizard(w: ProvidersWizardState): string {
- const preset = w.presetId ? findProviderPreset(w.presetId) : undefined;
- if (preset) return preset.envVar;
- if (w.kind === "openrouter") return "OPENROUTER_API_KEY";
- if (w.kind === "aimlapi") return "AIMLAPI_API_KEY";
- if (w.kind === "gemini") return "GEMINI_API_KEY";
- return "OPENAI_COMPAT_API_KEY";
-}
-
/** Service name for headings: the preset label wins over the raw kind. */
function providerLabelForWizard(w: ProvidersWizardState): string {
const preset = w.presetId ? findProviderPreset(w.presetId) : undefined;
@@ -96,12 +113,33 @@ function explainModelListError(error: string, w: ProvidersWizardState): string {
return `could not list models from ${service} (${error})`;
}
+/**
+ * What `submitting` means now that a save starts with a live key check:
+ * the wait is the provider answering, and Esc gets out of it.
+ */
+const CHECKING_KEY_HINT = "checking the key with the provider… (Esc cancels)";
+
function maskedKey(buffer: string): string {
const masked = "•".repeat(Math.min(buffer.length, 48));
const extra = buffer.length > 48 ? `+${buffer.length - 48}` : "";
return masked + extra;
}
+/**
+ * Actions hint for a list screen, with the key check folded in.
+ *
+ * A pick screen is where the save happens for the curated kinds, so it
+ * is also where the operator waits on the provider answering. Saying
+ * nothing for those seconds is what made a refused key read as a frozen
+ * wizard. While the check runs the normal actions are REPLACED rather
+ * than appended to: every key but Esc is swallowed until it settles, so
+ * listing them would be a lie, and the combined line was long enough to
+ * lose "(Esc cancels)" off the right edge of a 100-column terminal.
+ */
+function listActionsHint(base: string, submitting: boolean): string {
+ return submitting ? CHECKING_KEY_HINT : base;
+}
+
function renderLineField(props: {
title: string;
value: string;
@@ -139,6 +177,7 @@ function renderLineField(props: {
function CompatChatModelStep(props: {
wizard: ProvidersWizardState;
+ maxRows?: number;
}): ReactElement {
const w = props.wizard;
const baseUrl = baseUrlForWizard(w);
@@ -190,12 +229,19 @@ function CompatChatModelStep(props: {
options: picks.map((id) => ({ label: id })),
cursor: w.cursor,
moveHint: "↑/↓ move",
- actionsHint:
+ actionsHint: listActionsHint(
"PgUp/PgDn jump · Enter select · type to enter an id by hand · Esc back",
+ w.submitting,
+ ),
+ ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }),
+ ...PICK_LIST_MOUSE,
+ error: w.error,
});
}
- const hint = !canList
+ const hint = w.submitting
+ ? CHECKING_KEY_HINT
+ : !canList
? "Enter to save · Esc back"
: status.loading
? isGemini
@@ -230,6 +276,7 @@ function CompatChatModelStep(props: {
function CatalogChatModelStep(props: {
wizard: ProvidersWizardState;
kind: "openrouter" | "aimlapi";
+ maxRows?: number;
}): ReactElement {
const { wizard: w, kind } = props;
const getCached =
@@ -274,14 +321,29 @@ function CatalogChatModelStep(props: {
options: listChatModelsForKind(kind),
cursor: w.cursor,
moveHint: "j/k move",
- actionsHint,
+ actionsHint: listActionsHint(actionsHint, w.submitting),
+ ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }),
+ ...PICK_LIST_MOUSE,
+ error: w.error,
});
}
+/**
+ * `maxRows` is the terminal budget the wizard must fit in, not a
+ * preference. The wizard is a modal: `LlmPanel` hands it the whole tab
+ * budget and renders nothing behind it, and every box below sizes
+ * itself so the frame cannot outgrow the terminal. It used to be drawn
+ * on top of the full LLM panel with no budget at all, and Ink 7 answers
+ * an over-tall frame by painting later lines over earlier ones — which
+ * is how a 24-row provider list arrived on screen as seven half-eaten
+ * rows with OpenRouter's row wearing Codex's tail (reports #1 and #2).
+ */
export function ProvidersWizard(props: {
wizard: ProvidersWizardState;
+ maxRows?: number;
}): ReactElement {
const w = props.wizard;
+ const maxRows = props.maxRows === undefined ? {} : { maxRows: props.maxRows };
const modeLabel = w.mode === "configure" ? `configure ${w.providerId}` : "add provider";
if (w.phase === "pick_kind") {
@@ -291,18 +353,19 @@ export function ProvidersWizard(props: {
cursor: w.cursor,
moveHint: "j/k move",
actionsHint: "Enter pick · Esc cancel",
+ ...maxRows,
+ ...PICK_LIST_MOUSE,
+ error: w.error,
});
}
if (w.phase === "api_key") {
const envHint = envHintForWizard(w);
- const preset = w.presetId ? findProviderPreset(w.presetId) : undefined;
// Local servers and keyless-listing services save with an empty key;
// promising ".env only" here would contradict their own list rows.
- const emptyMeans =
- preset && (preset.local || preset.listsModelsWithoutKey)
- ? "Optional for this service — leave empty to connect without a key."
- : "Leave empty only if the key is already in .env.";
+ const emptyMeans = wizardKeyIsOptional(w)
+ ? "Optional for this service — leave empty to connect without a key."
+ : "Leave empty only if the key is already in .env.";
return (
Enter to continue · Esc back · Backspace edit
- {w.submitting ? " · saving…" : ""}
+ {w.submitting ? ` · ${CHECKING_KEY_HINT}` : ""}
);
@@ -338,26 +401,30 @@ export function ProvidersWizard(props: {
w.phase === "pick_chat_model" &&
(w.kind === "openrouter" || w.kind === "aimlapi")
) {
- return ;
- }
-
- if (w.phase === "pick_embedding" && w.kind === "openrouter") {
- return renderPickList({
- title: "Embedding backend",
- options: listOpenRouterEmbeddingModels(),
- cursor: w.cursor,
- moveHint: "j/k move",
- actionsHint: "PgUp/PgDn jump · Enter finish · Esc back",
- });
+ return ;
}
- if (w.phase === "pick_embedding" && w.kind === "aimlapi") {
+ if (
+ w.phase === "pick_embedding" &&
+ (w.kind === "openrouter" || w.kind === "aimlapi")
+ ) {
return renderPickList({
title: "Embedding backend",
- options: listAimlapiEmbeddingModels(),
+ options:
+ w.kind === "openrouter"
+ ? listOpenRouterEmbeddingModels()
+ : listAimlapiEmbeddingModels(),
cursor: w.cursor,
moveHint: "j/k move",
- actionsHint: "PgUp/PgDn jump · Enter finish · Esc back",
+ // This is the last screen of the curated flow, so Enter here is
+ // the save — and the save is what runs the key check.
+ actionsHint: listActionsHint(
+ "PgUp/PgDn jump · Enter finish · Esc back",
+ w.submitting,
+ ),
+ ...maxRows,
+ ...PICK_LIST_MOUSE,
+ error: w.error,
});
}
@@ -372,7 +439,7 @@ export function ProvidersWizard(props: {
}
if (w.phase === "chat_model_line") {
- return ;
+ return ;
}
return (
diff --git a/src/tui/components/queued-messages.test.tsx b/src/tui/components/queued-messages.test.tsx
new file mode 100644
index 00000000..c2ab959a
--- /dev/null
+++ b/src/tui/components/queued-messages.test.tsx
@@ -0,0 +1,36 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { previewOf, QueuedMessages } from "./queued-messages.js";
+
+describe("QueuedMessages", () => {
+ it("renders nothing when the queue is empty", () => {
+ const { lastFrame } = render();
+ expect(lastFrame()?.trim()).toBe("");
+ });
+
+ it("lists parked messages one per row", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("run the tests");
+ expect(frame).toContain("then deploy");
+ });
+
+ it("collapses everything past the third row into a counter", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("and 2 more queued");
+ expect(frame).not.toContain("queued: d");
+ });
+
+ it("flattens newlines so a multi-line message stays one row", () => {
+ expect(previewOf("first\nsecond", 40)).toBe("first second");
+ });
+
+ it("elides a preview past the width budget", () => {
+ expect(previewOf("x".repeat(50), 10)).toBe(`${"x".repeat(9)}…`);
+ });
+});
diff --git a/src/tui/components/queued-messages.tsx b/src/tui/components/queued-messages.tsx
new file mode 100644
index 00000000..6e4017b8
--- /dev/null
+++ b/src/tui/components/queued-messages.tsx
@@ -0,0 +1,61 @@
+import { Box, Text } from "ink";
+import type { ReactElement } from "react";
+import { theme } from "../theme/theme.js";
+
+interface QueuedMessagesProps {
+ /** Messages the operator submitted while a turn was still running. */
+ queued: readonly string[];
+ /** Terminal width available to the strip; used to elide long previews. */
+ width?: number;
+}
+
+/** How many rows we render before collapsing the rest into a counter. */
+const MAX_VISIBLE_ROWS = 3;
+/** Fallback preview width when the caller does not know the terminal size. */
+const DEFAULT_PREVIEW_WIDTH = 60;
+
+/**
+ * Dim strip rendered directly above the prompt listing messages that are
+ * parked behind the running turn. It exists because the queue used to be
+ * invisible: `ChatOrchestrator` has always buffered submissions made while
+ * a turn was in flight, but nothing on screen told the operator that their
+ * message had been accepted rather than swallowed.
+ *
+ * Renders nothing when the queue is empty so the prompt does not jump by a
+ * row on every turn boundary.
+ */
+export function QueuedMessages({
+ queued,
+ width,
+}: QueuedMessagesProps): ReactElement | null {
+ if (queued.length === 0) return null;
+ const previewWidth = Math.max(20, (width ?? DEFAULT_PREVIEW_WIDTH) - 8);
+ const visible = queued.slice(0, MAX_VISIBLE_ROWS);
+ const hidden = queued.length - visible.length;
+ return (
+
+ {visible.map((text, idx) => (
+
+ {" "}
+ {theme.glyphs.dotSeparator} queued: {previewOf(text, previewWidth)}
+
+ ))}
+ {hidden > 0 ? (
+
+ {" "}
+ {theme.glyphs.dotSeparator} …and {hidden} more queued
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Single-line preview: newlines become spaces (the strip is one row per
+ * message) and anything past `max` is elided.
+ */
+export function previewOf(text: string, max: number): string {
+ const flat = text.replace(/\s+/g, " ").trim();
+ if (flat.length <= max) return flat;
+ return `${flat.slice(0, Math.max(1, max - 1))}…`;
+}
diff --git a/src/tui/components/run-mode-bar.test.tsx b/src/tui/components/run-mode-bar.test.tsx
new file mode 100644
index 00000000..0f9e7487
--- /dev/null
+++ b/src/tui/components/run-mode-bar.test.tsx
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { render } from "ink-testing-library";
+
+import { RunModeBar } from "./run-mode-bar.js";
+import { createInitialRunModePanelState } from "../run-mode/run-mode-panel-state.js";
+import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js";
+
+function stripAnsi(value: string): string {
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
+}
+
+function panelOf(over: Partial = {}): RunModePanelState {
+ return { ...createInitialRunModePanelState(), ...over };
+}
+
+function frameOf(panel: RunModePanelState): string {
+ const { lastFrame } = render();
+ return stripAnsi(lastFrame() ?? "");
+}
+
+describe("RunModeBar", () => {
+ it("lists all three modes", () => {
+ const frame = frameOf(panelOf());
+ expect(frame).toContain("Local");
+ expect(frame).toContain("Cloud");
+ expect(frame).toContain("Fusion");
+ });
+
+ it("marks the mode in force with the chevron", () => {
+ expect(frameOf(panelOf({ effective: "local" }))).toContain("\u25b8 Local");
+ expect(frameOf(panelOf({ effective: "cloud" }))).toContain("\u25b8 Cloud");
+ });
+
+ it("shows the dial only while fusion is actually in force", () => {
+ expect(
+ frameOf(panelOf({ effective: "fusion", cloudShare: 40 })),
+ ).toContain("Fusion 40%");
+ expect(
+ frameOf(panelOf({ effective: "local", cloudShare: 40 })),
+ ).not.toContain("40%");
+ });
+
+ it("says why an unreachable mode is unreachable", () => {
+ // Silently hiding the pill would leave the operator wondering where
+ // Cloud went.
+ expect(frameOf(panelOf({ cloudProviderMissing: true }))).toContain(
+ "no cloud provider",
+ );
+ });
+
+ it("renders as a single row", () => {
+ expect(frameOf(panelOf({ effective: "fusion" })).split("\n")).toHaveLength(1);
+ });
+
+ it("surfaces a failed switch inline", () => {
+ expect(
+ frameOf(panelOf({ lastError: "provider not configured" })),
+ ).toContain("provider not configured");
+ });
+});
diff --git a/src/tui/components/run-mode-bar.tsx b/src/tui/components/run-mode-bar.tsx
new file mode 100644
index 00000000..b7d30977
--- /dev/null
+++ b/src/tui/components/run-mode-bar.tsx
@@ -0,0 +1,105 @@
+import { Box, Text } from "ink";
+import type { ReactElement } from "react";
+
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { theme } from "../theme/theme.js";
+import { RUN_MODES } from "../run-mode/run-mode-nav.js";
+import { runModePillLabel } from "../run-mode/run-mode-selectors.js";
+import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js";
+import type { RunModeName } from "../../config/index.js";
+
+export interface RunModeBarProps {
+ panel: RunModePanelState;
+}
+
+/**
+ * The Run section's submenu: a one-row pill strip reading
+ * `▸ Local · Cloud · Fusion 40%`, rendered directly under the status bar
+ * while the chat surface is showing.
+ *
+ * It is a new row rather than `DebugPane`'s `SubTabBar` because that
+ * component only renders in debug mode — its `section === "run"` branch
+ * is unreachable. And it is a persistent strip rather than an overlay
+ * because a run mode is a state you are IN: an operator has to be able
+ * to see at a glance whether the next turn spends cloud tokens.
+ *
+ * Each pill is its own `` rather than one flat `` run so the
+ * mouse layer can measure it — the same shape the nav pills took in
+ * #165. A visible control that cannot be clicked reads as broken once
+ * every neighbouring control can be.
+ */
+export function RunModeBar({ panel }: RunModeBarProps): ReactElement {
+ return (
+
+ {RUN_MODES.map((mode, idx) => (
+
+
+ {idx < RUN_MODES.length - 1 ? (
+
+ {" "}
+ {theme.glyphs.dotSeparator}
+ {" "}
+
+ ) : null}
+
+ ))}
+ {/*
+ The strip showed three names and nothing else, so there was no
+ way to learn it was a control at all — reported as "there is no
+ hint anywhere how to switch them". Naming the key is cheap; the
+ pills are clickable too.
+ */}
+
+ {" "}
+ {theme.glyphs.pipeSeparator} ctrl+r or click · /run to configure
+
+ {panel.lastError ? (
+
+ {" "}
+ {theme.glyphs.pipeSeparator} {panel.lastError}
+
+ ) : null}
+
+ );
+}
+
+function RunModePill({
+ mode,
+ panel,
+}: {
+ mode: RunModeName;
+ panel: RunModePanelState;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const active = mode === panel.effective;
+ const label = (
+
+ {active ? `${theme.glyphs.chevronRight} ` : " "}
+ {runModePillLabel(mode, panel)}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ // Clicking the mode already in effect opens the dial instead of
+ // re-applying it: on Fusion that is the only way to reach the
+ // cloud-share slider with the mouse, and re-applying a mode the
+ // agent is already in would be a wasted provider swap.
+ if (active) {
+ mouse.dispatch({ type: "run_mode_picker_opened" });
+ return true;
+ }
+ mouse.callbacks.onRunModeChangeRequested?.(mode);
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
diff --git a/src/tui/components/run-mode-picker.test.tsx b/src/tui/components/run-mode-picker.test.tsx
new file mode 100644
index 00000000..92a25d61
--- /dev/null
+++ b/src/tui/components/run-mode-picker.test.tsx
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import { render } from "ink-testing-library";
+
+import { RunModePicker } from "./run-mode-picker.js";
+import { createInitialRunModePanelState } from "../run-mode/run-mode-panel-state.js";
+import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js";
+
+function stripAnsi(value: string): string {
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
+}
+
+function open(over: Partial = {}): RunModePanelState {
+ return {
+ ...createInitialRunModePanelState(),
+ picker: {
+ cursor: 2,
+ draftMode: "fusion",
+ draftCloudShare: 40,
+ digitBuffer: "",
+ },
+ ...over,
+ };
+}
+
+function frameOf(panel: RunModePanelState): string {
+ const { lastFrame } = render();
+ return stripAnsi(lastFrame() ?? "");
+}
+
+describe("RunModePicker", () => {
+ it("renders nothing while closed", () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(stripAnsi(lastFrame() ?? "").trim()).toBe("");
+ });
+
+ it("lists the modes with the draft highlighted", () => {
+ const frame = frameOf(open());
+ expect(frame).toContain("Local");
+ expect(frame).toContain("\u25b8 Fusion");
+ });
+
+ it("shows the dial value and a bar", () => {
+ const frame = frameOf(open());
+ expect(frame).toContain("40%");
+ expect(frame).toMatch(/[\u2588\u2591]/);
+ });
+
+ it("explains what the dial means in prose", () => {
+ expect(frameOf(open())).toContain("steps scoring \u2265 60");
+ });
+
+ it("names the extremes plainly", () => {
+ const allLocal = open();
+ allLocal.picker!.draftCloudShare = 0;
+ expect(frameOf(allLocal)).toContain("everything local");
+ const allCloud = open();
+ allCloud.picker!.draftCloudShare = 100;
+ expect(frameOf(allCloud)).toContain("everything cloud");
+ });
+
+ it("says the dial is inert unless Fusion is selected", () => {
+ const local = open();
+ local.picker!.draftMode = "local";
+ local.picker!.cursor = 0;
+ expect(frameOf(local)).toContain("only applies to Fusion");
+ });
+
+ it("surfaces a degradation warning", () => {
+ expect(
+ frameOf(open({ degradedMessage: "Fusion needs a cloud orchestrator" })),
+ ).toContain("Fusion needs a cloud orchestrator");
+ });
+
+ it("advertises its own key bindings", () => {
+ const frame = frameOf(open());
+ expect(frame).toContain("enter apply");
+ expect(frame).toContain("esc cancel");
+ });
+
+ /**
+ * Reported as "I don't see a way to configure fusion anywhere". Every
+ * mode here names a PAIR of providers and the overlay named neither,
+ * so with two cloud providers configured there was nothing on screen
+ * to say which one Fusion would orchestrate through — i.e. which
+ * account gets billed.
+ */
+ describe("the two legs", () => {
+ it("names the provider filling each leg", () => {
+ const frame = frameOf(
+ open({
+ cloudProviderId: "aimlapi",
+ cloudLabel: "gpt-5",
+ localProviderId: "local-llama",
+ localLabel: "qwen-3.5-4b",
+ }),
+ );
+ expect(frame).toContain("cloud leg");
+ expect(frame).toContain("aimlapi · gpt-5");
+ expect(frame).toContain("local leg");
+ expect(frame).toContain("local-llama · qwen-3.5-4b");
+ });
+
+ it("says a missing cloud leg is missing, and how to fix it", () => {
+ const frame = frameOf(
+ open({ cloudProviderId: null, localProviderId: "local-llama" }),
+ );
+ expect(frame).toContain("cloud leg");
+ expect(frame).toContain("press n to add one");
+ });
+
+ it("does not repeat the id when no model name resolved", () => {
+ const frame = frameOf(
+ open({ cloudProviderId: "openrouter", cloudLabel: "openrouter" }),
+ );
+ expect(frame).toContain("openrouter");
+ expect(frame).not.toContain("openrouter · openrouter");
+ });
+ });
+});
diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx
new file mode 100644
index 00000000..2b9cc7f5
--- /dev/null
+++ b/src/tui/components/run-mode-picker.tsx
@@ -0,0 +1,296 @@
+import { Box, Text } from "ink";
+import type { ReactElement } from "react";
+
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
+import {
+ openRunModeSetup,
+ runModeSetupOffer,
+ type RunModeSetupTarget,
+} from "../run-mode/run-mode-setup.js";
+import { theme } from "../theme/theme.js";
+import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js";
+import {
+ CLOUD_SHARE_BAR_WIDTH,
+ describeCloudShare,
+ formatCloudShareBar,
+} from "../run-mode/run-mode-selectors.js";
+import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js";
+
+export interface RunModePickerProps {
+ panel: RunModePanelState;
+}
+
+const MODE_BLURBS: Record = {
+ local: "llama-server only",
+ cloud: "cloud provider only",
+ fusion: "cloud plans, local executes",
+};
+
+/**
+ * Overlay for choosing a run mode and, for Fusion, the cloud share.
+ *
+ * The dial is why this exists at all: a 0-100 control cannot live in the
+ * one-row strip. Everything here is a draft — Esc discards it and the
+ * committed mode is untouched, the same contract `ThemePicker` offers.
+ *
+ * Mouse: a row click moves the cursor to that mode, and clicking the row
+ * already under the cursor applies it — the two-step rule the rest of the
+ * mouse layer uses for lists, because applying a mode swaps providers.
+ * The dial is the exception: it is a slider, and clicking a slider at a
+ * position means "put it here", so one click sets the share.
+ *
+ * The leg rows report, they do not edit. Pinning a leg writes
+ * `llm.runMode.cloudProvider` / `localProvider`, and the only wire this
+ * screen has to the orchestrator that owns config writes is
+ * `onRunModeChangeRequested(mode, cloudShare?)` — which has no room for
+ * a provider id. Widening it means editing `TuiAppCallbacks` in
+ * `tui-app.tsx`. Until then the pins stay a config-file setting, and
+ * this screen at least says which providers are in force.
+ */
+export function RunModePicker({ panel }: RunModePickerProps): ReactElement | null {
+ const picker = panel.picker;
+ if (!picker) return null;
+ const fusionSelected = picker.draftMode === "fusion";
+ return (
+
+
+ Run mode
+
+ {RUN_MODES.map((mode, idx) => (
+
+ ))}
+
+
+ {" "}
+ {fusionSelected
+ ? describeCloudShare(picker.draftCloudShare)
+ : "the dial only applies to Fusion"}
+
+
+ {panel.degradedMessage ? (
+ {panel.degradedMessage}
+ ) : null}
+ {/*
+ The row follows the cursor, not the config: highlight Local on a
+ machine with no llama-server and the thing to set up is the local
+ runtime, not another cloud key.
+ */}
+
+
+ ↑↓ mode · ←→ share (shift ±25) · digits set · enter apply · esc cancel
+
+
+ );
+}
+
+/**
+ * The two legs a mode runs on, named.
+ *
+ * Every mode on this screen is a statement about a PAIR of providers —
+ * Fusion runs both at once — and the overlay used to name neither. With
+ * more than one cloud provider configured that is not a cosmetic gap:
+ * the cloud leg is `llm.runMode.cloudProvider`, or failing that the
+ * first non-`llama-server` entry in `llm.providers`, which is not
+ * necessarily the one the operator was last using. "Fusion" with no
+ * further information does not say which account is about to be billed.
+ *
+ * Read-only for now, and deliberately so — see the note on
+ * `RunModePicker`. Changing a leg means writing `llm.runMode`, and this
+ * screen has exactly one wire to the orchestrator that can do that.
+ */
+function LegRows({ panel }: { panel: RunModePanelState }): ReactElement {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function LegRow({
+ name,
+ providerId,
+ model,
+ missingHint,
+}: {
+ name: string;
+ providerId: string | null;
+ model: string | null;
+ missingHint: string;
+}): ReactElement {
+ return (
+
+ {" "}
+ {name}{" "}
+ {providerId ? (
+
+ {providerId}
+ {model && model !== providerId ? ` · ${model}` : ""}
+
+ ) : (
+ {missingHint}
+ )}
+
+ );
+}
+
+const SETUP_LABELS: Record = {
+ "cloud-provider": "Set up a cloud provider…",
+ "local-runtime": "Set up the local llama-server…",
+};
+
+/**
+ * On a fresh install two of the three modes cannot be entered at all,
+ * and this overlay was where you found that out and then had nowhere to
+ * go. The fix belongs on the screen that raises the problem.
+ *
+ * `null` renders an empty row rather than nothing, because this overlay
+ * has to hold its height: Ink 7 paints an over-tall frame's later lines
+ * over its earlier ones, and a row that appears and disappears as the
+ * cursor moves between modes would make the box breathe under the chat
+ * surface it floats over.
+ */
+function SetUpLegRow({
+ target,
+}: {
+ target: RunModeSetupTarget | null;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ if (!target) return ;
+ const label = (
+
+ {" "}
+ {theme.glyphs.chevronRight} {SETUP_LABELS[target]}{" "}
+ (n)
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ openRunModeSetup(mouse.dispatch, target);
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
+function ModeRow({
+ mode,
+ index,
+ selected,
+ current,
+}: {
+ mode: (typeof RUN_MODES)[number];
+ index: number;
+ selected: boolean;
+ current: boolean;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {selected ? `${theme.glyphs.chevronRight} ` : " "}
+ {RUN_MODE_LABELS[mode]}
+ — {MODE_BLURBS[mode]}
+ {current ? (current) : null}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ if (selected) {
+ const state = mouse.getState();
+ const share = state.runModePanel.picker?.draftCloudShare;
+ mouse.callbacks.onRunModeChangeRequested?.(mode, share);
+ mouse.dispatch({ type: "run_mode_picker_closed" });
+ return true;
+ }
+ mouse.dispatch({ type: "run_mode_picker_cursor_set", cursor: index });
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
+/**
+ * The 0-100 dial. Clicking column N of the bar sets the share to the
+ * value that column represents, so the gesture matches what the bar
+ * shows rather than nudging by a fixed step.
+ */
+function ShareDial({
+ cloudShare,
+ active,
+}: {
+ cloudShare: number;
+ active: boolean;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {" "}
+ cloud share {String(cloudShare).padStart(3, " ")}%{" "}
+ {formatCloudShareBar(cloudShare)}
+
+ );
+ if (!mouse) return label;
+ // Columns before the bar: two spaces + "cloud share " + a 3-wide
+ // percentage + "%" + two spaces.
+ const barStartColumn = 2 + "cloud share ".length + 3 + 1 + 2;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ const column = hit.localX - barStartColumn;
+ if (column < 0) return false;
+ const share = Math.round(
+ (Math.min(column, CLOUD_SHARE_BAR_WIDTH - 1) /
+ (CLOUD_SHARE_BAR_WIDTH - 1)) *
+ 100,
+ );
+ mouse.dispatch({ type: "run_mode_picker_share_set", cloudShare: share });
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
diff --git a/src/tui/components/session-picker.tsx b/src/tui/components/session-picker.tsx
index dd85cc8a..126c913d 100644
--- a/src/tui/components/session-picker.tsx
+++ b/src/tui/components/session-picker.tsx
@@ -2,6 +2,9 @@ import { Box, Text } from "ink";
import type { ReactElement } from "react";
import type { SessionPickerEntry } from "../tui-state.js";
import { theme } from "../theme/theme.js";
+import { MouseListRow } from "../mouse/mouse-list-row.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
+import { handleEditorSubmit } from "../submit-handler.js";
export interface SessionPickerProps {
sessions: readonly SessionPickerEntry[];
@@ -45,12 +48,31 @@ export function SessionPicker(props: SessionPickerProps): ReactElement {
↑ {hiddenBefore} above
) : null}
{visible.map((entry, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({
+ type: "session_picker_cursor_set",
+ row: windowStart + idx,
+ })
+ }
+ onActivate={(mouse) =>
+ handleEditorSubmit(
+ "",
+ mouse.getState(),
+ mouse.dispatch,
+ mouse.callbacks,
+ )
+ }
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx
index 89ec7b05..efa149d9 100644
--- a/src/tui/components/sidebar.test.tsx
+++ b/src/tui/components/sidebar.test.tsx
@@ -72,8 +72,11 @@ describe("Sidebar", () => {
/>,
);
const text = strip(lastFrame() ?? "");
- expect(text).toContain("Sessions");
- expect(text).toContain("Tasks");
+ // Upper-case since the rail became the app frame — it carries the
+ // brand, the version and the menu button now, so its own headings
+ // read as labels rather than as content.
+ expect(text).toContain("SESSIONS");
+ expect(text).toContain("TASKS");
expect(text).not.toContain("Workspace");
expect(text).not.toContain("LLM");
});
@@ -147,4 +150,85 @@ describe("Sidebar", () => {
expect(text).toContain("running task");
expect(text).toContain("pending task");
});
+ it("honours the per-pane row budget instead of a fixed 10/5 split", () => {
+ const manySessions = Array.from({ length: 12 }, (_, idx) => ({
+ ...SESSIONS[0]!,
+ sessionId: `s-${idx}`,
+ preview: `session number ${idx}`,
+ }));
+ const manyTasks = Array.from({ length: 8 }, (_, idx) =>
+ taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }),
+ );
+ const { lastFrame } = render(
+ ,
+ );
+ const text = strip(lastFrame() ?? "");
+ expect(text).toContain("session number 2");
+ expect(text).not.toContain("session number 3");
+ expect(text).toContain("task number 1");
+ expect(text).not.toContain("task number 2");
+ // Both panes admit what they are hiding.
+ expect(text).toContain("9 more");
+ expect(text).toContain("6 more");
+ // Two headers + 3 sessions + 2 tasks + 2 "more" rows + spacers, plus
+ // the brand block (mark, wordmark, version), the menu button and the
+ // breadcrumb slot the rail gained when it replaced the top bar.
+ expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(22);
+ });
+
+ it("scrolls the Tasks pane to keep the cursor visible", () => {
+ const manyTasks = Array.from({ length: 8 }, (_, idx) =>
+ taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }),
+ );
+ const { lastFrame } = render(
+ ,
+ );
+ const text = strip(lastFrame() ?? "");
+ expect(text).toContain("task number 7");
+ expect(text).not.toContain("task number 0");
+ // The chevron sits on the selected row, not on whatever row 0 is.
+ expect(text).toMatch(/▸ [^\n]*task number 7/);
+ });
+
+ it("narrows the previews with the rail rather than overflowing it", () => {
+ const long = [{ ...SESSIONS[0]!, preview: "a very long session preview indeed" }];
+ const { lastFrame } = render(
+ ,
+ );
+ const widest = strip(lastFrame() ?? "")
+ .split("\n")
+ .reduce((acc, line) => Math.max(acc, line.replace(/\s+$/, "").length), 0);
+ expect(widest).toBeLessThanOrEqual(24);
+ expect(strip(lastFrame() ?? "")).toContain("…");
+ });
});
diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx
index f7ce46b0..41bbdb20 100644
--- a/src/tui/components/sidebar.tsx
+++ b/src/tui/components/sidebar.tsx
@@ -1,8 +1,17 @@
import { Box, Text } from "ink";
-import type { ReactElement } from "react";
+import type { ReactElement, ReactNode } from "react";
+import {
+ MouseTarget,
+ useMouseCommands,
+ useMouseTarget,
+} from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { computeRowWindow } from "../row-window.js";
import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js";
import { theme } from "../theme/theme.js";
import type { SessionPickerEntry } from "../tui-state.js";
+import { getAppVersion } from "../../version.js";
+import { RAIL_MARK } from "./logo.js";
export type SidebarSection = "sessions" | "tasks";
@@ -17,23 +26,57 @@ export interface SidebarProps {
activeSection: SidebarSection;
/** Whether the sidebar owns keyboard focus right now. */
focused: boolean;
+ /** Short session id, shown under the wordmark. */
+ sessionId?: string | null;
+ /**
+ * Row budget for each pane, normally derived from the terminal height
+ * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep
+ * the pre-adaptive behaviour for callers that do not measure.
+ */
+ maxSessionRows?: number;
+ maxTaskRows?: number;
}
-const MAX_SESSION_ROWS = 10;
-const MAX_TASK_ROWS = 5;
+const DEFAULT_MAX_SESSION_ROWS = 10;
+const DEFAULT_MAX_TASK_ROWS = 5;
/**
- * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and
- * Tasks (bottom) — both navigable when the sidebar has focus. Tab
- * cycles editor → sessions → tasks → editor (handled by
- * `app-key-bindings.ts`); the sidebar component itself is purely
- * presentational and never measures the terminal directly so the
- * same component works under ink-testing-library's static viewport.
+ * Cells each list row spends before the preview text: the border, the
+ * two padding columns, the selection chevron and the status marker,
+ * plus the spaces between them.
+ */
+const ROW_CHROME_COLUMNS = 7;
+/** Never squeeze a preview below this — an ellipsis alone helps nobody. */
+const MIN_PREVIEW_COLUMNS = 6;
+
+/**
+ * The app rail: brand mark, menu button, where you are, then Sessions
+ * and Tasks. Always on screen, on the **left**, drawn on its own
+ * inverted ground.
+ *
+ * It used to be a plain right-hand list of sessions with the app title
+ * on a separate bar across the top. That is two pieces of chrome doing
+ * one job. Everything that says "which app, which version, where am I,
+ * what else is there" now lives in one column, which is where a reader
+ * coming from any normal application will look for it — and the top bar
+ * is gone entirely.
+ *
+ * **Why the inverted ground.** A terminal has no borders-and-shadows to
+ * separate regions, so two columns of the same text on the same ground
+ * read as one wrapped document. Giving the rail its own ground is the
+ * cheapest honest way to say "this is chrome, that is content". It is
+ * per-palette rather than literally white: `#fff` would vanish on the
+ * four light themes, and the property that has to hold is inversion.
+ *
+ * The ground is one `backgroundColor` on the rail container, so it fills
+ * the column's whole height on its own. Painting it line by line instead
+ * needs filler rows to reach the bottom, and a rail taller than the
+ * terminal makes Ink 7 overlap earlier lines rather than clip — the same
+ * trap `splash-fit.ts` exists to avoid.
*
- * Focus is layered: `focused` toggles the section header colour for
- * the active pane, and `activeSection` decides which pane gets the
- * cursor highlight. When `focused` is false, both panes render in
- * their muted resting state.
+ * Purely presentational: it never measures the terminal, so the same
+ * component works under ink-testing-library's static viewport. Width and
+ * per-pane row budgets arrive as props from `TuiApp`.
*/
export function Sidebar(props: SidebarProps): ReactElement {
const {
@@ -45,51 +88,245 @@ export function Sidebar(props: SidebarProps): ReactElement {
tasksCursor,
activeSection,
focused,
+ sessionId = null,
+ maxSessionRows = DEFAULT_MAX_SESSION_ROWS,
+ maxTaskRows = DEFAULT_MAX_TASK_ROWS,
} = props;
const sessionsActive = focused && activeSection === "sessions";
const tasksActive = focused && activeSection === "tasks";
+ const inner = Math.max(1, width - 2);
+ const previewWidth = Math.max(
+ MIN_PREVIEW_COLUMNS,
+ width - ROW_CHROME_COLUMNS,
+ );
+ const mouse = useMouseCommands();
+ // Wheel over the rail walks the pane that owns the cursor, so the
+ // gesture matches what ↑/↓ do once the rail has focus.
+ const wheelRef = useMouseTarget((hit) => {
+ if (hit.event.kind !== "wheel" || !mouse) return false;
+ const delta = hit.event.wheel === "up" ? -1 : 1;
+ mouse.dispatch(
+ activeSection === "tasks"
+ ? { type: "sidebar_tasks_cursor_moved", delta }
+ : { type: "sidebar_cursor_moved", delta },
+ );
+ return true;
+ });
+ // `flexShrink={0}`: Yoga shrinks flex children by default, so a wide
+ // chat column used to steal columns back from the rail — which made
+ // the width the splash was told to plan for a lie.
return (
-
+
+
+
+
-
-
-
+
+
+ {/*
+ The menu sits at the foot of the rail, the way an application
+ parks its account or settings control: it is the thing you reach
+ for occasionally, and the lists above it are what you look at.
+ The spacer pushes it down however tall the terminal is.
+ */}
+
+
);
}
+/** Clip to `width` columns; the ground is painted by the container. */
+function clip(text: string, width: number): string {
+ if (width <= 0) return "";
+ if (text.length > width) {
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
+ }
+ return text;
+}
+
+/**
+ * One rail line. The text is clipped to the rail width but not padded —
+ * the container's `backgroundColor` paints the rest of the row.
+ */
+function RailLine({
+ inner,
+ children,
+ color,
+ bold,
+}: {
+ inner: number;
+ children: string;
+ color?: string;
+ bold?: boolean;
+}): ReactElement {
+ return (
+
+ {clip(children, inner)}
+
+ );
+}
+
+/**
+ * One row of breathing space. An empty `` collapses to zero height
+ * in Ink, so the spacer has to be a sized Box.
+ */
+function RailBlank(): ReactElement {
+ return ;
+}
+
+/**
+ * Mark, wordmark, version — the mark on the left with the text beside
+ * it, the way a product lockup is normally set. Stacked, it spent six of
+ * the rail's rows on branding before the first useful line.
+ *
+ * The session id keeps its own full-width row underneath: it is the one
+ * piece here that can be long, and squeezing it into the column beside a
+ * six-column mark would truncate it to nothing.
+ */
+function RailBrand({
+ inner,
+ sessionId,
+}: {
+ inner: number;
+ sessionId: string | null;
+}): ReactElement {
+ const art = RAIL_MARK;
+ const textWidth = Math.max(0, inner - MARK_COLUMNS - 1);
+ return (
+
+
+
+
+ {art.map((row, idx) => (
+
+ {row}
+
+ ))}
+
+
+ {/* Blank rows centre the two text lines against the four-row mark. */}
+
+
+ {clip("atomic-agent", textWidth)}
+
+
+ {clip(`v${getAppVersion()}`, textWidth)}
+
+
+
+ {sessionId ? (
+
+ {shortenId(sessionId)}
+
+ ) : null}
+
+ );
+}
+
+/** Width of {@link RAIL_MARK}, kept beside it so the lockup can measure. */
+const MARK_COLUMNS = 6;
+
+/**
+ * Starts a fresh thread. It sits at the head of the session list because
+ * that is the list it adds to — and because `/new` was the only way to
+ * reach it, which is not a thing a first-time operator knows.
+ */
+function NewSessionButton({ inner }: { inner: number }): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {" + New session"}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ mouse.callbacks.onSessionNewRequested?.();
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
+/**
+ * The one control on the rail. `ctrl+p` opens the same menu; this is
+ * what makes it reachable without knowing that, which was the whole
+ * complaint about the old top bar — nothing on screen said the menu
+ * existed.
+ */
+function MenuButton({ inner }: { inner: number }): ReactElement {
+ const mouse = useMouseCommands();
+ const label = (
+
+ {`${theme.glyphs.menuGlyph} Menu${" ".repeat(Math.max(1, inner - 17))}ctrl+p`}
+
+ );
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ mouse.dispatch({ type: "menu_opened" });
+ return true;
+ }}
+ >
+ {label}
+
+ );
+}
+
+function shortenId(value: string): string {
+ if (value.length <= 8) return value;
+ return `${value.slice(0, 8)}…`;
+}
+
interface SectionHeaderProps {
title: string;
active: boolean;
+ inner: number;
}
-function SectionHeader({ title, active }: SectionHeaderProps): ReactElement {
+function SectionHeader({ title, active, inner }: SectionHeaderProps): ReactElement {
return (
-
- {title}
-
+
+ {title.toUpperCase()}
+
);
}
@@ -98,6 +335,9 @@ interface SessionsListProps {
cursor: number;
focused: boolean;
currentSessionId: string | null;
+ maxRows: number;
+ previewWidth: number;
+ inner: number;
}
function SessionsList({
@@ -105,30 +345,43 @@ function SessionsList({
cursor,
focused,
currentSessionId,
+ maxRows,
+ previewWidth,
+ inner,
}: SessionsListProps): ReactElement {
if (sessions.length === 0) {
return (
- (no sessions yet)
+
+ {"(no sessions yet)"}
+
);
}
- const clamped = Math.max(0, Math.min(cursor, sessions.length - 1));
- const windowStart = computeWindowStart(clamped, sessions.length, MAX_SESSION_ROWS);
- const visible = sessions.slice(windowStart, windowStart + MAX_SESSION_ROWS);
- const visibleCursor = clamped - windowStart;
- const hiddenAfter = Math.max(0, sessions.length - windowStart - visible.length);
+ const window = computeRowWindow(sessions.length, cursor, maxRows);
+ const visible = sessions.slice(window.start, window.start + window.count);
+ const visibleCursor =
+ Math.max(0, Math.min(cursor, sessions.length - 1)) - window.start;
return (
{visible.map((entry, idx) => (
-
+ onActivate={(mouse) =>
+ mouse.callbacks.onSessionSwitchRequested?.(entry.sessionId)
+ }
+ >
+
+
))}
- {hiddenAfter > 0 ? (
- ↓ {hiddenAfter} more
- ) : null}
+
);
}
@@ -137,20 +390,28 @@ interface SessionRowProps {
entry: SessionPickerEntry;
selected: boolean;
current: boolean;
+ previewWidth: number;
+ inner: number;
}
-function SessionRow({ entry, selected, current }: SessionRowProps): ReactElement {
- const preview = truncate(entry.preview, 28);
+function SessionRow({
+ entry,
+ selected,
+ current,
+ previewWidth,
+ inner,
+}: SessionRowProps): ReactElement {
+ const preview = truncate(entry.preview, previewWidth);
const marker = current ? theme.glyphs.assistantMarker : " ";
const chevron = selected ? theme.glyphs.chevronRight : " ";
return (
-
- {chevron} {marker} {preview}
-
+ {`${chevron} ${marker} ${preview}`}
+
);
}
@@ -158,24 +419,51 @@ interface TasksListProps {
tasks: readonly TaskSummaryRow[];
cursor: number;
focused: boolean;
+ maxRows: number;
+ previewWidth: number;
+ inner: number;
}
-function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement {
+function TasksList({
+ tasks,
+ cursor,
+ focused,
+ maxRows,
+ previewWidth,
+ inner,
+}: TasksListProps): ReactElement {
if (tasks.length === 0) {
- return (no active tasks);
+ return (
+
+ {"(no active tasks)"}
+
+ );
}
- const clamped = Math.max(0, Math.min(cursor, tasks.length - 1));
- const visible = tasks.slice(0, MAX_TASK_ROWS);
- const visibleCursor = Math.min(clamped, visible.length - 1);
+ const window = computeRowWindow(tasks.length, cursor, maxRows);
+ const visible = tasks.slice(window.start, window.start + window.count);
+ const visibleCursor =
+ Math.max(0, Math.min(cursor, tasks.length - 1)) - window.start;
return (
{visible.map((row, idx) => (
-
+ onActivate={(mouse) =>
+ mouse.callbacks.onSidebarTaskActivated?.(row.id)
+ }
+ >
+
+
))}
+
);
}
@@ -183,20 +471,43 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement {
interface TaskRowProps {
row: TaskSummaryRow;
selected: boolean;
+ previewWidth: number;
+ inner: number;
}
-function TaskRow({ row, selected }: TaskRowProps): ReactElement {
- const preview = truncate(row.userMessage, 24);
+function TaskRow({
+ row,
+ selected,
+ previewWidth,
+ inner,
+}: TaskRowProps): ReactElement {
+ const preview = truncate(row.userMessage, previewWidth);
const chevron = selected ? theme.glyphs.chevronRight : " ";
const badge = statusBadge(row);
return (
-
- {chevron} {badge} {preview}
-
+ {`${chevron} ${badge} ${preview}`}
+
+ );
+}
+
+/** "↓ N more" footer, or nothing at all when the tail is visible. */
+function MoreRow({
+ hidden,
+ inner,
+}: {
+ hidden: number;
+ inner: number;
+}): ReactElement | null {
+ if (hidden <= 0) return null;
+ return (
+
+ {`↓ ${hidden} more`}
+
);
}
@@ -216,11 +527,53 @@ function truncate(text: string, max: number): string {
const oneLine = text.replace(/\s+/g, " ").trim();
if (oneLine.length === 0) return "(empty)";
if (oneLine.length <= max) return oneLine;
- return `${oneLine.slice(0, max - 1)}…`;
+ return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
+}
+
+interface SidebarRowProps {
+ section: SidebarSection;
+ /** Absolute index into the pane's data, not the visible window. */
+ row: number;
+ selected: boolean;
+ onActivate: (mouse: NonNullable>) => void;
+ children: ReactNode;
}
-function computeWindowStart(cursor: number, total: number, size: number): number {
- if (total <= size) return 0;
- if (cursor < size) return 0;
- return Math.min(cursor - size + 1, total - size);
+/**
+ * Click behaviour shared by both rails: the first click focuses the
+ * rail and moves the cursor, a click on the row that is already
+ * selected activates it. Two deliberate clicks instead of a
+ * double-click — no timing window to guess, and it matches what the
+ * keyboard does (arrow to the row, then Enter).
+ */
+function SidebarRow({
+ section,
+ row,
+ selected,
+ onActivate,
+ children,
+}: SidebarRowProps): ReactElement {
+ const mouse = useMouseCommands();
+ if (!mouse) return <>{children}>;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ if (selected) {
+ onActivate(mouse);
+ return true;
+ }
+ mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" });
+ mouse.dispatch({ type: "sidebar_section_focused", section });
+ mouse.dispatch(
+ section === "tasks"
+ ? { type: "sidebar_tasks_cursor_set", row }
+ : { type: "sidebar_cursor_set", row },
+ );
+ return true;
+ }}
+ >
+ {children}
+
+ );
}
diff --git a/src/tui/components/skills-hub-list.tsx b/src/tui/components/skills-hub-list.tsx
index 76908701..71005232 100644
--- a/src/tui/components/skills-hub-list.tsx
+++ b/src/tui/components/skills-hub-list.tsx
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleSkillsTabKey } from "../skills/skills-key-bindings.js";
import { formatDownloads } from "../skills/format-downloads.js";
import type {
HubSkillRow,
@@ -84,11 +86,19 @@ function renderBody(panel: SkillsPanelState, maxRows: number): ReactElement {
↑ {hiddenBefore} above
) : null}
{pageRows.map((row, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({
+ type: "skills_hub_cursor_set",
+ row: idx + windowStart,
+ })
+ }
+ onActivate={pressEnter(handleSkillsTabKey)}
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/skills-list.tsx b/src/tui/components/skills-list.tsx
index 81978f0d..ea61e1f8 100644
--- a/src/tui/components/skills-list.tsx
+++ b/src/tui/components/skills-list.tsx
@@ -1,6 +1,8 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleSkillsTabKey } from "../skills/skills-key-bindings.js";
import type {
SkillSummaryRow,
SkillsPanelState,
@@ -45,11 +47,16 @@ export function SkillsList(props: SkillsListProps): ReactElement {
↑ {hiddenBefore} above
) : null}
{pageRows.map((row, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({ type: "skills_cursor_set", row: idx + windowStart })
+ }
+ onActivate={pressEnter(handleSkillsTabKey)}
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/slash-palette.tsx b/src/tui/components/slash-palette.tsx
index 08c567e8..ac26f61a 100644
--- a/src/tui/components/slash-palette.tsx
+++ b/src/tui/components/slash-palette.tsx
@@ -3,6 +3,9 @@ import type { ReactElement } from "react";
import { filterSlashCommands } from "../commands/slash-commands.js";
import type { SlashCommandDef } from "../commands/slash-commands.js";
import { theme } from "../theme/theme.js";
+import { MouseListRow } from "../mouse/mouse-list-row.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
+import { handleEditorSubmit } from "../submit-handler.js";
interface SlashPaletteProps {
query: string;
@@ -51,11 +54,28 @@ export function SlashPalette(props: SlashPaletteProps): ReactElement | null {
↑ {hiddenBefore} above
) : null}
{visible.map((cmd, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({
+ type: "slash_palette_cursor_set",
+ row: windowStart + idx,
+ })
+ }
+ onActivate={(mouse) => {
+ const state = mouse.getState();
+ handleEditorSubmit(
+ state.inputValue,
+ state,
+ mouse.dispatch,
+ mouse.callbacks,
+ );
+ }}
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/splash-banner.test.tsx b/src/tui/components/splash-banner.test.tsx
index a96f3c9b..51d9fae2 100644
--- a/src/tui/components/splash-banner.test.tsx
+++ b/src/tui/components/splash-banner.test.tsx
@@ -4,14 +4,18 @@ import { SplashBanner } from "./splash-banner.js";
function strip(value: string): string {
return value
- .replace(/\u001b\[[0-9;]*m/g, "")
- .replace(/\u001b\]8;;[^\u0007]*\u0007/g, "");
+ .replace(/\[[0-9;]*m/g, "")
+ .replace(/\]8;;[^]*/g, "");
+}
+
+function frameAt(columns: number, rows: number): string {
+ const { lastFrame } = render();
+ return strip(lastFrame() ?? "");
}
describe("SplashBanner", () => {
- it("renders the plus-mark middle bar and the wordmark", () => {
- const { lastFrame } = render();
- const frame = strip(lastFrame() ?? "");
+ it("renders the plus-mark middle bar and the wordmark on a roomy surface", () => {
+ const frame = frameAt(96, 40);
// Middle bar of the plus — longest uninterrupted `:` run in the art.
expect(frame).toContain("::::::::::::::::::::::::::::::::::");
// Both halves of the `ATOMIC AGENT` half-block wordmark.
@@ -21,14 +25,50 @@ describe("SplashBanner", () => {
});
it("advertises the core slash commands and hotkeys", () => {
- const { lastFrame } = render();
- const frame = strip(lastFrame() ?? "");
+ const frame = frameAt(96, 40);
expect(frame).toContain("/help");
expect(frame).toContain("/sessions");
expect(frame).toContain("/new");
- expect(frame).toContain("/observe");
- expect(frame).toContain("/manage");
- expect(frame).toContain("/run");
+ expect(frame).toContain("/model");
+ expect(frame).toContain("/tasks");
+ expect(frame).toContain("/import");
expect(frame).toContain("Ctrl+C");
});
+
+ it("keeps the most useful tips when the surface is too short for all of them", () => {
+ const frame = frameAt(96, 16);
+ expect(frame).toContain("/help");
+ expect(frame).toContain("/sessions");
+ // The tail of the list is what gives way first.
+ expect(frame).not.toContain("/import");
+ });
+
+ it("swaps in terse descriptions on a narrow surface", () => {
+ const frame = frameAt(44, 20);
+ expect(frame).toContain("/help");
+ expect(frame).toContain("all commands");
+ expect(frame).not.toContain("list all slash commands");
+ });
+
+ it("keeps the tips and drops the mark when four rows is all there is", () => {
+ // The mark is scaled artwork at every size now — the smallest is 4
+ // rows, so on a 4-row surface it would leave nothing for the tips
+ // and Ink would paint it over the chat above. Tips win.
+ const frame = frameAt(38, 4);
+ expect(frame).toContain("Enter");
+ expect(frame).not.toMatch(/:::|[█▀▄]/u);
+ });
+
+ it("still shows a brand mark and a tip once there is room for both", () => {
+ const frame = frameAt(38, 8);
+ expect(frame).toMatch(/:::|[█▀▄]/u);
+ expect(frame).toContain("Enter");
+ });
+
+ it("measures the terminal itself when no size is given", () => {
+ const { lastFrame } = render();
+ const frame = strip(lastFrame() ?? "");
+ expect(frame).toMatch(/:::|[█▀▄]/u);
+ expect(frame).toContain("/help");
+ });
});
diff --git a/src/tui/components/splash-banner.tsx b/src/tui/components/splash-banner.tsx
index 64fe5ddc..8fd99afe 100644
--- a/src/tui/components/splash-banner.tsx
+++ b/src/tui/components/splash-banner.tsx
@@ -1,7 +1,17 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
-import { Logo } from "./logo.js";
+import { useTerminalSize } from "../hooks/use-terminal-size.js";
+import { computeChatViewportRows, computeChatWidth } from "../layout.js";
import { theme } from "../theme/theme.js";
+import { Logo } from "./logo.js";
+import {
+ computeSplashFit,
+ SPLASH_TIPS,
+ type SplashFit,
+ type SplashSize,
+ type SplashTip,
+ type TipDescriptions,
+} from "./splash-fit.js";
/**
* Welcome screen shown in place of an empty chat-log. Renders the brand
@@ -9,41 +19,73 @@ import { theme } from "../theme/theme.js";
* spacers, with a compact tip-list underneath that surfaces the most
* useful slash commands and hotkeys.
*
+ * Everything on it is sized against the live terminal: the mark shrinks
+ * (34×20 → 17×10 → one line) as the window narrows or shortens, the tip
+ * list drops entries from its tail, and the tip descriptions collapse to
+ * terse copy before disappearing entirely. See `splash-fit.ts` for the
+ * breakpoints — this component only renders the plan it is handed.
+ *
* Visibility is decided by the parent (`ChatLog`) based on
* `messages.length === 0`, so restoring a historical session via
* `/sessions` swaps the banner out for the transcript.
*/
-export function SplashBanner(): ReactElement {
+export interface SplashBannerProps {
+ /**
+ * Explicit surface size, bypassing the terminal measurement. Only
+ * used by tests — ink-testing-library's stdout stub reports a fixed
+ * 100×0, which would pin every rendered frame to one breakpoint.
+ */
+ size?: SplashSize;
+}
+
+export function SplashBanner({ size }: SplashBannerProps = {}): ReactElement {
+ const terminal = useTerminalSize();
+ const surface: SplashSize = size ?? {
+ columns: computeChatWidth(terminal.columns),
+ rows: computeChatViewportRows(terminal.rows, terminal.columns),
+ };
+ const fit = computeSplashFit(surface);
+ const tips = SPLASH_TIPS.slice(0, fit.tipCount);
return (
-
-
-
-
-
-
-
-
-
-
-
+ {fit.logo === "none" ? null : (
+
+ )}
+ {tips.length > 0 ? (
+
+ {tips.map((tip) => (
+
+ ))}
+
+ ) : null}
);
}
interface TipProps {
- left: string;
- right: string;
+ tip: SplashTip;
+ fit: SplashFit;
}
-function Tip({ left, right }: TipProps): ReactElement {
+function Tip({ tip, fit }: TipProps): ReactElement {
+ const label =
+ fit.labelWidth > 0 ? tip.label.padEnd(fit.labelWidth, " ") : tip.label;
return (
-
+
{theme.glyphs.bullet}
- {left.padEnd(24, " ")}
- {right}
+ {label}
+ {description(tip, fit.descriptions)}
);
}
+
+function description(tip: SplashTip, mode: TipDescriptions): string {
+ if (mode === "full") return tip.description;
+ if (mode === "short") return tip.short;
+ return "";
+}
diff --git a/src/tui/components/splash-fit.render.test.tsx b/src/tui/components/splash-fit.render.test.tsx
new file mode 100644
index 00000000..b3ef03f3
--- /dev/null
+++ b/src/tui/components/splash-fit.render.test.tsx
@@ -0,0 +1,86 @@
+import { render } from "ink-testing-library";
+import { Box } from "ink";
+import { describe, expect, it } from "vitest";
+import { computeChatViewportRows, computeChatWidth } from "../layout.js";
+import { SplashBanner } from "./splash-banner.js";
+
+function lines(frame: string): string[] {
+ return frame
+ .replace(/\[[0-9;]*m/g, "")
+ .split("\n")
+ .map((line) => line.replace(/\s+$/, ""));
+}
+
+/**
+ * Regression guard for the "small window garbles the start page" bug,
+ * modelled on `manage-panel-fit.test.tsx`. Ink 7 does NOT clip a frame
+ * taller than the terminal — it overlaps earlier lines — and it wraps a
+ * line wider than the surface into confetti. The splash therefore has
+ * to plan its own size, and the plan has to survive contact with Yoga.
+ *
+ * ink-testing-library pins its stdout at 100 columns and reports no
+ * rows at all, so each case renders `SplashBanner` at an explicit
+ * surface size inside a `Box` of that width — the same geometry the
+ * chat column hands it in production.
+ */
+const TERMINALS: ReadonlyArray<{ columns: number; rows: number }> = [
+ { columns: 40, rows: 12 },
+ { columns: 60, rows: 20 },
+ { columns: 80, rows: 24 },
+ { columns: 100, rows: 30 },
+ { columns: 100, rows: 50 },
+];
+
+describe("SplashBanner fit", () => {
+ it.each(TERMINALS)("fits a $columns x $rows terminal", (terminal) => {
+ const size = {
+ columns: computeChatWidth(terminal.columns),
+ rows: computeChatViewportRows(terminal.rows),
+ };
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ const rendered = lines(lastFrame() ?? "");
+ const widest = rendered.reduce((acc, line) => Math.max(acc, line.length), 0);
+ expect(widest).toBeLessThanOrEqual(size.columns);
+ expect(rendered.length).toBeLessThanOrEqual(size.rows);
+ // A splash with no recognisable brand mark is not a splash — except
+ // on a surface with no room for one, where drawing it anyway is the
+ // bug this file guards against. The mark is half-block art below
+ // full size, so match the glyphs rather than the source shading.
+ if (size.rows >= 6) {
+ expect(rendered.join("\n")).toMatch(/ATOMIC AGENT|:::|[█▀▄]/u);
+ }
+ });
+
+ it("renders the full artwork, wordmark and every tip when there is room", () => {
+ const size = { columns: 96, rows: 40 };
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ const frame = lines(lastFrame() ?? "").join("\n");
+ expect(frame).toContain("::::::::::::::::::::::::::::::::::");
+ expect(frame).toContain("▄▀█ ▀█▀ █▀█");
+ expect(frame).toContain("Local AI-First Agent");
+ expect(frame).toContain("/import");
+ });
+
+ it("collapses to the smallest mark and bare labels on a tiny surface", () => {
+ const size = { columns: 24, rows: 10 };
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ const frame = lines(lastFrame() ?? "").join("\n");
+ // The mini mark is scaled from the full drawing, not a text stand-in.
+ expect(frame).toMatch(/[█▀▄]/u);
+ expect(frame).not.toContain("▄▀█ ▀█▀ █▀█");
+ expect(frame).toContain("/help");
+ expect(frame).not.toContain("list all slash commands");
+ });
+});
diff --git a/src/tui/components/splash-fit.test.ts b/src/tui/components/splash-fit.test.ts
new file mode 100644
index 00000000..5a155533
--- /dev/null
+++ b/src/tui/components/splash-fit.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeSplashFit,
+ LOGO_METRICS,
+ SPLASH_TIPS,
+ type LogoVariant,
+} from "./splash-fit.js";
+
+const SIZE_ORDER: readonly LogoVariant[] = ["mini", "small", "full"];
+
+describe("computeSplashFit", () => {
+ it("gives a roomy terminal the full artwork, the wordmark and every tip", () => {
+ expect(computeSplashFit({ columns: 92, rows: 40 })).toEqual({
+ logo: "full",
+ wordmark: true,
+ tagline: true,
+ tipCount: SPLASH_TIPS.length,
+ labelWidth: 24,
+ descriptions: "full",
+ });
+ });
+
+ it("drops the wordmark before the mark when the surface narrows", () => {
+ // 82 inner columns — one short of mark + gap + wordmark.
+ const fit = computeSplashFit({ columns: 86, rows: 40 });
+ expect(fit.logo).toBe("full");
+ expect(fit.wordmark).toBe(false);
+ expect(fit.tagline).toBe(false);
+ });
+
+ it("shrinks the mark when the surface is too short for the tall artwork", () => {
+ // A 100x24 terminal leaves the chat surface 73x16.
+ expect(computeSplashFit({ columns: 73, rows: 16 })).toEqual({
+ logo: "small",
+ wordmark: false,
+ tagline: false,
+ // `small` is 12 rows now, not 10 — it is scaled from the full mark
+ // rather than hand-drawn, and the honest half-scale of a 20-row
+ // drawing is 12 half-block rows. Two of those rows come out of the
+ // tip list, which is the documented mark-over-tips priority.
+ tipCount: 3,
+ labelWidth: 24,
+ descriptions: "full",
+ });
+ });
+
+ it("falls back to the smallest mark and terse copy on a small window", () => {
+ expect(computeSplashFit({ columns: 38, rows: 12 })).toEqual({
+ logo: "mini",
+ wordmark: false,
+ tagline: false,
+ // 12 rows − 4 for the mark − 1 margin leaves 7 of the 8 tips.
+ tipCount: SPLASH_TIPS.length - 1,
+ labelWidth: 10,
+ descriptions: "short",
+ });
+ });
+
+ it("keeps bare labels when there is no room for any description", () => {
+ const fit = computeSplashFit({ columns: 20, rows: 10 });
+ expect(fit.logo).toBe("mini");
+ expect(fit.descriptions).toBe("none");
+ expect(fit.labelWidth).toBe(0);
+ expect(fit.tipCount).toBeGreaterThan(0);
+ });
+
+ it("drops the mark rather than overflow a two-row surface", () => {
+ // Reversed deliberately. The old floor was a one-line text mark, so
+ // the tips were what got dropped. The mark is real artwork at every
+ // size now, and on a two-row surface the tips are the half worth
+ // keeping — Ink paints an over-tall frame over the rows above it, so
+ // "draw the mark anyway" is the bug this whole module exists for.
+ expect(computeSplashFit({ columns: 92, rows: 2 })).toMatchObject({
+ logo: "none",
+ tipCount: 2,
+ });
+ });
+
+ it("survives a degenerate surface without going negative", () => {
+ const fit = computeSplashFit({ columns: 0, rows: 0 });
+ expect(fit.tipCount).toBe(0);
+ expect(fit.labelWidth).toBe(0);
+ expect(fit.logo).toBe("none");
+ });
+
+ it("plans a layout that fits the surface it was given", () => {
+ for (let columns = 10; columns <= 200; columns += 3) {
+ for (let rows = 2; rows <= 60; rows += 3) {
+ const fit = computeSplashFit({ columns, rows });
+ const markHeight =
+ fit.logo === "none" ? 0 : LOGO_METRICS[fit.logo].height;
+ const height =
+ markHeight +
+ (fit.tipCount > 0 ? (markHeight > 0 ? 1 : 0) + fit.tipCount : 0);
+ expect(height).toBeLessThanOrEqual(rows);
+ expect(fit.tipCount).toBeGreaterThanOrEqual(0);
+ expect(fit.labelWidth).toBeGreaterThanOrEqual(0);
+ if (fit.wordmark) expect(fit.logo).toBe("full");
+ }
+ }
+ });
+
+ it("never shrinks the mark as the terminal gets wider", () => {
+ let previous = -1;
+ for (let columns = 10; columns <= 200; columns += 1) {
+ const choice = computeSplashFit({ columns, rows: 60 }).logo;
+ const rank = choice === "none" ? -1 : SIZE_ORDER.indexOf(choice);
+ expect(rank).toBeGreaterThanOrEqual(previous);
+ previous = rank;
+ }
+ });
+
+ it("never shows fewer tips as the terminal grows, for a fixed mark", () => {
+ // Across a variant change the count legitimately drops: a taller
+ // window buys a taller mark, which is paid for in tip rows. Within
+ // one variant the list may only grow.
+ const perVariant = new Map();
+ for (let rows = 2; rows <= 80; rows += 1) {
+ const { logo, tipCount } = computeSplashFit({ columns: 92, rows });
+ expect(tipCount).toBeGreaterThanOrEqual(perVariant.get(logo) ?? 0);
+ perVariant.set(logo, tipCount);
+ }
+ expect(perVariant.get("full")).toBe(SPLASH_TIPS.length);
+ });
+});
diff --git a/src/tui/components/splash-fit.ts b/src/tui/components/splash-fit.ts
new file mode 100644
index 00000000..b100d4ca
--- /dev/null
+++ b/src/tui/components/splash-fit.ts
@@ -0,0 +1,212 @@
+/**
+ * Fit maths for the start-page splash — which brand mark to draw, how
+ * many tips to keep, and how wide the tip columns may be for a given
+ * chat-surface size.
+ *
+ * The splash used to be a fixed 83×20 mark plus eight fixed tip rows,
+ * i.e. it needed 90 columns and ~29 rows no matter what the terminal
+ * offered. Ink 7 does not clip an over-tall frame — it overlaps
+ * earlier lines (see `../row-window.ts`) — so a short window garbled
+ * the whole start page, and a narrow one wrapped the artwork into
+ * confetti.
+ *
+ * The mark has priority over the tip list: a window that grows tall
+ * enough for a bigger mark spends its new rows on the artwork first, so
+ * the tip count can legitimately drop across a variant change. Within a
+ * variant the list only ever grows.
+ *
+ * This module is deliberately React-free so the breakpoints can be
+ * unit-tested as a table instead of through rendered frames.
+ */
+
+export type LogoVariant = "full" | "small" | "mini";
+
+/**
+ * What the splash draws for a mark. `"none"` is a real outcome, not a
+ * failure: below ~8 rows the mark and the tips cannot both fit, and Ink
+ * paints an over-tall frame *over* the rows above it rather than
+ * clipping — so drawing it anyway is what garbled the start page in the
+ * first place. The tips are the useful half at that size.
+ */
+export type LogoChoice = LogoVariant | "none";
+
+export interface SplashSize {
+ columns: number;
+ rows: number;
+}
+
+export type TipDescriptions = "full" | "short" | "none";
+
+export interface SplashFit {
+ /** Which brand mark to draw, or `"none"` when nothing fits. */
+ logo: LogoChoice;
+ /** Whether the `ATOMIC AGENT` wordmark sits beside the mark. */
+ wordmark: boolean;
+ /** Whether the "Local AI-First Agent" tagline is drawn. */
+ tagline: boolean;
+ /** How many tips fit, taken from the head of `SPLASH_TIPS`. */
+ tipCount: number;
+ /** Padded width of the tip label column (0 when unpadded). */
+ labelWidth: number;
+ /** Which description text to pair with each tip label. */
+ descriptions: TipDescriptions;
+}
+
+export interface SplashTip {
+ label: string;
+ /** Roomy copy, used when the surface can carry it. */
+ description: string;
+ /** Terse copy for narrow surfaces. */
+ short: string;
+}
+
+/**
+ * Start-page tips in priority order — the tail is dropped first when
+ * the surface runs out of rows, so the entries that keep a first-run
+ * operator moving have to come first.
+ */
+export const SPLASH_TIPS: readonly SplashTip[] = [
+ {
+ label: "Enter",
+ description: "submit message to the agent",
+ short: "send message",
+ },
+ {
+ label: "/help",
+ description: "list all slash commands",
+ short: "all commands",
+ },
+ {
+ label: "/sessions",
+ description: "switch to a previous thread",
+ short: "past threads",
+ },
+ { label: "/new", description: "start a fresh session", short: "new session" },
+ { label: "/model", description: "change the chat model", short: "pick model" },
+ {
+ label: "/tasks",
+ description: "jump to the Tasks tab (cron + ingress UI)",
+ short: "Tasks tab",
+ },
+ {
+ label: "/import",
+ description: "open the Import tab (Hermes migration)",
+ short: "Hermes import",
+ },
+ {
+ label: "Ctrl+C ×2",
+ description: "quit (once aborts a running turn)",
+ short: "quit",
+ },
+];
+
+interface LogoMetrics {
+ width: number;
+ height: number;
+}
+
+/**
+ * Rendered footprint of each mark, in cells. Kept beside the art in
+ * `logo.tsx` by `logo-fit.test.ts`, which re-measures the row data and
+ * fails if the two ever drift apart.
+ */
+export const LOGO_METRICS: Readonly> = {
+ full: { width: 34, height: 20 },
+ small: { width: 20, height: 12 },
+ mini: { width: 7, height: 4 },
+};
+
+/** `ATOMIC AGENT` half-block wordmark, plus the gap that precedes it. */
+export const WORDMARK_WIDTH = 46;
+const WORDMARK_GAP = 3;
+
+/** `paddingX` on the splash container. */
+const SPLASH_PADDING_X = 2;
+/** `" • "` in front of every tip label. */
+const TIP_PREFIX_WIDTH = 4;
+/** Roomy tip-label column, matching the pre-adaptive layout. */
+const TIP_LABEL_WIDE = 24;
+/** Tips are worth keeping only if a few of them survive together. */
+const MIN_TIPS = 3;
+/** One blank row separates the mark from the tip list. */
+const TIP_LIST_MARGIN_ROWS = 1;
+
+const VARIANTS_WIDEST_FIRST: readonly LogoVariant[] = ["full", "small", "mini"];
+
+/** Width at which the mark and the wordmark fit side by side. */
+const FULL_WITH_WORDMARK_WIDTH =
+ LOGO_METRICS.full.width + WORDMARK_GAP + WORDMARK_WIDTH;
+
+function maxLength(values: readonly string[]): number {
+ return values.reduce((acc, value) => Math.max(acc, value.length), 0);
+}
+
+/**
+ * Resolve the splash layout for a chat surface of `size`.
+ *
+ * `size` is the space the splash itself owns — already net of the root
+ * padding, the right rail and the prompt chrome (see `../layout.ts`).
+ * Width picks the mark, height then downgrades it until at least
+ * {@link MIN_TIPS} tips can sit underneath, and whatever rows are left
+ * decide how much of the tip list survives.
+ */
+export function computeSplashFit(size: SplashSize): SplashFit {
+ const inner = Math.max(0, size.columns - SPLASH_PADDING_X * 2);
+ const rows = Math.max(0, size.rows);
+
+ let index = VARIANTS_WIDEST_FIRST.findIndex(
+ (variant) => LOGO_METRICS[variant].width <= inner,
+ );
+ if (index === -1) index = VARIANTS_WIDEST_FIRST.length - 1;
+ while (
+ index < VARIANTS_WIDEST_FIRST.length - 1 &&
+ LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height +
+ TIP_LIST_MARGIN_ROWS +
+ MIN_TIPS >
+ rows
+ ) {
+ index += 1;
+ }
+ let logo: LogoChoice = VARIANTS_WIDEST_FIRST[index]!;
+ if (
+ LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height +
+ TIP_LIST_MARGIN_ROWS +
+ 1 >
+ rows ||
+ LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.width > inner
+ ) {
+ logo = "none";
+ }
+
+ // The wordmark is a 46-column luxury; it only rides along with the
+ // full mark, and only once both fit side by side.
+ const wordmark = logo === "full" && inner >= FULL_WITH_WORDMARK_WIDTH;
+ const tagline = wordmark;
+
+ const markRows =
+ logo === "none" ? 0 : LOGO_METRICS[logo].height + TIP_LIST_MARGIN_ROWS;
+ const spare = rows - markRows;
+ const tipCount = Math.max(0, Math.min(SPLASH_TIPS.length, spare));
+ const visible = SPLASH_TIPS.slice(0, tipCount);
+
+ if (visible.length === 0) {
+ return { logo, wordmark, tagline, tipCount: 0, labelWidth: 0, descriptions: "none" };
+ }
+
+ const longestLabel = maxLength(visible.map((tip) => tip.label));
+ const longestFull = maxLength(visible.map((tip) => tip.description));
+ const longestShort = maxLength(visible.map((tip) => tip.short));
+ const tightLabel = longestLabel + 1;
+ const budget = inner - TIP_PREFIX_WIDTH;
+
+ if (budget >= TIP_LABEL_WIDE + longestFull) {
+ return { logo, wordmark, tagline, tipCount, labelWidth: TIP_LABEL_WIDE, descriptions: "full" };
+ }
+ if (budget >= tightLabel + longestFull) {
+ return { logo, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "full" };
+ }
+ if (budget >= tightLabel + longestShort) {
+ return { logo, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "short" };
+ }
+ return { logo, wordmark, tagline, tipCount, labelWidth: 0, descriptions: "none" };
+}
diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx
index 33750987..162eaf50 100644
--- a/src/tui/components/status-bar.tsx
+++ b/src/tui/components/status-bar.tsx
@@ -1,11 +1,10 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
-import {
- getCurrentSection,
- SECTION_ORDER,
- type TuiSection,
-} from "../section.js";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { getCurrentSection, type TuiSection } from "../section.js";
+import { menuPlaceByTab } from "../menu/menu-registry.js";
import { theme } from "../theme/theme.js";
import type { TuiState } from "../tui-state.js";
import { getAppVersion } from "../../version.js";
@@ -15,7 +14,13 @@ interface StatusBarProps {
}
/**
- * One-row operator status bar. Replaces the legacy `header-line` +
+ * One-row operator status bar. Shows **where you are**, not where you could
+ * go: the three-section pill row was a menu, and the menu now lives behind
+ * `ctrl+p` where it can hold every destination instead of only the top three.
+ * What is left is a breadcrumb — `Manage › Tasks` — which is the one thing
+ * the popup cannot tell you, because you have to open it to read it.
+ *
+ * Replaces the legacy `header-line` +
* `status-line` + `footer-line` trio: only signal that needs to be
* visible at every glance stays on screen — current section and a
* short session id when one exists. Verbose details (full cwd, llama
@@ -37,7 +42,7 @@ export function StatusBar({ state }: StatusBarProps): ReactElement {
v{getAppVersion()}
-
+
);
@@ -49,32 +54,54 @@ const SECTION_LABELS: Record = {
manage: "Manage",
};
-function SectionPills({ active }: { active: TuiSection }): ReactElement {
- return (
+/**
+ * Where you are, as one line: `Manage › Tasks`.
+ *
+ * #172 retired the Run / Observe / Manage pill row — it was a menu, and
+ * the menu now lives behind `ctrl+p` where it can hold every destination
+ * instead of only the top three. The breadcrumb is what the popup cannot
+ * tell you, because you have to open it to read it.
+ *
+ * It stays clickable, though. Losing the pills would otherwise take away
+ * the only mouse route into navigation, so a click here opens the menu —
+ * the same thing `ctrl+p` does. Everything visible stays reachable with
+ * the mouse, which is the rule the mouse layer (#165) is built on.
+ */
+function Breadcrumb({
+ state,
+ section,
+}: {
+ state: TuiState;
+ section: TuiSection;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const tabLabel =
+ state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined;
+ const label = (
- {SECTION_ORDER.map((id, idx) => {
- const isActive = id === active;
- return (
-
-
- {isActive ? `${theme.glyphs.chevronRight} ` : " "}
- {SECTION_LABELS[id]}
-
- {idx < SECTION_ORDER.length - 1 ? (
-
- {" "}
- {theme.glyphs.dotSeparator}
- {" "}
-
- ) : null}
-
- );
- })}
+
+ {SECTION_LABELS[section]}
+
+ {tabLabel ? (
+
+ {" "}
+ {theme.glyphs.chevronRight} {tabLabel}
+
+ ) : null}
);
+ if (!mouse) return label;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ mouse.dispatch({ type: "menu_opened" });
+ return true;
+ }}
+ >
+ {label}
+
+ );
}
interface SessionTagProps {
diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx
index abed9ca4..60c63650 100644
--- a/src/tui/components/tasks-list.tsx
+++ b/src/tui/components/tasks-list.tsx
@@ -1,111 +1,184 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
import { theme } from "../theme/theme.js";
+import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js";
+import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js";
+import {
+ computeTaskListLayout,
+ computeTasksListFit,
+ describeEmptyTaskList,
+ fitTaskListHints,
+ formatTaskListHeader,
+ formatTaskRowCells,
+ type TaskListLayout,
+} from "../tasks/tasks-list-fit.js";
+import { computeRowWindow } from "../row-window.js";
import type {
TaskSummaryRow,
TasksPanelState,
} from "../tasks/tasks-panel-state.js";
import type { TaskStatus } from "../../tasks/task-types.js";
-import { formatRelativeMs } from "../tasks/tasks-summary.js";
export interface TasksListProps {
panel: TasksPanelState;
visibleRows: readonly TaskSummaryRow[];
+ /** Rows the whole table may occupy, chrome included. */
maxRows: number;
now: number;
+ /** Columns the panel owns — see `tasks-list-fit.ts` for why it matters. */
+ width: number;
}
/**
* Scrollable table view. `visibleRows` is already filtered + sorted by
* the caller; this component owns only the cursor windowing and the
* per-row rendering contract.
+ *
+ * Every column width — including the footer hints — comes from
+ * `tasks-list-fit.ts` for the panel's real width, because a row that
+ * wraps takes two terminal lines and collides its own columns into
+ * each other. `maxRows` is the budget for the *whole* table, header and
+ * hints included: the header, the scroll markers and the hint strip
+ * used to be drawn on top of it, which is how the panel outgrew the
+ * space the debug pane had reserved.
*/
export function TasksList(props: TasksListProps): ReactElement {
- const { panel, visibleRows, maxRows, now } = props;
+ const { panel, visibleRows, maxRows, now, width } = props;
+ const layout = computeTaskListLayout(width);
if (visibleRows.length === 0) {
- return (
-
-
- no tasks match the current filter — press `n` to create one,
- `f` to cycle filter, `r` to refresh.
-
-
- );
+ return ;
}
+ const fit = computeTasksListFit(maxRows, visibleRows.length);
const clamped = Math.max(0, Math.min(panel.cursor, visibleRows.length - 1));
- const windowStart = computeWindowStart(clamped, visibleRows.length, maxRows);
- const pageRows = visibleRows.slice(windowStart, windowStart + maxRows);
- const hiddenBefore = windowStart;
- const hiddenAfter = Math.max(0, visibleRows.length - windowStart - pageRows.length);
+ const rowWindow = computeRowWindow(visibleRows.length, clamped, fit.listRows);
+ const windowStart = rowWindow.start;
+ const pageRows = visibleRows.slice(windowStart, windowStart + rowWindow.count);
+ const { hiddenBefore, hiddenAfter } = rowWindow;
return (
-
+ {fit.header ? : null}
{hiddenBefore > 0 ? (
↑ {hiddenBefore} above
) : null}
{pageRows.map((row, idx) => (
-
+ onSelect={(mouse) =>
+ mouse.dispatch({ type: "tasks_cursor_set", row: idx + windowStart })
+ }
+ onActivate={pressEnter(handleTasksTabKey)}
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
) : null}
-
+ {fit.hints ? : null}
);
}
-function HeaderRow(): ReactElement {
+/**
+ * What the tab shows before the first task exists — and the screen a
+ * first-run operator meets on `/tasks`. It carries the same hint strip
+ * as the populated table: the keys are the only thing to learn here,
+ * and hiding them until a task exists is a chicken-and-egg.
+ */
+function EmptyState({
+ panel,
+ width,
+ maxRows,
+}: {
+ panel: TasksPanelState;
+ width: number;
+ maxRows: number;
+}): ReactElement {
+ const { headline, detail } = describeEmptyTaskList({
+ totalRows: panel.rows.length,
+ filterStatus: panel.filterStatus,
+ searchQuery: panel.searchQuery,
+ });
+ // Same ladder as the table: spend rows on the message, then the
+ // context line, then the breathing room around them.
+ const roomy = maxRows >= 5;
+ return (
+
+ {headline}
+ {detail && maxRows >= 3 ? (
+ {detail}
+ ) : null}
+ {maxRows >= 4 ? (
+
+ {fitTaskListHints(width)}
+
+ ) : null}
+
+ );
+}
+
+function HeaderRow({ layout }: { layout: TaskListLayout }): ReactElement {
return (
- {" "}status schedule next-run session message
+ {formatTaskListHeader(layout)}
);
}
-function HintsRow(): ReactElement {
+function HintsRow({
+ width,
+ spacer,
+}: {
+ width: number;
+ spacer: boolean;
+}): ReactElement {
+ // The blank row above the hints is the house look for a manage panel,
+ // but it is the first thing to go when the budget is tight: a hint
+ // strip the operator can read beats the whitespace around it.
return (
-
-
- j/k move · Enter detail · n new · c cancel · R run-now · r refresh
- · a auto · f filter · / search · Esc clear search
-
+
+ {fitTaskListHints(width)}
);
}
function TaskRow({
row,
+ layout,
selected,
now,
}: {
row: TaskSummaryRow;
+ layout: TaskListLayout;
selected: boolean;
now: number;
}): ReactElement {
const chevron = selected ? theme.glyphs.chevronRight : " ";
- const statusText = row.status.padEnd(9);
- const scheduleText = truncate(row.scheduleLabel, 22).padEnd(22);
- const nextRunText = formatRelativeMs(row.scheduledFor, now).padEnd(14);
- const sessionText = (row.sessionId ? shortId(row.sessionId) : "—").padEnd(10);
+ const cells = formatTaskRowCells(row, layout, now);
const color = selected ? theme.colors.accentSoft : undefined;
+ // The middle columns are one `` per cell rather than one joined
+ // string so a dropped column takes its separating space with it.
return (
- {chevron} {statusText}
+ {chevron} {cells.status}
- {scheduleText} {nextRunText} {sessionText}
+ {cells.schedule ? ` ${cells.schedule}` : ""}
+ {cells.nextRun ? ` ${cells.nextRun}` : ""}
+ {cells.session ? ` ${cells.session}` : ""}
- {truncate(row.userMessage, 64)}
+ {cells.message ? ` ${cells.message}` : ""}
);
}
@@ -126,19 +199,3 @@ function statusColor(status: TaskStatus): string {
return theme.colors.muted;
}
}
-
-function shortId(id: string): string {
- if (id.length <= 10) return id;
- return `${id.slice(0, 10)}…`;
-}
-
-function truncate(text: string, max: number): string {
- if (text.length <= max) return text;
- return `${text.slice(0, max - 1)}…`;
-}
-
-function computeWindowStart(cursor: number, total: number, size: number): number {
- if (total <= size) return 0;
- if (cursor < size) return 0;
- return Math.min(cursor - size + 1, total - size);
-}
diff --git a/src/tui/components/tasks-panel-fit.test.tsx b/src/tui/components/tasks-panel-fit.test.tsx
new file mode 100644
index 00000000..99b8877c
--- /dev/null
+++ b/src/tui/components/tasks-panel-fit.test.tsx
@@ -0,0 +1,185 @@
+import { describe, expect, it } from "vitest";
+import { render } from "ink-testing-library";
+import { Box } from "ink";
+import React from "react";
+import { TasksPanel } from "./tasks-panel.js";
+import {
+ createInitialTasksPanelState,
+ type TaskSummaryRow,
+ type TasksPanelState,
+} from "../tasks/tasks-panel-state.js";
+
+/**
+ * Regression guard for the "Tasks screen is completely broken" report.
+ *
+ * The table laid its columns out to ~123 characters regardless of the
+ * panel's real width, so under the permanent left rail (88 columns at
+ * 120×40, 73 at 100×30) every row wrapped onto a second line. That
+ * collided the columns into each other and doubled the table's height —
+ * and Ink 7 does not clip an over-tall frame, it paints later lines
+ * over earlier ones, so the filter bar and the column header were
+ * overwritten by task text.
+ *
+ * The invariant is therefore about *lines*, not characters: one task
+ * must occupy exactly one row of the frame.
+ */
+
+const NOW = Date.UTC(2026, 7, 19, 12, 0, 0);
+
+/** Panel widths the rail leaves at 120×40, 80×24 and 100×30. */
+const REAL_WIDTHS = [88, 78, 73];
+
+function taskRow(index: number): TaskSummaryRow {
+ return {
+ id: `t-${index}`,
+ status: "pending",
+ origin: "cli",
+ triggerSource: null,
+ sessionId: `s-${index}0e7169e-7491-418b-9a1d-6b4a2f0d1c33`,
+ userMessage: `task number ${index} — do the thing that needs doing regularly`,
+ scheduleKind: "cron",
+ scheduleLabel: `cron: 0 ${index} * * * (Europe/Berlin)`,
+ recurring: true,
+ scheduledFor: NOW + index * 3_600_000,
+ createdAt: NOW,
+ updatedAt: NOW,
+ startedAt: null,
+ completedAt: null,
+ attempts: 0,
+ maxAttempts: 3,
+ lastError: null,
+ };
+}
+
+function panelWithRows(count: number): TasksPanelState {
+ return {
+ ...createInitialTasksPanelState(),
+ rows: Array.from({ length: count }, (_, i) => taskRow(i + 1)),
+ lastRefreshedAt: NOW,
+ };
+}
+
+function frameFor(width: number, rowCount: number, maxRows: number): string[] {
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ return (lastFrame() ?? "").split("\n");
+}
+
+/** Budget that comfortably carries `rowCount` tasks plus the chrome. */
+function roomyBudget(rowCount: number): number {
+ return rowCount + 6;
+}
+
+describe("Tasks list rows stay on one line", () => {
+ for (const width of REAL_WIDTHS) {
+ it(`draws one line per task at width ${width}`, () => {
+ const rowCount = 6;
+ const lines = frameFor(width, rowCount, roomyBudget(rowCount));
+ // filter bar + header + rows + blank spacer + hints.
+ expect(lines.length).toBe(rowCount + 4);
+ for (const line of lines) {
+ expect(line.length).toBeLessThanOrEqual(width);
+ }
+ });
+
+ it(`keeps status and message of a task on the same line at ${width}`, () => {
+ const lines = frameFor(width, 3, roomyBudget(3));
+ const messageLines = lines.filter((l) => l.includes("task number 2"));
+ expect(messageLines).toHaveLength(1);
+ expect(messageLines[0]).toContain("pending");
+ });
+
+ it(`keeps the filter bar and the column header intact at ${width}`, () => {
+ const lines = frameFor(width, 6, roomyBudget(6));
+ expect(lines[0]).toContain("filter: all");
+ expect(lines[1]).toContain("status");
+ expect(lines[1]).toContain("schedule");
+ });
+
+ it(`spells the action keys in the footer at ${width}`, () => {
+ const lines = frameFor(width, 3, roomyBudget(3));
+ const hints = lines[lines.length - 1] ?? "";
+ expect(hints).toContain("Enter detail");
+ expect(hints).toContain("n new");
+ expect(hints).toContain("c cancel");
+ });
+ }
+});
+
+/**
+ * The row budget is the other half of the same bug. `maxRows` is what
+ * the debug pane reserved for the whole tab; the table drew that many
+ * *task rows* and then added a header, scroll markers and a hint strip
+ * on top, so the frame ran past the reservation — and Ink paints the
+ * overflow over the filter bar instead of clipping it.
+ *
+ * Budgets below are the ones `debug-pane.tsx` computes for a 120×40,
+ * 100×30 and 80×24 terminal, plus the extremes.
+ */
+describe("Tasks panel never exceeds its row budget", () => {
+ for (const budget of [4, 8, 11, 17, 27]) {
+ it(`fits budget ${budget} with more tasks than rows`, () => {
+ const lines = frameFor(88, 40, budget);
+ expect(lines.length).toBeLessThanOrEqual(budget);
+ });
+
+ it(`fits budget ${budget} with a short list`, () => {
+ const lines = frameFor(73, 2, budget);
+ expect(lines.length).toBeLessThanOrEqual(budget);
+ });
+ }
+
+ it("keeps the scroll markers inside the budget from any cursor row", () => {
+ // The window slides as the cursor moves; the marker rows have to be
+ // reserved up front or the frame grows a row mid-scroll.
+ for (const cursor of [0, 1, 7, 20, 39]) {
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ expect((lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(11);
+ }
+ });
+
+ it("still shows a task row and the hints at a realistic small budget", () => {
+ const lines = frameFor(78, 12, 11);
+ expect(lines.length).toBeLessThanOrEqual(11);
+ expect(lines.some((l) => l.includes("task number"))).toBe(true);
+ expect(lines.some((l) => l.includes("Enter detail"))).toBe(true);
+ });
+});
+
+describe("Tasks panel with an empty queue", () => {
+ it("tells a first-run operator what a task is and which key makes one", () => {
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("no tasks yet");
+ expect(frame).not.toContain("match the current filter");
+ // The key surface stays on screen with nothing in the list.
+ expect(frame).toContain("Enter detail");
+ expect(frame.split("\n").length).toBeLessThanOrEqual(11);
+ });
+});
diff --git a/src/tui/components/tasks-panel.tsx b/src/tui/components/tasks-panel.tsx
index 785d96b1..4d9d978d 100644
--- a/src/tui/components/tasks-panel.tsx
+++ b/src/tui/components/tasks-panel.tsx
@@ -1,6 +1,8 @@
import { Box } from "ink";
import { useMemo } from "react";
import type { ReactElement } from "react";
+import { useTerminalSize } from "../hooks/use-terminal-size.js";
+import { computeChatWidth } from "../layout.js";
import type { TasksPanelState } from "../tasks/tasks-panel-state.js";
import { selectVisibleTaskRows } from "../tasks/tasks-filter.js";
import { TasksFilterBar } from "./tasks-filter-bar.js";
@@ -12,22 +14,42 @@ export interface TasksPanelProps {
panel: TasksPanelState;
now: number;
maxRows?: number;
+ /**
+ * Columns the panel may draw into. Defaults to the live chat-column
+ * width; tests pass it explicitly to pin a size.
+ */
+ width?: number;
}
+/**
+ * Rows the panel spends on its own chrome before the table starts: the
+ * one-line filter bar. `maxRows` is the budget for everything the tab
+ * draws, so the table below only ever gets what is left.
+ */
+const FILTER_BAR_ROWS = 1;
+
/**
* Top-level router for the Tasks tab. Switches between list, detail
* and create-form views based on `panel.mode`. The cancel-confirm
* modal is rendered separately by `TuiApp` above the editor, not
* inside the panel, so it never shifts the table layout.
+ *
+ * The panel measures itself: the debug pane hands down a row budget but
+ * not a width, and the table below needs one, because the left rail
+ * takes a quarter of the terminal away from every panel (see
+ * `../layout.ts`). Deriving it here keeps the wiring inside the Tasks
+ * tab rather than threading another prop through the shared pane.
*/
export function TasksPanel(props: TasksPanelProps): ReactElement {
+ const terminal = useTerminalSize();
const { panel, now, maxRows = 14 } = props;
+ const width = props.width ?? computeChatWidth(terminal.columns);
const visibleRows = useMemo(
() => selectVisibleTaskRows(panel),
[panel.rows, panel.filterStatus, panel.searchQuery],
);
return (
-
+
) : null}
{panel.mode === "detail" ? (
diff --git a/src/tui/components/theme-picker.tsx b/src/tui/components/theme-picker.tsx
index 3ad7c744..effe5f80 100644
--- a/src/tui/components/theme-picker.tsx
+++ b/src/tui/components/theme-picker.tsx
@@ -1,6 +1,15 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
-import { THEME_NAMES, THEMES, theme, type ThemeName } from "../theme/theme.js";
+import { MouseListRow } from "../mouse/mouse-list-row.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
+import { handleEditorSubmit } from "../submit-handler.js";
+import {
+ setActiveTheme,
+ THEME_NAMES,
+ THEMES,
+ theme,
+ type ThemeName,
+} from "../theme/theme.js";
export interface ThemePickerProps {
/** Highlighted row index into {@link THEME_NAMES}. */
@@ -52,12 +61,34 @@ export function ThemePicker(props: ThemePickerProps): ReactElement {
↑ {hiddenBefore} above
) : null}
{visible.map((name, idx) => (
-
+ onSelect={(mouse) => {
+ // Same live preview the arrow keys give: the palette swaps
+ // under the cursor, Enter (or a second click) commits it.
+ setActiveTheme(THEMES[name]);
+ mouse.dispatch({
+ type: "theme_picker_cursor_set",
+ row: windowStart + idx,
+ });
+ }}
+ onActivate={(mouse) =>
+ handleEditorSubmit(
+ "",
+ mouse.getState(),
+ mouse.dispatch,
+ mouse.callbacks,
+ )
+ }
+ >
+
+
))}
{hiddenAfter > 0 ? (
↓ {hiddenAfter} below
diff --git a/src/tui/components/tool-card.tsx b/src/tui/components/tool-card.tsx
index b27bcb7f..d3a9068e 100644
--- a/src/tui/components/tool-card.tsx
+++ b/src/tui/components/tool-card.tsx
@@ -1,5 +1,7 @@
import { Box, Text } from "ink";
-import type { ReactElement } from "react";
+import type { ReactElement, ReactNode } from "react";
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
import {
formatToolArgsBlock,
previewToolArgs,
@@ -18,6 +20,11 @@ interface ToolCardProps {
* reveals the full args block and the full summary/details text. Pending
* (in-flight) calls render with a spinner-less hourglass glyph and no
* duration yet.
+ *
+ * Clicking the header line toggles the card. Until now the per-card
+ * toggle existed in the reducer but had no key binding at all — only
+ * `/expand` and `/collapse`, which act on every card at once — so the
+ * mouse is the first way to open one specific card.
*/
export function ToolCard({ card, expanded }: ToolCardProps): ReactElement {
const isFinalised = "status" in card;
@@ -29,6 +36,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement {
? `${card.finishedAt - card.startedAt}ms`
: "…";
const header = (
+
{theme.glyphs.toolBoxTopLeft}
@@ -51,6 +59,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement {
) : null}
+
);
if (!expanded) {
return (
@@ -135,3 +144,29 @@ function toGlyph(status: "pending" | "ok" | "error"): string {
function splitLines(text: string): string[] {
return text.replace(/\r\n/g, "\n").split("\n");
}
+
+/**
+ * Wraps the card header so a click folds / unfolds that one card.
+ * Transparent when the mouse layer is absent.
+ */
+function ExpandToggle({
+ cardId,
+ children,
+}: {
+ cardId: string;
+ children: ReactNode;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ if (!mouse) return <>{children}>;
+ return (
+ {
+ if (!isPrimaryPress(hit.event)) return false;
+ mouse.dispatch({ type: "tool_expand_toggled", toolCardId: cardId });
+ return true;
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/src/tui/components/wizard-pick-list.tsx b/src/tui/components/wizard-pick-list.tsx
index e90e319e..b748a71f 100644
--- a/src/tui/components/wizard-pick-list.tsx
+++ b/src/tui/components/wizard-pick-list.tsx
@@ -1,14 +1,71 @@
import { Box, Text } from "ink";
import type { ReactElement } from "react";
+import type { MouseContextValue } from "../mouse/mouse-context.js";
+import { MouseListRow } from "../mouse/mouse-list-row.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
import { theme } from "../theme/theme.js";
/**
- * Viewport height for every wizard pick list, and the jump distance for
- * PgUp/PgDn in `providers-wizard-key-bindings`. Keep the two in sync by
- * importing this constant, never by copying the number.
+ * Largest viewport any wizard pick list will use, and the jump distance
+ * for PgUp/PgDn in `providers-wizard-key-bindings`. Keep the two in sync
+ * by importing this constant, never by copying the number.
+ *
+ * The rendered viewport shrinks below this on short terminals (see
+ * `pickWindowRows`); the paging distance deliberately does not. PgDn is
+ * "go a screenful further down a 300-row catalog", and pinning it to a
+ * 3-row window on an 80x24 terminal would turn it into ↓↓↓.
*/
export const PICK_WINDOW = 12;
+/** Never shrink the viewport below this — one row is not a list. */
+export const PICK_MIN_WINDOW = 3;
+
+/**
+ * Rows the box spends on things that are not options: two border lines,
+ * the top and bottom margins, the title, and the hint.
+ */
+const PICK_CHROME_ROWS = 6;
+
+/**
+ * How many option rows fit in `maxRows` total rows of terminal.
+ *
+ * `undefined` means "no budget was passed" and keeps the historical
+ * fixed viewport. Callers that know the budget must pass it: Ink 7 does
+ * not clip a frame taller than the terminal, it paints later lines over
+ * earlier ones, so a 16-row box on an 11-row budget does not lose its
+ * bottom — it eats whatever was above it.
+ */
+export function pickWindowRows(
+ maxRows: number | undefined,
+ extraChromeRows = 0,
+): number {
+ if (maxRows === undefined) return PICK_WINDOW;
+ return Math.max(
+ PICK_MIN_WINDOW,
+ Math.min(PICK_WINDOW, maxRows - PICK_CHROME_ROWS - extraChromeRows),
+ );
+}
+
+/** Most error lines the box will spend rows on. */
+const MAX_ERROR_ROWS = 2;
+
+/**
+ * Break a refusal into at most two truncated lines, split at the first
+ * sentence end.
+ *
+ * The verdicts from `describeProviderVerifyOutcome` are two sentences —
+ * what happened, then what to do about it — and run past 80 columns
+ * together. Truncating the pair to one line keeps the verdict and throws
+ * away the instruction, which is the half the operator needs. Splitting
+ * on the sentence boundary is width-independent, so the box height stays
+ * predictable at any terminal width.
+ */
+function errorLines(error: string): readonly string[] {
+ const split = error.indexOf(". ");
+ if (split === -1) return [error];
+ return [error.slice(0, split + 1), error.slice(split + 2)];
+}
+
/**
* Bordered option list windowed around the cursor.
*
@@ -30,14 +87,33 @@ export function renderPickList(props: {
moveHint: string;
/** Actions part of the hint, e.g. "Enter select · Esc cancel". */
actionsHint: string;
+ /** Total terminal rows this box may occupy; omit for the fixed viewport. */
+ maxRows?: number;
+ /**
+ * Why the last action was refused. A list screen used to have nowhere
+ * to say this, so a save the key check rejected looked exactly like a
+ * keypress that did nothing — the whole of report #3.
+ */
+ error?: string | null;
+ /**
+ * Move the cursor to the clicked row. Omit to leave the list
+ * keyboard-only: the local-models wizard renders in its own Ink tree
+ * with no mouse context, and a list without this renders exactly as
+ * it did before.
+ */
+ onRowSelect?: (index: number, mouse: MouseContextValue) => void;
+ /** Run the row that already holds the cursor — the click's "Enter". */
+ onRowActivate?: (mouse: MouseContextValue) => void;
}): ReactElement {
const total = props.options.length;
const clamped = Math.min(Math.max(props.cursor, 0), Math.max(0, total - 1));
+ const errors = props.error ? errorLines(props.error).slice(0, MAX_ERROR_ROWS) : [];
+ const window = pickWindowRows(props.maxRows, errors.length);
const start = Math.min(
- Math.max(0, clamped - Math.floor(PICK_WINDOW / 2)),
- Math.max(0, total - PICK_WINDOW),
+ Math.max(0, clamped - Math.floor(window / 2)),
+ Math.max(0, total - window),
);
- const visible = props.options.slice(start, start + PICK_WINDOW);
+ const visible = props.options.slice(start, start + window);
const position = total === 0 ? "(0/0)" : `(${clamped + 1}/${total})`;
return (
{
const index = start + i;
const mark = index === clamped ? ">" : " ";
- return (
+ const row = (
);
+ const select = props.onRowSelect;
+ if (!select) return row;
+ return (
+ // The wizard is a modal — `TuiApp` raises the registry floor
+ // while it owns the keyboard, so its rows have to live at the
+ // modal layer or the click is dropped. Two-step, like every
+ // other cursor list here: these are 300-row catalogs, one text
+ // row looks much like the next, and the last screen's Enter is
+ // the save that writes the provider and runs the live key
+ // check. Selecting first is what makes a mis-click free.
+ select(index, mouse)}
+ {...(props.onRowActivate ? { onActivate: props.onRowActivate } : {})}
+ >
+ {row}
+
+ );
})}
-
+ {errors.map((line, i) => (
+
+ {i === 0 ? "! " : " "}
+ {line}
+
+ ))}
+
{props.moveHint} {position} · {props.actionsHint}
diff --git a/src/tui/escape-abort-running.test.tsx b/src/tui/escape-abort-running.test.tsx
new file mode 100644
index 00000000..df850eb9
--- /dev/null
+++ b/src/tui/escape-abort-running.test.tsx
@@ -0,0 +1,102 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js";
+import type { TuiSessionInfo } from "./tui-state.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/smoke",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+/**
+ * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds`
+ * (20ms) to disambiguate it from a longer escape sequence, so every
+ * assertion waits past that flush window before reading the frame.
+ */
+const ESC = String.fromCharCode(27);
+const FLUSH_MS = 60;
+
+const settle = (): Promise =>
+ new Promise((resolve) => setTimeout(resolve, FLUSH_MS));
+
+function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {
+ counts.abort++;
+ },
+ onQuit: () => {
+ counts.quit++;
+ },
+ onMessageSubmitted: () => {},
+ };
+}
+
+describe("Esc while a turn is running", () => {
+ it("aborts the run from the chat surface", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "message_submitted" });
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+
+ // The editor is `disabled` for the whole run, which switches its
+ // `useInput` off — so this has to be claimed by the global key layer
+ // or the advertised "[esc] abort" does nothing at all.
+ expect(counts.abort).toBeGreaterThan(0);
+ expect(counts.quit).toBe(0);
+ unmount();
+ });
+
+ it("aborts the run from a debug tab too", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "logs" });
+ bus.emit({ type: "message_submitted" });
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+
+ // The hint strip checks `running` before `uiMode === "debug"`, so a
+ // run in flight aborts rather than navigating back to Run.
+ expect(counts.abort).toBeGreaterThan(0);
+ expect(counts.quit).toBe(0);
+ unmount();
+ });
+
+ it("leaves an idle session alone", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "tasks" });
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+
+ expect(counts.abort).toBe(0);
+ unmount();
+ });
+});
diff --git a/src/tui/escape-chat-editor.test.tsx b/src/tui/escape-chat-editor.test.tsx
new file mode 100644
index 00000000..d112d1ab
--- /dev/null
+++ b/src/tui/escape-chat-editor.test.tsx
@@ -0,0 +1,112 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js";
+import type { TuiSessionInfo } from "./tui-state.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/smoke",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+/**
+ * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds`
+ * (20ms) to disambiguate it from a longer escape sequence, so every
+ * assertion waits past that flush window before reading the frame.
+ */
+const ESC = String.fromCharCode(27);
+const FLUSH_MS = 60;
+
+const strip = (value: string): string =>
+ value
+ .replace(/\u001b\[[0-9;]*m/g, "")
+ .replace(/\u001b\]8;;[^\u0007]*\u0007/g, "");
+
+const settle = (): Promise =>
+ new Promise((resolve) => setTimeout(resolve, FLUSH_MS));
+
+function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {
+ counts.abort++;
+ },
+ onQuit: () => {
+ counts.quit++;
+ },
+ onMessageSubmitted: () => {},
+ };
+}
+
+describe("Esc in the chat editor", () => {
+ it("does not quit an idle agent", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { stdin, unmount } = render(
+ ,
+ );
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+
+ expect(counts.quit).toBe(0);
+ expect(counts.abort).toBe(0);
+ unmount();
+ });
+
+ it("clears a half-typed draft instead of killing the agent", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { lastFrame, stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ stdin.write("draft message");
+ await settle();
+ expect(strip(lastFrame() ?? "")).toContain("draft message");
+
+ stdin.write(ESC);
+ await settle();
+
+ expect(strip(lastFrame() ?? "")).not.toContain("draft message");
+ expect(counts.quit).toBe(0);
+ unmount();
+ });
+
+ it("survives leaving a Manage panel and pressing Esc again", async () => {
+ // The reported trap: Esc walks back from the panel to Run, and the
+ // next Esc — the natural "and back out of here too" press — used to
+ // terminate the agent.
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { lastFrame, stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "skills" });
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+ // The rail dropped its breadcrumb row, so "on the Run screen" is now
+ // the run-mode strip, which only renders on the chat surface.
+ expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local");
+ expect(counts.quit).toBe(0);
+
+ stdin.write(ESC);
+ await settle();
+ expect(counts.quit).toBe(0);
+
+ stdin.write(ESC);
+ await settle();
+ expect(counts.quit).toBe(0);
+ unmount();
+ });
+});
diff --git a/src/tui/escape-import-tab.test.tsx b/src/tui/escape-import-tab.test.tsx
new file mode 100644
index 00000000..7caedfb7
--- /dev/null
+++ b/src/tui/escape-import-tab.test.tsx
@@ -0,0 +1,67 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js";
+import type { TuiSessionInfo } from "./tui-state.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/smoke",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+/**
+ * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds`
+ * (20ms) to disambiguate it from a longer escape sequence, so every
+ * assertion waits past that flush window before reading the frame.
+ */
+const ESC = String.fromCharCode(27);
+const FLUSH_MS = 60;
+
+const strip = (value: string): string =>
+ value
+ .replace(/\u001b\[[0-9;]*m/g, "")
+ .replace(/\u001b\]8;;[^\u0007]*\u0007/g, "");
+
+const settle = (): Promise =>
+ new Promise((resolve) => setTimeout(resolve, FLUSH_MS));
+
+describe("Esc on the Import tab", () => {
+ it("returns to Run instead of being swallowed by the form", async () => {
+ let quit = 0;
+ const callbacks: TuiAppCallbacks = {
+ onApprovalDecision: () => {},
+ onAbort: () => {},
+ onQuit: () => {
+ quit++;
+ },
+ onMessageSubmitted: () => {},
+ };
+ const bus = makeTuiEventBus();
+ const { lastFrame, stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "import" });
+ await settle();
+ expect(strip(lastFrame() ?? "")).toContain("\u25b8 Import");
+
+
+ stdin.write(ESC);
+ await settle();
+
+ // The configure-mode handler ends in a catch-all `return true` that
+ // swallows stray letters; before the fix it swallowed Esc too, so the
+ // operator was stuck on the tab with no "back" gesture at all.
+ // The rail dropped its breadcrumb row, so "on the Run screen" is now
+ // the run-mode strip, which only renders on the chat surface.
+ expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local");
+ expect(quit).toBe(0);
+ unmount();
+ });
+});
diff --git a/src/tui/escape-observe-tabs.test.tsx b/src/tui/escape-observe-tabs.test.tsx
new file mode 100644
index 00000000..dabacc80
--- /dev/null
+++ b/src/tui/escape-observe-tabs.test.tsx
@@ -0,0 +1,93 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js";
+import { OBSERVE_TABS } from "./section.js";
+import type { TuiSessionInfo } from "./tui-state.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/smoke",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+/**
+ * A lone Esc byte is held back by Ink's input parser for
+ * `pendingInputFlushDelayMilliseconds` (20ms) so it can be disambiguated
+ * from the start of a longer escape sequence. Every assertion therefore
+ * has to wait past that flush window before reading the frame.
+ */
+const ESC = String.fromCharCode(27);
+const FLUSH_MS = 60;
+
+const strip = (value: string): string =>
+ value
+ .replace(/\u001b\[[0-9;]*m/g, "")
+ .replace(/\u001b\]8;;[^\u0007]*\u0007/g, "");
+
+const settle = (): Promise =>
+ new Promise((resolve) => setTimeout(resolve, FLUSH_MS));
+
+function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {
+ counts.abort++;
+ },
+ onQuit: () => {
+ counts.quit++;
+ },
+ onMessageSubmitted: () => {},
+ };
+}
+
+describe("Esc on the Observe tabs", () => {
+ for (const tab of OBSERVE_TABS) {
+ it(`returns to Run from "${tab}" instead of quitting the agent`, async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { lastFrame, stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab });
+ await settle();
+ // The Observe sub-tab strip is what says we are on a panel; which
+ // tab is marked varies across the cases this loop drives.
+ expect(strip(lastFrame() ?? "")).toContain("Reasoning");
+
+ stdin.write(ESC);
+ await settle();
+
+ // The hint strip promises "[esc] back to Run" on every debug tab.
+ // These five have no panel key layer, so before the fix the keypress
+ // reached the still-focused chat editor and quit the process.
+ expect(counts.quit).toBe(0);
+ expect(counts.abort).toBe(0);
+ expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local");
+ unmount();
+ });
+ }
+
+ it("does not quit when Esc is pressed twice from an Observe tab", async () => {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const { stdin, unmount } = render(
+ ,
+ );
+ await settle();
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "logs" });
+ await settle();
+
+ stdin.write(ESC);
+ await settle();
+ expect(counts.quit).toBe(0);
+ unmount();
+ });
+});
diff --git a/src/tui/escape-opens-menu.test.tsx b/src/tui/escape-opens-menu.test.tsx
new file mode 100644
index 00000000..c9442225
--- /dev/null
+++ b/src/tui/escape-opens-menu.test.tsx
@@ -0,0 +1,297 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js";
+import type { TuiSessionInfo } from "./tui-state.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/smoke",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+/**
+ * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds`
+ * (20ms) to disambiguate it from a longer escape sequence, so every
+ * assertion waits past that flush window before reading the frame.
+ */
+const ESC = String.fromCharCode(27);
+const TAB = "\t";
+const FLUSH_MS = 80;
+
+/**
+ * The menu popup's footer. Asserting on the word "Menu" would not do —
+ * the rail carries a `menu / ctrl+p` affordance whether the popup is up
+ * or not, so that assertion is already true before the key is pressed.
+ */
+const MENU_FOOTER = "↑↓ move";
+
+const BEL = String.fromCharCode(7);
+// Built from `ESC` rather than written as literals: a raw control byte in
+// a regex source is exactly what it looks like — a stray escape sequence
+// — and every frame is full of SGR runs and OSC-8 hyperlinks that a
+// `.toContain` would otherwise trip over.
+const SGR = new RegExp(ESC + "\\[[0-9;]*m", "g");
+const OSC8 = new RegExp(ESC + "\\]8;;[^" + BEL + "]*" + BEL, "g");
+
+const strip = (value: string): string =>
+ value.replace(SGR, "").replace(OSC8, "");
+
+const sleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Wait for Ink to finish reacting to whatever was just written.
+ *
+ * A flat sleep is not good enough here and the failure mode is nasty: the
+ * splash screen is expensive to lay out, so under load a repaint can land
+ * hundreds of milliseconds late, and a test that reads too early asserts
+ * against the *previous* frame — which for a "did nothing happen?" case
+ * passes for the wrong reason. So: clear Ink's Esc-disambiguation window
+ * first, then wait until the frame has stopped moving.
+ */
+async function settleOn(read: () => string): Promise {
+ await sleep(FLUSH_MS);
+ let quiet = 0;
+ let previous = read();
+ for (let i = 0; i < 25; i++) {
+ await sleep(30);
+ const current = read();
+ if (current === previous) {
+ if (++quiet === 3) return;
+ continue;
+ }
+ quiet = 0;
+ previous = current;
+ }
+}
+
+/**
+ * Poll until `predicate` holds. Used where the frame never goes quiet —
+ * a running turn animates its waiting phrase forever, so `settleOn` would
+ * burn its whole budget and still read a half-updated screen.
+ */
+async function waitUntil(predicate: () => boolean): Promise {
+ await sleep(FLUSH_MS);
+ for (let i = 0; i < 80; i++) {
+ if (predicate()) return;
+ await sleep(25);
+ }
+}
+
+function trackingCallbacks(counts: {
+ quit: number;
+ abort: number;
+}): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {
+ counts.abort++;
+ },
+ onQuit: () => {
+ counts.quit++;
+ },
+ onMessageSubmitted: () => {},
+ };
+}
+
+function mount() {
+ const counts = { quit: 0, abort: 0 };
+ const bus = makeTuiEventBus();
+ const rendered = render(
+ ,
+ );
+ const frame = (): string => strip(rendered.lastFrame() ?? "");
+ const menuIsOpen = (): boolean => frame().includes(MENU_FOOTER);
+ const settle = (): Promise => settleOn(frame);
+ return { ...rendered, bus, counts, frame, menuIsOpen, settle, waitUntil };
+}
+
+/**
+ * Esc reaching the bottom of its ladder opens the operator menu. Every
+ * case below is one rung of that ladder holding, or the bottom being
+ * reached. The rung that is *not* here is the scrolled-transcript one:
+ * this harness starts on the splash screen, whose content measures zero
+ * rows, so `ChatLog` self-corrects any scroll offset back to zero within
+ * a frame or two and the state cannot be held open long enough to press
+ * a key into. `escapeOpensMenu` covers that rung directly in
+ * `app-key-bindings.test.ts`.
+ */
+describe("Esc opens the operator menu", () => {
+ it("opens the menu on an idle, empty Run screen", async () => {
+ const app = mount();
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(false);
+
+ app.stdin.write(ESC);
+ await app.settle();
+
+ expect(app.menuIsOpen()).toBe(true);
+ expect(app.counts.quit).toBe(0);
+ expect(app.counts.abort).toBe(0);
+ app.unmount();
+ });
+
+ it("closes on the next Esc rather than toggling on the opening press", async () => {
+ // Ink hands the same keypress to every live `useInput`, so the press
+ // that opens the menu also reaches `handleAppKey`. If that layer saw
+ // the fresh `menuOpen` it would close the popup in the same frame and
+ // Esc would look inert.
+ const app = mount();
+ await app.settle();
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(true);
+
+ app.stdin.write(ESC);
+ await app.settle();
+
+ expect(app.menuIsOpen()).toBe(false);
+ app.unmount();
+ });
+
+ it("lets a draft keep Esc, and opens the menu on the press after", async () => {
+ const app = mount();
+ await app.settle();
+ app.stdin.write("half typed");
+ await app.settle();
+ expect(app.frame()).toContain("half typed");
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.frame()).not.toContain("half typed");
+ expect(app.menuIsOpen()).toBe(false);
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(true);
+ app.unmount();
+ });
+
+ it("lets the slash palette keep Esc", async () => {
+ const app = mount();
+ await app.settle();
+ app.stdin.write("/");
+ await app.settle();
+ // The palette's own hint row. The command names themselves are no
+ // good as a marker — the splash screen lists half of them as tips.
+ expect(app.frame()).toContain("tab/enter");
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.frame()).not.toContain("tab/enter");
+ expect(app.menuIsOpen()).toBe(false);
+
+ // Closing the palette leaves the `/` it was completing in the buffer,
+ // so the draft rung is next and it takes a third press to reach the
+ // menu. Two rungs, two presses — the point is that neither is skipped.
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(false);
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(true);
+ app.unmount();
+ });
+
+ it("lets the run-mode dial keep Esc", async () => {
+ const app = mount();
+ await app.settle();
+ app.bus.emit({ type: "run_mode_picker_opened" });
+ await app.settle();
+ expect(app.frame()).toContain("Run mode");
+
+ app.stdin.write(ESC);
+ await app.settle();
+
+ expect(app.frame()).not.toContain("Run mode");
+ expect(app.menuIsOpen()).toBe(false);
+ app.unmount();
+ });
+
+ it("lets a focused sidebar keep Esc", async () => {
+ const app = mount();
+ await app.settle();
+ app.stdin.write(TAB);
+ await app.settle();
+ // The rail's own hint row — proof focus really moved, so the Esc
+ // below is being declined by the sidebar rather than by nothing.
+ expect(app.frame()).toContain("back to editor");
+
+ app.stdin.write(ESC);
+ await app.settle();
+
+ expect(app.frame()).not.toContain("back to editor");
+ expect(app.menuIsOpen()).toBe(false);
+ app.unmount();
+ });
+
+ it("lets a running turn keep Esc for the abort", async () => {
+ const app = mount();
+ await app.settle();
+ app.bus.emitAgentEvent({ type: "turn_started", turnIndex: 0 });
+ // The running screen animates its waiting phrase, so wait on the
+ // hint strip flipping to the running row rather than on a quiet frame.
+ await app.waitUntil(() => app.frame().includes("ctrl+t"));
+ expect(app.frame()).toContain("ctrl+t");
+
+ app.stdin.write(ESC);
+ await app.waitUntil(() => app.counts.abort > 0);
+
+ expect(app.counts.abort).toBeGreaterThan(0);
+ expect(app.menuIsOpen()).toBe(false);
+ app.unmount();
+ });
+
+ it("lets an open panel keep Esc for the way home to Run", async () => {
+ const app = mount();
+ await app.settle();
+ app.bus.emit({ type: "ui_mode_set", mode: "debug" });
+ app.bus.emit({ type: "tab_changed", tab: "skills" });
+ await app.settle();
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(false);
+ // Back on Run — the run-mode strip only renders on the chat surface.
+ expect(app.frame()).toContain("▸ Local");
+
+ app.stdin.write(ESC);
+ await app.settle();
+ expect(app.menuIsOpen()).toBe(true);
+ app.unmount();
+ });
+
+ it("still never quits, however many times Esc is pressed", async () => {
+ const app = mount();
+ await app.settle();
+ for (let i = 0; i < 6; i++) {
+ app.stdin.write(ESC);
+ await app.settle();
+ }
+ expect(app.counts.quit).toBe(0);
+ app.unmount();
+ });
+
+ it("keeps a stray key out of the prompt once Esc has opened the menu", async () => {
+ // The menu takes focus off the editor — the only thing that stops a
+ // key reaching it — so what the operator types next drives the menu's
+ // search box and never lands in the message they were not writing.
+ const app = mount();
+ await app.settle();
+ app.stdin.write(ESC);
+ await app.settle();
+
+ app.stdin.write("z");
+ await app.settle();
+
+ expect(app.menuIsOpen()).toBe(true);
+ expect(app.frame()).toContain("Menu ❯ z");
+ app.unmount();
+ });
+});
diff --git a/src/tui/format-event.ts b/src/tui/format-event.ts
index 696eb816..8e33dd80 100644
--- a/src/tui/format-event.ts
+++ b/src/tui/format-event.ts
@@ -40,6 +40,14 @@ export type FeedLineInput =
| {
type: "rare_tool_autoloaded";
tool: string;
+ }
+ | {
+ type: "step_routed";
+ stepIndex: number;
+ role: "orchestrator" | "executor";
+ providerId: string;
+ complexity: number;
+ cloudShare: number;
};
const ARGS_PREVIEW_LIMIT = 160;
@@ -66,6 +74,15 @@ export function formatFeedLine(input: FeedLineInput): string {
case "rare_tool_autoloaded": {
return ` ↻ loaded schema for ${input.tool} after tool error`;
}
+ case "step_routed": {
+ // Show the cutoff alongside the score so the line explains the
+ // decision rather than just announcing it.
+ const cutoff = 100 - input.cloudShare;
+ const leg =
+ input.role === "orchestrator" ? "cloud orchestrator" : "local executor";
+ const comparison = input.role === "orchestrator" ? "≥" : "<";
+ return `[step ${input.stepIndex}] → ${leg} ${input.providerId} (complexity ${input.complexity} ${comparison} ${cutoff})`;
+ }
default:
return "";
}
diff --git a/src/tui/hooks/use-transient-status.ts b/src/tui/hooks/use-transient-status.ts
new file mode 100644
index 00000000..2019f29e
--- /dev/null
+++ b/src/tui/hooks/use-transient-status.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * A status value that falls back to `idle` on its own after
+ * `revertAfterMs` — the "label flips for two seconds, then reverts"
+ * pattern the chat-log buttons are built on.
+ *
+ * The bookkeeping is fussier than it looks, which is why both buttons
+ * share it rather than each carrying a copy:
+ *
+ * - **The timer id lives in a ref.** The chat log repaints on every
+ * streamed token, so an id kept in component state is replaced
+ * mid-flight and the old timeout fires against a stale closure,
+ * stranding the label on its badge.
+ * - **A re-flash restarts the window** instead of stacking a second
+ * timeout behind it — otherwise the older timeout clears the badge
+ * early and the label blinks mid-feedback.
+ * - **Unmount clears it.** A message can scroll out of the ring buffer
+ * while its badge is still up.
+ */
+export function useTransientStatus(
+ idle: T,
+ revertAfterMs: number,
+): [T, (next: T) => void] {
+ const [status, setStatus] = useState(idle);
+ const timerRef = useRef(null);
+ const mountedRef = useRef(true);
+ // Read through a ref so `flash` never has to be re-created when the
+ // caller passes a fresh object/array as the idle value.
+ const idleRef = useRef(idle);
+ idleRef.current = idle;
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ };
+ }, []);
+ const flash = useCallback(
+ (next: T) => {
+ if (!mountedRef.current) return;
+ setStatus(next);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ if (mountedRef.current) setStatus(idleRef.current);
+ }, revertAfterMs);
+ },
+ [revertAfterMs],
+ );
+ return [status, flash];
+}
diff --git a/src/tui/import/import-key-bindings.ts b/src/tui/import/import-key-bindings.ts
index f40d2252..69195e1a 100644
--- a/src/tui/import/import-key-bindings.ts
+++ b/src/tui/import/import-key-bindings.ts
@@ -48,6 +48,12 @@ function handleConfigureKey(
form: ImportFormState,
): boolean {
const { dispatch, callbacks } = ctx;
+ // Decline Esc so `handlePanelEscape` can turn it into "back to Run" —
+ // the gesture the hint strip advertises on every debug tab. The
+ // catch-all `return true` at the bottom of this handler (which swallows
+ // stray letters so they cannot leak into the form) would otherwise eat
+ // it and leave the operator with no way off the Import tab.
+ if (key.escape) return false;
if (key.ctrl && key.return) {
callbacks.onImportPreview?.(form);
return true;
diff --git a/src/tui/index.ts b/src/tui/index.ts
index 229903db..b770f94d 100644
--- a/src/tui/index.ts
+++ b/src/tui/index.ts
@@ -5,6 +5,7 @@ export { reduceTuiState } from "./agent-event-reducer.js";
export type { TuiAction } from "./tui-action.js";
export {
canAcceptMessage,
+ canTypeMessage,
createInitialTuiState,
DEFAULT_RING_BUFFER_SIZE,
} from "./tui-state.js";
@@ -47,3 +48,14 @@ export {
resolveStartupTheme,
} from "./theme/detect-terminal-background.js";
export type { TerminalBackgroundMode } from "./theme/detect-terminal-background.js";
+export {
+ createInitialRunModePanelState,
+ cycleRunMode,
+ reduceRunModeAction,
+ RUN_MODES,
+ RunModeOrchestrator,
+ runModeModelSummary,
+ runModePillLabel,
+ type RunModePanelState,
+} from "./run-mode/index.js";
+export { setRunModeInConfig, RunModePersistError } from "./persist-run-mode.js";
diff --git a/src/tui/layout.test.ts b/src/tui/layout.test.ts
new file mode 100644
index 00000000..e0dd0a91
--- /dev/null
+++ b/src/tui/layout.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeChatViewportRows,
+ computeChatWidth,
+ computeSidebarRowBudget,
+ computeSidebarWidth,
+ isSidebarVisible,
+ SIDEBAR_MAX_WIDTH,
+ SIDEBAR_MIN_COLUMNS,
+ SIDEBAR_MIN_WIDTH,
+} from "./layout.js";
+
+describe("isSidebarVisible", () => {
+ it("collapses the rail one column below the threshold", () => {
+ expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS - 1)).toBe(false);
+ expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS)).toBe(true);
+ });
+});
+
+describe("computeSidebarWidth", () => {
+ it("scales with the terminal between the two clamps", () => {
+ expect(computeSidebarWidth(100)).toBe(25);
+ expect(computeSidebarWidth(120)).toBe(30);
+ });
+
+ it("never leaves the [min, max] band", () => {
+ expect(computeSidebarWidth(60)).toBe(SIDEBAR_MIN_WIDTH);
+ expect(computeSidebarWidth(400)).toBe(SIDEBAR_MAX_WIDTH);
+ for (let columns = 20; columns <= 400; columns += 1) {
+ const width = computeSidebarWidth(columns);
+ expect(width).toBeGreaterThanOrEqual(SIDEBAR_MIN_WIDTH);
+ expect(width).toBeLessThanOrEqual(SIDEBAR_MAX_WIDTH);
+ }
+ });
+});
+
+describe("computeChatWidth", () => {
+ it("only subtracts the rail once it is actually drawn", () => {
+ expect(computeChatWidth(80)).toBe(78);
+ expect(computeChatWidth(100)).toBe(100 - 2 - 25);
+ expect(computeChatWidth(120)).toBe(120 - 2 - 30);
+ });
+
+ it("grows monotonically with the terminal", () => {
+ let previous = 0;
+ for (let columns = 40; columns <= 400; columns += 1) {
+ const width = computeChatWidth(columns);
+ expect(width).toBeGreaterThanOrEqual(0);
+ // The rail appearing at 100 columns is the one allowed step back.
+ if (columns !== SIDEBAR_MIN_COLUMNS) {
+ expect(width).toBeGreaterThanOrEqual(previous);
+ }
+ previous = width;
+ }
+ });
+});
+
+describe("computeSidebarRowBudget", () => {
+ it("splits the usable height roughly 2:1 in favour of sessions", () => {
+ const budget = computeSidebarRowBudget(24);
+ expect(budget.sessions).toBe(10);
+ expect(budget.tasks).toBe(5);
+ expect(computeSidebarRowBudget(16).sessions).toBe(6);
+ expect(computeSidebarRowBudget(16).tasks).toBe(3);
+ });
+
+ it("keeps both panes alive on a very short window", () => {
+ for (let rows = 4; rows <= 12; rows += 1) {
+ const budget = computeSidebarRowBudget(rows);
+ expect(budget.sessions).toBeGreaterThanOrEqual(1);
+ expect(budget.tasks).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it("stops growing once the caps are reached", () => {
+ expect(computeSidebarRowBudget(200)).toEqual({ sessions: 10, tasks: 5 });
+ });
+});
+
+describe("computeChatViewportRows", () => {
+ it("reserves the prompt chrome but never returns less than five rows", () => {
+ expect(computeChatViewportRows(40)).toBe(32);
+ expect(computeChatViewportRows(10)).toBe(4);
+ expect(computeChatViewportRows(2)).toBe(4);
+ });
+
+ it("reserves more chrome on a narrow terminal, where it wraps", () => {
+ expect(computeChatViewportRows(24, 45)).toBe(12);
+ expect(computeChatViewportRows(24, 80)).toBe(16);
+ });
+});
diff --git a/src/tui/layout.ts b/src/tui/layout.ts
new file mode 100644
index 00000000..ac416d8c
--- /dev/null
+++ b/src/tui/layout.ts
@@ -0,0 +1,141 @@
+/**
+ * Shared terminal-geometry maths for the chat shell.
+ *
+ * Two components need to agree on how the terminal width is carved up:
+ * `TuiApp` decides whether the right rail is drawn and how wide it is,
+ * and `SplashBanner` has to know how much room is left for the brand
+ * artwork. Keeping the arithmetic here means the splash can never
+ * disagree with the rail about where the boundary sits.
+ *
+ * Nothing in this module touches React or `process.stdout` — callers
+ * pass the size they already read via `useTerminalSize()`.
+ */
+
+/** `paddingLeft` on the TUI root box (`tui-app.tsx`). */
+export const ROOT_PADDING_LEFT = 2;
+
+/**
+ * Minimum terminal width (in columns) at which the right-rail sidebar
+ * is rendered. Narrower terminals collapse the layout back to the
+ * single-column form so cramped sessions over SSH stay usable. Picked
+ * to match opencode's threshold.
+ */
+export const SIDEBAR_MIN_COLUMNS = 100;
+
+/** Narrowest rail that still fits a chevron, a badge and a preview. */
+export const SIDEBAR_MIN_WIDTH = 24;
+/** Widest rail — beyond this the previews stop gaining information. */
+export const SIDEBAR_MAX_WIDTH = 34;
+/** Share of the terminal the rail is allowed to claim. */
+const SIDEBAR_WIDTH_RATIO = 0.25;
+
+/**
+ * Rows the rail spends on its own chrome before a single list row is
+ * drawn: the status bar above it, the two section headers, the blank
+ * row between the panes, a "↓ N more" footer per pane and one row of
+ * slack.
+ */
+const SIDEBAR_CHROME_ROWS = 7;
+
+/**
+ * Rows of "chrome" outside the chat surface: status bar + prompt
+ * meta-row + prompt input + prompt tail-cap + hotkey hint + a small
+ * safety pad. Used to convert `terminal.rows` into the chat-area
+ * viewport height. Slightly conservative — better to leave one empty
+ * row than to clip the prompt.
+ */
+export const CHROME_ROWS = 8;
+
+/**
+ * Below this width the status bar, the hotkey hint strip and the prompt
+ * placeholder all start wrapping onto extra lines, so the chat surface
+ * gets less room than `CHROME_ROWS` alone would suggest. Measured
+ * against the real TUI at 45 columns: the status bar takes 2 rows, the
+ * hint strip 3, and the longer rotating placeholders push the prompt to
+ * 2 — hence one row of slack on top of the three observed.
+ */
+const NARROW_COLUMNS = 60;
+const NARROW_CHROME_EXTRA = 4;
+
+/** Floor for the chat viewport — below this nothing readable survives. */
+const MIN_VIEWPORT_ROWS = 4;
+
+/** Row caps at which extra height stops buying useful context. */
+const SIDEBAR_MAX_SESSION_ROWS = 10;
+const SIDEBAR_MAX_TASK_ROWS = 5;
+
+export interface SidebarRowBudget {
+ sessions: number;
+ tasks: number;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+/** Whether the terminal is wide enough to carry the right rail. */
+export function isSidebarVisible(columns: number): boolean {
+ return columns >= SIDEBAR_MIN_COLUMNS;
+}
+
+/**
+ * Rail width as a share of the terminal rather than a flat 30 columns.
+ * A flat width left a 100-column terminal with only 70 columns of chat
+ * — not enough for the full-size brand artwork — while a 200-column
+ * terminal got a rail that looked stranded.
+ */
+export function computeSidebarWidth(columns: number): number {
+ return clamp(
+ Math.round(columns * SIDEBAR_WIDTH_RATIO),
+ SIDEBAR_MIN_WIDTH,
+ SIDEBAR_MAX_WIDTH,
+ );
+}
+
+/**
+ * Columns available to the chat column (and therefore to the splash)
+ * once the root padding and the rail have taken their share.
+ */
+export function computeChatWidth(columns: number): number {
+ const rail = isSidebarVisible(columns) ? computeSidebarWidth(columns) : 0;
+ return Math.max(0, columns - ROOT_PADDING_LEFT - rail);
+}
+
+/**
+ * Split the rail's usable height between the Sessions and Tasks panes,
+ * roughly 2:1 in favour of sessions. Both panes keep at least one row
+ * so neither header is ever left dangling over an empty pane, and both
+ * stay under the caps that used to be hard-coded in `sidebar.tsx`.
+ *
+ * Ink 7 does not clip a frame taller than the terminal — it overlaps
+ * earlier lines (see `row-window.ts`) — so this budget is what keeps a
+ * short window from garbling the rail.
+ */
+export function computeSidebarRowBudget(rows: number): SidebarRowBudget {
+ const usable = Math.max(2, rows - SIDEBAR_CHROME_ROWS);
+ const sessions = clamp(
+ Math.ceil((usable * 2) / 3),
+ 1,
+ SIDEBAR_MAX_SESSION_ROWS,
+ );
+ const tasks = clamp(usable - sessions, 1, SIDEBAR_MAX_TASK_ROWS);
+ return { sessions, tasks };
+}
+
+/**
+ * Rows the chat surface actually gets once the status bar, prompt and
+ * hint strip have taken theirs. Both `ChatLog` (scroll viewport) and
+ * `SplashBanner` (fit budget) read the same number so the splash can
+ * never plan for more rows than the surface it is rendered into.
+ *
+ * `columns` is optional so existing callers keep the wide-terminal
+ * behaviour; pass it to get the narrow-terminal correction.
+ */
+export function computeChatViewportRows(
+ rows: number,
+ columns = Number.POSITIVE_INFINITY,
+): number {
+ const chrome =
+ CHROME_ROWS + (columns < NARROW_COLUMNS ? NARROW_CHROME_EXTRA : 0);
+ return Math.max(MIN_VIEWPORT_ROWS, rows - chrome);
+}
diff --git a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts
index 695c40e2..16e27617 100644
--- a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts
+++ b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts
@@ -25,6 +25,10 @@ export function handleLlmModalKey(
return true;
}
if ("wizard" in result) {
+ if ("cancelSubmit" in result && result.cancelSubmit) {
+ callbacks.onProvidersWizardSubmitCancel?.();
+ return true;
+ }
if ("submit" in result && result.submit) {
void callbacks.onProvidersWizardSubmit?.(result.wizard);
return true;
diff --git a/src/tui/llm-panel/llm-panel-reducer.test.ts b/src/tui/llm-panel/llm-panel-reducer.test.ts
index fbc38ed3..96a21be7 100644
--- a/src/tui/llm-panel/llm-panel-reducer.test.ts
+++ b/src/tui/llm-panel/llm-panel-reducer.test.ts
@@ -59,8 +59,64 @@ describe("llm-panel reducer", () => {
expect(refreshed.llmPanel.mode).toBe("cloud");
expect(refreshed.llmPanel.syncModeToActiveRoute).toBe(false);
});
+
+ it("applies the /model filter focus deferred by an unresolved route", () => {
+ // The first /model of a session: provider rows have not landed yet,
+ // so `llm_mode_set_to_active_route` cannot say which pane this is.
+ const base = createInitialTuiState(fakeSession());
+ const opened = MODEL_COMMAND_ACTIONS.reduce(reduceTuiState, base);
+
+ expect(opened.llmPanel.syncModeToActiveRoute).toBe(true);
+ expect(opened.llmPanel.cloudModelFilterFocused).toBe(false);
+
+ const refreshed = reduceTuiState(opened, {
+ type: "providers_refresh",
+ rows: [
+ providerRow("local-llama", "llama-server", false),
+ providerRow("openrouter", "openrouter", true),
+ ],
+ });
+
+ expect(refreshed.llmPanel.mode).toBe("cloud");
+ expect(refreshed.llmPanel.cloudModelFilterFocused).toBe(true);
+ // One cloud provider row precedes the model section (the llama-server
+ // entry is not listed on this pane), and the cursor has to land
+ // inside the section: parked at 0 it sits on that provider row, and
+ // the first ↑/↓ is spent climbing in — which reads as a swallowed
+ // keypress because the counter clamps to the first model either way.
+ expect(refreshed.llmPanel.cloudCursor).toBe(1);
+ });
+
+ it("drops the deferred /model filter focus when the route is local", () => {
+ const base = createInitialTuiState(fakeSession());
+ const opened = MODEL_COMMAND_ACTIONS.reduce(reduceTuiState, base);
+
+ const refreshed = reduceTuiState(opened, {
+ type: "providers_refresh",
+ rows: [
+ providerRow("local-llama", "llama-server", true),
+ providerRow("openrouter", "openrouter", false),
+ ],
+ });
+
+ expect(refreshed.llmPanel.mode).toBe("local");
+ expect(refreshed.llmPanel.cloudModelFilterFocused).toBe(false);
+ expect(refreshed.llmPanel.pendingCloudFilterFocus).toBe(false);
+ });
});
+/**
+ * What `/model` dispatches, in order (see `dispatchModelsSub`). The
+ * `providers_inline_models_ensure_requested` action is intercepted by
+ * `submit-handler` and never reaches the reducer, so it is not here.
+ */
+const MODEL_COMMAND_ACTIONS = [
+ { type: "ui_mode_set", mode: "debug" },
+ { type: "tab_changed", tab: "llm" },
+ { type: "llm_mode_set_to_active_route" },
+ { type: "llm_cloud_filter_focus_set", focused: true },
+] as const;
+
function providerRow(id: string, kind: string, isActiveText: boolean) {
return {
id,
diff --git a/src/tui/llm-panel/llm-panel-reducer.ts b/src/tui/llm-panel/llm-panel-reducer.ts
index f42d8c08..498fdcab 100644
--- a/src/tui/llm-panel/llm-panel-reducer.ts
+++ b/src/tui/llm-panel/llm-panel-reducer.ts
@@ -75,30 +75,31 @@ export function reduceLlmPanelAction(
if (!action.focused) {
return {
...state,
- llmPanel: { ...panel, cloudModelFilterFocused: false },
+ llmPanel: {
+ ...panel,
+ cloudModelFilterFocused: false,
+ pendingCloudFilterFocus: false,
+ },
};
}
- // Focus only applies to the Cloud pane: /model on a local route
- // dispatches this right after `llm_mode_set_to_active_route`
- // resolved to `local`, and there the tab switch is the whole
- // effect. (`f` flips the pane to cloud explicitly before this.)
- if (panel.mode !== "cloud") return state;
- // Focusing the filter drops the cursor into the model section so
- // ↑/↓ and Enter act on models immediately. The section lists the
- // current model first; when the cursor is already inside the
- // section, leave it where the operator put it.
- const section = selectCloudModelSection(state);
- const sectionEnd = section.sectionStart + section.filtered.length - 1;
- const cursorInSection =
- panel.cloudCursor >= section.sectionStart &&
- panel.cloudCursor <= sectionEnd;
+ if (panel.mode === "cloud") return focusCloudModelFilter(state);
+ // The pane is not Cloud. Two very different reasons for that, and
+ // they must not be treated alike:
+ //
+ // - the route resolved to `local`, so /model legitimately landed
+ // on the Local pane and the tab switch is the whole effect
+ // (`f` flips the pane to cloud explicitly before this);
+ // - the route has not resolved yet, because `providersPanel.rows`
+ // was still empty when `llm_mode_set_to_active_route` ran one
+ // action earlier — the first /model of a session. Dropping the
+ // request there left the operator on a Cloud pane whose filter
+ // was not focused and whose cursor still pointed at a provider
+ // row above the list, so the first ↑/↓ was spent climbing into
+ // the section and looked like a swallowed keypress.
+ if (!panel.syncModeToActiveRoute) return state;
return {
...state,
- llmPanel: {
- ...panel,
- cloudModelFilterFocused: true,
- cloudCursor: cursorInSection ? panel.cloudCursor : section.sectionStart,
- },
+ llmPanel: { ...panel, pendingCloudFilterFocus: true },
};
}
case "llm_cloud_filter_set": {
@@ -122,6 +123,35 @@ export function reduceLlmPanelAction(
}
}
+/**
+ * Give the `filter:` row of the inline Cloud list the keyboard and drop
+ * the cursor into the model section, so ↑/↓ and Enter act on models
+ * immediately. The section lists the current model first; when the
+ * cursor is already inside the section, leave it where the operator put
+ * it.
+ *
+ * Shared with `providers_refresh` (see `providers-reducer.ts`): the
+ * route that /model asks for can only be resolved once provider rows
+ * exist, and this is the half of the request that has to wait for them.
+ */
+export function focusCloudModelFilter(state: TuiState): TuiState {
+ const panel = state.llmPanel;
+ const section = selectCloudModelSection(state);
+ const sectionEnd = section.sectionStart + section.filtered.length - 1;
+ const cursorInSection =
+ panel.cloudCursor >= section.sectionStart &&
+ panel.cloudCursor <= sectionEnd;
+ return {
+ ...state,
+ llmPanel: {
+ ...panel,
+ cloudModelFilterFocused: true,
+ pendingCloudFilterFocus: false,
+ cloudCursor: cursorInSection ? panel.cloudCursor : section.sectionStart,
+ },
+ };
+}
+
/** Step `delta` panes from `mode`, wrapping at both ends. */
export function nextMode(mode: LlmPanelMode, delta: number): LlmPanelMode {
const at = LLM_PANEL_MODES.indexOf(mode);
diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts
index 4b542caa..51cd6dbf 100644
--- a/src/tui/llm-panel/llm-panel-row-builders.ts
+++ b/src/tui/llm-panel/llm-panel-row-builders.ts
@@ -7,6 +7,7 @@ import { getCachedGeminiModelsForPanel } from "../../llm/provider/gemini/fetch-g
import { GEMINI_DEFAULT_CHAT_MODEL } from "../../llm/provider/gemini/gemini-provider.js";
import { filterModelIds, type ProviderRow } from "../providers/providers-panel-state.js";
import {
+ catalogEntryLookupForKind,
formatAimlapiChatModelDetails,
formatAimlapiEmbeddingModelDetails,
formatOpenRouterChatModelDetails,
@@ -18,6 +19,8 @@ import {
OPENAI_COMPAT_DEFAULT_CHAT_MODEL,
} from "../providers/providers-model-options.js";
import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js";
+import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js";
+import { CLAUDE_CLI_CHAT_MODELS } from "../../llm/provider/subscription-cli/claude-cli-models.js";
import type { TuiState } from "../tui-state.js";
import type { LlmPanelRow } from "./llm-panel-selectors.js";
@@ -101,6 +104,7 @@ export function selectCloudModelSection(state: TuiState): CloudModelSection {
const filtered = filterModelIds(
catalog.models,
state.llmPanel.cloudModelFilter,
+ catalogEntryLookupForKind(provider.kind),
);
return { provider, ...catalog, filtered, sectionStart };
}
@@ -399,6 +403,18 @@ function inlineModelsForProvider(
for (const option of listAimlapiChatModels()) out.add(option.id);
return { models: [...out], status: "ready", error: null };
}
+ // subscription-cli: nothing to fetch. Falling through to the
+ // openai-compatible tail would inject `gpt-5.4-mini` as a fake row and
+ // spin on "loading" forever, because no request will ever complete for
+ // a provider that has no HTTP endpoint.
+ if (provider.kind === SUBSCRIPTION_CLI_KIND) {
+ // Claude ships a curated list; Codex publishes none and resolves the
+ // model itself, so its pane shows only whatever the entry pinned.
+ if (provider.subscriptionCli?.cli === "claude") {
+ for (const id of CLAUDE_CLI_CHAT_MODELS) out.add(id);
+ }
+ return { models: [...out], status: "ready", error: null };
+ }
// gemini: no baseUrl on the entry, so the openai-compat URL-keyed cache
// can never hit. Read the gemini-keyed cache the orchestrator warms, and
// fall back to the gemini default — never the openai-compat placeholder
diff --git a/src/tui/llm-panel/llm-panel-selectors.test.ts b/src/tui/llm-panel/llm-panel-selectors.test.ts
index d4c73db2..f8452f02 100644
--- a/src/tui/llm-panel/llm-panel-selectors.test.ts
+++ b/src/tui/llm-panel/llm-panel-selectors.test.ts
@@ -77,7 +77,10 @@ describe("llm-panel selectors", () => {
providerId: "openrouter",
modelId: "qwen/qwen3.7-max",
active: true,
- enterEffect: expect.stringContaining("$1.25/$3.75"),
+ // Price comes from the bundled catalog, refreshed from the live
+ // OpenRouter list on 2026-08-19 ($1.475/$4.425 per 1M, shown to
+ // two decimals).
+ enterEffect: expect.stringContaining("$1.48/$4.42"),
}),
);
const activeCloud = cloudRows.find(
@@ -128,6 +131,88 @@ describe("llm-panel selectors", () => {
cloudLabel: "cloud",
});
});
+
+ /**
+ * Cloud and Fusion share an active provider — the fusion rule requires
+ * the cloud leg to be the active one — so following `isActiveText`
+ * alone printed the same single model for both. Switching Cloud →
+ * Fusion moved nothing in the composer, and the local executor that
+ * runs most of a fusion turn was never named at all.
+ */
+ it("names both legs in the prompt when fusion is in force", () => {
+ const base = createInitialTuiState(fakeSession());
+ const state = {
+ ...base,
+ runModePanel: {
+ ...base.runModePanel,
+ effective: "fusion" as const,
+ cloudProviderId: "openrouter",
+ localProviderId: "local-llama",
+ cloudLabel: "openai/gpt-4o-mini",
+ localLabel: "qwen3-4b",
+ },
+ providersPanel: {
+ ...base.providersPanel,
+ rows: [
+ {
+ id: "openrouter",
+ kind: "openrouter",
+ isActiveText: true,
+ isActiveEmbedding: false,
+ hasApiKey: true,
+ chatModel: "openai/gpt-4o-mini",
+ embeddingModel: null,
+ },
+ ],
+ },
+ };
+
+ expect(selectPromptLlmMeta(state)).toEqual({
+ model: "openai/gpt-4o-mini ⇄ qwen3-4b",
+ provider: "openrouter",
+ usesLocalHealth: false,
+ cloudLabel: "fusion",
+ });
+ });
+
+ /**
+ * `activeTextProvider` stays authoritative: an operator who moved the
+ * active provider by hand drops out of fusion, `effective` reports
+ * `cloud`, and the composer must follow the resolution rather than the
+ * stored mode.
+ */
+ it("falls back to the active provider when fusion is only stored", () => {
+ const base = createInitialTuiState(fakeSession());
+ const state = {
+ ...base,
+ runModePanel: {
+ ...base.runModePanel,
+ effective: "cloud" as const,
+ stored: "fusion" as const,
+ cloudProviderId: "openrouter",
+ localProviderId: "local-llama",
+ cloudLabel: "openai/gpt-4o-mini",
+ localLabel: "qwen3-4b",
+ },
+ providersPanel: {
+ ...base.providersPanel,
+ rows: [
+ {
+ id: "openrouter",
+ kind: "openrouter",
+ isActiveText: true,
+ isActiveEmbedding: false,
+ hasApiKey: true,
+ chatModel: "openai/gpt-4o-mini",
+ embeddingModel: null,
+ },
+ ],
+ },
+ };
+
+ expect(selectPromptLlmMeta(state).model).toBe("openai/gpt-4o-mini");
+ expect(selectPromptLlmMeta(state).cloudLabel).toBe("cloud");
+ });
});
function localDef(id: LocalModelDef["id"]): LocalModelDef {
diff --git a/src/tui/llm-panel/llm-panel-selectors.ts b/src/tui/llm-panel/llm-panel-selectors.ts
index 752f95c2..ce1bdf77 100644
--- a/src/tui/llm-panel/llm-panel-selectors.ts
+++ b/src/tui/llm-panel/llm-panel-selectors.ts
@@ -1,5 +1,6 @@
import type { EmbeddingModelRow, LocalModelRow } from "../local-models/local-models-panel-state.js";
import type { ProviderRow } from "../providers/providers-panel-state.js";
+import { runModeModelSummary } from "../run-mode/run-mode-selectors.js";
import type { TuiState } from "../tui-state.js";
import type { LlmPanelMode } from "./llm-panel-state.js";
import {
@@ -171,8 +172,32 @@ export function selectLlmActiveRouteSummary(
};
}
+/**
+ * What the composer's meta row names as the route for the next turn.
+ *
+ * Fusion is read off the run-mode mirror rather than the active provider
+ * because it is the one mode whose route is a PAIR. The fusion rule puts
+ * the cloud leg in `activeTextProvider`, so following the active row
+ * alone would print the same single cloud model for Cloud and for
+ * Fusion — the composer would not move at all on a switch between them,
+ * and it would never name the local executor that runs most of the
+ * steps. `activeTextProvider` stays authoritative: `effective` is only
+ * ever `fusion` when that provider IS the cloud leg, so this reports the
+ * resolution, it does not compete with it.
+ */
export function selectPromptLlmMeta(state: TuiState): PromptLlmMeta {
const active = state.providersPanel.rows.find((row) => row.isActiveText) ?? null;
+ const runMode = state.runModePanel;
+ const fusionPair =
+ runMode.effective === "fusion" ? runModeModelSummary(runMode) : null;
+ if (fusionPair) {
+ return {
+ model: fusionPair,
+ provider: runMode.cloudProviderId,
+ usesLocalHealth: false,
+ cloudLabel: "fusion",
+ };
+ }
if (active && active.kind !== "llama-server") {
return {
model: active.chatModel,
diff --git a/src/tui/llm-panel/llm-panel-state.ts b/src/tui/llm-panel/llm-panel-state.ts
index 6561d53a..45788479 100644
--- a/src/tui/llm-panel/llm-panel-state.ts
+++ b/src/tui/llm-panel/llm-panel-state.ts
@@ -46,6 +46,15 @@ export interface LlmPanelState {
* `f` or `/model`.
*/
cloudModelFilterFocused: boolean;
+ /**
+ * `/model` asked for the filter row while `syncModeToActiveRoute` was
+ * still waiting on provider rows, so the pane was not yet known to be
+ * Cloud and the focus could not be applied. The refresh that resolves
+ * the route applies it — see `focusCloudModelFilter`. Without this the
+ * request was simply dropped and `/model` landed on an unfocused pane
+ * with the cursor still sitting on a provider row above the list.
+ */
+ pendingCloudFilterFocus: boolean;
}
export function createInitialLlmPanelState(): LlmPanelState {
@@ -60,6 +69,7 @@ export function createInitialLlmPanelState(): LlmPanelState {
externalUrlDraft: null,
cloudModelFilter: "",
cloudModelFilterFocused: false,
+ pendingCloudFilterFocus: false,
};
}
diff --git a/src/tui/local-models/local-models-actions.ts b/src/tui/local-models/local-models-actions.ts
index b08b8652..76faab72 100644
--- a/src/tui/local-models/local-models-actions.ts
+++ b/src/tui/local-models/local-models-actions.ts
@@ -32,6 +32,8 @@ export type LocalModelsAction =
embeddingDaemon: EmbeddingDaemonInfo;
}
| { type: "local_models_cursor_up" }
+ /** Put the model-list cursor on an absolute row (mouse click). */
+ | { type: "local_models_cursor_set"; row: number }
| { type: "local_models_cursor_down" }
| {
type: "local_models_embedding_remove_confirm_opened";
diff --git a/src/tui/local-models/local-models-reducer.ts b/src/tui/local-models/local-models-reducer.ts
index e61cb7d2..c20d8c5a 100644
--- a/src/tui/local-models/local-models-reducer.ts
+++ b/src/tui/local-models/local-models-reducer.ts
@@ -48,6 +48,14 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui
},
};
}
+ case "local_models_cursor_set":
+ return {
+ ...state,
+ localModelsPanel: {
+ ...p,
+ cursor: clampCursor(action.row, totalRowCount(p)),
+ },
+ };
case "local_models_cursor_up":
return {
...state,
diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts
new file mode 100644
index 00000000..f3ea2e80
--- /dev/null
+++ b/src/tui/menu/menu-behaviour.test.ts
@@ -0,0 +1,156 @@
+import { describe, expect, it } from "vitest";
+
+import { handleMenuKey, resolveLeaderChord } from "./menu-keys.js";
+import type { MenuNode } from "./menu-registry.js";
+import {
+ selectMenuItems,
+ selectMenuRows,
+ selectMenuTitle,
+} from "./menu-selectors.js";
+import type { TuiAction } from "../tui-action.js";
+import { createInitialTuiState } from "../tui-state.js";
+import type { TuiState } from "../tui-state.js";
+import { fakeSession } from "../test-fixtures.js";
+
+const KEY = {
+ upArrow: false, downArrow: false, leftArrow: false, rightArrow: false,
+ pageDown: false, pageUp: false, return: false, escape: false, ctrl: false,
+ shift: false, tab: false, backspace: false, delete: false, meta: false,
+} as const;
+
+function open(patch: Partial = {}): TuiState {
+ return { ...createInitialTuiState(fakeSession()), menuOpen: true, ...patch };
+}
+
+function drive(state: TuiState, input: string, key: Partial) {
+ const actions: TuiAction[] = [];
+ const activated: MenuNode[] = [];
+ const handled = handleMenuKey(input, { ...KEY, ...key } as never, {
+ state,
+ dispatch: (a) => actions.push(a),
+ activate: (n) => activated.push(n),
+ });
+ return { handled, actions, activated };
+}
+
+describe("menu rows", () => {
+ it("shows group headings and the two submenus at the root", () => {
+ const rows = selectMenuRows(open());
+ const headers = rows.flatMap((r) => (r.kind === "header" ? [r.label] : []));
+ expect(headers).toEqual(["Go", "Session", "Model", "Run", "Setup", "Help"]);
+ // `go` is where you are going. The debug pane is a diagnostic you
+ // switch on, so it lives under Help.
+ const go = rows.filter((r) => r.kind === "item" && r.node.group === "go");
+ expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([
+ "Run",
+ "Observe",
+ "Manage",
+ ]);
+ const help = rows.filter((r) => r.kind === "item" && r.node.group === "help");
+ expect(help.map((r) => (r.kind === "item" ? r.node.label : ""))).toContain(
+ "Toggle debug pane",
+ );
+ });
+
+ it("lists a submenu's children and titles the popup with a breadcrumb", () => {
+ const state = open({ menuPath: "go.manage" });
+ expect(selectMenuTitle(state)).toContain("Manage");
+ const labels = selectMenuItems(state).map((r) => r.node.label);
+ expect(labels).toEqual([
+ "Tasks", "Skills", "Memory", "MCP", "LLM", "Telegram", "Import", "Privacy",
+ ]);
+ });
+
+ it("flattens the tree when searching and keeps a breadcrumb on each hit", () => {
+ const state = open({ menuQuery: "privacy" });
+ const items = selectMenuItems(state);
+ const privacy = items.find((r) => r.node.id === "go.manage.privacy");
+ expect(privacy).toBeDefined();
+ expect(privacy?.crumb).toBe("Manage");
+ expect(items.some((r) => r.node.kind === "submenu")).toBe(false);
+ });
+
+ it("searching from inside a submenu still reaches the whole registry", () => {
+ const state = open({ menuPath: "go.manage", menuQuery: "feed" });
+ const ids = selectMenuItems(state).map((r) => r.node.id);
+ expect(ids).toContain("go.observe.feed");
+ });
+
+ it("carries live counts onto destinations", () => {
+ const base = createInitialTuiState(fakeSession());
+ const state = open({
+ tasksPanel: { ...base.tasksPanel, rows: [{}, {}] as never },
+ menuPath: "go.manage",
+ });
+ const tasks = selectMenuItems(state).find((r) => r.node.id === "go.manage.tasks");
+ expect(tasks?.status).toBe("2 tasks");
+ });
+});
+
+describe("menu keys", () => {
+ it("moves the cursor with the arrows only, so letters stay available for search", () => {
+ expect(drive(open(), "", { downArrow: true }).actions).toEqual([
+ { type: "menu_cursor_moved", delta: 1 },
+ ]);
+ expect(drive(open(), "j", {}).actions).toEqual([
+ { type: "menu_query_changed", query: "j" },
+ { type: "menu_cursor_set", cursor: 0 },
+ ]);
+ });
+
+ it("opens a submenu with the right arrow and leaves it with the left", () => {
+ // Cursor 1 is Observe now that the debug toggle moved to Help.
+ const atObserve = open({ menuCursor: 1 });
+ expect(drive(atObserve, "", { rightArrow: true }).actions).toEqual([
+ { type: "menu_path_set", path: "go.observe" },
+ { type: "menu_cursor_set", cursor: 0 },
+ ]);
+ const inside = open({ menuPath: "go.manage" });
+ expect(drive(inside, "", { leftArrow: true }).actions).toEqual([
+ { type: "menu_path_set", path: null },
+ { type: "menu_cursor_set", cursor: 0 },
+ ]);
+ });
+
+ it("closes before activating, so the menu is never left over a new screen", () => {
+ const state = open({ menuPath: "go.manage" });
+ const { actions, activated } = drive(state, "", { return: true });
+ expect(actions).toEqual([{ type: "menu_closed" }]);
+ expect(activated.map((n) => n.id)).toEqual(["go.manage.tasks"]);
+ });
+
+ it("takes a whole burst into the search box, so a paste is not swallowed", () => {
+ expect(drive(open(), "privacy", {}).actions).toEqual([
+ { type: "menu_query_changed", query: "privacy" },
+ { type: "menu_cursor_set", cursor: 0 },
+ ]);
+ });
+
+ it("keeps escape-sequence fragments out of the query", () => {
+ const arrow = String.fromCharCode(27) + "[A";
+ expect(drive(open(), arrow, {}).actions).toEqual([]);
+ });
+
+ it("swallows every key while open so no panel below can act on it", () => {
+ for (const [input, key] of [["x", {}], ["", { tab: true }], ["", { pageUp: true }]] as const) {
+ expect(drive(open(), input, key).handled).toBe(true);
+ }
+ });
+
+ it("declines every key when closed", () => {
+ const closed = { ...createInitialTuiState(fakeSession()), menuOpen: false };
+ expect(drive(closed, "x", {}).handled).toBe(false);
+ });
+});
+
+describe("leader chords", () => {
+ it("resolves a place from the key pressed after ctrl+g", () => {
+ expect(resolveLeaderChord("t", KEY as never)?.id).toBe("go.manage.tasks");
+ expect(resolveLeaderChord("f", KEY as never)?.id).toBe("go.observe.feed");
+ });
+
+ it("resolves nothing for an unclaimed key or an escape", () => {
+ expect(resolveLeaderChord("z", KEY as never)).toBeNull();
+ expect(resolveLeaderChord("", { ...KEY, escape: true } as never)).toBeNull();
+ });
+});
diff --git a/src/tui/menu/menu-keys.ts b/src/tui/menu/menu-keys.ts
new file mode 100644
index 00000000..22db0efe
--- /dev/null
+++ b/src/tui/menu/menu-keys.ts
@@ -0,0 +1,157 @@
+import type { Key } from "ink";
+
+import type { TuiAction } from "../tui-action.js";
+import type { TuiState } from "../tui-state.js";
+import { menuNodeByChord, type MenuNode } from "./menu-registry.js";
+import { clampMenuCursor, selectMenuSelection } from "./menu-selectors.js";
+
+/**
+ * Prefix for direct jumps: `ctrl+g` then a single key. A leader is what
+ * keeps the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …)
+ * usable — the chord namespace is disjoint from both those letters and from
+ * ordinary typing, so nothing had to be renamed to make room for it.
+ *
+ * `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix
+ * and is intercepted by some terminals.
+ */
+export const MENU_LEADER_LABEL = "ctrl+g";
+
+export interface MenuKeyContext {
+ state: TuiState;
+ dispatch: (action: TuiAction) => void;
+ /** Run the node — navigate to a place, or run an action's slash command. */
+ activate: (node: MenuNode) => void;
+}
+
+/** True when the keypress opens the menu. */
+export function isMenuOpenKey(input: string, key: Key): boolean {
+ return key.ctrl && !key.meta && !key.shift && input === "p";
+}
+
+/** True when the keypress arms the `ctrl+g` leader. */
+export function isMenuLeaderKey(input: string, key: Key): boolean {
+ return key.ctrl && !key.meta && !key.shift && input === "g";
+}
+
+/**
+ * Resolve the key pressed after the leader. Returns the node to activate,
+ * or `null` when nothing claims that key — an unknown chord is swallowed
+ * rather than falling through, so a mistyped leader can never land a stray
+ * letter in the prompt or fire a panel hotkey.
+ */
+export function resolveLeaderChord(input: string, key: Key): MenuNode | null {
+ if (key.escape || input.length === 0) return null;
+ return menuNodeByChord(input);
+}
+
+/**
+ * Key layer for the open menu. Runs before every other handler, and claims
+ * every printable key — the search box owns typing, which is why the list is
+ * navigated with arrows only and never with `j`/`k`.
+ *
+ * Returns `true` when the key was consumed.
+ */
+export function handleMenuKey(
+ input: string,
+ key: Key,
+ ctx: MenuKeyContext,
+): boolean {
+ const { state, dispatch } = ctx;
+ if (!state.menuOpen) return false;
+
+ if (key.escape) {
+ dispatch({ type: "menu_closed" });
+ return true;
+ }
+ if (key.downArrow) {
+ dispatch({ type: "menu_cursor_moved", delta: 1 });
+ return true;
+ }
+ if (key.upArrow) {
+ dispatch({ type: "menu_cursor_moved", delta: -1 });
+ return true;
+ }
+
+ const searching = state.menuQuery.trim().length > 0;
+ const selection = selectMenuSelection(state);
+
+ if (key.rightArrow) {
+ if (!searching && selection?.node.kind === "submenu") {
+ enterSubmenu(dispatch, selection.node.id);
+ }
+ return true;
+ }
+ if (key.leftArrow) {
+ if (!searching && state.menuPath !== null) {
+ dispatch({ type: "menu_path_set", path: null });
+ dispatch({ type: "menu_cursor_set", cursor: 0 });
+ }
+ return true;
+ }
+ if (key.return) {
+ if (!selection) return true;
+ if (selection.node.kind === "submenu") {
+ enterSubmenu(dispatch, selection.node.id);
+ return true;
+ }
+ dispatch({ type: "menu_closed" });
+ ctx.activate(selection.node);
+ return true;
+ }
+ if (key.backspace || key.delete) {
+ if (state.menuQuery.length > 0) {
+ setQuery(dispatch, state.menuQuery.slice(0, -1));
+ }
+ return true;
+ }
+ if (isPrintable(input, key)) {
+ setQuery(dispatch, state.menuQuery + input);
+ return true;
+ }
+ // Anything else (Tab, page keys, stray control bytes) is swallowed so the
+ // panel layer underneath cannot act on a key aimed at the menu.
+ return true;
+}
+
+/** Clamp helper shared with the reducer so cursor moves stay in range. */
+export function nextMenuCursor(state: TuiState, delta: number): number {
+ return clampMenuCursor(state, state.menuCursor + delta);
+}
+
+function enterSubmenu(
+ dispatch: (action: TuiAction) => void,
+ id: string,
+): void {
+ dispatch({ type: "menu_path_set", path: id });
+ dispatch({ type: "menu_cursor_set", cursor: 0 });
+}
+
+/**
+ * Typing flattens the tree: a query ranks across the whole registry, so any
+ * submenu the operator had walked into is dropped at the same time.
+ */
+function setQuery(
+ dispatch: (action: TuiAction) => void,
+ query: string,
+): void {
+ dispatch({ type: "menu_query_changed", query });
+ dispatch({ type: "menu_cursor_set", cursor: 0 });
+}
+
+/**
+ * Printable text destined for the search box.
+ *
+ * Accepts a whole burst, not just one character: a paste arrives as a single
+ * input event, and so does fast typing under a slow render. Control bytes and
+ * escape-sequence fragments are rejected per code point so a stray arrow can
+ * never end up inside the query.
+ */
+function isPrintable(input: string, key: Key): boolean {
+ if (input.length === 0) return false;
+ if (key.ctrl || key.meta || key.tab || key.return || key.escape) return false;
+ for (const char of input) {
+ const code = char.codePointAt(0) ?? 0;
+ if (code < 0x20 || code === 0x7f) return false;
+ }
+ return true;
+}
diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx
new file mode 100644
index 00000000..7975bc88
--- /dev/null
+++ b/src/tui/menu/menu-popup.tsx
@@ -0,0 +1,259 @@
+import { Box, Text } from "ink";
+import type { ReactElement } from "react";
+
+import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js";
+import { isPrimaryPress } from "../mouse/mouse-event.js";
+import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js";
+import { chromeTheme } from "../theme/theme.js";
+import type { TuiState } from "../tui-state.js";
+import type { MenuNode } from "./menu-registry.js";
+import type { MenuItemRow } from "./menu-selectors.js";
+import {
+ clampMenuCursor,
+ selectMenuRows,
+ selectMenuTitle,
+} from "./menu-selectors.js";
+import { MENU_LEADER_LABEL } from "./menu-keys.js";
+
+/** Popup width, clamped to the terminal on narrow windows. */
+const PREFERRED_WIDTH = 64;
+/** Rows of list body at most, before the window starts scrolling. */
+const MAX_BODY_ROWS = 16;
+/** Border (2) + title row + footer row. */
+const CHROME_ROWS = 4;
+/** Column reserved for the entry label. */
+const LABEL_WIDTH = 26;
+
+interface MenuPopupProps {
+ state: TuiState;
+ /** Rows available in the pane the menu floats over. */
+ availableRows: number;
+ /** Columns available in that pane. */
+ availableColumns: number;
+ /**
+ * Runs a node. The very same callback `handleMenuKey` fires on Enter —
+ * passed down rather than reached through the mouse context so a click
+ * and a keypress cannot drift into two different activation paths.
+ */
+ onActivate: (node: MenuNode) => void;
+}
+
+/**
+ * The operator menu: one key (`ctrl+p`) to every destination and every verb.
+ *
+ * Rendered as a true overlay — `position="absolute"` inside the content pane,
+ * so it floats **on top of** the chat log or the active panel instead of
+ * displacing them. Nothing below it reflows when the menu opens or closes.
+ *
+ * It sits **centred** in that pane, both axes. A dropdown hanging off the
+ * prompt was the first shape, but this is not a dropdown: it is the app's
+ * one modal surface, and a modal belongs in the middle of the window with
+ * the app faded behind it — the same thing a web app would do.
+ *
+ * Terminals have no compositing and Ink has no z-index, so occlusion has to
+ * be earned: every interior line is padded to the popup's exact inner width,
+ * which paints spaces over whatever was underneath. That is also why the rows
+ * are laid out as fixed-width columns rather than with `flexGrow` — a flexed
+ * row stops at its content and lets the background show through.
+ *
+ * A background colour would do the same job in one line, but only by picking
+ * a colour, and the TUI ships eleven themes across light and dark grounds.
+ * Spaces are theme-agnostic.
+ *
+ * The app behind is dimmed by `setBackdropDimmed` (see `theme.ts`); this
+ * component reads {@link chromeTheme}, which ignores that flag, so the menu
+ * stays at full contrast against a faded backdrop.
+ *
+ * Pure presentation: every key is handled by `handleMenuKey`.
+ */
+export function MenuPopup({
+ state,
+ availableRows,
+ availableColumns,
+ onActivate,
+}: MenuPopupProps): ReactElement {
+ const width = Math.max(28, Math.min(PREFERRED_WIDTH, availableColumns - 2));
+ // Interior columns between the two border columns. Ink's own `paddingX`
+ // is NOT painted by our rows — it leaves real gaps the backdrop shows
+ // through — so the one-column gutter is baked into every string instead.
+ const inner = width - 2;
+
+ const rows = selectMenuRows(state);
+ const cursor = clampMenuCursor(state, state.menuCursor);
+ const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : []));
+ const cursorRowIdx = itemIndexes[cursor] ?? -1;
+
+ const bodyRows = Math.max(
+ 3,
+ Math.min(MAX_BODY_ROWS, availableRows - CHROME_ROWS),
+ );
+ const start = windowStart(rows.length, cursorRowIdx, bodyRows);
+ const visible = rows.slice(start, start + bodyRows);
+ const hiddenAfter = Math.max(0, rows.length - start - visible.length);
+
+ // Centred in the pane on both axes.
+ const height = visible.length + CHROME_ROWS;
+ const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2));
+ const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2));
+
+ return (
+
+
+ {visible.map((row, idx) =>
+ row.kind === "header" ? (
+
+ {fit(` ${row.label.toUpperCase()}`, inner)}
+
+ ) : (
+
+ ),
+ )}
+ {rows.length === 0 ? (
+ {fit(" nothing matches", inner)}
+ ) : null}
+
+ {fit(` ${footer(state, hiddenAfter)}`, inner)}
+
+
+ );
+}
+
+function TitleRow({
+ state,
+ inner,
+}: {
+ state: TuiState;
+ inner: number;
+}): ReactElement {
+ const title = selectMenuTitle(state);
+ const caret = `${chromeTheme.glyphs.promptCaret} ${state.menuQuery}`;
+ const left = fit(` ${title}`, Math.min(title.length + 3, inner));
+ const rest = inner - left.length;
+ return (
+
+
+ {left}
+
+ {fit(caret, Math.max(0, rest))}
+
+ );
+}
+
+function MenuItem({
+ row,
+ inner,
+ selected,
+ itemIndex,
+ onActivate,
+}: {
+ row: MenuItemRow;
+ inner: number;
+ selected: boolean;
+ /** Index among the *item* rows — what `menuCursor` counts. */
+ itemIndex: number;
+ onActivate: (node: MenuNode) => void;
+}): ReactElement {
+ const mouse = useMouseCommands();
+ const { node } = row;
+ const marker = selected ? chromeTheme.glyphs.chevronRight : " ";
+ const arrow = node.kind === "submenu" ? ` ${chromeTheme.glyphs.arrowRight}` : "";
+ // Leading and trailing space are part of the row, not Box padding, so the
+ // whole line is opaque edge to edge.
+ const label = fit(` ${marker} ${node.label}${arrow}`, Math.min(LABEL_WIDTH, inner));
+ const chordText = node.chord ? `${MENU_LEADER_LABEL} ${node.chord} ` : " ";
+ const chord = fit(chordText, Math.min(chordText.length, Math.max(0, inner - label.length)));
+ const detailWidth = Math.max(0, inner - label.length - chord.length);
+ const detail = fit(
+ [row.crumb, row.status].filter((part) => part.length > 0).join(" "),
+ detailWidth,
+ );
+ const body = (
+ <>
+
+ {label}
+
+ {detail}
+ {chord}
+ >
+ );
+ if (!mouse) return {body};
+ return (
+ {
+ // Let a wheel notch fall through to the backdrop, which owns
+ // scrolling for the whole popup.
+ if (hit.event.kind === "wheel") return false;
+ if (!isPrimaryPress(hit.event)) return false;
+ // One click acts, the way a menu item does everywhere else. The
+ // rest of the mouse layer selects first and acts on the second
+ // click, because there a mis-click starts a download or switches
+ // sessions. Here the operator opened a menu to pick something —
+ // making them click twice would be the surprising choice.
+ if (itemIndex >= 0) {
+ mouse.dispatch({ type: "menu_cursor_set", cursor: itemIndex });
+ }
+ if (node.kind === "submenu") {
+ mouse.dispatch({ type: "menu_path_set", path: node.id });
+ return true;
+ }
+ mouse.dispatch({ type: "menu_closed" });
+ onActivate(node);
+ return true;
+ }}
+ >
+ {body}
+
+ );
+}
+
+/**
+ * Footer names exactly the moves that are legal right now — `←` only appears
+ * once there is a level to go back to, `→` only while one is reachable.
+ */
+function footer(state: TuiState, hiddenAfter: number): string {
+ const searching = state.menuQuery.trim().length > 0;
+ const parts = ["↑↓ move"];
+ if (!searching && state.menuPath !== null) parts.push("← back");
+ if (!searching && state.menuPath === null) parts.push("→ open");
+ parts.push("enter go", "esc close");
+ if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`);
+ return parts.join(" ");
+}
+
+/**
+ * Pad or truncate to exactly `width` columns. Every interior line goes
+ * through this — it is what makes the popup opaque.
+ */
+function fit(text: string, width: number): string {
+ if (width <= 0) return "";
+ if (text.length > width) {
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
+ }
+ return text.padEnd(width);
+}
+
+/** Scroll window that keeps the cursor row visible. */
+function windowStart(total: number, cursorRowIdx: number, size: number): number {
+ if (total <= size || cursorRowIdx < 0) return 0;
+ if (cursorRowIdx < size) return 0;
+ return Math.min(cursorRowIdx - size + 1, total - size);
+}
diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts
new file mode 100644
index 00000000..3689e052
--- /dev/null
+++ b/src/tui/menu/menu-registry.test.ts
@@ -0,0 +1,293 @@
+import { describe, expect, it } from "vitest";
+
+import { SLASH_COMMANDS } from "../commands/slash-commands.js";
+import {
+ MENU,
+ MENU_GROUP_ORDER,
+ menuChildren,
+ menuNodeByChord,
+ menuNodeById,
+ menuRoots,
+} from "./menu-registry.js";
+
+/**
+ * The slash palette in full, listed in the order the operator sees it.
+ * `SLASH_COMMANDS` is now derived from `MENU`; this snapshot is what
+ * makes "the registry refactor changed no command and no position" a
+ * claim the suite can check rather than a promise in a PR description.
+ * The v0.2.2 palette plus the five commands this release adds (`mouse`,
+ * `queue`, `steer`, `run`, `window`), each at its own rank.
+ */
+const RELEASE_SLASH_COMMANDS = [
+ {
+ name: "dump",
+ description:
+ "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug",
+ },
+ {
+ name: "help",
+ description:
+ "list available slash commands",
+ },
+ {
+ name: "tools",
+ description:
+ "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `",
+ },
+ {
+ name: "mouse",
+ description:
+ "mouse support on/off/status (off restores drag-to-select)",
+ },
+ {
+ name: "theme",
+ description:
+ "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)",
+ },
+ {
+ name: "clear",
+ description:
+ "clear chat transcript (keeps session)",
+ },
+ {
+ name: "abort",
+ description:
+ "abort the running turn",
+ },
+ {
+ name: "queue",
+ description:
+ "Enter parks messages behind the running turn: `/queue` (switch + list) | `/queue clear` | `/queue `",
+ },
+ {
+ name: "steer",
+ description:
+ "Enter folds messages into the running turn: `/steer` (switch) | `/steer `",
+ },
+ {
+ name: "quit",
+ description:
+ "exit atomic-agent",
+ aliases: ["exit"],
+ },
+ {
+ name: "debug",
+ description:
+ "toggle debug pane (feed / logs / world …)",
+ },
+ {
+ name: "chat",
+ description:
+ "return to single-view chat mode",
+ },
+ {
+ name: "run",
+ description:
+ "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` — fusion orchestrates on cloud, executes locally",
+ },
+ {
+ name: "observe",
+ description:
+ "switch to the Observe section (feed / world / reasoning / logs / llm-logs)",
+ },
+ {
+ name: "manage",
+ description:
+ "switch to the Manage section (tasks / skills / LLM / telegram)",
+ },
+ {
+ name: "feed",
+ description:
+ "jump to the Observe → Feed tab",
+ },
+ {
+ name: "logs",
+ description:
+ "jump to the Observe → Logs tab",
+ },
+ {
+ name: "reasoning",
+ description:
+ "jump to the Observe → Reasoning tab",
+ },
+ {
+ name: "world",
+ description:
+ "jump to the Observe → World tab",
+ },
+ {
+ name: "expand",
+ description:
+ "expand every tool card in the chat log",
+ },
+ {
+ name: "collapse",
+ description:
+ "collapse every tool card in the chat log",
+ },
+ {
+ name: "session",
+ description:
+ "show current session id",
+ },
+ {
+ name: "sessions",
+ description:
+ "open session picker to switch threads",
+ },
+ {
+ name: "new",
+ description:
+ "start a fresh session (keeps warm runtime)",
+ },
+ {
+ name: "window",
+ description:
+ "open a new terminal window running atomic-agent (ctrl+n)",
+ aliases: ["newwindow"],
+ },
+ {
+ name: "skills",
+ description:
+ "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat",
+ },
+ {
+ name: "skill",
+ description:
+ "skill subcommand: `/skill enable ` | `/skill disable `",
+ },
+ {
+ name: "memory",
+ description:
+ "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat",
+ },
+ {
+ name: "llm",
+ description:
+ "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider",
+ },
+ {
+ name: "mcp",
+ description:
+ "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm",
+ },
+ {
+ name: "model",
+ description:
+ "open chat model picker · subcommands: pull | use | status | ",
+ aliases: ["models", "local"],
+ },
+ {
+ name: "tasks",
+ description:
+ "jump to the Tasks tab (Option 4 cron + ingress UI)",
+ },
+ {
+ name: "task",
+ description:
+ "task subcommand: `/task new` | `/task cancel ` | `/task run `",
+ },
+ {
+ name: "telegram",
+ description:
+ "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token",
+ },
+ {
+ name: "import",
+ description:
+ "open the Import tab (one-shot Hermes -> atomic-agent migration)",
+ },
+ {
+ name: "privacy",
+ description:
+ "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`",
+ },
+ {
+ name: "analytics",
+ description:
+ "toggle anonymous analytics: `/analytics on|off|status`",
+ },
+];
+
+
+describe("menu registry", () => {
+ it("derives the whole slash palette — same commands, same order", () => {
+ expect(SLASH_COMMANDS).toEqual(RELEASE_SLASH_COMMANDS);
+ });
+
+ it("gives every node a unique id", () => {
+ const ids = MENU.map((node) => node.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("never lets two nodes claim the same ctrl+g chord", () => {
+ const chords = MENU.flatMap((node) => (node.chord ? [node.chord] : []));
+ expect(chords.length).toBeGreaterThan(0);
+ expect(new Set(chords).size).toBe(chords.length);
+ });
+
+ it("never lets two nodes claim the same slash name or alias", () => {
+ const names = MENU.flatMap((node) =>
+ node.slash ? [node.slash.name, ...(node.slash.aliases ?? [])] : [],
+ );
+ expect(new Set(names).size).toBe(names.length);
+ });
+
+ it("gives every slash command a distinct palette rank", () => {
+ const ranks = MENU.flatMap((node) => (node.slash ? [node.slash.rank] : []));
+ expect(new Set(ranks).size).toBe(ranks.length);
+ });
+
+ it("points every parent at a real submenu", () => {
+ for (const node of MENU) {
+ if (node.parent === undefined) continue;
+ const parent = menuNodeById(node.parent);
+ expect(parent, `${node.id} -> ${node.parent}`).not.toBeNull();
+ expect(parent?.kind).toBe("submenu");
+ }
+ });
+
+ it("keeps the tree exactly one level deep", () => {
+ for (const node of MENU) {
+ if (node.parent === undefined) continue;
+ const parent = menuNodeById(node.parent);
+ expect(parent?.parent).toBeUndefined();
+ }
+ });
+
+ it("leaves no submenu empty", () => {
+ for (const node of MENU) {
+ if (node.kind !== "submenu") continue;
+ expect(menuChildren(node.id).length, node.id).toBeGreaterThan(0);
+ }
+ });
+
+ it("puts every node in a group the menu knows how to render", () => {
+ for (const node of MENU) {
+ expect(MENU_GROUP_ORDER).toContain(node.group);
+ }
+ });
+
+ it("resolves places by chord", () => {
+ expect(menuNodeByChord("t")?.id).toBe("go.manage.tasks");
+ expect(menuNodeByChord("p")?.id).toBe("go.manage.privacy");
+ expect(menuNodeByChord("§")).toBeNull();
+ });
+
+ it("lists Observe and Manage as the browsable destinations under Go", () => {
+ const roots = menuRoots("go").map((node) => node.id);
+ expect(roots).toContain("go.observe");
+ expect(roots).toContain("go.manage");
+ expect(roots).not.toContain("go.manage.tasks");
+ expect(menuChildren("go.manage").map((n) => n.label)).toEqual([
+ "Tasks",
+ "Skills",
+ "Memory",
+ "MCP",
+ "LLM",
+ "Telegram",
+ "Import",
+ "Privacy",
+ ]);
+ });
+});
diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts
new file mode 100644
index 00000000..554631bc
--- /dev/null
+++ b/src/tui/menu/menu-registry.ts
@@ -0,0 +1,690 @@
+import type { TuiSection } from "../section.js";
+import type { TuiTab } from "../tui-state.js";
+
+/**
+ * Top-level grouping of the operator menu. `go` holds destinations and
+ * deliberately mirrors the product's own Run / Observe / Manage split
+ * rather than inventing a second taxonomy for the same rooms; the rest
+ * are verbs grouped by *what they act on* — the thread, the model, the
+ * turn in flight, the configuration — which is the only grouping that
+ * stays true as entries are added.
+ */
+export type MenuGroup =
+ | "go"
+ | "session"
+ | "model"
+ | "run"
+ | "setup"
+ | "help";
+
+/** Display order of the groups in the menu. */
+export const MENU_GROUP_ORDER: readonly MenuGroup[] = [
+ "go",
+ "session",
+ "model",
+ "run",
+ "setup",
+ "help",
+];
+
+export const MENU_GROUP_LABELS: Record = {
+ go: "Go",
+ session: "Session",
+ model: "Model",
+ run: "Run",
+ setup: "Setup",
+ help: "Help",
+};
+
+/**
+ * The slash command a node is also reachable as. A node that carries one
+ * is *activated* by running that command, so the menu never grows a
+ * second dispatch path alongside `slash-command-handler.ts`.
+ */
+export interface MenuSlash {
+ readonly name: string;
+ readonly description: string;
+ readonly aliases?: readonly string[];
+ /**
+ * Position in the slash palette listing. Kept explicit because the
+ * palette order is user-visible (empty query lists the registry in
+ * order, and ties in a fuzzy search break by index) and is not the
+ * same as the menu's own order.
+ */
+ readonly rank: number;
+}
+
+interface MenuNodeBase {
+ /** Stable identifier, e.g. `go.manage.tasks`. Never shown to the operator. */
+ readonly id: string;
+ readonly label: string;
+ readonly group: MenuGroup;
+ /**
+ * Single key pressed after the `ctrl+g` leader. Unique across the whole
+ * registry — `menu-registry.test.ts` fails the build if two nodes claim
+ * the same one.
+ */
+ readonly chord?: string;
+ readonly slash?: MenuSlash;
+ /**
+ * Literal command line run when this node is activated, for entries that
+ * take arguments (`/run fusion`). Kept separate from {@link MenuSlash}
+ * because the palette should list `/run` once, not once per mode — this
+ * is an activation channel, not a listing.
+ */
+ readonly command?: string;
+ /** Parent submenu id, for nodes one level down. */
+ readonly parent?: string;
+}
+
+/** A destination: a section, or a tab inside one. */
+export interface MenuPlaceNode extends MenuNodeBase {
+ readonly kind: "place";
+ readonly section: TuiSection;
+ readonly tab?: TuiTab;
+}
+
+/** A one-level-deep grouping of places. The tree never goes deeper. */
+export interface MenuSubmenuNode extends MenuNodeBase {
+ readonly kind: "submenu";
+}
+
+/** A verb. Activating it runs `slash.name` through the existing handler. */
+export interface MenuActionNode extends MenuNodeBase {
+ readonly kind: "action";
+}
+
+export type MenuNode = MenuPlaceNode | MenuSubmenuNode | MenuActionNode;
+
+/**
+ * The single source of truth for the operator menu, the slash palette and
+ * the `ctrl+g` chord table. Everything that used to be a hand-kept
+ * parallel list is now a projection of this array — see
+ * `toSlashCommands()` below and `slash-commands.ts`.
+ */
+export const MENU: readonly MenuNode[] = [
+ {
+ kind: "place",
+ id: "go.run",
+ label: "Run",
+ group: "go",
+ chord: "r",
+ slash: {
+ name: "chat",
+ description:
+ "return to single-view chat mode",
+ rank: 8,
+ },
+ section: "run",
+ },
+ {
+ kind: "submenu",
+ id: "go.observe",
+ label: "Observe",
+ group: "go",
+ slash: {
+ name: "observe",
+ description:
+ "switch to the Observe section (feed / world / reasoning / logs / llm-logs)",
+ rank: 9,
+ },
+ },
+ {
+ kind: "place",
+ id: "go.observe.feed",
+ label: "Feed",
+ group: "go",
+ chord: "f",
+ slash: {
+ name: "feed",
+ description:
+ "jump to the Observe → Feed tab",
+ rank: 11,
+ },
+ section: "observe",
+ tab: "feed",
+ parent: "go.observe",
+ },
+ {
+ kind: "place",
+ id: "go.observe.world",
+ label: "World",
+ group: "go",
+ chord: "w",
+ slash: {
+ name: "world",
+ description:
+ "jump to the Observe → World tab",
+ rank: 14,
+ },
+ section: "observe",
+ tab: "world",
+ parent: "go.observe",
+ },
+ {
+ kind: "place",
+ id: "go.observe.reasoning",
+ label: "Reasoning",
+ group: "go",
+ chord: "e",
+ slash: {
+ name: "reasoning",
+ description:
+ "jump to the Observe → Reasoning tab",
+ rank: 13,
+ },
+ section: "observe",
+ tab: "reasoning",
+ parent: "go.observe",
+ },
+ {
+ kind: "place",
+ id: "go.observe.logs",
+ label: "Logs",
+ group: "go",
+ chord: "o",
+ slash: {
+ name: "logs",
+ description:
+ "jump to the Observe → Logs tab",
+ rank: 12,
+ },
+ section: "observe",
+ tab: "logs",
+ parent: "go.observe",
+ },
+ {
+ kind: "place",
+ id: "go.observe.llm-logs",
+ label: "LLM logs",
+ group: "go",
+ chord: "L",
+ section: "observe",
+ tab: "llm-logs",
+ parent: "go.observe",
+ },
+ {
+ kind: "submenu",
+ id: "go.manage",
+ label: "Manage",
+ group: "go",
+ slash: {
+ name: "manage",
+ description:
+ "switch to the Manage section (tasks / skills / LLM / telegram)",
+ rank: 10,
+ },
+ },
+ {
+ kind: "place",
+ id: "go.manage.tasks",
+ label: "Tasks",
+ group: "go",
+ chord: "t",
+ slash: {
+ name: "tasks",
+ description:
+ "jump to the Tasks tab (Option 4 cron + ingress UI)",
+ rank: 26,
+ },
+ section: "manage",
+ tab: "tasks",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.skills",
+ label: "Skills",
+ group: "go",
+ chord: "s",
+ slash: {
+ name: "skills",
+ description:
+ "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat",
+ rank: 20,
+ },
+ section: "manage",
+ tab: "skills",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.memory",
+ label: "Memory",
+ group: "go",
+ chord: "m",
+ slash: {
+ name: "memory",
+ description:
+ "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat",
+ rank: 22,
+ },
+ section: "manage",
+ tab: "memory",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.mcp",
+ label: "MCP",
+ group: "go",
+ chord: "c",
+ slash: {
+ name: "mcp",
+ description:
+ "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm",
+ rank: 24,
+ },
+ section: "manage",
+ tab: "mcp",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.llm",
+ label: "LLM",
+ group: "go",
+ chord: "l",
+ slash: {
+ name: "llm",
+ description:
+ "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider",
+ rank: 23,
+ },
+ section: "manage",
+ tab: "llm",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.telegram",
+ label: "Telegram",
+ group: "go",
+ chord: "g",
+ slash: {
+ name: "telegram",
+ description:
+ "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token",
+ rank: 28,
+ },
+ section: "manage",
+ tab: "telegram",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.import",
+ label: "Import",
+ group: "go",
+ chord: "i",
+ slash: {
+ name: "import",
+ description:
+ "open the Import tab (one-shot Hermes -> atomic-agent migration)",
+ rank: 29,
+ },
+ section: "manage",
+ tab: "import",
+ parent: "go.manage",
+ },
+ {
+ kind: "place",
+ id: "go.manage.privacy",
+ label: "Privacy",
+ group: "go",
+ chord: "p",
+ slash: {
+ name: "privacy",
+ description:
+ "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`",
+ rank: 30,
+ },
+ section: "manage",
+ tab: "privacy",
+ parent: "go.manage",
+ },
+ {
+ kind: "action",
+ id: "session.new",
+ label: "New session",
+ group: "session",
+ chord: "n",
+ slash: {
+ name: "new",
+ description:
+ "start a fresh session (keeps warm runtime)",
+ rank: 19,
+ },
+ },
+ {
+ kind: "action",
+ id: "session.switch",
+ label: "Switch session…",
+ group: "session",
+ chord: "u",
+ slash: {
+ name: "sessions",
+ description:
+ "open session picker to switch threads",
+ rank: 18,
+ },
+ },
+ {
+ kind: "action",
+ id: "session.clear",
+ label: "Clear transcript",
+ group: "session",
+ slash: {
+ name: "clear",
+ description:
+ "clear chat transcript (keeps session)",
+ rank: 4,
+ },
+ },
+ {
+ kind: "action",
+ id: "session.id",
+ label: "Show session id",
+ group: "session",
+ slash: {
+ name: "session",
+ description:
+ "show current session id",
+ rank: 17,
+ },
+ },
+ {
+ kind: "action",
+ id: "model.chat",
+ label: "Switch chat model…",
+ group: "model",
+ chord: "k",
+ slash: {
+ name: "model",
+ description:
+ "open chat model picker · subcommands: pull | use | status | ",
+ aliases: ["models", "local"],
+ rank: 25,
+ },
+ },
+ {
+ kind: "action",
+ id: "run.abort",
+ label: "Abort turn",
+ group: "run",
+ chord: "a",
+ slash: {
+ name: "abort",
+ description:
+ "abort the running turn",
+ rank: 5,
+ },
+ },
+ {
+ kind: "action",
+ id: "run.picker",
+ label: "Run type\u2026",
+ group: "run",
+ slash: {
+ name: "run",
+ description:
+ "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` \u2014 fusion orchestrates on cloud, executes locally",
+ // Fractional on purpose: `/run` slots between `chat` (8) and
+ // `observe` (9) without renumbering every entry after it.
+ rank: 8.5,
+ },
+ },
+ {
+ kind: "submenu",
+ id: "run.mode",
+ label: "Run type",
+ group: "run",
+ },
+ {
+ kind: "action",
+ id: "run.mode.local",
+ label: "Local",
+ group: "run",
+ parent: "run.mode",
+ chord: "1",
+ command: "/run local",
+ },
+ {
+ kind: "action",
+ id: "run.mode.cloud",
+ label: "Cloud",
+ group: "run",
+ parent: "run.mode",
+ chord: "2",
+ command: "/run cloud",
+ },
+ {
+ kind: "action",
+ id: "run.mode.fusion",
+ label: "Fusion",
+ group: "run",
+ parent: "run.mode",
+ chord: "3",
+ command: "/run fusion",
+ },
+ {
+ kind: "action",
+ id: "run.expand",
+ label: "Expand all tool cards",
+ group: "run",
+ slash: {
+ name: "expand",
+ description:
+ "expand every tool card in the chat log",
+ rank: 15,
+ },
+ },
+ {
+ kind: "action",
+ id: "run.collapse",
+ label: "Collapse all tool cards",
+ group: "run",
+ slash: {
+ name: "collapse",
+ description:
+ "collapse every tool card in the chat log",
+ rank: 16,
+ },
+ },
+ {
+ kind: "action",
+ id: "setup.theme",
+ label: "Theme…",
+ group: "setup",
+ chord: "h",
+ slash: {
+ name: "theme",
+ description:
+ "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)",
+ rank: 3,
+ },
+ },
+ {
+ kind: "action",
+ id: "setup.analytics",
+ label: "Analytics",
+ group: "setup",
+ slash: {
+ name: "analytics",
+ description:
+ "toggle anonymous analytics: `/analytics on|off|status`",
+ rank: 31,
+ },
+ },
+ {
+ kind: "action",
+ id: "setup.skill",
+ label: "Enable or disable a skill…",
+ group: "setup",
+ slash: {
+ name: "skill",
+ description:
+ "skill subcommand: `/skill enable ` | `/skill disable `",
+ rank: 21,
+ },
+ },
+ {
+ kind: "action",
+ id: "setup.task",
+ label: "Create, cancel or run a task…",
+ group: "setup",
+ slash: {
+ name: "task",
+ description:
+ "task subcommand: `/task new` | `/task cancel ` | `/task run `",
+ rank: 27,
+ },
+ },
+ {
+ kind: "action",
+ id: "help.commands",
+ label: "Commands",
+ group: "help",
+ slash: {
+ name: "help",
+ description:
+ "list available slash commands",
+ rank: 1,
+ },
+ },
+ {
+ kind: "action",
+ id: "help.tools",
+ label: "List built-in tools",
+ group: "help",
+ slash: {
+ name: "tools",
+ description:
+ "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `",
+ rank: 2,
+ },
+ },
+ {
+ kind: "action",
+ id: "help.debug",
+ label: "Toggle debug pane",
+ group: "help",
+ slash: {
+ name: "debug",
+ description:
+ "toggle debug pane (feed / logs / world …)",
+ rank: 7,
+ },
+ },
+ {
+ kind: "action",
+ id: "help.dump",
+ label: "Write debug bundle",
+ group: "help",
+ chord: "d",
+ slash: {
+ name: "dump",
+ description:
+ "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug",
+ rank: 0,
+ },
+ },
+ {
+ kind: "action",
+ id: "help.quit",
+ label: "Quit",
+ group: "help",
+ chord: "q",
+ slash: {
+ name: "quit",
+ description:
+ "exit atomic-agent",
+ aliases: ["exit"],
+ rank: 6,
+ },
+ },
+ {
+ kind: "action",
+ id: "run.queue",
+ label: "Queue what you type while a turn runs",
+ group: "run",
+ slash: {
+ name: "queue",
+ description:
+ "Enter parks messages behind the running turn: `/queue` (switch + list) | `/queue clear` | `/queue `",
+ rank: 5.3,
+ },
+ },
+ {
+ kind: "action",
+ id: "run.steer",
+ label: "Steer the turn that is already running",
+ group: "run",
+ slash: {
+ name: "steer",
+ description:
+ "Enter folds messages into the running turn: `/steer` (switch) | `/steer `",
+ rank: 5.6,
+ },
+ },
+ {
+ kind: "action",
+ id: "session.window",
+ label: "New terminal window",
+ group: "session",
+ slash: {
+ name: "window",
+ description:
+ "open a new terminal window running atomic-agent (ctrl+n)",
+ aliases: ["newwindow"],
+ rank: 19.5,
+ },
+ },
+ {
+ kind: "action",
+ id: "setup.mouse",
+ label: "Mouse support",
+ group: "setup",
+ slash: {
+ name: "mouse",
+ description:
+ "mouse support on/off/status (off restores drag-to-select)",
+ rank: 2.5,
+ },
+ },
+];
+
+/** Every node that is also a slash command, in palette order. */
+export function toSlashCommands(): readonly MenuSlash[] {
+ return MENU.flatMap((node) => (node.slash ? [node.slash] : [])).sort(
+ (a, b) => a.rank - b.rank,
+ );
+}
+
+/** Children of a submenu, in registry order. */
+export function menuChildren(parentId: string): readonly MenuNode[] {
+ return MENU.filter((node) => node.parent === parentId);
+}
+
+/** Top-level nodes of a group — submenu children are excluded. */
+export function menuRoots(group: MenuGroup): readonly MenuNode[] {
+ return MENU.filter((node) => node.group === group && node.parent === undefined);
+}
+
+/** Resolve a node by id. */
+export function menuNodeById(id: string): MenuNode | null {
+ return MENU.find((node) => node.id === id) ?? null;
+}
+
+/** Resolve the node bound to a `ctrl+g` chord key. */
+export function menuNodeByChord(key: string): MenuNode | null {
+ return MENU.find((node) => node.chord === key) ?? null;
+}
+
+/** The destination that owns a debug tab, for breadcrumbs and status text. */
+export function menuPlaceByTab(tab: TuiTab): MenuPlaceNode | null {
+ for (const node of MENU) {
+ if (node.kind === "place" && node.tab === tab) return node;
+ }
+ return null;
+}
diff --git a/src/tui/menu/menu-selectors.ts b/src/tui/menu/menu-selectors.ts
new file mode 100644
index 00000000..9faf24ff
--- /dev/null
+++ b/src/tui/menu/menu-selectors.ts
@@ -0,0 +1,183 @@
+import fuzzysort from "fuzzysort";
+
+import {
+ MENU,
+ MENU_GROUP_LABELS,
+ MENU_GROUP_ORDER,
+ menuChildren,
+ menuNodeById,
+ menuRoots,
+ type MenuNode,
+} from "./menu-registry.js";
+import type { TuiState } from "../tui-state.js";
+
+/** A group heading. Rendered, never selectable. */
+export interface MenuHeaderRow {
+ readonly kind: "header";
+ readonly label: string;
+}
+
+/** A selectable entry. */
+export interface MenuItemRow {
+ readonly kind: "item";
+ readonly node: MenuNode;
+ /** Live state for a destination, e.g. `3 scheduled`. Empty when unknown. */
+ readonly status: string;
+ /** Where the node lives, shown only while searching flattens the tree. */
+ readonly crumb: string;
+}
+
+export type MenuRow = MenuHeaderRow | MenuItemRow;
+
+/**
+ * Rows the menu should render for the current state.
+ *
+ * Three modes, and the rule that decides between them is the whole design:
+ * **hierarchy to browse, flat to search.** With a query, every node in the
+ * registry competes on one ranked list and the tree is irrelevant; without
+ * one, the operator walks groups and submenus.
+ */
+export function selectMenuRows(state: TuiState): readonly MenuRow[] {
+ const query = state.menuQuery.trim();
+ if (query.length > 0) return searchRows(state, query);
+ if (state.menuPath !== null) return submenuRows(state, state.menuPath);
+ return rootRows(state);
+}
+
+/** Only the selectable rows, in render order — the cursor indexes these. */
+export function selectMenuItems(state: TuiState): readonly MenuItemRow[] {
+ return selectMenuRows(state).flatMap((row) =>
+ row.kind === "item" ? [row] : [],
+ );
+}
+
+/** The row under the cursor, or `null` when the list is empty. */
+export function selectMenuSelection(state: TuiState): MenuItemRow | null {
+ const items = selectMenuItems(state);
+ if (items.length === 0) return null;
+ return items[clampMenuCursor(state, state.menuCursor)] ?? null;
+}
+
+/** Clamp a cursor into the current item list. */
+export function clampMenuCursor(state: TuiState, cursor: number): number {
+ const max = selectMenuItems(state).length - 1;
+ if (max < 0) return 0;
+ return Math.max(0, Math.min(cursor, max));
+}
+
+/** Title shown in the popup border — `Menu` or `Menu › Manage`. */
+export function selectMenuTitle(state: TuiState): string {
+ if (state.menuPath === null || state.menuQuery.trim().length > 0) {
+ return "Menu";
+ }
+ const parent = menuNodeById(state.menuPath);
+ return parent ? `Menu ${String.fromCodePoint(0x203a)} ${parent.label}` : "Menu";
+}
+
+function rootRows(state: TuiState): readonly MenuRow[] {
+ const rows: MenuRow[] = [];
+ for (const group of MENU_GROUP_ORDER) {
+ const nodes = menuRoots(group);
+ if (nodes.length === 0) continue;
+ rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] });
+ for (const node of nodes) {
+ rows.push(itemRow(state, node, ""));
+ }
+ }
+ return rows;
+}
+
+function submenuRows(state: TuiState, parentId: string): readonly MenuRow[] {
+ return menuChildren(parentId).map((node) => itemRow(state, node, ""));
+}
+
+function searchRows(state: TuiState, query: string): readonly MenuRow[] {
+ // Submenus are excluded: "open the Manage submenu" is a browsing move, and
+ // a search that already found `Privacy` should offer Privacy, not the
+ // folder it happens to sit in.
+ const candidates = MENU.filter((node) => node.kind !== "submenu");
+ const scored = candidates
+ .map((node, idx) => {
+ const haystacks = [node.label, node.slash?.name ?? "", crumbFor(node)];
+ const best = Math.max(
+ ...haystacks.map(
+ (h) => (h ? (fuzzysort.single(query, h)?.score ?? -Infinity) : -Infinity),
+ ),
+ );
+ return { node, score: best, idx };
+ })
+ .filter(({ score }) => score > -Infinity)
+ .sort((a, b) => b.score - a.score || a.idx - b.idx);
+
+ const rows: MenuRow[] = [];
+ for (const group of MENU_GROUP_ORDER) {
+ const hits = scored.filter(({ node }) => node.group === group);
+ if (hits.length === 0) continue;
+ rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] });
+ for (const { node } of hits) {
+ rows.push(itemRow(state, node, crumbFor(node)));
+ }
+ }
+ return rows;
+}
+
+function crumbFor(node: MenuNode): string {
+ if (node.parent === undefined) return "";
+ return menuNodeById(node.parent)?.label ?? "";
+}
+
+function itemRow(state: TuiState, node: MenuNode, crumb: string): MenuItemRow {
+ return { kind: "item", node, status: statusFor(state, node), crumb };
+}
+
+/**
+ * Live one-liner for a destination. Deliberately reads the same state slices
+ * the sub-tab strip already counts (`debug-pane.tsx`), so opening the menu
+ * costs a few array lengths and never a refresh.
+ */
+function statusFor(state: TuiState, node: MenuNode): string {
+ switch (node.id) {
+ case "go.manage.tasks":
+ return countLabel(state.tasksPanel.rows.length, "task");
+ case "go.manage.skills":
+ return countLabel(state.skillsPanel.rows.length, "skill");
+ case "go.manage.memory":
+ return countLabel(state.memoryPanel.rows.length, "note");
+ case "go.manage.mcp":
+ return countLabel(state.mcpPanel.rows.length, "server");
+ case "go.observe.feed":
+ return countLabel(state.feed.length, "event");
+ case "go.observe.reasoning":
+ return countLabel(state.reasoning.length, "entry");
+ case "go.observe.logs":
+ return countLabel(state.logs.length, "line");
+ case "go.run":
+ return countLabel(state.messages.length, "message");
+ case "run.mode":
+ return runModeLabel(state);
+ case "session.switch":
+ return countLabel(state.recentSessions.length, "recent");
+ default:
+ return "";
+ }
+}
+
+/**
+ * Current run mode for the `Run type` row, so the menu answers "what am I on"
+ * as well as "what can I switch to".
+ */
+function runModeLabel(state: TuiState): string {
+ const panel = (state as { runModePanel?: { effective?: string; cloudShare?: number } })
+ .runModePanel;
+ const mode = panel?.effective;
+ if (!mode) return "";
+ if (mode === "fusion" && typeof panel?.cloudShare === "number") {
+ return `fusion \u00b7 ${panel.cloudShare}% cloud`;
+ }
+ return mode;
+}
+
+function countLabel(count: number, noun: string): string {
+ if (count === 0) return "";
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
+}
diff --git a/src/tui/mouse/index.ts b/src/tui/mouse/index.ts
new file mode 100644
index 00000000..090c0d6c
--- /dev/null
+++ b/src/tui/mouse/index.ts
@@ -0,0 +1,48 @@
+export {
+ isPrimaryPress,
+ type MouseButton,
+ type MouseEventKind,
+ type TuiMouseEvent,
+ type WheelDirection,
+} from "./mouse-event.js";
+export {
+ decodeMouseEvents,
+ type DecodedMouseChunk,
+} from "./parse-mouse-events.js";
+export {
+ enableMouseTracking,
+ type MouseTrackingController,
+ type MouseTrackingOptions,
+} from "./mouse-tracking.js";
+export { createMouseStdin, type MouseStdin } from "./mouse-stdin.js";
+export {
+ makeMouseSource,
+ type MouseSource,
+ type MouseSourceEmitter,
+} from "./mouse-source.js";
+export {
+ absoluteRect,
+ MOUSE_LAYER_BASE,
+ MOUSE_LAYER_MODAL,
+ MOUSE_LAYER_PANEL,
+ MouseTargetRegistry,
+ type MouseHit,
+ type MouseRect,
+ type MouseTargetHandler,
+} from "./mouse-registry.js";
+export {
+ MouseProvider,
+ MouseTarget,
+ useMouseCommands,
+ useMouseTarget,
+ type MouseContextValue,
+} from "./mouse-context.js";
+export {
+ createSelectionPassthrough,
+ DEFAULT_SELECTION_WINDOW_MS,
+ type SelectionPassthrough,
+ type SelectionPassthroughOptions,
+ type SelectionSuspendable,
+} from "./selection-passthrough.js";
+export { MouseListRow, pressEnter } from "./mouse-list-row.js";
+export { arrowKey, returnKey } from "./synthetic-key.js";
diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx
new file mode 100644
index 00000000..091ff517
--- /dev/null
+++ b/src/tui/mouse/mouse-app.test.tsx
@@ -0,0 +1,426 @@
+import { render } from "ink-testing-library";
+import { describe, expect, it } from "vitest";
+import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js";
+import type { TuiSessionInfo } from "../tui-state.js";
+import { makeMouseSource, type MouseSourceEmitter } from "./mouse-source.js";
+import type { TuiMouseEvent } from "./mouse-event.js";
+import { computeSidebarWidth } from "../layout.js";
+
+const SESSION: TuiSessionInfo = {
+ sessionId: null,
+ workingDir: "/tmp/mouse",
+ llamaUrl: "http://127.0.0.1:8080",
+ browserChannel: "chrome",
+ browserHeadless: false,
+ approvalLevel: 5,
+ maxSteps: 10,
+ skillCount: 0,
+};
+
+function noopCallbacks(): TuiAppCallbacks {
+ return {
+ onApprovalDecision: () => {},
+ onAbort: () => {},
+ onQuit: () => {},
+ onMessageSubmitted: () => {},
+ };
+}
+
+function strip(value: string): string {
+ return value
+ .replace(/\u001B\[[0-9;]*m/g, "")
+ .replace(/\u001B\]8;;[^]*/g, "");
+}
+
+/**
+ * Screen position of `needle` in the rendered frame. Stripping SGR
+ * codes leaves the visual grid intact, so the returned column/row are
+ * the same cells the terminal would report for a click.
+ */
+function locate(frame: string, needle: string): { x: number; y: number } {
+ const lines = strip(frame).split("\n");
+ for (const [y, line] of lines.entries()) {
+ const x = line.indexOf(needle);
+ if (x !== -1) return { x, y };
+ }
+ throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`);
+}
+
+function click(x: number, y: number): TuiMouseEvent {
+ return {
+ kind: "press",
+ button: "left",
+ wheel: null,
+ x,
+ y,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ };
+}
+
+function wheel(direction: "up" | "down", x: number, y: number): TuiMouseEvent {
+ return {
+ kind: "wheel",
+ button: "none",
+ wheel: direction,
+ x,
+ y,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ };
+}
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Ink commits frames on its own throttle (`maxFps` 30) and React
+ * flushes the effects that register click targets after that commit, so
+ * a freshly rendered target is not clickable for a frame or two. Under a
+ * loaded test runner that window stretches, which is why nothing here
+ * waits a fixed number of milliseconds: `waitUntil` polls the rendered
+ * frame, and `clickUntil` re-sends the click until it takes effect —
+ * the terminal equivalent of a user who clicks again when the first one
+ * lands mid-repaint.
+ */
+async function waitUntil(
+ condition: () => boolean,
+ describe: string,
+ timeoutMs = 10_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (condition()) return;
+ await delay(25);
+ }
+ throw new Error(`timed out waiting for ${describe}`);
+}
+
+async function clickUntil(
+ mouse: MouseSourceEmitter,
+ point: () => { x: number; y: number },
+ settled: () => boolean,
+ describe: string,
+): Promise {
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ const { x, y } = point();
+ mouse.emit(click(x, y));
+ await delay(50);
+ if (settled()) return;
+ }
+ throw new Error(`click never took effect: ${describe}`);
+}
+
+function mountApp(): {
+ frame: () => string;
+ mouse: MouseSourceEmitter;
+ stdin: { write: (data: string) => void };
+ openSkillsPanel: () => void;
+ say: (text: string) => void;
+ unmount: () => void;
+} {
+ const bus = makeTuiEventBus();
+ const mouse = makeMouseSource();
+ const { lastFrame, stdin, unmount } = render(
+ ,
+ );
+ return {
+ frame: () => strip(lastFrame() ?? ""),
+ mouse,
+ stdin,
+ openSkillsPanel: () => {
+ bus.emit({ type: "ui_mode_set", mode: "debug" });
+ bus.emit({ type: "tab_changed", tab: "skills" });
+ bus.emit({
+ type: "skills_refreshed",
+ at: 0,
+ rows: [
+ {
+ name: "alpha-skill",
+ description: "first",
+ version: "1.0.0",
+ source: "builtin",
+ disabled: false,
+ },
+ {
+ name: "beta-skill",
+ description: "second",
+ version: "1.0.0",
+ source: "builtin",
+ disabled: false,
+ },
+ ],
+ });
+ },
+ say: (text) => bus.emit({ type: "system_message", text }),
+ unmount,
+ };
+}
+
+describe("TuiApp mouse", () => {
+ it("opens the menu when the rail's Menu button is clicked", async () => {
+ // The pills are gone and the top bar with them; the rail's Menu
+ // button is the mouse route into navigation. Without it the mouse
+ // would have no way to change section at all.
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Menu"),
+ () => app.frame().includes("GO"),
+ "click on the rail's Menu button",
+ );
+ expect(app.frame()).toContain("Menu");
+ app.unmount();
+ });
+
+ it("navigates when a menu row is clicked", async () => {
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Menu"),
+ () => app.frame().includes("GO"),
+ "click on the rail's Menu button",
+ );
+ // One click acts on a menu row — the menu is the one surface where
+ // the two-step select-then-activate rule would be the surprise.
+ // The debug toggle is filed under Help, which sits below the fold on
+ // a fresh menu — search for it first, the way an operator would.
+ app.stdin.write("debug");
+ await waitUntil(
+ () => app.frame().includes("Toggle debug pane"),
+ "the searched menu row",
+ );
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Toggle debug pane"),
+ () => app.frame().includes("▸ Feed"),
+ "click on a menu row",
+ );
+ expect(app.frame()).toContain("▸ Feed");
+ app.unmount();
+ });
+
+ it("switches sub-tab when a tab label is clicked", async () => {
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Menu"),
+ () => app.frame().includes("GO"),
+ "click on the rail's Menu button",
+ );
+ // The debug toggle is filed under Help, which sits below the fold on
+ // a fresh menu — search for it first, the way an operator would.
+ app.stdin.write("debug");
+ await waitUntil(
+ () => app.frame().includes("Toggle debug pane"),
+ "the searched menu row",
+ );
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Toggle debug pane"),
+ () => app.frame().includes("▸ Feed"),
+ "click on a menu row",
+ );
+ await waitUntil(() => app.frame().includes("Logs"), "the Observe sub-tabs");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Logs"),
+ () => app.frame().includes("▸ Logs"),
+ "click on the Logs sub-tab",
+ );
+ expect(app.frame()).toContain("▸ Logs");
+ app.unmount();
+ });
+
+ it("reaches the per-message copy button through the whole app", async () => {
+ // Proves the button survives the real tree — the chat viewport's
+ // `overflow: hidden` clip, the base-layer wheel target covering the
+ // entire content area, and the layer floor. The badge says "failed"
+ // because no `ClipboardProvider` is mounted and the default writer
+ // refuses to act on a non-TTY stdout, which is exactly the guard
+ // that keeps the suite off the developer's real clipboard.
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ // The bus subscription is installed by an effect; emitting before it
+ // runs drops the event on the floor.
+ await delay(50);
+ app.say("a message worth copying");
+ await waitUntil(() => app.frame().includes("[copy]"), "the copy button");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "[copy]"),
+ () => app.frame().includes("[copy failed]"),
+ "click on the message's copy button",
+ );
+ expect(app.frame()).toContain("[copy failed]");
+ app.unmount();
+ });
+
+ it("ignores a click that lands on no target", async () => {
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ const before = app.frame();
+ app.mouse.emit(click(0, 0));
+ await delay(150);
+ expect(app.frame()).toBe(before);
+ app.unmount();
+ });
+
+ it("places the editor caret where the prompt is clicked", async () => {
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ app.stdin.write("hello");
+ await waitUntil(() => app.frame().includes("hello"), "the typed buffer");
+ // Click the second "l" (index 3) then type: the character has to land
+ // at the caret, not at the end of the buffer.
+ await clickUntil(
+ app.mouse,
+ () => {
+ const at = locate(app.frame(), "hello");
+ return { x: at.x + 3, y: at.y };
+ },
+ () => true,
+ "click inside the prompt",
+ );
+ app.stdin.write("X");
+ await waitUntil(
+ () => app.frame().includes("helXlo"),
+ "the character inserted at the clicked caret",
+ );
+ expect(app.frame()).toContain("helXlo");
+ app.unmount();
+ });
+
+ it("clamps a click past the end of a line to the line end", async () => {
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen");
+ app.stdin.write("hi");
+ await waitUntil(() => app.frame().includes("hi"), "the typed buffer");
+ await clickUntil(
+ app.mouse,
+ () => {
+ const at = locate(app.frame(), "hi");
+ return { x: at.x + 30, y: at.y };
+ },
+ () => true,
+ "click past the end of the line",
+ );
+ app.stdin.write("!");
+ await waitUntil(
+ () => app.frame().includes("hi!"),
+ "the character appended at the clamped caret",
+ );
+ expect(app.frame()).toContain("hi!");
+ app.unmount();
+ });
+
+ it("moves a panel cursor with the wheel", async () => {
+ const app = mountApp();
+ app.openSkillsPanel();
+ // The rail shares every terminal row with the panel, so the first
+ // glyph on a line belongs to the rail, not to the row. Slice the
+ // rail off first — ink-testing-library pins stdout at 100 columns,
+ // which is the width the rail sizes itself against.
+ const railColumns = computeSidebarWidth(100);
+ const marker = (name: string): string => {
+ const line = app
+ .frame()
+ .split("\n")
+ .find((candidate) => candidate.includes(name));
+ if (!line) return "";
+ return line.slice(railColumns).trimStart().slice(0, 1);
+ };
+ await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows");
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ // Aim at the panel column: x=10 is inside the rail now.
+ app.mouse.emit(wheel("down", 60, 6));
+ await delay(50);
+ if (marker("beta-skill") === "▸") break;
+ }
+ expect(marker("beta-skill")).toBe("▸");
+ app.unmount();
+ });
+
+ it("routes a click to a list row and moves the cursor there", async () => {
+ const app = mountApp();
+ app.openSkillsPanel();
+ // The rail shares every terminal row with the panel, so the first
+ // glyph on a line belongs to the rail, not to the row. Slice the
+ // rail off first — ink-testing-library pins stdout at 100 columns,
+ // which is the width the rail sizes itself against.
+ const railColumns = computeSidebarWidth(100);
+ const marker = (name: string): string => {
+ const line = app
+ .frame()
+ .split("\n")
+ .find((candidate) => candidate.includes(name));
+ if (!line) return "";
+ return line.slice(railColumns).trimStart().slice(0, 1);
+ };
+ await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "beta-skill"),
+ () => marker("beta-skill") === "▸",
+ "click on the beta-skill row",
+ );
+ expect(marker("beta-skill")).toBe("▸");
+ expect(marker("alpha-skill")).not.toBe("▸");
+ app.unmount();
+ });
+});
+
+describe("TuiApp mouse — run modes", () => {
+ it("asks for the run mode a clicked pill names", async () => {
+ const asked: string[] = [];
+ const bus = makeTuiEventBus();
+ const mouse = makeMouseSource();
+ const { lastFrame, unmount } = render(
+ asked.push(mode),
+ }}
+ mouse={mouse}
+ />,
+ );
+ const frame = (): string => strip(lastFrame() ?? "");
+ await waitUntil(() => frame().includes("Fusion"), "the run-mode strip");
+ await clickUntil(
+ mouse,
+ () => locate(frame(), "Fusion"),
+ () => asked.includes("fusion"),
+ "click on the Fusion pill",
+ );
+ expect(asked).toContain("fusion");
+ unmount();
+ });
+
+ it("opens the dial when the pill already in effect is clicked", async () => {
+ // Re-applying the mode you are already in would be a wasted provider
+ // swap, and on Fusion the dial is otherwise unreachable by mouse.
+ const app = mountApp();
+ await waitUntil(() => app.frame().includes("Local"), "the run-mode strip");
+ await clickUntil(
+ app.mouse,
+ () => locate(app.frame(), "Local"),
+ () => app.frame().includes("cloud share"),
+ "click on the active pill",
+ );
+ expect(app.frame()).toContain("Run mode");
+ expect(app.frame()).toContain("cloud share");
+ app.unmount();
+ });
+});
diff --git a/src/tui/mouse/mouse-context.tsx b/src/tui/mouse/mouse-context.tsx
new file mode 100644
index 00000000..c0502243
--- /dev/null
+++ b/src/tui/mouse/mouse-context.tsx
@@ -0,0 +1,130 @@
+/**
+ * React glue for the mouse layer.
+ *
+ * The TUI's panels are presentational: `DebugPane` hands each panel the
+ * state slice it renders and nothing else, so wiring clicks by
+ * prop-drilling `dispatch` and the orchestrator callbacks through ten
+ * panels would be a far larger change than the feature warrants. A
+ * context instead gives any component the three things a click handler
+ * needs — `dispatch`, `callbacks`, and a *fresh* read of state — while
+ * leaving every existing prop signature untouched.
+ *
+ * Outside the app (component tests, the wizard's separate Ink tree) the
+ * context is absent and `useMouseTarget` degrades to a no-op ref, so a
+ * clickable component still renders exactly as before.
+ */
+import { Box, type DOMElement } from "ink";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactElement,
+ type ReactNode,
+ type RefObject,
+} from "react";
+import type { TuiAction } from "../tui-action.js";
+import type { TuiAppCallbacks } from "../tui-app.js";
+import type { TuiState } from "../tui-state.js";
+import {
+ MOUSE_LAYER_BASE,
+ MouseTargetRegistry,
+ type MouseTargetHandler,
+} from "./mouse-registry.js";
+
+export interface MouseContextValue {
+ readonly registry: MouseTargetRegistry;
+ readonly dispatch: (action: TuiAction) => void;
+ readonly callbacks: TuiAppCallbacks;
+ /** Reads the live state — handlers fire outside React's render pass. */
+ readonly getState: () => TuiState;
+}
+
+const MouseContext = createContext