diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..a87e18bc 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,26 @@ 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. + +**Navigation.** The breadcrumb in the status bar is the one clickable navigation control: clicking it opens the menu, exactly as `ctrl+p` does. An earlier draft of this layer made a Run / Observe / Manage pill strip clickable, but the menu registry replaced that strip — reinstating pills would give one job two competing controls. + +**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). + +**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 v40, 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. + +**The toggle is not a prop.** `tui-command.ts` hands `TuiApp` the `mouse` source unconditionally, whatever `tui.mouse` said at startup. The mounted tree cannot be re-parented from a plain `let` reassignment, so gating that prop on the startup value silently made `/mouse on` a no-op for the rest of the session. The live gate is the tracking controller: it decides whether the terminal reports at all, and whether decoded reports are forwarded to the source. + +**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. `tui-command.mouse.test.ts` covers the other end — that a runtime `/mouse on` actually reaches the source the tree subscribed to at mount. + ## Module map | Folder | Responsibility | @@ -166,7 +186,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 +197,7 @@ 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). See §"Mouse support". | ## Secrets and process environment @@ -203,10 +224,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 one documented exception is `subscription-cli` — a subprocess backend has no baseUrl, no key and no HTTP path, so a preset cannot express it; within that kind the preset philosophy re-applies one level down (a new vendor CLI is a descriptor entry, never a new kind). Vendors that do not authenticate with `Authorization: Bearer` set `apiKeyHeader` (Anthropic: `x-api-key`) plus any mandatory static `headers` (Anthropic: `anthropic-version`); both are copied onto the saved config entry by [providers-wizard-build-entry.ts](src/tui/providers/providers-wizard-build-entry.ts) and applied to **both** request paths by the single [openai-auth-headers.ts](src/llm/provider/openai/openai-auth-headers.ts) builder, so discovery and chat cannot disagree. The bar for a new entry: probe `/v1/models` **with the headers the preset will actually send** and get either 200 with a `data` array, or a 401/403 that rejects the *credential* — a 401 whose body names a header the preset does not send (`x-api-key header is required`, `Invalid bearer token` for what is an API key) is a **failing** probe, not a passing one. Either way the same host must answer 404 for a bogus sibling path; 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`). Tag matching is exact equality, so a context window is tagged three ways — as displayed (`1.0m`), floored to the whole unit (`1m`, the bucket a window falls in rather than a `>=` filter: 1_310_720 answers to both `1m` and `1.3m`, a 2M window only to `2m`), and, when the window is an exact multiple of 1024, in binary (131_072 answers to `128k`). Add a tag rather than changing [format-model-details.ts](src/llm/provider/format-model-details.ts): the display string is what the rows render. 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 +243,16 @@ 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. + +**A cancelled check is inert, at both call sites.** `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning still comes back as an ordinary verdict — never `"cancelled"`. Both callers therefore re-ask after the await whether the answer is still wanted: `completeWizard` on `abort.signal.aborted`, `CloudProviderOnboarding` on the same signal plus its mount check, at the success **and** the failure exit. A React `submitting` flag cannot carry this on its own: it is captured in the submit closure and the cancel handler resets it, so Enter after Esc — and two key events drained from stdin in one turn — read a stale `false` and started a second check racing the first to write the same provider. Re-entry is guarded on the in-flight `AbortController` ref instead, which is written before the first await and cleared only by the run that owns it or by a cancel. + +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), the `completeWizard` cases in [providers-orchestrator.test.ts](src/tui/providers/providers-orchestrator.test.ts), and the cancel-then-resolve cases in [src/tui/components/cloud-provider-onboarding.test.tsx](src/tui/components/cloud-provider-onboarding.test.tsx). + ### 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 +263,10 @@ 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"). + +9. **A broken stdin pipe is expected, not fatal.** Because the prompt travels on stdin, every spawn site that writes it carries an `error` listener on `child.stdin` — [command-runner.ts](src/sandbox/command-runner.ts) and [stream-cli-completion.ts](src/llm/provider/subscription-cli/stream-cli-completion.ts). A CLI that rejects the request (signed out, unknown model, rate-limited) exits without draining stdin, and a prompt past the ~64 KiB pipe buffer then raises `EPIPE`; an `error` on a stream with no listener is fatal, and `installGlobalErrorHandlers` deliberately preserves that, so the operator would lose the whole session instead of seeing `SubscriptionCliAuthError`. The same fires on our own Ctrl+C, where `stop("abort")` SIGTERMs the child mid-write. Broken-pipe codes are absorbed and the child's own exit code and stderr report the failure; `CommandResult.inputTruncated` covers the CLIs that exit 0 regardless (`codex`), which would otherwise pass a half-delivered prompt off as a good completion. Every other stdin error still travels. Relatedly, `streamCliCommand`'s `finally` cancels the SIGKILL escalation **only once the child has exited** — clearing it unconditionally cancelled the timer `stop` had just armed, leaving one orphan per aborted turn behind any child that traps SIGTERM. Pinned by [command-runner.test.ts](src/sandbox/command-runner.test.ts), [run-cli-completion.test.ts](src/llm/provider/subscription-cli/run-cli-completion.test.ts) and [stream-cli-completion.test.ts](src/llm/provider/subscription-cli/stream-cli-completion.test.ts) ("force-kills a child that traps SIGTERM instead of orphaning it"). + ### 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 +1021,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 +1031,38 @@ 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`, and queues nothing, unless a running turn can still pick the message up. 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`. +- **Acceptance is one fact, not two.** The inbox itself owns the window: `AgentLoop.runTurn` calls `open(sessionId)` on entry and `closeAndDrain(sessionId)` on the way out, and `push` refuses whenever the window is shut. `steer()` does **not** consult `turnController.isBusy` — `isBusy` stops being true at a *different moment* than "a drain is still coming" (the loop's final drain happens inside `runTurn`, `busy.delete` later in the controller's own `finally`), and a check-then-act across those two facts loses the message in between: accepted, never delivered, and resurfacing at step 0 of some later turn under a "while you were working" notice about a turn that had already ended. Because the same call closes the window and takes what is pending, there is no window at all: a message is either delivered at a step boundary, returned on `undelivered`, or refused outright. +- **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. +- **The UI sees it land.** `steer_applied` (`{ text, stepIndex }`) is emitted at the step the message was folded into. `reduceAgentEvent` ([src/tui/agent-event-reducer.ts](src/tui/agent-event-reducer.ts)) renders it inline in the turn already running — a user bubble plus a feed line naming the step — with none of the per-turn resets `user_message` triggers. That switch is exhaustiveness-checked (`const unhandled: never = event`), so the next `AgentLoopEvent` added without a case is a compile error rather than a silent no-op; it still returns `state` at runtime, because a UI reducer must not throw on an event it does not know. +- **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: `ChatOrchestrator.rerouteUndelivered` ([src/tui/chat-orchestrator.ts](src/tui/chat-orchestrator.ts)) puts it at the **head** of its pending-message queue, ahead of anything typed after `steer` started refusing. A caller that ignores `undelivered` drops a message `steer` already answered "yes" to — that is a bug, not a style choice. `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process. +- **The caller offers, then falls back.** `ChatOrchestrator.sendMessage` is the reference shape: while a turn is in flight it calls `runtime.steer` first and only queues on its own when that returns `false`. (The editor stays live during a turn — a mid-turn submission routes through `handleEditorSubmit` as `message_queued` and reaches this path; steer first, queue on refusal.) +- **A refusal is not a demotion.** The caller's "a turn is in flight" is strictly WIDER than the window: `ChatOrchestrator` sets `currentController` before `runtime.runTurn`, and the loop's `open()` runs only once `turnController.enqueue` stops parking in `waitOrAbort` — i.e. after any out-of-band turn on the same session finishes settling. A steer aimed into that span is refused, so the fallback must keep the operator's ordering: `queueAsSteer` splices it in at the **front** of the pending queue (behind steers already re-routed for the same turn, so their typing order survives) and still emits a "steering the running turn" acknowledgement — worded so it holds whether the window was already shut, not yet open, or full. The window is deliberately NOT opened at the caller's commit point instead: it is a per-session single slot, so opening it before the submission owns the session lock aliases two turns onto one window — the turn still running would drain a message meant for the parked one, and its `closeAndDrain` would carry off the parked turn's pending steers as its own `undelivered`. And on the abort-while-parked path `run()` never executes, so nothing would close the window or hand anything back. +- **The caller reads exactly one fact.** `sendMessage` does not consult `steeringInbox.isOpen` to tell "window shut" from "inbox full" apart, and no caller should gate on `turnController.isBusy` before calling `steer`. Both are second facts read at a different moment than the one `steer` acts on — the check-then-act this mechanism exists to remove. `steer`'s return value is the whole answer. +- **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` switches to steer mode, `/queue mode` to queue mode — bare `/queue` stays a side-effect-free listing, because the menu node and the `/queue N parked` chip both invite running it just to look. All three routes to the setting (Ctrl+T, bare `/steer`, `/queue mode`) go through the single `onWhileBusyModePersistRequested` callback into `persistUserWhileBusySubmit`, so the choice survives a restart and there is one place that can fail. + +**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 no running turn will pick the message up (it 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. Neither pre-checks `turnController.isBusy` either — `runtime.steer` is the single authority on accept/refuse, and the HTTP route reads the inbox only *after* a refusal, to choose between the two status codes. A pre-check would be a second, staler fact that can reject a steer the runtime would have taken. +**Every host surface consumes `undelivered`.** Accepting a steer is a promise to say where it ended up, so no surface may drop `RunTurnResult.undelivered`: + +- **Sidecar.** `send_message` emits one `steer_undelivered` event per stranded message. The host owns it from there. +- **HTTP.** `POST /api/sessions/{id}/steer` and the turn that would have carried the message are different exchanges — the steer was answered long before the turn closed, and the completion response goes to whoever owns the turn, who is not necessarily whoever steered. So the route that ran the turn ([src/http/openai-chat-completions.ts](src/http/openai-chat-completions.ts)) always parks the hand-back in `UndeliveredSteerStore` ([src/http/undelivered-steers.ts](src/http/undelivered-steers.ts)), on the success, failed and threw paths alike, and additionally mirrors it onto that response where one can carry it: `undelivered_steers` on the non-stream `chat.completion` body (absent when the turn delivered everything), an `event: steer_undelivered` SSE frame for extensions-opt-in streams (a vanilla OpenAI stream stays strict). The mirrored entries carry the parked `seq`, so they are the same message, not a second copy. Hosts read parked messages with `GET /api/sessions/{id}/steer` and acknowledge with `DELETE /api/sessions/{id}/steer?through={seq}&discarded={n}` (either parameter alone is fine; at least one is required); reads are non-destructive because a retried or prefetched `GET` must not be able to lose the text, and the ack is by cursor so a steer parked between the two calls survives. `DELETE /api/sessions/{id}` drops that session's parked messages with the row. The store is per-server and in-memory (bounded by `MAX_PARKED_STEERS` per session and `MAX_PARKED_SESSIONS` sessions), matching the inbox it drains from — neither survives a restart. Two properties the cap must not break: **`discarded` is acked separately** from the entries — the discarded messages have no `seq` the host was ever shown, so the entry cursor cannot stand in for having read the loss count, and a box outlives its entries while a loss is unacknowledged (still reclaimed by the session purge and by session eviction); and **a hand-back is returned whole** — the cap evicts only entries parked by *earlier* calls, never the batch it was just handed, because that return value is what becomes `undelivered_steers` / `steer_undelivered` and trimming it would omit messages from the one payload meant to carry them. +- **Anything else that calls `runTurn` directly** (task runner, channels) inherits the same obligation. + +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), [src/tui/chat-orchestrator-steering.test.ts](src/tui/chat-orchestrator-steering.test.ts) (steer-then-queue fallback, `undelivered` re-route and its ordering, plus a real-`TurnController` harness that parks a TUI turn in `waitOrAbort` behind an out-of-band one and steers into the gap) and the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts) — including the one that stands in the window between the loop's final drain and `busy.delete`. + ### 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 no running turn can still pick it up. Deliberately not gated on `isBusy` — 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 +1396,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. diff --git a/README.md b/README.md index 1b8ff1ee..248995e1 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 @@ -228,8 +231,14 @@ 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). +**Mouse.** The TUI is clickable: the breadcrumb (which opens the menu, the same as `ctrl+p`), 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. +
@@ -256,6 +265,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), a size term names a whole-unit bucket whatever the row displays (`1m` finds windows from 1M up to 2M, including the 1,048,576-token ones that render as `1.0M`; a 2M window answers to `2m`; `128k` finds 131,072), results are ranked best-first, and the same query works in the TUI Cloud pane — press `f`. +
@@ -423,6 +443,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; @@ -481,6 +502,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/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..3ece9217 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?: SteeringChannel; metrics?: AgentMetrics; logger?: StructuredLogger; } @@ -247,6 +256,23 @@ export interface LessonLifecycleHook { }): void; } +/** + * The turn's side of the steering inbox. Declared structurally (like + * {@link MemoryContextProvider}) so `src/agent/` does not import from + * `src/runtime/`, which imports it. + * + * The loop owns the window in which steering is accepted: `open` when + * the turn starts, `drain` at every step boundary, `closeAndDrain` + * exactly once on the way out. `closeAndDrain` is what makes "the turn + * can still pick messages up" and "the last drain has happened" the + * same fact — see the comment on `SteeringInbox.accepting`. + */ +export interface SteeringChannel { + open(sessionId: string): void; + drain(sessionId: string): readonly string[]; + closeAndDrain(sessionId: string): readonly string[]; +} + export interface RunTurnOptions { maxSteps: number; signal: AbortSignal; @@ -264,6 +290,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 +359,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 { @@ -342,10 +383,40 @@ export class AgentLoop { * - On `finish`: returns with `reason: "finish"`, session marked completed. * - On `max_steps`: synthesises a fallback assistant reply so the user * is never left without a turn closing. + * + * The wrapper owns the mid-turn steering window: it is open for + * exactly the lifetime of this call, and it closes in the same + * indivisible step as the loop's final drain (see `flushSteering`). + * A `steer()` that lands after that is refused, not stranded. */ async runTurn( session: SessionState, options: RunTurnOptions, + ): Promise { + this.deps.steeringInbox?.open(session.id); + try { + return await this.runTurnInner(session, options); + } finally { + // Every ordinary exit already closed the window through + // `flushSteering` — a `return` expression is evaluated before + // this block runs, so `undelivered` is unaffected and this call + // is a no-op. What it catches is the throw path (a programming + // bug escaping the classified-error handling above): without it + // the session would stay open forever and every later `steer()` + // would be accepted into an inbox nobody drains. + const stranded = this.deps.steeringInbox?.closeAndDrain(session.id) ?? []; + if (stranded.length > 0) { + this.deps.logger?.warn("mid-turn steering stranded by a failed turn", { + sessionId: session.id, + count: stranded.length, + }); + } + } + } + + private async runTurnInner( + session: SessionState, + options: RunTurnOptions, ): Promise { let state = session; @@ -451,6 +522,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 +800,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 +836,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 +992,30 @@ export class AgentLoop { } } - return { session: state, reason, stepCount: stepsTaken }; + return { + session: state, + reason, + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; + } + + /** + * Close the steering window and empty the inbox on the way out of a + * turn — one indivisible step, which is the whole point. + * + * 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. + * What is already pending is handed back to the caller as + * `undelivered`; what arrives from here on is refused at `push`, so + * the sender learns immediately that it was not steered. Together + * that keeps "the message you sent always goes somewhere" true on + * every exit path, with no window in between. + */ + private flushSteering(sessionId: string): readonly string[] { + return this.deps.steeringInbox?.closeAndDrain(sessionId) ?? []; } } @@ -1034,7 +1159,11 @@ function collectLastUserAssistantPairs( for (const turn of state.turns) { if (!turn) continue; if (turn.kind === "user") { - pendingUser = turn.text; + // Consecutive user rows exist since mid-turn steering: the steer + // must not REPLACE the founding message in the reflection pair — + // memory extraction would then attribute the whole turn to the + // correction alone. Join them in order instead. + pendingUser = pendingUser === null ? turn.text : `${pendingUser}\n\n${turn.text}`; } else if (turn.kind === "assistant_reply" && pendingUser !== null) { pairs.push({ user: pendingUser, assistant: turn.text }); pendingUser = null; diff --git a/src/agent/batch-executor.test.ts b/src/agent/batch-executor.test.ts index a72b5772..2921c358 100644 --- a/src/agent/batch-executor.test.ts +++ b/src/agent/batch-executor.test.ts @@ -497,6 +497,131 @@ describe("executeBatch", () => { expect(out.loopSignals[0]!.detector).toBe("wandering"); }); + // Issue #186: the veto body must name the invariant that held across + // the blocked attempts and offer a concrete alternative. + it("veto body names the repeated host and offers the search-first alternative", async () => { + const registry = buildRegistry({ "os.web.fetch": async () => okResult("os.web.fetch") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { url: "https://web.archive.org/web/2020/https://x.test/a?k=SECRET" }; + seedCriticalStreak(tracker, "os.web.fetch", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("web.archive.org"); + expect(body).toContain("`os.web.search`"); + // The full URL — path, query, secret — must NOT reach model context. + expect(body).not.toContain("SECRET"); + expect(body).not.toContain("/web/2020/"); + }); + + it("veto body names the command for a shell loop", async () => { + const registry = buildRegistry({ "os.shell.run": async () => okResult("os.shell.run") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { command: "curl -s https://x.test --header 'Authorization: Bearer SECRET'" }; + seedCriticalStreak(tracker, "os.shell.run", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.shell.run", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("`curl`"); + expect(body).not.toContain("SECRET"); + }); + + it("veto body degrades to generic wording when args carry no extractable target", async () => { + const registry = buildRegistry({ "os.fs.read": async () => okResult("os.fs.read") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + seedCriticalStreak(tracker, "os.fs.read", { path: "a" }, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.fs.read", args: { path: "a" } }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).toContain("2 consecutive calls returned the same no-progress outcome"); + expect(body).not.toContain("undefined"); + }); + + // The wandering spread is a property of the history window, so it stays + // above the threshold once the model stops varying its argument. Reporting + // a verbatim repeat as "N different attempts" is the same false statement + // the wandering wording exists to avoid, in the mirror case. + it("stops claiming different attempts once a wandering model settles on one url", async () => { + const registry = buildRegistry({ + "os.web.fetch": async () => okResult("os.web.fetch"), + }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 3, + wanderingThreshold: 3, + wanderingEscalation: 4, + }); + // Wander first: four distinct URLs on one host crosses the escalation. + for (const path of ["a", "b", "c", "d"]) { + const wandered = { url: `https://web.archive.org/${path}` }; + tracker.check("os.web.fetch", wandered); + tracker.recordCall("os.web.fetch", wandered); + tracker.recordOutcome( + "os.web.fetch", + wandered, + okResult("os.web.fetch", path), + ); + } + // Then settle: the same URL, twice, so the second call is a repeat. + const settled = { url: "https://web.archive.org/same" }; + tracker.check("os.web.fetch", settled); + tracker.recordCall("os.web.fetch", settled); + tracker.recordOutcome( + "os.web.fetch", + settled, + okResult("os.web.fetch", "same"), + ); + + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args: settled }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).toContain("web.archive.org"); + expect(body).not.toContain("different attempts"); + // A count the verdict cannot substantiate must not be quoted either. + expect(body).not.toContain("0 consecutive"); + }); + + it("does not throw and stays generic when args are malformed", async () => { + const registry = buildRegistry({ "os.web.fetch": async () => okResult("os.web.fetch") }); + const tracker = new ToolLoopTracker({ + warningThreshold: 2, + criticalThreshold: 2, + }); + const args = { url: "://not a url" }; + seedCriticalStreak(tracker, "os.web.fetch", args, 2); + const out = await executeBatch( + toBatchInputs([{ tool: "os.web.fetch", args }]), + registry, + { ...ctx(new AbortController().signal), tracker }, + ); + const body = out.results[0]!.compressed!.summary; + expect(body).toContain("BLOCKED"); + expect(body).not.toContain("undefined"); + }); + it("marks tail calls as cancelled when the signal aborts mid-serialised-group", async () => { const ctrl = new AbortController(); const registry = new ToolRegistry(); diff --git a/src/agent/batch-executor.ts b/src/agent/batch-executor.ts index 14f0427c..0e2fb382 100644 --- a/src/agent/batch-executor.ts +++ b/src/agent/batch-executor.ts @@ -11,6 +11,7 @@ import { type ResourceClass, } from "./tool-resource-class.js"; import { + extractLoopTarget, formatVetoInstruction, LOOP_VETO_DENIED_REASON, type LoopCheckVerdict, @@ -456,14 +457,31 @@ function runSyncLoopGate( const count = breakerTripped ? Math.max(verdict.count, ctx.tracker.breakerThreshold) : verdict.count; + // Name the invariant that held across the blocked attempts (host for + // web/HTTP, command name for shell) so the message says WHAT stayed + // the same instead of only that something did. + const target = extractLoopTarget(tool, args); + // A wandering escalation rides this same veto path but its `count` is + // a spread of DISTINCT arguments; pass the detector so the wording + // does not claim they were identical. + // + // The verdict decides, not the escalation flag. `isWanderingEscalated` + // answers for the whole history window, so it stays true after the model + // stops wandering and settles on repeating one argument -- and borrowing + // it there would announce "N different attempts" about a verbatim + // repeat, quoting a count the verdict never established. + const detector = + wanderingEscalated && verdict.detector === "wandering" + ? "wandering" + : verdict.detector; const vetoResult = compressToolResult({ tool, status: "error", - output: formatVetoInstruction({ tool, count }), + output: formatVetoInstruction({ tool, count, target, detector }), details: { deniedReason: LOOP_VETO_DENIED_REASON, loopCount: count, - detector: verdict.detector, + detector, }, }); ctx.tracker.recordOutcome(tool, args, vetoResult); @@ -471,7 +489,7 @@ function runSyncLoopGate( kind: forceBreaker ? "breaker" : "critical", tool, count, - detector: verdict.detector, + detector, warningKey: verdict.warningKey, }); return { proceed: false, vetoResult }; diff --git a/src/agent/index.ts b/src/agent/index.ts index 696f40f1..6abc67d3 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -21,6 +21,7 @@ export { formatRepeatNotice, formatVetoInstruction, formatForcedLoopReply, + extractLoopTarget, BATCH_LOOP_LABEL, LOOP_VETO_DENIED_REASON, LOOP_WARNING_BUCKET_SIZE, diff --git a/src/agent/loop-detector.test.ts b/src/agent/loop-detector.test.ts index e392c585..bfce6ee1 100644 --- a/src/agent/loop-detector.test.ts +++ b/src/agent/loop-detector.test.ts @@ -4,6 +4,7 @@ import { BATCH_LOOP_LABEL, LOOP_VETO_DENIED_REASON, ToolLoopTracker, + extractLoopTarget, formatForcedLoopReply, formatRepeatNotice, formatVetoInstruction, @@ -312,6 +313,155 @@ describe("loop notice formatters", () => { }); }); +describe("extractLoopTarget", () => { + it("reduces a web fetch URL to its host, dropping path and query", () => { + expect( + extractLoopTarget("os.web.fetch", { + url: "https://web.archive.org/web/2020/https://x.test/a?token=SECRET", + }), + ).toBe("web.archive.org"); + }); + + it("handles os.http.request and schemeless URLs", () => { + expect( + extractLoopTarget("os.http.request", { + url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed", + }), + ).toBe("eutils.ncbi.nlm.nih.gov"); + expect(extractLoopTarget("os.web.fetch", { url: "en.wikipedia.org/wiki/X" })).toBe( + "en.wikipedia.org", + ); + }); + + it("reduces a shell command to the executable name only", () => { + expect( + extractLoopTarget("os.shell.run", { + command: "curl -s https://x.test/a --header 'Authorization: Bearer SECRET'", + }), + ).toBe("curl"); + }); + + it("returns undefined for unextractable or malformed args", () => { + expect(extractLoopTarget("os.web.fetch", {})).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", { url: "" })).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", { url: 42 })).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", null)).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", undefined)).toBeUndefined(); + expect(extractLoopTarget("os.web.fetch", "not-an-object")).toBeUndefined(); + expect(extractLoopTarget("os.shell.run", { command: " " })).toBeUndefined(); + expect(extractLoopTarget("browser.click", { selector: "#a" })).toBeUndefined(); + }); + + it("never throws on hostile or malformed URL values", () => { + for (const url of ["http://", "://", "%%%", "h ttp://a b", ""]) { + expect(() => extractLoopTarget("os.web.fetch", { url })).not.toThrow(); + } + }); +}); + +describe("veto message names the invariant and an alternative (issue #186)", () => { + it("names the repeated host for a fetch loop", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto).toContain("BLOCKED"); + expect(veto).toContain("web.archive.org"); + expect(veto).toContain("5 consecutive calls"); + expect(veto).toContain("same no-progress outcome"); + }); + + it("offers the search-first alternative naming a different host", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto).toContain("`os.web.search`"); + expect(veto).toContain("DIFFERENT host"); + expect(veto.toLowerCase()).toContain("do not repeat"); + }); + + it("names the command for a shell loop", () => { + const veto = formatVetoInstruction({ + tool: "os.shell.run", + count: 5, + target: "curl", + detector: "no_progress", + }); + expect(veto).toContain("`curl`"); + expect(veto).toContain("5 consecutive calls"); + expect(veto).toContain("change the arguments or path"); + }); + + it("does not claim identical outcomes on a wandering escalation", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 13, + target: "web.archive.org", + detector: "wandering", + }); + expect(veto).toContain("13 different attempts"); + expect(veto).toContain("web.archive.org"); + expect(veto).not.toContain("identical"); + expect(veto).not.toContain("consecutive calls"); + // Wandering means many DIFFERENT URLs, so the hint says stop guessing + // rather than "stop retrying" (which would imply identical calls). + expect(veto).toContain("Stop guessing URLs"); + expect(veto).toContain("`os.web.search`"); + expect(veto).not.toContain("stop retrying"); + }); + + it("degrades to the generic wording when no target can be extracted", () => { + const veto = formatVetoInstruction({ tool: "noop", count: 5 }); + expect(veto).toContain("BLOCKED"); + expect(veto).toContain("`noop`"); + expect(veto).toContain("5 consecutive calls returned the same no-progress outcome"); + expect(veto).not.toContain("undefined"); + expect(veto.toLowerCase()).toContain("do not repeat"); + }); + + it("sanitizes a hostile target: no backticks or newlines leak through", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "evil`\n## injected heading\n`x", + detector: "no_progress", + }); + expect(veto).not.toContain("## injected heading\n"); + expect(veto.split("\n")[0]).toContain("evil"); + // Header stays a single line. + expect(veto.split("\n")[0]).not.toContain("injected heading\n"); + }); + + it("truncates an over-long target", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "a".repeat(200), + detector: "no_progress", + }); + expect(veto).toContain("..."); + // The 200-char target is capped at 60 chars, not echoed in full. + expect(veto).not.toContain("a".repeat(61)); + expect(veto.split("\n")[0]!.length).toBeLessThan(160); + }); + + it("stays short — the message is injected on every veto", () => { + const veto = formatVetoInstruction({ + tool: "os.web.fetch", + count: 5, + target: "web.archive.org", + detector: "no_progress", + }); + expect(veto.split("\n").length).toBeLessThanOrEqual(5); + expect(veto.length).toBeLessThan(600); + }); +}); + describe("ToolLoopTracker wandering detector", () => { it("flags a wandering loop on distinct web fetches", () => { const tracker = new ToolLoopTracker({ diff --git a/src/agent/loop-detector.ts b/src/agent/loop-detector.ts index db6e72d9..30a98cb9 100644 --- a/src/agent/loop-detector.ts +++ b/src/agent/loop-detector.ts @@ -168,7 +168,16 @@ export class ToolLoopTracker { } if (isWanderingProneTool(tool)) { const spread = this.effectiveSpread(tool, argsHash); - if (spread >= this.wanderingThreshold) { + // The spread is a property of the whole window, so it stays above the + // threshold after the model stops varying its argument and settles on + // repeating one. Classifying THIS call as wandering would then tell it + // "N different attempts" about a call that is a verbatim repeat -- the + // same kind of false statement the wandering wording exists to avoid. + // A repeat falls through to the repeat detector, which describes it + // accurately. + const repeatsEarlierCall = + getRepeatCount(this.history, tool, argsHash) > 0; + if (spread >= this.wanderingThreshold && !repeatsEarlierCall) { return { level: "warn", count: spread, @@ -571,19 +580,29 @@ function canonicalJson(value: unknown): string { export function formatRepeatNotice(verdict: { count: number; tool: string; + target?: string; }): string { - return formatLoopGuidance(verdict.tool, verdict.count, "notice"); + return formatLoopGuidance(verdict.tool, verdict.count, "notice", verdict); } /** * Body of the synthetic veto tool result (critical). Same class-aware * guidance as the notice, plus an explicit "do not repeat" instruction. + * + * `target` names the invariant that stayed the same across the blocked + * attempts (host for web/HTTP calls, command name for shell). `detector` + * distinguishes a true no-progress repeat from a `wandering` escalation + * riding the same veto path — the two need opposite wording, because a + * wandering `count` is a spread of DISTINCT arguments, not a run of + * identical outcomes. */ export function formatVetoInstruction(verdict: { count: number; tool: string; + target?: string; + detector?: LoopCheckVerdict["detector"]; }): string { - return formatLoopGuidance(verdict.tool, verdict.count, "veto"); + return formatLoopGuidance(verdict.tool, verdict.count, "veto", verdict); } /** @@ -629,27 +648,65 @@ function formatLoopGuidance( tool: string, count: number, mode: "notice" | "veto", + context: { + target?: string; + detector?: LoopCheckVerdict["detector"]; + } = {}, ): string { - const header = - mode === "veto" - ? `BLOCKED: \`${tool}\` was vetoed as a no-progress loop (${count} identical no-progress outcomes).` + const target = sanitizeLoopTarget(context.target); + const wandering = context.detector === "wandering"; + + let header: string; + if (mode === "veto" && wandering) { + // Wandering: `count` is a spread of DISTINCT arguments, so calling + // these "identical outcomes" would be flatly wrong. + header = target + ? `BLOCKED: \`${tool}\` — ${count} different attempts against \`${target}\` and still no answer.` + : `BLOCKED: \`${tool}\` — ${count} different attempts and still no answer.`; + } else if (mode === "veto" && count > 1) { + header = target + ? `BLOCKED: \`${tool}\` — ${count} consecutive calls to \`${target}\` returned the same no-progress outcome.` + : `BLOCKED: \`${tool}\` — ${count} consecutive calls returned the same no-progress outcome.`; + } else if (mode === "veto") { + // The breaker can fire on a verdict that carries no streak of its own + // (a wandering episode the model ended by settling on one argument). + // State only what is certainly true rather than quoting a count that + // would read as "0 consecutive calls". + header = target + ? `BLOCKED: \`${tool}\` — repeated calls to \`${target}\` are not making progress.` + : `BLOCKED: \`${tool}\` — repeated calls are not making progress.`; + } else { + header = target + ? `You called \`${tool}\` on \`${target}\` ${count} times with the same arguments and neither the result nor the world snapshot changed.` : `You called \`${tool}\` with the same arguments ${count} times and neither the result nor the world snapshot changed.`; + } - const webHint = - tool === "os.web.fetch" || tool === "os.http.request" - ? "- The URL may be dead or returning an HTTP error — read the status in the tool result and try a different source, endpoint, or search query." - : null; + // Actionable alternative, modelled on the wandering redirect: name the + // next move, do not restate the failure mode. + let webHint: string | null = null; + if (tool === "os.web.fetch" || tool === "os.http.request") { + if (wandering && target) { + webHint = `- Stop guessing URLs on \`${target}\`. Run \`os.web.search\` for the fact you need and fetch a result from a DIFFERENT host.`; + } else if (target) { + webHint = `- Run \`os.web.search\` for the fact you need and fetch a result from a DIFFERENT host — stop retrying \`${target}\`. The URL may be dead or returning an HTTP error; read the status in the tool result.`; + } else { + webHint = + "- Run `os.web.search` for the fact you need, then fetch one URL from the results — do not keep guessing URLs. The URL may be dead or returning an HTTP error; read the status in the tool result."; + } + } const browserHint = tool.startsWith("browser.") ? "- Re-read `### world` — the answer may already be on the page. Try `browser.scroll`, a different element, or `browser.navigate` to a more direct URL. An `[expanded]` element is already open." : null; const shellHint = tool.startsWith("os.shell.") || tool.startsWith("os.fs.") - ? "- Change the command, path, or arguments — repeating the same invocation will not produce a different result." + ? target + ? `- \`${target}\` will not behave differently on a re-run — change the arguments or path, or use a different command entirely.` + : "- Change the command, path, or arguments — repeating the same invocation will not produce a different result." : null; const lines = [ header, - "This is a no-progress loop. Change strategy BEFORE calling any tool again:", + "Change strategy BEFORE calling any tool again:", webHint, browserHint, shellHint, @@ -661,3 +718,59 @@ function formatLoopGuidance( return lines.join("\n"); } + +/** + * Defensive cleanup for a caller-supplied invariant label before it is + * echoed into model context: single line, no backticks (they would break + * the surrounding code span), length-capped. Returns `undefined` for + * anything empty so callers degrade to the generic wording. + */ +function sanitizeLoopTarget(raw: string | undefined): string | undefined { + if (typeof raw !== "string") return undefined; + const cleaned = raw.replace(/[`\r\n]+/g, " ").trim(); + if (cleaned.length === 0) return undefined; + return cleaned.length > 60 ? `${cleaned.slice(0, 57)}...` : cleaned; +} + +/** + * Extract the invariant that stayed the same across a loop's blocked + * attempts, for use as the `target` label in guidance messages. + * + * Deliberately coarse: web/HTTP calls collapse to the URL's HOST and + * shell calls to the leading command word, so no query parameters, + * credentials, paths, or other potentially sensitive argument content + * reaches the model context. Returns `undefined` when nothing meaningful + * can be extracted, so the caller falls back to the generic wording. + * Never throws on malformed args. + */ +export function extractLoopTarget( + tool: string, + args: unknown, +): string | undefined { + if (args === null || typeof args !== "object") return undefined; + const record = args as Record; + + if (tool === "os.web.fetch" || tool === "os.http.request") { + const raw = record.url ?? record.uri ?? record.endpoint; + if (typeof raw !== "string" || raw.length === 0) return undefined; + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) + ? raw + : `https://${raw}`; + try { + const host = new URL(candidate).hostname; + return host.length > 0 ? host : undefined; + } catch { + return undefined; + } + } + + if (tool === "os.shell.run") { + const raw = record.command ?? record.cmd; + if (typeof raw !== "string") return undefined; + // Leading word only: the executable name, never the full argv. + const name = raw.trim().split(/\s+/)[0]; + return name !== undefined && name.length > 0 ? name : undefined; + } + + return undefined; +} 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..0b4034fa --- /dev/null +++ b/src/agent/steer-notice.ts @@ -0,0 +1,80 @@ +/** + * 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; + +/** + * Aggregate cap across the whole block. Sixteen backlogged messages at + * the per-message cap would put ~10KB immediately before `### respond` + * and squeeze the conversation out of the token budget; past this the + * remaining messages are counted, not inlined — they are all real user + * turns in `### conversation` either way. + */ +const MAX_BLOCK_CHARS = 2400; + +/** + * 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 lines: string[] = []; + let used = 0; + let elided = 0; + for (const m of messages) { + const line = `- ${clip(m)}`; + if (used + line.length > MAX_BLOCK_CHARS && lines.length > 0) { + elided += 1; + continue; + } + used += line.length; + lines.push(line); + } + if (elided > 0) { + lines.push( + `- …and ${elided} more (all shown in full as the latest user turns in ### conversation)`, + ); + } + return `${header}\n${lines.join("\n")}`; +} + +function clip(text: string): string { + const flat = text.trim(); + if (flat.length <= MAX_INLINE_CHARS) return flat; + // Code-point slice, not a UTF-16 slice: a cut through a surrogate + // pair would put mojibake into the prompt. + const points = [...flat].slice(0, MAX_INLINE_CHARS).join(""); + return `${points}… (full text is the last user turn in ### conversation)`; +} diff --git a/src/channels/telegram/inbound-handler.ts b/src/channels/telegram/inbound-handler.ts index dfcbace5..a5cbf34a 100644 --- a/src/channels/telegram/inbound-handler.ts +++ b/src/channels/telegram/inbound-handler.ts @@ -256,11 +256,23 @@ async function dispatchToRuntime( progress?.start("🤔 Thinking…"); const stopKeepalive = startTypingKeepalive(ctx, chatId); try { - await ctx.runtime.runTurn(session, text, { + const result = await ctx.runtime.runTurn(session, text, { origin: "telegram", signal: controller.signal, eventHook, }); + // A steer accepted for this turn but never delivered: the chat is + // the host here, so tell it rather than dropping the text silently. + if (result.undelivered !== undefined && result.undelivered.length > 0) { + const lines = result.undelivered + .map((t) => `• ${t.length > 120 ? `${t.slice(0, 119)}…` : t}`) + .join("\n"); + await sendText( + ctx, + chatId, + `A message arrived too late for that turn and was not applied:\n${lines}`, + ); + } } catch (err) { failure = { error: err instanceof Error ? err : new Error(String(err)), 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..0a33becd --- /dev/null +++ b/src/cli/models-search-command.test.ts @@ -0,0 +1,236 @@ +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("`1m` returns every million-token row, whatever its rendered size reads", async () => { + writeConfig(); + // The README advertises this query. The bundled OpenRouter catalog + // holds 1_000_000, 1_048_576 and 1_050_000 windows, which render as + // `1m`, `1.0m` and `1.1m`; only the first used to answer to `1m`. + expect(await runModelsSearch(["1m", "--json"])).toBe(0); + const rows = JSON.parse(out.join("")) as { + id: string; + contextWindow: number; + }[]; + const windows = new Set(rows.map((row) => row.contextWindow)); + expect(windows).toEqual(new Set([1_000_000, 1_048_576, 1_050_000])); + for (const row of rows) { + expect(row.contextWindow).toBeGreaterThanOrEqual(1_000_000); + // `openrouter/auto` is 2M and belongs to `2m`, not to `1m`. + expect(row.contextWindow).toBeLessThan(2_000_000); + } + + // Same normalisation one unit down: 262_144 renders as `262k` and is + // sold as 256k. + out.length = 0; + expect(await runModelsSearch(["256k", "--json"])).toBe(0); + const kilo = JSON.parse(out.join("")) as { contextWindow: number }[]; + expect(kilo.length).toBeGreaterThan(0); + for (const row of kilo) expect(row.contextWindow).toBe(262_144); + }); + + 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..44fbf3dc 100644 --- a/src/cli/serve-command.ts +++ b/src/cli/serve-command.ts @@ -47,6 +47,9 @@ 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", + " GET /api/sessions/{id}/steer Steers a turn accepted but never delivered", + " DELETE /api/sessions/{id}/steer Acknowledge those: ?through={seq} and/or ?discarded={n}", " 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-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..5855300b 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -159,6 +159,27 @@ describe("parseUserConfigFile", () => { expect(parsed.tui.theme).toBe("auto"); }); + it("fills web.fetch defaults when migrating from v37", () => { + const parsed = parseUserConfigFile({ + version: 37, + web: { search: { provider: "exa", timeoutMs: 15_000 } }, + }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.web.fetch.timeoutMs).toBe(30_000); + expect(parsed.web.fetch.connectTimeoutMs).toBe(10_000); + expect(parsed.web.fetch.maxRetries).toBe(2); + // The version bump must not drop the settings a v37 file already carried. + expect(parsed.web.search.timeoutMs).toBe(15_000); + }); + + it("accepts every version between the oldest supported and the current one", () => { + // A bump that forgets to append the outgoing version to the supported + // list locks out everyone whose config is still on it. + for (let version = 5; version <= USER_CONFIG_VERSION; version += 1) { + expect(() => parseUserConfigFile({ version })).not.toThrow(); + } + }); + it("preserves an explicit tui.theme name", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION, @@ -175,6 +196,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({ @@ -993,3 +1045,32 @@ describe("parseUserConfigFile", () => { ).toThrow(/timeoutMs/); }); }); + +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..1a2d602f 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -31,6 +31,50 @@ export type BrowserChannel = "chrome" | "msedge" | "chromium"; export type WebSearchProviderName = "duckduckgo" | "searxng" | "exa" | "brave"; +/** + * Tunables for `os.web.fetch` (config v38). Before v38 the tool hard-coded a + * 30s overall budget with no connect timeout and no retries, so a single + * unreachable host burned 30s of the task budget and a transient 503 (the + * bulk of them from `web.archive.org`, which serves the same URL seconds + * later) ended the fetch outright. + */ +export interface WebFetchConfig { + /** + * Overall per-attempt budget in milliseconds, passed to curl `--max-time`. + * Kept at the historical 30_000 so unconfigured installs behave exactly as + * before. A per-call `timeoutMs` tool argument overrides it. + */ + timeoutMs: number; + /** + * TCP/TLS connect budget in milliseconds, passed to curl `--connect-timeout`. + * Much smaller than `timeoutMs` because a host that has not completed a + * handshake in 10s is almost never merely slow — it is firewalled, dead, or + * blackholing packets, and waiting the full overall budget for it is pure + * loss. A slow but reachable server still gets the whole `timeoutMs` to + * stream its body, since `--connect-timeout` only covers the handshake. + */ + connectTimeoutMs: number; + /** + * Extra attempts after the first for retryable failures (429/502/503/504 and + * curl exit 28 "operation timed out"). `0` disables retrying. Deliberately + * small: `os.web.fetch` is GET-only, so retries are always safe, but each one + * spends task budget. + */ + maxRetries: number; + /** + * Base delay in milliseconds for exponential backoff between retries + * (attempt N waits `retryBaseDelayMs * 2^(N-1)`). A server-sent `Retry-After` + * header wins over the computed delay when it is shorter than the cap. + */ + retryBaseDelayMs: number; + /** + * Upper bound in milliseconds on any single backoff wait, including one + * derived from `Retry-After`. Stops a hostile or overloaded origin from + * parking the agent for minutes on a header value. + */ + retryMaxDelayMs: number; +} + export interface WebSearchConfig { enabled: boolean; provider: WebSearchProviderName; @@ -293,6 +337,7 @@ export interface AtomicAgentConfig { }; web: { search: WebSearchConfig; + fetch: WebFetchConfig; }; /** * User-declared project root directories consumed by @@ -662,12 +707,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 @@ -714,6 +762,11 @@ export interface AtomicAgentConfig { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header carrying this entry's API key for services that do not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`). + */ + apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; @@ -725,6 +778,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"; @@ -935,6 +1001,7 @@ export interface UserConfigFile { }; web: { search: WebSearchConfig; + fetch: WebFetchConfig; }; /** * Project path resolution (config v36). `roots` lists directories @@ -1350,9 +1417,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 +1487,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; +// v39: new `web.fetch` block (`timeoutMs`, `connectTimeoutMs`, `maxRetries`, +// `retryBaseDelayMs`, `retryMaxDelayMs`) making `os.web.fetch` timeouts and +// retry/backoff configurable. Older files transparently inherit the defaults, +// and `timeoutMs` keeps its historical 30_000 value, so the migration does not +// change behaviour for anyone who does not opt in. +// v40: new `tui.mouse` flag gating the mouse layer. Defaults to true, so an +// older file inherits mouse support on upgrade; `--no-mouse` and `/mouse off` +// override it without rewriting the file. +export const USER_CONFIG_VERSION = 40 as const; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1529,6 +1612,9 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 34, 35, 36, + 37, + 38, + 39, USER_CONFIG_VERSION, ]; @@ -1590,6 +1676,13 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { apiKeyEnv: "BRAVE_SEARCH_API_KEY", }, }, + fetch: { + timeoutMs: 30_000, + connectTimeoutMs: 10_000, + maxRetries: 2, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, + }, }, projects: { roots: [], @@ -1767,6 +1860,8 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { }, tui: { theme: "auto", + whileBusySubmit: "steer", + mouse: true, }, analytics: { enabled: true, @@ -2664,6 +2759,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { const web = (obj.web as Record | undefined) ?? {}; const projects = (obj.projects as Record | undefined) ?? {}; const webSearch = (web.search as Record | undefined) ?? {}; + const webFetch = (web.fetch as Record | undefined) ?? {}; const webSearchProvider = parseWebSearchProviderName( webSearch.provider ?? USER_CONFIG_DEFAULTS.web.search.provider, "web.search.provider", @@ -2952,6 +3048,33 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { ), }, }, + fetch: { + timeoutMs: parsePositiveInt( + webFetch.timeoutMs ?? USER_CONFIG_DEFAULTS.web.fetch.timeoutMs, + "web.fetch.timeoutMs", + ), + connectTimeoutMs: parsePositiveInt( + webFetch.connectTimeoutMs ?? + USER_CONFIG_DEFAULTS.web.fetch.connectTimeoutMs, + "web.fetch.connectTimeoutMs", + ), + maxRetries: parseNonNegativeBoundedInt( + webFetch.maxRetries ?? USER_CONFIG_DEFAULTS.web.fetch.maxRetries, + "web.fetch.maxRetries", + 0, + 5, + ), + retryBaseDelayMs: parsePositiveInt( + webFetch.retryBaseDelayMs ?? + USER_CONFIG_DEFAULTS.web.fetch.retryBaseDelayMs, + "web.fetch.retryBaseDelayMs", + ), + retryMaxDelayMs: parsePositiveInt( + webFetch.retryMaxDelayMs ?? + USER_CONFIG_DEFAULTS.web.fetch.retryMaxDelayMs, + "web.fetch.retryMaxDelayMs", + ), + }, }, projects: { roots: @@ -3421,6 +3544,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( @@ -3461,6 +3589,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` (added in config v38). 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..c8b064a6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -8,15 +8,18 @@ export type { TelegramParseMode, UserConfigFile, UserManagedLocalLlmConfig, + WebFetchConfig, 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 +43,14 @@ export { type UserLlmFileConfig, type UserLlmFallbackConfig, type UserLlmProviderEntry, + type UserSubscriptionCliOptions, + type SubscriptionCliName, + SUBSCRIPTION_CLIS, } from "./llm-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..bc00e644 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -1,7 +1,34 @@ import { ConfigValidationError } from "./config-validation-error.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; @@ -19,9 +46,30 @@ export type UserLlmProviderEntry = { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header that carries this entry's API key. Set for known-service + * presets whose endpoint does not accept `Authorization: Bearer` + * (Anthropic wants `x-api-key`). Absent keeps the OpenAI convention. + * Stored on the entry rather than looked up from the preset table at + * request time, so a saved provider keeps authenticating after a + * restart and a hand-written entry can express the same thing. + */ + apiKeyHeader?: string; 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,8 +84,47 @@ 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. */ + 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 = { chain?: string[]; appendLocal?: boolean; @@ -63,6 +150,7 @@ const PROVIDER_KINDS = new Set([ "openrouter", "aimlapi", "gemini", + SUBSCRIPTION_CLI_KIND, ]); function parseProviderId(raw: unknown, field: string): string { @@ -120,6 +208,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, @@ -137,6 +237,10 @@ export function parseLlmProviderEntry( `${field}.defaultEmbeddingModel`, ), headers: parseOptionalHeaders(obj.headers, `${field}.headers`), + apiKeyHeader: parseOptionalString( + obj.apiKeyHeader, + `${field}.apiKeyHeader`, + ), supportsTools: obj.supportsTools === undefined ? undefined @@ -172,11 +276,84 @@ 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: parseSubscriptionCliOptions( + obj.subscriptionCli, + `${field}.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 +364,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, 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..33292d54 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); } @@ -282,6 +303,7 @@ export function loadConfig(): AtomicAgentConfig { }, web: { search: { ...user.web.search }, + fetch: { ...user.web.fetch }, }, projects: { roots: [...user.projects.roots], @@ -468,6 +490,8 @@ export function loadConfig(): AtomicAgentConfig { }, tui: { theme: user.tui.theme, + whileBusySubmit: user.tui.whileBusySubmit, + mouse: user.tui.mouse, }, analytics: { enabled: user.analytics.enabled, 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/http-server.ts b/src/http/http-server.ts index c5d8410e..e1c86056 100644 --- a/src/http/http-server.ts +++ b/src/http/http-server.ts @@ -4,6 +4,7 @@ import type { AgentRuntime } from "../runtime/bootstrap.js"; import { ApprovalBus } from "./approval-bus.js"; import { CompletionRegistry } from "./completion-registry.js"; import { openaiError } from "./openai-errors.js"; +import { UndeliveredSteerStore } from "./undelivered-steers.js"; import { BodyParseError, BodyTooLargeError, @@ -40,6 +41,7 @@ export interface HttpServerOptions { routes: RouteDefinition[]; approvalBus?: ApprovalBus; completionRegistry?: CompletionRegistry; + undeliveredSteers?: UndeliveredSteerStore; } export interface HttpServerHandle { @@ -48,6 +50,12 @@ export interface HttpServerHandle { port: number; approvalBus: ApprovalBus; completionRegistry: CompletionRegistry; + /** + * Steers the runtime accepted but no turn ever delivered. Exposed on + * the handle so an embedder can drain or inspect them the same way it + * can inspect pending approvals. + */ + undeliveredSteers: UndeliveredSteerStore; close: () => Promise; } @@ -116,6 +124,8 @@ export function createHttpServer( const approvalBus = options.approvalBus ?? new ApprovalBus(); const completionRegistry = options.completionRegistry ?? new CompletionRegistry(); + const undeliveredSteers = + options.undeliveredSteers ?? new UndeliveredSteerStore(); const compiled = options.routes.map(compileRoute); const server = createServer(async (req, res) => { @@ -125,6 +135,7 @@ export function createHttpServer( apiKey: options.apiKey, approvalBus, completionRegistry, + undeliveredSteers, }); } catch (err) { handleRouteError(res, err); @@ -153,6 +164,7 @@ export function createHttpServer( port: resolvedPort, approvalBus, completionRegistry, + undeliveredSteers, close: () => closeServer(server), }); }; diff --git a/src/http/index.ts b/src/http/index.ts index 00b173dc..3c58600c 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -4,6 +4,12 @@ export type { ApprovalListener } from "./approval-bus.js"; export { CompletionRegistry } from "./completion-registry.js"; export type { CompletionEntry } from "./completion-registry.js"; +export { + MAX_PARKED_STEERS, + UndeliveredSteerStore, +} from "./undelivered-steers.js"; +export type { UndeliveredSteer } from "./undelivered-steers.js"; + export { createHttpServer, } from "./http-server.js"; diff --git a/src/http/openai-chat-completions.test.ts b/src/http/openai-chat-completions.test.ts index 4eb6954d..1107793a 100644 --- a/src/http/openai-chat-completions.test.ts +++ b/src/http/openai-chat-completions.test.ts @@ -479,6 +479,218 @@ describe("POST /v1/chat/completions (streaming)", () => { }); }); +/** + * A steer accepted mid-turn but never shown to the model must not + * evaporate when the turn closes. `runTurn` hands it back on + * `RunTurnResult.undelivered`; these pin that this route consumes it — + * on the response where one can be carried, and in the undelivered + * store always, because the host that sent the steer is generally not + * the one holding this response. + */ +describe("POST /v1/chat/completions undelivered steers", () => { + function instantReply(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, + }; + } + + /** + * Steer from inside the final inference. The loop drains at the top + * of a step and this turn replies on the step already running, so no + * later boundary exists to drain it. `### respond` identifies the + * agent step — the recall/reflection helper prompts run on the same + * session id before the loop's first drain. + */ + function steeringLlama(sessionIdRef: { current: string | null }, text: string) { + let steered = false; + return async (params: { + sessionId: string; + prompt: string; + steer?: (sessionId: string, text: string) => boolean; + }): Promise => { + if ( + !steered && + params.sessionId === sessionIdRef.current && + params.prompt.includes("### respond") + ) { + steered = true; + params.steer?.(params.sessionId, text); + } + return instantReply("done"); + }; + } + + it("reports the stranded steer on the completion body and parks it", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "stop and summarise"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + undelivered_steers?: Array<{ seq: number; text: string; parked_at: number }>; + }; + expect(body.undelivered_steers).toEqual([ + { + seq: expect.any(Number) as unknown as number, + text: "stop and summarise", + parked_at: expect.any(Number) as unknown as number, + }, + ]); + // The same entry, not a second copy of the message: acking the + // seq the body reported clears exactly this one. + const parked = harness.handle.undeliveredSteers.list(session.id); + expect(parked.map((e) => e.seq)).toEqual( + body.undelivered_steers?.map((e) => e.seq), + ); + expect(harness.handle.undeliveredSteers.ack(session.id, parked[0]!.seq)).toBe(1); + } finally { + await harness.cleanup(); + } + }); + + it("omits the field entirely when the turn delivered everything", async () => { + const harness = await startTestHarness({ + llamaComplete: scriptedLlama(["hi back"]), + }); + try { + const response = await postChat(harness.baseUrl, { + messages: [{ role: "user", content: "hello" }], + }); + const body = (await response.json()) as Record; + expect("undelivered_steers" in body).toBe(false); + } finally { + await harness.cleanup(); + } + }); + + it("parks it even when the turn fails and the body is an error envelope", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + let failed = false; + const harness = await startTestHarness({ + llamaComplete: async (params) => { + if ( + params.sessionId === sessionIdRef.current && + params.prompt.includes("### respond") + ) { + steerFn?.(params.sessionId, "stop, the branch is wrong"); + failed = true; + throw new Error("llama backend exploded"); + } + return instantReply("done"); + }, + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + expect(failed).toBe(true); + expect(response.status).toBe(500); + // Nothing on the wire could carry it, so the store is the only + // place it can be — and it is there. + expect( + harness.handle.undeliveredSteers.list(session.id).map((e) => e.text), + ).toEqual(["stop, the branch is wrong"]); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([]); + } finally { + await harness.cleanup(); + } + }); + + it("emits a steer_undelivered SSE event to extension clients", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "abort the deploy"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat( + harness.baseUrl, + { + stream: true, + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }, + { [EXTENSIONS_HEADER]: "1" }, + ); + const text = await readAllText(response); + expect(text).toMatch(/event: steer_undelivered\n/); + expect(text).toMatch(/"text":"abort the deploy"/); + expect(text).toMatch(/data: \[DONE\]/); + expect( + harness.handle.undeliveredSteers.list(session.id).map((e) => e.text), + ).toEqual(["abort the deploy"]); + } finally { + await harness.cleanup(); + } + }); + + it("keeps the vanilla stream clean and leaves the message to be polled", async () => { + const sessionIdRef = { current: null as string | null }; + let steerFn: ((sessionId: string, text: string) => boolean) | null = null; + const base = steeringLlama(sessionIdRef, "abort the deploy"); + const harness = await startTestHarness({ + llamaComplete: (params) => + base({ ...params, ...(steerFn ? { steer: steerFn } : {}) }), + }); + try { + steerFn = (id, text) => harness.runtime.steer(id, text); + const session = harness.runtime.createSession(); + sessionIdRef.current = session.id; + const response = await postChat(harness.baseUrl, { + stream: true, + session_id: session.id, + messages: [{ role: "user", content: "go" }], + }); + const text = await readAllText(response); + expect(text).not.toMatch(/event: steer_undelivered\n/); + // Not on this stream, but not lost either: the host reads it off + // `GET /api/sessions/{id}/steer`. + const listed = await fetch( + `${harness.baseUrl}/api/sessions/${session.id}/steer`, + ); + const body = (await listed.json()) as { + undelivered: Array<{ text: string }>; + }; + expect(body.undelivered.map((e) => e.text)).toEqual(["abort the deploy"]); + } finally { + await harness.cleanup(); + } + }); +}); + async function readAllText(response: Response): Promise { if (!response.body) return ""; const reader = response.body.getReader(); diff --git a/src/http/openai-chat-completions.ts b/src/http/openai-chat-completions.ts index 148d9b72..d8f83112 100644 --- a/src/http/openai-chat-completions.ts +++ b/src/http/openai-chat-completions.ts @@ -20,6 +20,7 @@ import { type SseWriter, } from "./request-context.js"; import { deriveChatSessionId } from "./openai-session-id.js"; +import type { UndeliveredSteer } from "./undelivered-steers.js"; import { buildFinalAssistantPayload, buildStreamChunk, @@ -127,6 +128,7 @@ async function handleNonStream( // `transport`/`grammar`/`model`/`tool`/`cancelled`) come back as // `result.session.status === "failed"` instead of throwing — see // the next block. + parkUndelivered(ctx, env.session.id, null); const message = err instanceof Error ? err.message : String(err); sendError( res, @@ -135,10 +137,13 @@ async function handleNonStream( ); return; } + const parked = parkUndelivered(ctx, result.session.id, result); if (result.session.status === "failed") { // Surface classified LLM failures as HTTP 500 (matches the legacy // `runTurn`-throws contract that OpenAI clients depend on; an empty // body with finish_reason="stop" would silently strand the caller). + // The error envelope has no room for the parked steers; they stay + // in the store for `GET /api/sessions/{id}/steer`. sendError( res, 500, @@ -165,6 +170,14 @@ async function handleNonStream( }, ], usage, + // Present only when this turn stranded a steer, so an ordinary + // completion stays byte-identical for OpenAI clients. These are the + // parked entries, not copies of them: same `seq`, so acting on this + // list and acking it at `DELETE /api/sessions/{id}/steer?through=` + // is acting on one message, not two. + ...(parked.length > 0 + ? { undelivered_steers: parked.map(toWirePayload) } + : {}), }; sendJson(res, 200, payload, { [SESSION_ID_HEADER]: result.session.id, @@ -236,6 +249,11 @@ async function handleStream( ctx.completionRegistry.unregister(env.completionId); } + // Park before anything can end the stream: on the error paths below, + // and whenever the client is gone, the store is the only place a + // stranded steer can still be found. + const parked = parkUndelivered(ctx, env.session.id, result); + if (!error && result?.session.status === "failed") { error = new Error( `Agent loop failed: ${result.session.lastError ?? "unknown error"}`, @@ -260,6 +278,20 @@ async function handleStream( const usage = buildUsagePayload(result!); const final = buildFinalAssistantPayload(result!); + if (parked.length > 0 && env.request.extensionsEnabled) { + // Same name and meaning as the sidecar's `steer_undelivered` + // event. Extensions-off clients get nothing here — the stream stays + // strict OpenAI — and read the parked entries off + // `GET /api/sessions/{id}/steer` instead. + sse.writeEvent("steer_undelivered", { + id: env.completionId, + object: "chat.completion.steer_undelivered", + created: env.created, + model: env.request.model, + session_id: result!.session.id, + undelivered: parked.map(toWirePayload), + }); + } if (env.request.extensionsEnabled) { sse.writeEvent("usage", { id: env.completionId, @@ -361,6 +393,18 @@ function buildStreamEventHook( } return; } + if (event.type === "steer_applied") { + // Hosts that can observe failure (`steer_undelivered`) deserve the + // success signal too, or they can never render a steer inline. + if (env.request.extensionsEnabled) { + sse.writeEvent(null, { + object: "atomic.steer_applied", + text: event.text, + step_index: event.stepIndex, + }); + } + return; + } if (event.type === "loop_failed") { emitStreamError(sse, env, event.error.message, event.category); } @@ -398,6 +442,43 @@ function emitStreamError( ); } +/** + * Consume `RunTurnResult.undelivered` — the steers this turn accepted + * but never showed the model — and park them where the host can find + * them. + * + * Both halves matter. The steer arrived on its own `POST + * .../steer` exchange, which answered `200 {steered:true}` long before + * the turn ended, so this response is the first chance to say anything + * about it at all; and this response goes to whoever owns the turn, + * which is not necessarily whoever sent the steer. Parking is therefore + * unconditional and the response payload is a fast path on top of it, + * carrying the very entries that were parked rather than a second copy. + * + * `result` is `null` when `runTurn` threw. The inbox is deliberately NOT + * touched then: on the window core the loop's own `finally` already + * closed this turn's window and logged anything stranded — and a request + * that failed BEFORE acquiring the session lock (a queued submission + * whose client disconnected) never owned the window at all, so a drain + * here would steal steers accepted for the turn still running. + */ +function parkUndelivered( + ctx: HandlerContext, + sessionId: string, + result: RunTurnResult | null, +): UndeliveredSteer[] { + const texts = result ? (result.undelivered ?? []) : []; + return ctx.undeliveredSteers.park(sessionId, texts); +} + +function toWirePayload(entry: UndeliveredSteer): { + seq: number; + text: string; + parked_at: number; +} { + return { seq: entry.seq, text: entry.text, parked_at: entry.parkedAt }; +} + function safeStringify(value: unknown): string { try { return JSON.stringify(value); diff --git a/src/http/request-context.ts b/src/http/request-context.ts index 289a9ccc..93f3b009 100644 --- a/src/http/request-context.ts +++ b/src/http/request-context.ts @@ -3,6 +3,7 @@ import { openaiError, type OpenAiErrorPayload } from "./openai-errors.js"; import type { AgentRuntime } from "../runtime/bootstrap.js"; import type { ApprovalBus } from "./approval-bus.js"; import type { CompletionRegistry } from "./completion-registry.js"; +import type { UndeliveredSteerStore } from "./undelivered-steers.js"; /** * Small, dependency-free helpers that every HTTP route needs. Kept in @@ -22,6 +23,12 @@ export interface HandlerContext { params: Record; approvalBus: ApprovalBus; completionRegistry: CompletionRegistry; + /** + * Where a steer that the runtime accepted but never delivered ends + * up. Written by whichever route ran the turn, read by + * `GET /api/sessions/{id}/steer`. + */ + undeliveredSteers: UndeliveredSteerStore; } export type HttpHandler = ( diff --git a/src/http/route-sessions.test.ts b/src/http/route-sessions.test.ts index 82424173..b7478c89 100644 --- a/src/http/route-sessions.test.ts +++ b/src/http/route-sessions.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CompletionResult } from "../llm/llama-server-client.js"; + import { startTestHarness, type Harness } from "./test-harness.js"; +import { MAX_PARKED_STEERS } from "./undelivered-steers.js"; describe("/api/sessions", () => { let harness: Harness; @@ -59,3 +62,404 @@ 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 () => { + // A real turn opens the steering window on entry; on the current + // core the raw session lock alone does not make a session + // steerable — the window is the one acceptance fact. + harness.runtime.steeringInbox.open(sessionId); + 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("lets runtime.steer decide instead of pre-checking isBusy", async () => { + const session = harness.runtime.createSession(); + // Idle by every reading the controller can offer — a + // `turnController.isBusy` gate in the route would 409 here without + // ever asking the runtime. `isBusy` and "a step boundary is still + // coming" are different facts that expire at different moments, so + // the runtime's answer is the only one worth acting on. + expect(harness.runtime.turnController.isBusy(session.id)).toBe(false); + const seen: Array<[string, string]> = []; + harness.runtime.steer = (id, text) => { + seen.push([id, text]); + return true; + }; + const response = await steer(session.id, { text: "the runtime says yes" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + steered: true, + sessionId: session.id, + }); + expect(seen).toEqual([[session.id, "the runtime says yes"]]); + }); + + 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); + }); +}); + + +/** + * The other half of the steering promise: `200 {steered:true}` is + * acceptance, not delivery, and the surface has to say so when the turn + * ends without ever reading the message. Every steer here goes in + * through the real `POST /api/sessions/{id}/steer` route while a real + * turn holds the session lock — the loss this pins is the one a host + * actually hits. + */ +describe("GET|DELETE /api/sessions/{id}/steer (undelivered)", () => { + let harness: Harness; + let sessionId: string; + let steerStatus: number | null; + /** What the stub steers from inside the final inference, in order. */ + let steerTexts: string[]; + + function replyCompletion(text: string): CompletionResult { + return { + content: JSON.stringify({ tool: "reply", args: { text } }), + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 0, predictedMs: 0, promptTokens: 4, predictedTokens: 2 }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }; + } + + beforeEach(async () => { + steerStatus = null; + steerTexts = ["stop, summarise what you have instead"]; + harness = await startTestHarness({ + // Steer from inside the FINAL inference. The loop drains at the + // top of a step; this turn replies on the step already running, + // so no later boundary exists to drain it and the message comes + // back on `RunTurnResult.undelivered`. + llamaComplete: async ({ sessionId: turnSession, prompt }) => { + // `### respond` marks a real agent step; the recall / reflection + // helper prompts run on the same session id BEFORE the loop's + // first drain, and steering from one of those would be + // delivered normally instead of stranded. + const agentStep = prompt.includes("### respond"); + if (turnSession === sessionId && agentStep && steerStatus === null) { + for (const text of steerTexts) { + const accepted = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text }), + }, + ); + steerStatus = accepted.status; + } + } + return replyCompletion("done"); + }, + }); + sessionId = harness.runtime.createSession({ + metadata: { source: "undelivered" }, + }).id; + }); + + afterEach(async () => { + await harness.cleanup(); + }); + + /** Run one turn that strands the steer, and assert it really did. */ + async function runStrandingTurn(): Promise { + const completion = await fetch(`${harness.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + messages: [{ role: "user", content: "go" }], + }), + }); + expect(completion.status).toBe(200); + await completion.json(); + // The POST really was accepted — this is the `200 {steered:true}` + // whose message used to be able to vanish. + expect(steerStatus).toBe(200); + // And the inbox is empty: `flushSteering` swept it on the way out, + // so the text exists nowhere but the undelivered store. + expect(harness.runtime.steeringInbox.peek(sessionId)).toEqual([]); + } + + async function listUndelivered(): Promise<{ + sessionId: string; + undelivered: Array<{ seq: number; text: string; parkedAt: number }>; + discarded: number; + }> { + const response = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer`, + ); + expect(response.status).toBe(200); + return (await response.json()) as { + sessionId: string; + undelivered: Array<{ seq: number; text: string; parkedAt: number }>; + discarded: number; + }; + } + + it("surfaces a steer the turn accepted but never delivered", async () => { + await runStrandingTurn(); + const body = await listUndelivered(); + expect(body.sessionId).toBe(sessionId); + expect(body.undelivered.map((e) => e.text)).toEqual(steerTexts); + expect(body.undelivered[0]?.seq).toBeGreaterThan(0); + expect(body.discarded).toBe(0); + }); + + it("returns nothing for a session whose turns delivered everything", async () => { + const other = harness.runtime.createSession(); + const response = await fetch( + `${harness.baseUrl}/api/sessions/${other.id}/steer`, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + sessionId: other.id, + undelivered: [], + discarded: 0, + }); + }); + + it("does not consume on read — a retried GET still finds the message", async () => { + await runStrandingTurn(); + const first = await listUndelivered(); + const second = await listUndelivered(); + expect(second.undelivered).toEqual(first.undelivered); + }); + + it("lets the host resend the message and then ack it", async () => { + await runStrandingTurn(); + const parked = await listUndelivered(); + const entry = parked.undelivered[0]!; + + // The resend is an ordinary completion carrying the parked text. + steerStatus = -1; // stop the stub steering the resend turn as well + const resend = await fetch(`${harness.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + messages: [{ role: "user", content: entry.text }], + }), + }); + expect(resend.status).toBe(200); + const transcript = harness.runtime.sessionStore.load(sessionId); + expect( + transcript?.turns.some( + (turn) => turn.kind === "user" && turn.text === entry.text, + ), + ).toBe(true); + + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${entry.seq}`, + { method: "DELETE" }, + ); + expect(acked.status).toBe(200); + expect(await acked.json()).toEqual({ + sessionId, + acked: 1, + remaining: 0, + discardsAcked: 0, + discarded: 0, + }); + expect((await listUndelivered()).undelivered).toEqual([]); + }); + + it("acks by cursor, so a steer parked after the read survives", async () => { + await runStrandingTurn(); + const seen = (await listUndelivered()).undelivered[0]!; + // A second turn strands another message between the read and the + // ack. A bare "clear" would swallow it unseen. + steerStatus = null; + steerTexts = ["and cancel the deploy"]; + await runStrandingTurn(); + + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${seen.seq}`, + { method: "DELETE" }, + ); + expect((await acked.json()) as unknown).toEqual({ + sessionId, + acked: 1, + remaining: 1, + discardsAcked: 0, + discarded: 0, + }); + const left = await listUndelivered(); + expect(left.undelivered.map((e) => e.text)).toEqual([ + "and cancel the deploy", + ]); + }); + + it("keeps the discard notice when the host acks the entries it was shown", async () => { + // Fill the parking lot in one turn, then strand one more so the + // per-session cap genuinely has to throw a message away. + steerTexts = Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`); + await runStrandingTurn(); + steerStatus = null; + steerTexts = ["one too many"]; + await runStrandingTurn(); + + const listed = await listUndelivered(); + expect(listed.undelivered).toHaveLength(MAX_PARKED_STEERS); + expect(listed.discarded).toBe(1); + + // The host acks the highest seq it was given — which is all it can + // do about the entries, and says nothing about the loss count. + const acked = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?through=${listed.undelivered.at(-1)!.seq}`, + { method: "DELETE" }, + ); + expect((await acked.json()) as unknown).toEqual({ + sessionId, + acked: MAX_PARKED_STEERS, + remaining: 0, + discardsAcked: 0, + discarded: 1, + }); + + // ...and is still told a message was genuinely lost, rather than + // being shown `discarded: 0` for a session that dropped one. + const after = await listUndelivered(); + expect(after.undelivered).toEqual([]); + expect(after.discarded).toBe(1); + + // The counter clears only through its own ack. + const ackedDiscard = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer?discarded=1`, + { method: "DELETE" }, + ); + expect((await ackedDiscard.json()) as unknown).toEqual({ + sessionId, + acked: 0, + remaining: 0, + discardsAcked: 1, + discarded: 0, + }); + expect((await listUndelivered()).discarded).toBe(0); + }); + + it("rejects an ack without a usable cursor", async () => { + await runStrandingTurn(); + for (const query of [ + "", + "?through=", + "?through=abc", + "?through=-1", + "?discarded=", + "?discarded=abc", + "?discarded=-1", + ]) { + const response = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}/steer${query}`, + { method: "DELETE" }, + ); + expect(response.status).toBe(400); + } + expect((await listUndelivered()).undelivered).toHaveLength(1); + }); + + it("drops parked steers when the session itself is purged", async () => { + await runStrandingTurn(); + expect((await listUndelivered()).undelivered).toHaveLength(1); + const deleted = await fetch( + `${harness.baseUrl}/api/sessions/${sessionId}`, + { method: "DELETE" }, + ); + expect(deleted.status).toBe(200); + expect((await listUndelivered()).undelivered).toEqual([]); + }); +}); diff --git a/src/http/route-sessions.ts b/src/http/route-sessions.ts index ad3ddca7..709ae1a6 100644 --- a/src/http/route-sessions.ts +++ b/src/http/route-sessions.ts @@ -1,5 +1,11 @@ +import { MAX_PENDING_STEERS } from "../runtime/steering-inbox.js"; 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 +67,230 @@ 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 no + * running turn will pick the message up 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. + * + * `runtime.steer` decides, and this route only translates. There is no + * `turnController.isBusy` pre-check: "busy" and "a step boundary is + * still coming" stop being true at different moments (the loop's final + * drain happens inside `runTurn`, `busy.delete` later in the + * controller's `finally`), so gating on it would reject steers the + * runtime would have accepted. The inbox is consulted only after a + * refusal, to choose between 409 and 429. + * + * `200 {steered:true}` means accepted, **not** delivered: the loop + * drains the inbox at step boundaries, and a turn can end before the + * next one. Anything left over is parked, not dropped — the turn hands + * it back on `RunTurnResult.undelivered` and the route that ran the + * turn puts it in the undelivered store, where + * `GET /api/sessions/{id}/steer` finds it. That is the HTTP half of the + * same promise the sidecar keeps with its `steer_undelivered` event. + */ +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.steer(id, text)) { + // `steer()` is the only authority on whether the message landed, + // and it already refused. The inbox read below only *names* the + // refusal for the status code — it never gates the attempt, so a + // stale read here can at worst mislabel a message that was + // definitively not queued, where a pre-check could have rejected + // one the runtime would have taken. + const inboxFull = + ctx.runtime.steeringInbox.peek(id).length >= MAX_PENDING_STEERS; + if (inboxFull) { + sendError( + res, + 429, + openaiError( + `steering inbox for session ${id} is full — the running turn has not consumed the pending messages yet`, + ), + ); + return; + } + sendError( + res, + 409, + openaiError( + `session ${id} has no turn accepting steers — send the message with POST /v1/chat/completions instead`, + ), + ); + return; + } + sendJson(res, 200, { steered: true, sessionId: id }); + }; +} + +/** + * `GET /api/sessions/{id}/steer` — the messages this session's turns + * accepted for steering but never delivered. + * + * A steer that arrives during the final inference, or into a turn that + * is cancelled before its next step, is handed back when the turn ends; + * by then the `POST` that accepted it has long since answered, so the + * server parks it here. Polling this endpoint is how a host that only + * ever spoke to `POST .../steer` detects the loss; re-sending is a + * normal `POST /v1/chat/completions`. + * + * Reads do not consume. A retried or prefetched `GET` must not be able + * to lose a message — that is the bug this whole path exists to + * prevent. Acknowledge with `DELETE /api/sessions/{id}/steer?through=` + * once the text is safely somewhere else. + * + * `discarded` counts messages this session lost to the per-session cap + * (`MAX_PARKED_STEERS`) because nobody acked in time. Non-zero means + * text is genuinely gone, and the host is told rather than left to + * assume the list is complete. It has its own ack + * (`DELETE ...?discarded={n}`): acking the listed entries leaves it + * standing, so a host that acks first and reads later still sees the + * loss. + */ +export function createGetUndeliveredSteersHandler(): HttpHandler { + return async (_req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + const undelivered = ctx.undeliveredSteers.list(id); + sendJson(res, 200, { + sessionId: id, + undelivered: undelivered.map((entry) => ({ + seq: entry.seq, + text: entry.text, + parkedAt: entry.parkedAt, + })), + discarded: ctx.undeliveredSteers.discarded(id), + }); + }; +} + +/** + * `DELETE /api/sessions/{id}/steer?through={seq}&discarded={n}` — + * acknowledge what a prior `GET` reported. Both parameters are + * optional individually; at least one must be present. + * + * `through` acks parked steers up to and including `seq`. The cursor is + * mandatory rather than a bare "clear it" because a bare clear would + * also drop whatever was parked between the caller's `GET` and this + * call, which is a message the host never saw. Anything parked since + * carries a higher `seq` and survives. + * + * `discarded` acks up to `n` of the messages the per-session cap threw + * away. It is a **separate** ack, and for the same reason the cursor + * exists: those messages have no `seq` the host was ever shown, so the + * entry cursor cannot stand in for having read the loss count. Acking + * the entries alone leaves `discarded` reporting the loss on the next + * `GET` instead of quietly resetting it to zero. Counting rather than + * clearing keeps discards that happened since the host's `GET` + * outstanding. + * + * Idempotent — re-acking an already-acked cursor or count reports `0`. + * The response repeats the loss still outstanding as `discarded`, so a + * host that only ever calls `DELETE` still learns about it. + */ +export function createAckUndeliveredSteersHandler(): HttpHandler { + return async (req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + const url = new URL(req.url ?? "/", "http://localhost"); + const rawThrough = url.searchParams.get("through"); + const rawDiscarded = url.searchParams.get("discarded"); + if (rawThrough === null && rawDiscarded === null) { + sendError( + res, + 400, + openaiError( + "through and/or discarded is required — use the highest seq and the discarded count returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const through = parseCount(rawThrough); + if (through === null) { + sendError( + res, + 400, + openaiError( + "through must be a non-negative integer — use the highest seq returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const discarded = parseCount(rawDiscarded); + if (discarded === null) { + sendError( + res, + 400, + openaiError( + "discarded must be a non-negative integer — use the discarded count returned by GET /api/sessions/{id}/steer", + ), + ); + return; + } + const acked = + through === undefined ? 0 : ctx.undeliveredSteers.ack(id, through); + const discardsAcked = + discarded === undefined + ? 0 + : ctx.undeliveredSteers.ackDiscarded(id, discarded); + sendJson(res, 200, { + sessionId: id, + acked, + remaining: ctx.undeliveredSteers.list(id).length, + discardsAcked, + discarded: ctx.undeliveredSteers.discarded(id), + }); + }; +} + +/** + * `undefined` when the parameter was absent, `null` when it was present + * but not a non-negative integer (the caller turns that into a 400). + */ +function parseCount(raw: string | null): number | undefined | null { + if (raw === null) return undefined; + // Whole-string digits only: `parseInt` would silently truncate + // `12abc` to 12 and `1e9` to 1, acking through the wrong cursor. + if (!/^\d+$/.test(raw)) return null; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return parsed; +} + /** * `DELETE /api/sessions/{id}` — purge the session row. Idempotent: * returns 200 whether or not the row existed so orchestrators can @@ -74,6 +304,10 @@ export function createDeleteSessionHandler(): HttpHandler { return; } ctx.runtime.sessionStore.delete(id); + // Purging the session takes its parked steers with it: they are + // messages for a conversation the caller just said it is done with, + // and leaving them would strand rows nobody will ever ack. + ctx.undeliveredSteers.clear(id); sendJson(res, 200, { deleted: true, id }); }; } diff --git a/src/http/route-table.ts b/src/http/route-table.ts index 93e9cf1f..c0beffe0 100644 --- a/src/http/route-table.ts +++ b/src/http/route-table.ts @@ -16,9 +16,12 @@ import { createUninstallSkillHandler, } from "./route-skills.js"; import { + createAckUndeliveredSteersHandler, createDeleteSessionHandler, createGetSessionHandler, + createGetUndeliveredSteersHandler, createListSessionsHandler, + createSteerSessionHandler, } from "./route-sessions.js"; import { createApprovalEventsHandler, @@ -61,6 +64,21 @@ 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: "GET", + path: "/api/sessions/{id}/steer", + handler: createGetUndeliveredSteersHandler(), + }, + { + method: "DELETE", + path: "/api/sessions/{id}/steer", + handler: createAckUndeliveredSteersHandler(), + }, { 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/http/undelivered-steers.test.ts b/src/http/undelivered-steers.test.ts new file mode 100644 index 00000000..99525ff4 --- /dev/null +++ b/src/http/undelivered-steers.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_PARKED_SESSIONS, + MAX_PARKED_STEERS, + UndeliveredSteerStore, +} from "./undelivered-steers.js"; + +/** + * The store behind `GET /api/sessions/{id}/steer`. Pins the properties + * the route promises: reading never consumes, acking is by cursor so a + * message parked between the read and the ack cannot be swallowed + * unseen, the loss counter is not collateral damage of that ack, and a + * hand-back is returned whole. + */ +describe("UndeliveredSteerStore", () => { + it("keeps parked messages until they are acked", () => { + const store = new UndeliveredSteerStore(); + const parked = store.park("s1", ["stop", "do X instead"]); + expect(parked.map((e) => e.text)).toEqual(["stop", "do X instead"]); + expect(store.list("s1")).toHaveLength(2); + // Reading twice returns the same rows — a retried GET is safe. + expect(store.list("s1")).toHaveLength(2); + expect(store.ack("s1", parked[1]!.seq)).toBe(2); + expect(store.list("s1")).toEqual([]); + }); + + it("isolates sessions", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["for one"]); + store.park("s2", ["for two"]); + store.ack("s1", Number.MAX_SAFE_INTEGER); + expect(store.list("s1")).toEqual([]); + expect(store.list("s2").map((e) => e.text)).toEqual(["for two"]); + }); + + it("acks by cursor, so anything parked after the read survives", () => { + const store = new UndeliveredSteerStore(); + const seen = store.park("s1", ["first"]); + const later = store.park("s1", ["arrived after the GET"]); + expect(store.ack("s1", seen[0]!.seq)).toBe(1); + expect(store.list("s1").map((e) => e.seq)).toEqual([later[0]!.seq]); + }); + + it("is a no-op for an empty hand-back and for an unknown session", () => { + const store = new UndeliveredSteerStore(); + expect(store.park("s1", [])).toEqual([]); + expect(store.list("s1")).toEqual([]); + expect(store.ack("nope", 10)).toBe(0); + expect(store.discarded("nope")).toBe(0); + }); + + it("counts what the per-session cap discards instead of quietly shortening the list", () => { + const store = new UndeliveredSteerStore(); + // The cap bites across hand-backs: fill it, then strand three more. + const first = Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`); + store.park("s1", first); + const second = store.park("s1", ["late-1", "late-2", "late-3"]); + expect(store.list("s1")).toHaveLength(MAX_PARKED_STEERS); + expect(store.discarded("s1")).toBe(3); + // The three oldest went; what the latest caller is told it can + // retrieve matches what is actually retrievable. + expect(second.map((e) => e.text)).toEqual(["late-1", "late-2", "late-3"]); + expect(store.list("s1")[0]?.text).toBe("m3"); + expect(store.list("s1").at(-1)?.text).toBe("late-3"); + }); + + it("hands a batch back whole even when it alone exceeds the cap", () => { + const store = new UndeliveredSteerStore(); + const texts = Array.from({ length: MAX_PARKED_STEERS + 3 }, (_, i) => `m${i}`); + const parked = store.park("s1", texts); + // The return value IS the hand-back — it becomes + // `undelivered_steers` on the response — so trimming it would drop + // the oldest messages out of the one payload meant to carry them. + expect(parked.map((e) => e.text)).toEqual(texts); + // And everything returned is retrievable, so a host that only reads + // `GET .../steer` sees the same set. + expect(store.list("s1").map((e) => e.text)).toEqual(texts); + expect(store.discarded("s1")).toBe(0); + }); + + it("evicts earlier entries, never the batch it was just handed", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["old-1", "old-2"]); + const oversized = Array.from( + { length: MAX_PARKED_STEERS + 1 }, + (_, i) => `n${i}`, + ); + const parked = store.park("s1", oversized); + expect(parked.map((e) => e.text)).toEqual(oversized); + expect(store.list("s1").map((e) => e.text)).toEqual(oversized); + expect(store.discarded("s1")).toBe(2); + }); + + it("keeps the loss counter when the host acks the entries it was shown", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park("s1", ["late-1", "late-2", "late-3"]); + expect(store.discarded("s1")).toBe(3); + // Acking the highest seq in the listing is what a host does first; + // the discarded messages were never in that listing and have no + // seq it could point at, so this must not clear them. + const listed = store.list("s1"); + store.ack("s1", listed.at(-1)!.seq); + expect(store.list("s1")).toEqual([]); + expect(store.discarded("s1")).toBe(3); + // The box survives the entry ack precisely so the counter can. + expect(store.trackedSessions).toBe(1); + }); + + it("clears the loss counter only by its own ack, and reclaims the box then", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park("s1", ["late-1", "late-2", "late-3"]); + store.ack("s1", store.list("s1").at(-1)!.seq); + // By count, not by flag: a partial ack leaves the rest outstanding, + // so discards that happened after the host's GET are not cleared + // unseen. + expect(store.ackDiscarded("s1", 1)).toBe(1); + expect(store.discarded("s1")).toBe(2); + expect(store.trackedSessions).toBe(1); + // Over-acking clamps rather than going negative, and is idempotent. + expect(store.ackDiscarded("s1", 99)).toBe(2); + expect(store.ackDiscarded("s1", 99)).toBe(0); + expect(store.discarded("s1")).toBe(0); + expect(store.trackedSessions).toBe(0); + }); + + it("still reclaims a discard-only box on purge and on session eviction", () => { + const store = new UndeliveredSteerStore(); + const overflow = (id: string): void => { + store.park(id, Array.from({ length: MAX_PARKED_STEERS }, (_, i) => `m${i}`)); + store.park(id, ["one too many"]); + store.ack(id, Number.MAX_SAFE_INTEGER); + }; + overflow("purged"); + expect(store.discarded("purged")).toBe(1); + store.clear("purged"); + expect(store.discarded("purged")).toBe(0); + expect(store.trackedSessions).toBe(0); + + // A host that never acks anything cannot pin boxes open forever: + // the session cap still evicts the oldest. + overflow("stale"); + for (let i = 0; i < MAX_PARKED_SESSIONS; i += 1) { + store.park(`s${i}`, ["x"]); + } + expect(store.trackedSessions).toBe(MAX_PARKED_SESSIONS); + expect(store.discarded("stale")).toBe(0); + }); + + it("forgets a session on clear", () => { + const store = new UndeliveredSteerStore(); + store.park("s1", ["gone with the session"]); + store.clear("s1"); + expect(store.list("s1")).toEqual([]); + store.park("s2", ["x"]); + store.clearAll(); + expect(store.list("s2")).toEqual([]); + }); +}); diff --git a/src/http/undelivered-steers.ts b/src/http/undelivered-steers.ts new file mode 100644 index 00000000..ac1349a9 --- /dev/null +++ b/src/http/undelivered-steers.ts @@ -0,0 +1,216 @@ +import { MAX_PENDING_STEERS } from "../runtime/steering-inbox.js"; + +/** + * Parking lot for steering messages a turn handed back on + * `RunTurnResult.undelivered`. + * + * `POST /api/sessions/{id}/steer` answers `200 {steered:true}` as soon + * as the message is in the inbox, but acceptance is not delivery: a + * steer that lands during the final inference — or into a turn that is + * cancelled before its next step — comes back undelivered when the turn + * closes, and `AgentLoop.flushSteering` empties the inbox as it reads. + * The steer was its own HTTP exchange whose response was written long + * before that, so there is nowhere to hand the text back to unless the + * server keeps it. This store is that "somewhere": it is what makes the + * HTTP surface hold the same invariant as the sidecar's + * `steer_undelivered` event — the message you sent always goes + * somewhere the host can see. + * + * Retrieval is deliberately **non-destructive**. `GET` lists, `DELETE` + * acks by sequence number. A consuming read would lose the message to + * any retried or prefetched request, which is the exact failure mode + * this store exists to prevent; and because the ack carries a cursor + * taken from the listing, a steer parked between the two calls has a + * higher `seq` and survives the ack. + * + * The same reasoning applies to the loss counter. `discarded` is the + * "N messages are gone" signal, and it is **not** covered by the entry + * cursor — the discarded messages have no `seq` the host ever saw. It + * therefore has its own ack ({@link UndeliveredSteerStore.ackDiscarded}) + * and keeps a session's box alive on its own, so acking the entries + * cannot silently take the loss notice with them. + * + * Single-process, in-memory, one instance per HTTP server. Parked + * messages do not survive a restart — neither does the inbox they came + * from (`shutdown()` calls `SteeringInbox.clearAll`). + */ +export interface UndeliveredSteer { + /** Monotonic within one store. The ack cursor for `DELETE`. */ + seq: number; + text: string; + /** Epoch ms at which the turn handed the message back. */ + parkedAt: number; +} + +/** + * Per-session cap on **accumulation**, not on one hand-back. The inbox + * refuses past `MAX_PENDING_STEERS`, so a single turn cannot strand + * more than that; the cap bites when several turns strand messages and + * nobody ever acks. Past it the oldest entries go — and `discarded` + * counts them, so a host that comes back late learns it lost some + * instead of quietly seeing a short list. + * + * A single `park` batch is never trimmed, even if it alone exceeds this + * number: those entries are being handed back on a live response, and + * dropping them there would omit them from the one message that was + * supposed to carry them. See {@link UndeliveredSteerStore.park}. + */ +export const MAX_PARKED_STEERS = MAX_PENDING_STEERS; + +/** + * Cap on tracked sessions. Long-lived servers see unboundedly many + * session ids; the oldest box is evicted first (Map insertion order). + */ +export const MAX_PARKED_SESSIONS = 256; + +interface Box { + entries: UndeliveredSteer[]; + /** + * Messages lost to {@link MAX_PARKED_STEERS} that the host has not + * acknowledged yet. Counts down through `ackDiscarded`, never through + * the entry cursor: the two are separate acks because they carry + * separate information. + */ + discarded: number; +} + +export class UndeliveredSteerStore { + private nextSeq = 1; + private readonly bySession = new Map(); + + /** + * Take ownership of everything a turn could not deliver. Returns + * **the whole batch** — the same objects, with the same `seq`, that + * `list` will report — so the caller can mirror it onto a live + * response without that becoming a second copy of the message. + * + * Every entry returned here is retrievable until it is acked. That + * matters because the return value *is* the hand-back: it becomes + * `undelivered_steers` on the completion body and the + * `steer_undelivered` SSE frame. Returning only the survivors of the + * cap would omit the oldest messages from the very response that + * exists to give them back, leaving nothing behind but a counter — + * so the cap never trims the batch it was just handed. It evicts only + * entries parked by *earlier* calls, which the host has already been + * told about once and can still see on `GET`. + */ + park(sessionId: string, texts: readonly string[]): UndeliveredSteer[] { + if (texts.length === 0) return []; + const box = this.bySession.get(sessionId) ?? { entries: [], discarded: 0 }; + const parkedAt = Date.now(); + const parked = texts.map((text) => ({ + seq: this.nextSeq++, + text, + parkedAt, + })); + box.entries.push(...parked); + // `capacity >= parked.length`, so `overflow` can never reach into + // the batch that was just pushed — only into what was already here. + // One turn cannot hand back more than `MAX_PENDING_STEERS` anyway + // (the inbox refuses past it), so the wider capacity is a bound the + // caller has to breach deliberately, not a hole in the cap. + const capacity = Math.max(MAX_PARKED_STEERS, parked.length); + const overflow = box.entries.length - capacity; + if (overflow > 0) { + box.discarded += overflow; + box.entries.splice(0, overflow); + } + this.bySession.set(sessionId, box); + this.evictOldestSessions(); + return parked; + } + + /** Non-destructive listing, oldest first. */ + list(sessionId: string): readonly UndeliveredSteer[] { + return this.bySession.get(sessionId)?.entries ?? []; + } + + /** + * How many messages this session lost to `MAX_PARKED_STEERS` and has + * not been acknowledged for. Survives `ack` — see `ackDiscarded`. + */ + discarded(sessionId: string): number { + return this.bySession.get(sessionId)?.discarded ?? 0; + } + + /** + * Drop everything with `seq <= through` and report how many went. + * The cursor comes from a prior `list`, so a message parked in + * between carries a higher `seq` and is not swallowed by the ack. + * + * Deliberately does **not** touch `discarded`. The cursor covers the + * entries the host was shown; the discarded messages were never in + * that listing and have no `seq` the host could point at, so nothing + * about acking the entries proves the loss notice was read. + */ + ack(sessionId: string, through: number): number { + const box = this.bySession.get(sessionId); + if (!box) return 0; + const before = box.entries.length; + box.entries = box.entries.filter((entry) => entry.seq > through); + const acked = before - box.entries.length; + this.reapIfEmpty(sessionId, box); + return acked; + } + + /** + * Acknowledge up to `count` discarded messages and report how many + * that actually cleared. + * + * Separate from `ack` on purpose. A host that acks the highest `seq` + * it was given — before, or in the same pass as, reading `discarded` + * — must not thereby erase the "N messages were dropped" signal and + * be told on its next `GET` that nothing was lost. And it is a count, + * not a flag, so discards that happen between the host's `GET` and + * this call stay outstanding rather than being cleared unseen: the + * same cursor discipline as the entries, applied to a counter. + */ + ackDiscarded(sessionId: string, count: number): number { + const box = this.bySession.get(sessionId); + if (!box) return 0; + const cleared = Math.min(Math.max(count, 0), box.discarded); + box.discarded -= cleared; + this.reapIfEmpty(sessionId, box); + return cleared; + } + + /** Forget one session's parked messages (session purge). */ + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } + + /** Forget everything (server shutdown / tests). */ + clearAll(): void { + this.bySession.clear(); + } + + /** + * How many sessions currently hold a box. Introspection only — the + * seam that lets a test assert a box outlives its entries while a + * loss is unacknowledged, and is reclaimed once it is not. + */ + get trackedSessions(): number { + return this.bySession.size; + } + + /** + * Drop a box that has nothing left to say — no entries and no + * unacknowledged loss — so an idle server does not hold rows for + * sessions nobody is asking about. A box kept alive only by + * `discarded` is still reclaimed by `clear` (session purge) and by + * `MAX_PARKED_SESSIONS` eviction, so this cannot grow without bound. + */ + private reapIfEmpty(sessionId: string, box: Box): void { + if (box.entries.length === 0 && box.discarded === 0) { + this.bySession.delete(sessionId); + } + } + + private evictOldestSessions(): void { + while (this.bySession.size > MAX_PARKED_SESSIONS) { + const oldest = this.bySession.keys().next(); + if (oldest.done) return; + this.bySession.delete(oldest.value); + } + } +} 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/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..0c4130c2 --- /dev/null +++ b/src/llm/provider/model-search.test.ts @@ -0,0 +1,251 @@ +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("context window terms", () => { + // Ids are deliberately digit-free: a row must match on its window, not + // because "1m" happens to be a substring or subsequence of its id. + const WINDOWS: readonly { id: string; entry: ModelCatalogEntry }[] = [ + { id: "vendor/alpha", entry: entry({ contextWindow: 1_000_000 }) }, + { id: "vendor/bravo", entry: entry({ contextWindow: 1_048_576 }) }, + { id: "vendor/charlie", entry: entry({ contextWindow: 1_050_000 }) }, + { id: "vendor/delta", entry: entry({ contextWindow: 1_310_720 }) }, + { id: "vendor/echo", entry: entry({ contextWindow: 2_000_000 }) }, + { id: "vendor/foxtrot", entry: entry({ contextWindow: 131_072 }) }, + { id: "vendor/golf", entry: entry({ contextWindow: 204_800 }) }, + ]; + + it("`1m` finds every roughly-1M window, not only the ones rendered as 1m", () => { + // The bug this pins: the tag used to be the display string alone, so + // only the exactly-1_000_000 row answered to `1m` and an operator + // concluded the 1.0m/1.1m/1.3m rows had no million-token variant. + expect(ids(searchModels(WINDOWS, "1m"))).toEqual([ + "vendor/alpha", + "vendor/bravo", + "vendor/charlie", + "vendor/delta", + ]); + // Floor, not round, and a bucket rather than a `>=` filter: nothing + // under 1M leaks in and the 2M row answers to `2m` alone. This is the + // boundary the README documents, so pin it by name. + expect(ids(searchModels(WINDOWS, "1m"))).not.toContain("vendor/echo"); + expect(ids(searchModels(WINDOWS, "2m"))).toEqual(["vendor/echo"]); + }); + + it("keeps answering to the string the row displays", () => { + expect(ids(searchModels(WINDOWS, "1.0m"))).toEqual(["vendor/bravo"]); + expect(ids(searchModels(WINDOWS, "1.1m"))).toEqual(["vendor/charlie"]); + expect(ids(searchModels(WINDOWS, "1.3m"))).toEqual(["vendor/delta"]); + expect(ids(searchModels(WINDOWS, "131k"))).toEqual(["vendor/foxtrot"]); + expect(ids(searchModels(WINDOWS, "205k"))).toEqual(["vendor/golf"]); + }); + + it("answers to the binary reading of a power-of-two window", () => { + // 131_072 is sold as 128k and 204_800 as 200k; decimal rounding is + // what hid them. + expect(ids(searchModels(WINDOWS, "128k"))).toEqual(["vendor/foxtrot"]); + expect(ids(searchModels(WINDOWS, "200k"))).toEqual(["vendor/golf"]); + // A window that was never binary keeps only its decimal reading. + const decimal = [{ id: "vendor/hotel", entry: entry({ contextWindow: 200_000 }) }]; + expect(ids(searchModels(decimal, "200k"))).toEqual(["vendor/hotel"]); + expect(searchModels(decimal, "195k")).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"]); + }); + + it("tags a window as displayed, floored to the whole unit, and in binary", () => { + // The first three tags are kind / modality / tools; window forms follow. + const windowTags = (contextWindow: number): readonly string[] => + modelSearchTags(entry({ contextWindow })).slice(3); + expect(windowTags(1_000_000)).toEqual(["1m"]); + expect(windowTags(1_048_576)).toEqual(["1.0m", "1m"]); + expect(windowTags(1_310_720)).toEqual(["1.3m", "1m"]); + expect(windowTags(131_072)).toEqual(["131k", "128k"]); + expect(windowTags(204_800)).toEqual(["205k", "204k", "200k"]); + expect(windowTags(200_000)).toEqual(["200k"]); + // Below a thousand there is no shorthand to normalise. + expect(windowTags(512)).toEqual(["512"]); + }); +}); + +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..12095748 --- /dev/null +++ b/src/llm/provider/model-search.ts @@ -0,0 +1,197 @@ +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(...contextWindowTags(entry.contextWindow)); + // 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; +} + +/** + * Every shorthand an operator would type for one context window. + * + * The display string alone is not enough, because it is a rounded + * decimal rendering and tag matching is exact: `formatContextWindow` + * writes 1_048_576 as "1.0m" and 131_072 as "131k", so `1m` and `128k` — + * the numbers those vendors actually advertise — would drop the row. + * The formatter stays as it is; the extra forms ride alongside it. + * + * Three forms per window, deduped: + * + * 1. the display string, so searching for what the row shows works; + * 2. the whole-unit **floor** in that same unit — 1_310_720 -> `1m`, + * 1_050_000 -> `1m`, 202_752 -> `202k`. Floor rather than round, + * because a size term names the bucket a window falls in: `1m` means + * "a window in the millions", so it must find every row from 1M up to + * 2M — a 2M row answers to `2m`, not to `1m` — and must not find a + * 950k row that would round up to it; + * 3. the **binary** reading, when the window is an exact multiple of + * 1024 (1024² above a million) — 131_072 -> `128k`, 204_800 -> + * `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`. Those windows are + * power-of-two sized and are sold by the binary number; decimal + * rounding is what hides it. The exact-multiple guard keeps the + * reading off windows that were never binary (200_000 stays `200k`). + * + * Raw token counts (`131072`) are deliberately not tags: no surface + * renders one, so nobody reads it off a row to type it back. + */ +function contextWindowTags(tokens: number): readonly string[] { + const tags = [formatContextWindow(tokens).toLowerCase()]; + const add = (tag: string): void => { + if (!tags.includes(tag)) tags.push(tag); + }; + if (tokens >= 1_000_000) { + add(`${Math.floor(tokens / 1_000_000)}m`); + if (tokens % 1_048_576 === 0) add(`${tokens / 1_048_576}m`); + } else if (tokens >= 1_000) { + add(`${Math.floor(tokens / 1_000)}k`); + if (tokens % 1_024 === 0) add(`${tokens / 1_024}k`); + } + 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/ascii-header-guard.test.ts b/src/llm/provider/openai/ascii-header-guard.test.ts new file mode 100644 index 00000000..6cc68632 --- /dev/null +++ b/src/llm/provider/openai/ascii-header-guard.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { assertAsciiApiKey, isAsciiOnly } from "./ascii-header-guard.js"; +import { buildOpenAiAuthHeaders } from "./openai-auth-headers.js"; +import { + buildOpenAiHeaders, + OpenAiHttpError, + openAiFetch, +} from "./openai-http.js"; + +describe("isAsciiOnly", () => { + it("accepts plain ASCII keys and the empty string", () => { + expect(isAsciiOnly("")).toBe(true); + expect(isAsciiOnly("sk-abc123_-.")).toBe(true); + // Every printable ASCII byte is allowed in a header value. + expect(isAsciiOnly("Bearer sk-XYZ~!@#$%^&*()")).toBe(true); + }); + + it("rejects a key with a character above the ASCII range", () => { + expect(isAsciiOnly("sk-т")).toBe(false); // Cyrillic "т" (U+0442) + expect(isAsciiOnly("sk-café")).toBe(false); // "é" (U+00E9) + expect(isAsciiOnly("sk-“smart”")).toBe(false); // curly quotes + }); +}); + +describe("assertAsciiApiKey", () => { + it("returns an ASCII key unchanged", () => { + expect(assertAsciiApiKey("sk-plain")).toBe("sk-plain"); + }); + + it("throws a clear, actionable error for a non-ASCII key", () => { + expect(() => assertAsciiApiKey("sk-т")).toThrow( + "API key contains non-ASCII characters. Use a plain ASCII key.", + ); + }); +}); + +describe("buildOpenAiAuthHeaders header guard", () => { + it("guards the named api-key header path, not just the bearer default", () => { + // Anthropic-style presets carry the key in `x-api-key`; the assert + // sits in the one builder both paths share, so this throws too. + expect(() => + buildOpenAiAuthHeaders("sk-т", { apiKeyHeader: "x-api-key" }), + ).toThrow(/non-ASCII/); + }); + + it("passes an ASCII key through to the named header", () => { + const headers = buildOpenAiAuthHeaders("sk-ok", { apiKeyHeader: "x-api-key" }); + expect(headers["x-api-key"]).toBe("sk-ok"); + }); +}); + +describe("buildOpenAiHeaders header guard", () => { + const deps = { + baseUrl: "http://127.0.0.1:9931", + extraHeaders: {}, + requestTimeoutMs: 1000, + fetchImpl: fetch, + label: "local", + }; + + it("does not throw a raw ByteString error for a non-ASCII key", () => { + // The header building must fail with our named error, never the + // opaque "Cannot convert argument to a ByteString" from `fetch`. + let caught: unknown; + try { + buildOpenAiHeaders({ ...deps, apiKey: "sk-т" }, false); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("non-ASCII"); + expect((caught as Error).message).not.toContain("ByteString"); + }); + + it("builds an Authorization header for an ASCII key", () => { + const headers = buildOpenAiHeaders({ ...deps, apiKey: "sk-ok" }, false); + expect(headers.authorization).toBe("Bearer sk-ok"); + }); + + it("omits Authorization entirely for a keyless server", () => { + const headers = buildOpenAiHeaders({ ...deps, apiKey: "" }, false); + expect(headers.authorization).toBeUndefined(); + }); + + it("classifies a non-ASCII key as a 401 at request time, before any fetch", async () => { + // A legacy bad key in .env reaches openAiFetch directly. It must fail + // as an auth error — deterministic, unretried, and a fallback chain + // advances past it — with the guard's message intact, not wrapped as + // a network failure. + const fetchImpl = vi.fn(); + let caught: unknown; + try { + await openAiFetch({ ...deps, apiKey: "sk-т", fetchImpl }, "/v1/chat", null, {}, false); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(OpenAiHttpError); + expect((caught as OpenAiHttpError).status).toBe(401); + expect((caught as Error).message).toContain("non-ASCII"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/llm/provider/openai/ascii-header-guard.ts b/src/llm/provider/openai/ascii-header-guard.ts new file mode 100644 index 00000000..f8eb64ef --- /dev/null +++ b/src/llm/provider/openai/ascii-header-guard.ts @@ -0,0 +1,31 @@ +/** + * HTTP header values are byte strings: every character must fit in a + * single byte (0-255). `fetch` enforces this and throws a raw + * `ByteString` conversion error the moment a header value carries a + * character above that range. An API key with a stray non-ASCII + * character (a Cyrillic letter pasted by mistake, a smart quote from a + * doc) is the usual cause, and the raw error names an index and a code + * point rather than the key, so the guards here catch it first and say + * what to do instead. + */ + +/** True when every character of `s` is in the ASCII range (code points 0-127). */ +export function isAsciiOnly(s: string): boolean { + // eslint-disable-next-line no-control-regex + return /^[\x00-\x7f]*$/.test(s); +} + +/** + * Return `apiKey` unchanged when it can be sent in an `Authorization` + * header, or throw a clear error naming the fix. Header values must be + * ASCII, so a non-ASCII key would otherwise blow up inside `fetch` with + * an opaque `ByteString` message that never mentions the key at all. + */ +export function assertAsciiApiKey(apiKey: string): string { + if (!isAsciiOnly(apiKey)) { + throw new Error( + "API key contains non-ASCII characters. Use a plain ASCII key.", + ); + } + return apiKey; +} diff --git a/src/llm/provider/openai/fetch-openai-compat-models.test.ts b/src/llm/provider/openai/fetch-openai-compat-models.test.ts index 500883ae..8d7638fc 100644 --- a/src/llm/provider/openai/fetch-openai-compat-models.test.ts +++ b/src/llm/provider/openai/fetch-openai-compat-models.test.ts @@ -24,7 +24,7 @@ describe("fetchOpenAiCompatModels", () => { expect(ids).toEqual(["Qwen/Qwen3-8B", "zephyr"]); expect(fetchMock.mock.calls[0]?.[0]).toBe("https://vllm.example/v1/models"); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - headers: { Authorization: "Bearer key" }, + headers: { authorization: "Bearer key" }, }); expect(getCachedOpenAiCompatModels("https://vllm.example/", "key")).toEqual(ids); @@ -83,6 +83,46 @@ describe("fetchOpenAiCompatModels", () => { ).toBeUndefined(); }); + it("puts the key in the header the service names, not in Authorization", async () => { + // The blocker this parameter exists for: a service that reads + // `Authorization: Bearer` as an OAuth token rejects an API key sent + // that way, so discovery 401s before the operator ever reaches a + // model list. Nothing in the response shape reveals the cause. + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "claude-opus-5" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + await fetchOpenAiCompatModels("https://named-header.example", "sk-test", { + apiKeyHeader: "x-api-key", + headers: { "some-version": "2023-06-01" }, + }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers["x-api-key"]).toBe("sk-test"); + expect(headers["some-version"]).toBe("2023-06-01"); + expect(headers.authorization).toBeUndefined(); + }); + + it("still sends mandatory static headers when there is no key", async () => { + // A version header is part of the request contract, not part of the + // credential — dropping it with the key would turn a 401 into a 400. + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "m" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + await fetchOpenAiCompatModels("https://keyless-static.example", undefined, { + apiKeyHeader: "x-api-key", + headers: { "some-version": "2023-06-01" }, + }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers).toEqual({ "some-version": "2023-06-01" }); + }); + it("throws on a rejected request so callers can fall back to typing", async () => { vi.stubGlobal( "fetch", @@ -93,4 +133,19 @@ describe("fetchOpenAiCompatModels", () => { ).rejects.toThrow("http 401"); expect(getCachedOpenAiCompatModels("https://locked.example")).toBeUndefined(); }); + + it("rejects a non-ASCII key with a readable reason, never a ByteString crash", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "m" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + // "sk-т" carries a Cyrillic character that cannot sit in a header. + await expect( + fetchOpenAiCompatModels("https://byte.example", "sk-т"), + ).rejects.toThrow(/non-ASCII/); + // The guard fires before the request, so `fetch` never runs. + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/llm/provider/openai/fetch-openai-compat-models.ts b/src/llm/provider/openai/fetch-openai-compat-models.ts index 4c0e94b9..cebd0d9f 100644 --- a/src/llm/provider/openai/fetch-openai-compat-models.ts +++ b/src/llm/provider/openai/fetch-openai-compat-models.ts @@ -4,6 +4,10 @@ * synchronously through the module cache, same shape as the OpenRouter picker. */ +import { + buildOpenAiAuthHeaders, + type OpenAiCompatAuth, +} from "./openai-auth-headers.js"; import { normalizeOpenAiBaseUrl } from "./normalize-openai-base-url.js"; const CACHE_TTL_MS = 60 * 60 * 1000; @@ -45,17 +49,28 @@ export function getCachedOpenAiCompatModelsForBaseUrl( return best?.ids; } -/** Throws on unreachable/unauthorized servers so the caller can fall back to typing. */ +/** + * Throws on unreachable/unauthorized servers so the caller can fall back to typing. + * + * `auth` describes how this endpoint wants credentials presented; both a + * `ProviderPreset` and a saved `UserLlmProviderEntry` satisfy it + * structurally, so callers pass whichever they hold. It is deliberately + * **not** part of the cache key: the header contract is a property of the + * endpoint, so the same base URL always implies the same headers, and + * keying on it would only fragment the cache that the read-only lookups + * (which know a URL and a key, never a header set) share. + */ export async function fetchOpenAiCompatModels( baseUrl: string, apiKey?: string, + auth?: OpenAiCompatAuth, ): Promise { const cached = getCachedOpenAiCompatModels(baseUrl, apiKey); if (cached) return cached; const base = normalizeOpenAiBaseUrl(baseUrl); const res = await fetch(`${base}/v1/models`, { - headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + headers: buildOpenAiAuthHeaders(apiKey, auth), signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`http ${res.status}`); diff --git a/src/llm/provider/openai/openai-auth-headers.ts b/src/llm/provider/openai/openai-auth-headers.ts new file mode 100644 index 00000000..d7d98237 --- /dev/null +++ b/src/llm/provider/openai/openai-auth-headers.ts @@ -0,0 +1,69 @@ +/** + * The one place that decides how an API key is attached to an outgoing + * request for an `openai-compatible` endpoint. + * + * Both request paths — model discovery (`fetch-openai-compat-models.ts`) + * and every chat/embedding call (`openai-http.ts`) — go through this + * function so they cannot drift. They did drift in spirit before: each + * hard-coded `Authorization: Bearer`, which is why a preset for a vendor + * that authenticates any other way could 401 on discovery *and* on every + * subsequent turn with nothing in config able to correct it. + */ + +import { assertAsciiApiKey } from "./ascii-header-guard.js"; + +/** + * How a service wants credentials presented. Both fields are optional and + * the empty object reproduces the historical behaviour exactly: + * `Authorization: Bearer ` and no extra headers. + * + * `ProviderPreset` and `UserLlmProviderEntry` both carry these two field + * names, so a preset or a saved config entry can be passed straight in. + */ +export type OpenAiCompatAuth = { + /** + * Header that carries the API key verbatim, for services that do not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`; a + * `Bearer sk-ant-…` is read as an OAuth token and always rejected). + * Absent means the OpenAI convention: `authorization: Bearer `. + */ + readonly apiKeyHeader?: string; + /** + * Static headers every request to the service must carry, e.g. + * Anthropic's mandatory `anthropic-version`. Never holds secrets — + * the key travels in `apiKeyHeader` (or the bearer default) so it can + * keep coming from the environment instead of `config.json`. + */ + readonly headers?: Readonly>; +}; + +/** + * Headers that authenticate one request. Keyless servers (a local LM + * Studio, an unauthenticated vLLM) get no auth header at all: `Bearer ` + * with an empty token is malformed and some proxies reject it outright. + * The static `headers` still go out — a version header is part of the + * request contract whether or not a key exists. + */ +export function buildOpenAiAuthHeaders( + apiKey: string | undefined, + auth: OpenAiCompatAuth | undefined, +): Record { + const out: Record = {}; + if (apiKey) { + // A non-ASCII key cannot travel in a header value — `fetch` throws an + // opaque ByteString conversion error from inside the call. Assert in + // the one place every request path passes through, so the failure + // names the key and the fix, on the bearer and named-header paths alike. + assertAsciiApiKey(apiKey); + const named = auth?.apiKeyHeader?.trim(); + if (named) { + out[named.toLowerCase()] = apiKey; + } else { + out.authorization = `Bearer ${apiKey}`; + } + } + for (const [name, value] of Object.entries(auth?.headers ?? {})) { + out[name.toLowerCase()] = value; + } + return out; +} diff --git a/src/llm/provider/openai/openai-http.test.ts b/src/llm/provider/openai/openai-http.test.ts index 8a4e8d57..eecd66a9 100644 --- a/src/llm/provider/openai/openai-http.test.ts +++ b/src/llm/provider/openai/openai-http.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { OpenAiHttpError, + buildOpenAiHeaders, humanizeOpenAiHttpError, openAiPostJson, openAiStartStream, @@ -255,3 +256,60 @@ describe("classification", () => { ); }); }); + +describe("buildOpenAiHeaders", () => { + const base: OpenAiHttpDeps = { + baseUrl: "https://api.example.com", + apiKey: "k", + extraHeaders: {}, + requestTimeoutMs: 1, + fetchImpl: fetch, + label: "p", + }; + + it("defaults to Authorization: Bearer", () => { + expect(buildOpenAiHeaders(base, false)).toMatchObject({ + authorization: "Bearer k", + "content-type": "application/json", + accept: "application/json", + }); + }); + + it("moves the key into apiKeyHeader and drops Authorization entirely", () => { + // Not "in addition to": a service that reads Authorization as an + // OAuth token rejects the request on the stray header alone. + const headers = buildOpenAiHeaders( + { ...base, apiKeyHeader: "x-api-key" }, + false, + ); + expect(headers["x-api-key"]).toBe("k"); + expect(headers.authorization).toBeUndefined(); + }); + + it("sends no auth header at all for a keyless server", () => { + // `Bearer ` with an empty token is malformed; so is an empty + // `x-api-key`. Neither shape may be emitted. + const headers = buildOpenAiHeaders( + { ...base, apiKey: "", apiKeyHeader: "x-api-key" }, + false, + ); + expect(headers.authorization).toBeUndefined(); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("carries the entry's static headers alongside the key", () => { + const headers = buildOpenAiHeaders( + { + ...base, + apiKeyHeader: "x-api-key", + extraHeaders: { "anthropic-version": "2023-06-01" }, + }, + true, + ); + expect(headers).toMatchObject({ + "x-api-key": "k", + "anthropic-version": "2023-06-01", + accept: "text/event-stream", + }); + }); +}); diff --git a/src/llm/provider/openai/openai-http.ts b/src/llm/provider/openai/openai-http.ts index 3bfbe7ff..85fa1055 100644 --- a/src/llm/provider/openai/openai-http.ts +++ b/src/llm/provider/openai/openai-http.ts @@ -1,7 +1,15 @@ +import { buildOpenAiAuthHeaders } from "./openai-auth-headers.js"; + export type OpenAiHttpDeps = { baseUrl: string; apiKey: string; extraHeaders: Record; + /** + * Header that carries the API key when the service does not accept + * `Authorization: Bearer` (Anthropic wants `x-api-key`). Absent keeps + * the OpenAI convention. See `openai-auth-headers.ts`. + */ + apiKeyHeader?: string; requestTimeoutMs: number; fetchImpl: typeof fetch; /** Provider id shown in user-facing failure messages ("openrouter"). */ @@ -105,11 +113,13 @@ export function buildOpenAiHeaders( return { "content-type": "application/json", accept: stream ? "text/event-stream" : "application/json", - // Keyless servers (a local LM Studio, an unauthenticated vLLM) get no - // authorization header at all: `Bearer ` with an empty token is - // malformed and some proxies reject it outright. - ...(deps.apiKey ? { authorization: `Bearer ${deps.apiKey}` } : {}), - ...deps.extraHeaders, + // Auth (and any service-mandated static headers) come from the one + // builder model discovery also uses, so the two request paths cannot + // disagree about how this endpoint is authenticated. + ...buildOpenAiAuthHeaders(deps.apiKey, { + ...(deps.apiKeyHeader ? { apiKeyHeader: deps.apiKeyHeader } : {}), + headers: deps.extraHeaders, + }), }; } @@ -172,6 +182,26 @@ export async function openAiFetch( stream: boolean, method: "GET" | "POST" = "POST", ): Promise { + // Built before the try below, which would wrap the throw as a + // retryable "network error" and replace its message with a + // connectivity hint. Classified as a 401 instead: a key that cannot + // form a header is the same class as a dead key — deterministic, never + // retried, and a fallback chain advances past it to a link whose key + // may work. + let headers: Record; + try { + headers = buildOpenAiHeaders(deps, stream); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new OpenAiHttpError( + detail, + 401, + `${deps.baseUrl}${path}`, + false, + null, + deps.label, + ); + } const controller = new AbortController(); let timedOut = false; const timer = setTimeout(() => { @@ -190,7 +220,7 @@ export async function openAiFetch( try { return await deps.fetchImpl(`${deps.baseUrl}${path}`, { method, - headers: buildOpenAiHeaders(deps, stream), + headers, ...(body && method === "POST" ? { body: JSON.stringify(body) } : {}), signal: controller.signal, }); diff --git a/src/llm/provider/openai/openai-provider.ts b/src/llm/provider/openai/openai-provider.ts index a3c11b9b..974943f9 100644 --- a/src/llm/provider/openai/openai-provider.ts +++ b/src/llm/provider/openai/openai-provider.ts @@ -36,6 +36,11 @@ export interface OpenAiProviderOptions { apiKey: string; defaultChatModel: string; headers?: Record; + /** + * Header that carries the API key when the service does not accept + * `Authorization: Bearer`. See `openai-auth-headers.ts`. + */ + apiKeyHeader?: string; supportsVision?: boolean; supportsParallelTools?: boolean; supportsPromptCache?: boolean; @@ -91,6 +96,7 @@ export class OpenAiProvider implements LlmProvider { baseUrl: normalizeOpenAiBaseUrl(options.baseUrl), apiKey: options.apiKey, extraHeaders: options.headers ?? {}, + ...(options.apiKeyHeader ? { apiKeyHeader: options.apiKeyHeader } : {}), requestTimeoutMs: options.requestTimeoutMs ?? 600_000, fetchImpl: options.fetchImpl ?? fetch, label: options.id, 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..e7a67281 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", () => { diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index 9aa7de62..3066198b 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -1,4 +1,5 @@ import type { AtomicAgentConfig } from "../../../config/index.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"; @@ -26,6 +27,13 @@ export type LlmProviderConfigEntry = { defaultChatModel?: string; defaultEmbeddingModel?: string; headers?: Record; + /** + * Header that carries this entry's API key when the service does not + * accept `Authorization: Bearer` (Anthropic wants `x-api-key`). Rides + * on the entry, not on the preset table, so the saved provider keeps + * working after a restart. See `openai/openai-auth-headers.ts`. + */ + apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; @@ -43,6 +51,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; }; diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index e3e61f1b..4f8dc7a5 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; @@ -54,6 +60,7 @@ export function registerBuiltInProviderKinds(): void { apiKey: entry.apiKey ?? "", defaultChatModel: entry.defaultChatModel, headers: entry.headers, + apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, @@ -74,6 +81,7 @@ export function registerBuiltInProviderKinds(): void { apiKey: entry.apiKey ?? "", defaultChatModel: entry.defaultChatModel, headers: entry.headers, + apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, supportsParallelTools: entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, @@ -126,4 +134,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.test.ts b/src/llm/provider/subscription-cli/run-cli-completion.test.ts new file mode 100644 index 00000000..3b439af3 --- /dev/null +++ b/src/llm/provider/subscription-cli/run-cli-completion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { runCliCommand, type CliRunOptions } from "./run-cli-completion.js"; +import { SubscriptionCliAuthError } from "./subscription-cli-errors.js"; + +/** + * Real children, not a mocked runner: the case these cover is what + * happens to a pending stdin write when the child stops reading, which + * only the kernel can produce. + */ +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, + }; +} + +/** + * Past the ~64 KiB pipe buffer. The provider's own comment notes a + * two-zone prompt "routinely exceeds the 128 KiB single-argument limit", + * so this is an ordinary session, not a pathological one. + */ +const BIG_PROMPT = "x".repeat(1024 * 1024); + +describe("runCliCommand with an undrained prompt", () => { + it("reports a signed-out CLI as an auth error, not a broken pipe", async () => { + const script = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); + `; + await expect( + runCliCommand(options(script, { input: BIG_PROMPT })), + ).rejects.toBeInstanceOf(SubscriptionCliAuthError); + }); + + it("refuses a half-delivered prompt even when the CLI exits 0", async () => { + // `codex` exits 0 even on failure, so without this the caller would + // parse a completion computed from a prompt we never finished + // sending and treat it as a good answer. + const script = `process.stdout.write("{}", () => process.exit(0));`; + await expect( + runCliCommand(options(script, { input: BIG_PROMPT })), + ).rejects.toThrow(/stopped reading the prompt/); + }); + + it("passes a prompt the CLI actually reads straight through", async () => { + const script = ` + let n = 0; + process.stdin.on("data", (c) => { n += c.length; }); + process.stdin.on("end", () => process.stdout.write(String(n))); + `; + const out = await runCliCommand(options(script, { input: BIG_PROMPT })); + expect(out.stdout).toBe(String(BIG_PROMPT.length)); + }); +}); 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..1ba70c31 --- /dev/null +++ b/src/llm/provider/subscription-cli/run-cli-completion.ts @@ -0,0 +1,97 @@ +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; + } + + // `inputTruncated` is its own failure condition: a CLI that stops + // reading mid-prompt answered a different question than the one we + // asked, and `codex` exits 0 even when it fails, so the exit code + // alone would let that through as a good completion. + if ( + result.exitCode !== 0 || + result.timedOut || + result.truncated || + result.inputTruncated + ) { + 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, + inputTruncated: result.inputTruncated, + 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..cf877706 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { CliRunOptions } from "./run-cli-completion.js"; +import { streamCliCommand } from "./stream-cli-completion.js"; +import { + SubscriptionCliAuthError, + 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(); + }); +}); + +/** A prompt well past the ~64 KiB pipe buffer, so the write cannot flush at once. */ +const BIG_PROMPT = "x".repeat(1024 * 1024); + +describe("streamCliCommand stdin", () => { + it("maps a signed-out CLI that never read a 1 MiB prompt to an auth error", async () => { + // Without an `error` listener on `child.stdin` the EPIPE from the + // undrained write is an uncaught exception, and + // `installGlobalErrorHandlers` keeps that fatal — the operator loses + // the session instead of being told to run /login. + const script = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); + `; + await expect( + collect(options(script, { input: BIG_PROMPT })), + ).rejects.toBeInstanceOf(SubscriptionCliAuthError); + }); + + it("survives an abort fired while a 1 MiB prompt is still draining", async () => { + // Ctrl+C in the TUI: onAbort -> stop("abort") -> SIGTERM lands on a + // child that has not read its stdin, so the pending write faults. + const controller = new AbortController(); + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const lines: string[] = []; + await expect( + (async () => { + for await (const line of streamCliCommand( + options(script, { input: BIG_PROMPT, signal: controller.signal }), + )) { + lines.push(line); + controller.abort(); + } + })(), + ).rejects.toThrow(); + expect(lines).toEqual(['{"type":"a"}']); + }); + + it("refuses a run whose prompt was only half delivered, even on exit 0", async () => { + // `codex` exits 0 even when it fails, so the exit code alone would + // let a completion computed from a truncated prompt through. + const script = ` + process.stdout.write('{"type":"a"}\\n', () => process.exit(0)); + `; + await expect(collect(options(script, { input: BIG_PROMPT }))).rejects.toThrow( + /stopped reading the prompt/, + ); + }); +}); + +describe("streamCliCommand SIGKILL escalation", () => { + const strays: number[] = []; + + afterEach(() => { + // Belt and braces: nothing this file spawns may outlive the suite. + for (const pid of strays.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone, which is the point of the test + } + } + }); + + function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + async function waitUntilGone(pid: number, budgetMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < budgetMs) { + if (!alive(pid)) return Date.now() - started; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return -1; + } + + it("force-kills a child that traps SIGTERM instead of orphaning it", async () => { + // The `finally` used to clear the SIGKILL timer `stop` had just + // armed, so this child survived every abort — one orphan per + // cancelled turn. It reports its own pid so the test can watch it. + const script = ` + process.on("SIGTERM", () => {}); + process.stdout.write(process.pid + "\\n"); + setInterval(() => {}, 1000); + `; + const iterator = streamCliCommand(options(script)); + const first = await iterator.next(); + const pid = Number(first.value); + expect(Number.isInteger(pid)).toBe(true); + strays.push(pid); + + await iterator.return(); + // SIGTERM is ignored, so only the 2s escalation can end it. + expect(alive(pid)).toBe(true); + const tookMs = await waitUntilGone(pid, 8_000); + expect(tookMs).toBeGreaterThanOrEqual(0); + }); +}); 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..3ac907d4 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.ts @@ -0,0 +1,177 @@ +import { spawn } from "node:child_process"; +import { isBrokenPipe } from "../../../sandbox/index.js"; +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 inputTruncated = false; + let stdinError: Error | null = null; + 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. A second `stop` (abort + // followed by the generator's own cleanup) must not re-arm it, or + // the first timer is orphaned and fires at a pid we no longer track. + if (killTimer) return; + 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(() => {}); + + // Ctrl+C in the TUI runs `onAbort` -> `stop("abort")` -> SIGTERM while + // a prompt past the pipe buffer (~64 KiB) is still draining, so the + // write fails with EPIPE. An `error` on a stream with no listener is + // fatal for the process, which would turn the most routine action in + // the TUI — cancelling a turn — into a lost session. The broken pipe + // is expected here; the child's exit code still reports the outcome. + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) { + inputTruncated = true; + return; + } + stdinError ??= err; + }); + + 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; + // A stdin failure that is not a broken pipe is a local fault, not + // something the CLI's exit code explains — report it as itself. + if (stdinError) throw stdinError; + if (code !== 0 || timedOut || inputTruncated) { + throw mapCliFailure({ + binary: options.binary, + installHint: options.installHint, + authHint: options.authHint, + exitCode: code, + stdout: "", + stderr, + timedOut, + truncated: false, + inputTruncated, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + }); + } + } finally { + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + if (!settled) stop("done"); + // Cancel the SIGKILL escalation only once the child is actually + // gone. Clearing it unconditionally cancelled the timer `stop` had + // armed microseconds earlier, so a child that traps SIGTERM was + // never force-killed and survived as an orphan — one per aborted + // turn. While it is still alive, let the delay run and disarm on + // exit instead. + if (killTimer) { + const armed = killTimer; + const disarm = () => clearTimeout(armed); + // `.then(f, f)` rather than `.finally`: the latter returns a + // promise that re-throws, and nobody is left to await it here. + if (settled) disarm(); + else void exited.then(disarm, disarm); + } + } +}; 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..fc3bad06 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-errors.ts @@ -0,0 +1,127 @@ +/** + * 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; + /** The CLI stopped reading stdin before the prompt was fully written. */ + inputTruncated?: 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), + ); + } + // Checked after the auth patterns: a signed-out CLI is what usually + // drops the pipe, and "run /login" is the more actionable message. + if (input.inputTruncated) { + return new SubscriptionCliInvocationError( + `"${input.binary}" stopped reading the prompt before it was fully written (exit ${ + input.exitCode ?? "null" + }): ${tail(input.stderr || input.stdout) || "no output"}`, + input.exitCode, + ); + } + 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/prompt/default-tool-args-schemas.test.ts b/src/prompt/default-tool-args-schemas.test.ts index a88577dc..80bffb87 100644 --- a/src/prompt/default-tool-args-schemas.test.ts +++ b/src/prompt/default-tool-args-schemas.test.ts @@ -69,6 +69,45 @@ describe("default tool argsJsonSchema map", () => { }); }); + // Issue #185: `paths` was a bare string[] with no upper bound, so + // neither cloud schema validation nor grammar-constrained decoding + // could hint the 4-image cap and the model learned it by failing. + it("pins vision.describe schema (paths capped at maxItems 4)", () => { + const schema = getDefaultArgsJsonSchema("vision.describe"); + expect(schema).toMatchObject({ + type: "object", + required: ["prompt"], + additionalProperties: false, + }); + const properties = (schema as { properties: Record }).properties; + expect(properties.paths).toEqual({ + type: "array", + items: { type: "string" }, + maxItems: 4, + }); + }); + + it("does not leak vision.describe's maxItems onto other string[] schemas", () => { + const properties = ( + getDefaultArgsJsonSchema("os.shell.run") as { + properties: Record; + } + ).properties; + expect(properties.args).not.toHaveProperty("maxItems"); + }); + + it("documents the vision.describe image cap in the stable-prefix descriptor", () => { + const descriptor = DEFAULT_TOOL_DESCRIPTORS.find( + (d) => d.name === "vision.describe", + ); + expect(descriptor).toBeDefined(); + // The descriptor array is static and cannot read config, so it + // documents the DEFAULT with wording that stays true if someone + // raises `vision.maxImagesPerCall`. + expect(descriptor!.summary).toContain("at most 4 images per call by default"); + expect(descriptor!.argsSchema).toContain("at most 4 by default"); + }); + it("attachDefaultArgsJsonSchema preserves an explicit override (MCP inputSchema path)", () => { const override = { type: "object" as const, diff --git a/src/prompt/default-tool-args-schemas.ts b/src/prompt/default-tool-args-schemas.ts index 7049acaf..dc1da011 100644 --- a/src/prompt/default-tool-args-schemas.ts +++ b/src/prompt/default-tool-args-schemas.ts @@ -462,6 +462,7 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap = new Map< url: stringSchema, extractMode: { type: "string", enum: ["markdown", "text"] }, maxChars: numberSchema, + timeoutMs: numberSchema, }, ["url"], ), @@ -587,7 +588,12 @@ const DEFAULT_TOOL_ARGS_SCHEMAS: ReadonlyMap = new Map< { prompt: stringSchema, path: stringSchema, - paths: stringArraySchema, + // `maxItems` mirrors the DEFAULT of `config.vision.maxImagesPerCall` + // (4). The runtime check in `buildVisionDescribeTool` reads the live + // config and stays authoritative; this bound is a hint so cloud + // providers and grammar-constrained decoding stop the model from + // emitting a 20-image call it can only discover is invalid by failing. + paths: { ...stringArraySchema, maxItems: 4 }, }, ["prompt"], ), diff --git a/src/prompt/default-tool-descriptors-a.ts b/src/prompt/default-tool-descriptors-a.ts index c5fefaf6..007a151c 100644 --- a/src/prompt/default-tool-descriptors-a.ts +++ b/src/prompt/default-tool-descriptors-a.ts @@ -213,7 +213,7 @@ export const DEFAULT_TOOL_DESCRIPTORS_A: readonly ToolDescriptor[] = [ name: "os.web.fetch", summary: "Read a web page as readable markdown/text (cf-markdown → Readability → basic). GET only, no JS, no auth; SSRF-guarded; read-only. For raw API/JSON or POST, use os.http.request.", - argsSchema: `{ url: string, extractMode?: "markdown" | "text", maxChars?: number }`, + argsSchema: `{ url: string, extractMode?: "markdown" | "text", maxChars?: number, timeoutMs?: number }`, examples: [ '{"url":"https://example.com/article"}', '{"url":"https://docs.example.com/guide","extractMode":"text","maxChars":20000}', diff --git a/src/prompt/default-tool-descriptors-b.ts b/src/prompt/default-tool-descriptors-b.ts index 6fa9957b..1783dde0 100644 --- a/src/prompt/default-tool-descriptors-b.ts +++ b/src/prompt/default-tool-descriptors-b.ts @@ -162,9 +162,9 @@ export const DEFAULT_TOOL_DESCRIPTORS_B: readonly ToolDescriptor[] = [ // `{paths: [...]}` without `prompt` and burning a step on the // schema error before retrying with the right shape. name: "vision.describe", - summary: "Describe one or more images via the configured vision LLM. Only available when the active model + provider support multimodal input.", + summary: "Describe one or more images via the configured vision LLM. Only available when the active model + provider support multimodal input. Accepts at most 4 images per call by default (`vision.maxImagesPerCall`); to cover more images, split them across several calls.", argsSchema: - "{ prompt: string, path?: string, paths?: string[] /* png|jpg|jpeg|webp|gif */ }", + "{ prompt: string, path?: string, paths?: string[] /* png|jpg|jpeg|webp|gif; at most 4 by default */ }", examples: [ '{"path":"./screenshot.png","prompt":"What error is shown?"}', '{"paths":["a.png","b.png"],"prompt":"Compare these two diagrams"}', diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index 66f28fe1..1827fc5f 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -34,6 +34,8 @@ import type { TypeInput, } from "../tools/browser/browser-backend.js"; import type { LogRecord } from "../tracing/structured-logger.js"; +import type { AgentLoopEvent } from "../agent/agent-loop.js"; +import type { CompletionResult } from "../llm/llama-server-client.js"; class FakeBackend implements BrowserBackend { public shutdowns = 0; @@ -918,3 +920,225 @@ 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 () => { + // Stand in for `AgentLoop.runTurn`, which opens the steering + // window on entry — the queue lock alone is not what makes a + // session steerable. + runtime.steeringInbox.open(session.id); + 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(); + } + }); + + /** + * The lost-update window. `runTurn` is `enqueue({ run: () => + * executeTurn(...) })`; spelling that composition out by hand is the + * only way to stand *between* the loop's final drain and the + * controller's `busy.delete`, which is where a `steer()` used to be + * accepted and then stranded. Everything else here is the production + * wiring: real `TurnController`, real `SteeringInbox`, real + * `AgentLoop`, real `runtime.steer`. No sleeps, no timing luck. + */ + it("refuses a steer that lands after the turn's final drain", async () => { + const events: AgentLoopEvent[] = []; + let inferences = 0; + // Assigned right after bootstrap; the completer only runs inside a + // turn, which is later still. + let sessionId = ""; + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + handlers: { onAgentEvent: (event) => events.push(event) }, + overrides: { + browserBackend: new FakeBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => { + inferences += 1; + if (inferences === 1) { + // Sent while step 0's inference is in flight — the window + // is open, so this one must be accepted AND delivered. + expect(runtime.steer(sessionId, "check the logs first")).toBe(true); + return completion(JSON.stringify({ tool: "noop", args: {} })); + } + return completion( + JSON.stringify({ tool: "reply", args: { text: "done" } }), + ); + }, + }, + }); + // A trivial non-terminal tool so the turn has a step boundary at + // all; a one-step turn could not exercise steering. + runtime.toolRegistry.register({ + name: "noop", + description: "does nothing", + readonly: true, + run: async () => ({ + tool: "noop", + status: "ok" as const, + summary: "noop", + details: {}, + truncated: false, + }), + }); + const session = runtime.createSession(); + sessionId = session.id; + const lateSteerResults: boolean[] = []; + try { + const result = await runtime.turnController.enqueue({ + sessionId, + origin: "tui", + run: async () => { + const r = await runtime.executeTurn(session, "do the thing", { + maxSteps: 4, + }); + // The loop has returned, so its final drain has happened. + // The controller clears `busy` in its own `finally`, i.e. + // after this body settles — so right here the two facts + // disagree, and `isBusy` is the stale one. + expect(runtime.turnController.isBusy(sessionId)).toBe(true); + lateSteerResults.push(runtime.steer(sessionId, "too late, stop")); + return r; + }, + }); + + // The in-flight steer landed where it should: a real user turn, + // folded into the next step. + expect(result.reason).toBe("reply"); + expect( + result.session.turns + .filter((t) => t.kind === "user") + .map((t) => (t as { text: string }).text), + ).toEqual(["do the thing", "check the logs first"]); + expect(events).toContainEqual({ + type: "steer_applied", + text: "check the logs first", + stepIndex: 1, + }); + + // The late one did not. The caller is told "not steered" while + // that is still true, so it can re-route... + expect(lateSteerResults).toEqual([false]); + // ...and nothing is left behind for a later turn to pick up. + expect(runtime.steeringInbox.peek(sessionId)).toEqual([]); + expect(result.undelivered).toEqual([]); + + // The symptom, spelled out: the next turn on this session must + // not open with a "while you were working" notice about a turn + // that ended before it started. + events.length = 0; + const tails: string[] = []; + const next = await runtime.runTurn(result.session, "next question", { + maxSteps: 4, + eventHook: (event) => { + if ( + event.type === "llm_event" && + event.event.type === "prompt_captured" + ) { + tails.push(event.event.tail); + } + }, + }); + expect(next.reason).toBe("reply"); + expect(events.filter((e) => e.type === "steer_applied")).toEqual([]); + expect(tails.length).toBeGreaterThan(0); + for (const tail of tails) expect(tail).not.toContain("too late, stop"); + } 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.open(session.id); + runtime.steeringInbox.push(session.id, "stale"); + await runtime.shutdown(); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + // The window is closed too: nothing will ever drain it again. + expect(runtime.steer(session.id, "after shutdown")).toBe(false); + }); +}); + +function completion(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", + }; +} + diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 68000664..5ff00167 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -11,6 +11,7 @@ import { import type { LlmStreamParams } from "../agent/step-executor.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 +294,29 @@ 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 is the same call with the intent documented. + */ + readonly steeringInbox: SteeringInbox; + /** + * Fold `text` into the turn currently running on `sessionId`. + * + * Returns `false` — and queues nothing — when no running turn is + * still able to pick the message up (no turn in flight, or the turn + * has already done its final drain), when the text is blank, 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. `true` means the message is + * either delivered at a step boundary or returned on + * `RunTurnResult.undelivered` — never stranded. 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 +683,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", { @@ -1753,6 +1778,8 @@ export async function createAgentRuntime( slotManager, grammar, llmComplete, + // Mid-turn steering: the loop drains this at every step boundary. + steeringInbox, ...(llmCompleteStream ? { llmCompleteStream } : {}), toolDescriptors: effectiveToolDescriptors, capabilities, @@ -1851,6 +1878,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 +2101,27 @@ 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. + * + * One call, one decision. It deliberately does NOT pre-check + * `turnController.isBusy`: that is a second fact which stops being + * true at a different moment than "the loop will drain this again" + * (the loop's final drain happens inside `runTurn`, `busy.delete` + * later in the controller's `finally`). Guarding on it made this a + * check-then-act with a real lost-update window — accepted here, + * never delivered, and resurfacing at step 0 of some later turn under + * a "while you were working" notice about a turn that had already + * ended. `push` alone is authoritative: it accepts only while the + * running turn's window is open, and that window is closed by the + * same call that performs the final drain. + */ + const steer = (sessionId: string, text: string): boolean => + steeringInbox.push(sessionId, text); + const runTurn = async ( session: SessionState, userMessage: string, @@ -2404,6 +2455,8 @@ export async function createAgentRuntime( slotManager, sessionStore, turnController, + steeringInbox, + steer, profileStore, notesStore, lessonStore, diff --git a/src/runtime/steering-inbox.test.ts b/src/runtime/steering-inbox.test.ts new file mode 100644 index 00000000..b225f989 --- /dev/null +++ b/src/runtime/steering-inbox.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { MAX_PENDING_STEERS, SteeringInbox } from "./steering-inbox.js"; + +/** An inbox with one session already accepting steers, as a live turn leaves it. */ +function openInbox(...sessionIds: string[]): SteeringInbox { + const inbox = new SteeringInbox(); + for (const id of sessionIds) inbox.open(id); + return inbox; +} + +describe("SteeringInbox", () => { + it("drains what was pushed, in order", () => { + const inbox = openInbox("s1"); + 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 = openInbox("s1"); + 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 = openInbox("a", "b"); + 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 = openInbox("s1"); + 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 = openInbox("s1"); + 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 = openInbox("s1"); + 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 = openInbox("s1"); + 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 = openInbox("a", "b"); + 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([]); + }); + + describe("acceptance window", () => { + it("refuses a push for a session no turn has opened", () => { + const inbox = new SteeringInbox(); + expect(inbox.isOpen("s1")).toBe(false); + expect(inbox.push("s1", "nobody is listening")).toBe(false); + expect(inbox.peek("s1")).toEqual([]); + }); + + it("closeAndDrain hands back what is pending and refuses the next push", () => { + const inbox = openInbox("s1"); + expect(inbox.push("s1", "just in time")).toBe(true); + expect(inbox.closeAndDrain("s1")).toEqual(["just in time"]); + // This is the lost-update window: the turn's final drain has + // happened, so accepting here would strand the message until an + // unrelated later turn picked it up. + expect(inbox.push("s1", "one microtask too late")).toBe(false); + expect(inbox.peek("s1")).toEqual([]); + expect(inbox.isOpen("s1")).toBe(false); + }); + + it("closeAndDrain is idempotent", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "x"); + expect(inbox.closeAndDrain("s1")).toEqual(["x"]); + expect(inbox.closeAndDrain("s1")).toEqual([]); + }); + + it("a mid-turn drain keeps the window open", () => { + const inbox = openInbox("s1"); + inbox.push("s1", "step 0"); + expect(inbox.drain("s1")).toEqual(["step 0"]); + expect(inbox.isOpen("s1")).toBe(true); + expect(inbox.push("s1", "step 1")).toBe(true); + }); + + it("closes only the session it was asked about", () => { + const inbox = openInbox("a", "b"); + inbox.closeAndDrain("a"); + expect(inbox.push("a", "no")).toBe(false); + expect(inbox.push("b", "yes")).toBe(true); + }); + + it("clear and clearAll close the window too", () => { + const inbox = openInbox("a", "b"); + inbox.clear("a"); + expect(inbox.push("a", "no")).toBe(false); + inbox.clearAll(); + expect(inbox.push("b", "no")).toBe(false); + }); + + it("reopens for the next turn on the same session", () => { + const inbox = openInbox("s1"); + inbox.closeAndDrain("s1"); + inbox.open("s1"); + expect(inbox.push("s1", "next turn")).toBe(true); + }); + }); +}); diff --git a/src/runtime/steering-inbox.ts b/src/runtime/steering-inbox.ts new file mode 100644 index 00000000..444d60de --- /dev/null +++ b/src/runtime/steering-inbox.ts @@ -0,0 +1,141 @@ +/** + * 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; + +/** + * The turn's side of the inbox: open the gate when the turn starts + * accepting steers, drain at each step boundary, and close+drain in one + * step on the way out. Declared narrow so `AgentLoop` never sees `push`. + */ +export interface SteeringChannel { + open(sessionId: string): void; + drain(sessionId: string): readonly string[]; + closeAndDrain(sessionId: string): readonly string[]; +} + +export class SteeringInbox implements SteeringChannel { + private readonly bySession = new Map(); + /** + * Sessions whose running turn is still willing to pick messages up. + * This — not `TurnController.isBusy` — is what `push` gates on. + * + * `isBusy` and "a step boundary is still coming" are two different + * facts that stop being true at two different moments: the loop does + * its final drain inside `runTurn`, while the controller clears + * `busy` later, in its own `finally`. A `push` in that window used to + * be accepted (busy was still true) and then sat here until some + * unrelated later turn drained it — the operator saw the message + * accepted and the running turn never saw it. Making acceptance a + * property of *this* object, flipped by the same call that performs + * the final drain, collapses the two facts into one. + */ + private readonly accepting = new Set(); + + /** + * Start accepting steers for the turn now running on `sessionId`. + * Called by `AgentLoop.runTurn` on entry. Idempotent. + */ + open(sessionId: string): void { + this.accepting.add(sessionId); + } + + /** True while a turn on `sessionId` can still pick messages up. */ + isOpen(sessionId: string): boolean { + return this.accepting.has(sessionId); + } + + /** + * Queue a message for the turn currently running on `sessionId`. + * Returns `false` when no turn is accepting steers for that session, + * when the text is blank, or when the per-session cap is reached — + * callers treat any `false` as "not steered, park it instead". + */ + push(sessionId: string, text: string): boolean { + if (!this.accepting.has(sessionId)) return false; + 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; + } + + /** + * Stop accepting and take what is left, as a single indivisible step. + * + * This is the turn's LAST act on the inbox. Everything returned here + * is `RunTurnResult.undelivered` — the caller's to re-route. Every + * `push` that lands after it is refused, so the sender is told "not + * steered" while the fact is still true, instead of being told "yes" + * and having the text stranded until an unrelated later turn. + * + * Idempotent: a second call returns `[]`. + */ + closeAndDrain(sessionId: string): readonly string[] { + this.accepting.delete(sessionId); + return this.drain(sessionId); + } + + /** Non-destructive read, for UI badges and tests. */ + peek(sessionId: string): readonly string[] { + // A copy: the readonly type does not stop the live array from + // mutating under a caller that cached it across a push. + return [...(this.bySession.get(sessionId) ?? [])]; + } + + /** Discard pending messages for one session (session switch / abort). */ + clear(sessionId: string): void { + this.accepting.delete(sessionId); + this.bySession.delete(sessionId); + } + + /** Discard everything (runtime shutdown). */ + clearAll(): void { + this.accepting.clear(); + this.bySession.clear(); + } +} diff --git a/src/sandbox/command-runner.test.ts b/src/sandbox/command-runner.test.ts new file mode 100644 index 00000000..85ec9ee6 --- /dev/null +++ b/src/sandbox/command-runner.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { isBrokenPipe, runCommand } from "./command-runner.js"; + +/** + * These run real children rather than a mocked `child_process`: the + * behaviour under test is what the kernel does when a pipe's reader + * disappears mid-write, which a mock cannot reproduce. + */ +function node(script: string) { + return { command: process.execPath, args: ["-e", script] }; +} + +/** Exits without reading stdin — the shape of a CLI that rejects the request. */ +const REJECTS_INPUT = ` + process.stderr.write("Please run /login to authenticate", () => process.exit(1)); +`; + +/** Reads stdin to the end and reports how many bytes arrived. */ +const COUNTS_INPUT = ` + let n = 0; + process.stdin.on("data", (c) => { n += c.length; }); + process.stdin.on("end", () => process.stdout.write(String(n))); +`; + +const KIB = 1024; + +describe("runCommand stdin", () => { + it("survives a child that exits before draining a 1 MiB payload", async () => { + // Without an `error` listener on `child.stdin` the EPIPE raised here + // is an uncaught exception, which `installGlobalErrorHandlers` keeps + // fatal: the whole agent exits(1) instead of reporting the child's + // own failure. Measured boundary: 16 and 64 KiB flush into the pipe + // buffer and never fault, 128 KiB and up always do. + const { command, args } = node(REJECTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(1024 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("/login"); + expect(result.inputTruncated).toBe(true); + }); + + it("reports the child's own exit code, not the broken pipe", async () => { + const { command, args } = node(` + process.stderr.write("weekly limit reached", () => process.exit(7)); + `); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(256 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(7); + expect(result.stderr).toBe("weekly limit reached"); + }); + + it("leaves inputTruncated false when the payload fits the pipe buffer", async () => { + // 64 KiB lands in the buffer before the child is gone, so nothing + // faults even though the child never reads it. The flag has to track + // the actual write, not the mere fact that the child ignored stdin. + const { command, args } = node(REJECTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(64 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.inputTruncated).toBe(false); + }); + + it("delivers the whole payload to a child that reads it", async () => { + const { command, args } = node(COUNTS_INPUT); + const result = await runCommand(command, args, { + cwd: process.cwd(), + input: "x".repeat(1024 * KIB), + timeoutMs: 10_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(String(1024 * KIB)); + expect(result.inputTruncated).toBe(false); + }); +}); + +describe("isBrokenPipe", () => { + it("matches the codes a vanished reader produces", () => { + for (const code of [ + "EPIPE", + "ECONNRESET", + "EOF", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_WRITE_AFTER_END", + ]) { + expect(isBrokenPipe(Object.assign(new Error("x"), { code }))).toBe(true); + } + }); + + it("does not swallow errors that mean something else", () => { + // These have to keep travelling as errors — absorbing everything on + // the stream would turn a real local fault into a silent success. + expect(isBrokenPipe(Object.assign(new Error("x"), { code: "EACCES" }))).toBe( + false, + ); + expect(isBrokenPipe(new Error("no code at all"))).toBe(false); + expect(isBrokenPipe(null)).toBe(false); + }); +}); diff --git a/src/sandbox/command-runner.ts b/src/sandbox/command-runner.ts index ba4b5896..58842f73 100644 --- a/src/sandbox/command-runner.ts +++ b/src/sandbox/command-runner.ts @@ -2,6 +2,25 @@ import { spawn } from "node:child_process"; const IS_WINDOWS = process.platform === "win32"; +/** + * Stdin errors that all mean the same thing: the far end of the pipe is + * gone because the child exited (or was killed) before it drained its + * input. Expected whenever a command rejects the request without reading + * it, so they are absorbed rather than raised — see the handler below. + */ +const BROKEN_PIPE_CODES = new Set([ + "EPIPE", + "ECONNRESET", + "EOF", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_WRITE_AFTER_END", +]); + +export function isBrokenPipe(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code; + return typeof code === "string" && BROKEN_PIPE_CODES.has(code); +} + export interface CommandOptions { cwd: string; timeoutMs?: number; @@ -22,6 +41,14 @@ export interface CommandResult { durationMs: number; timedOut: boolean; truncated: boolean; + /** + * The child stopped reading before `input` was fully written, so it + * answered a prompt we only partially delivered. Only reachable with + * payloads past the pipe buffer (~64 KiB); a non-zero `exitCode` + * usually says why, but a CLI that exits 0 regardless would otherwise + * look like a clean run over a truncated prompt. + */ + inputTruncated: boolean; } /** @@ -57,6 +84,7 @@ export async function runCommand( let stdoutBytes = 0; let stderrBytes = 0; let truncated = false; + let inputTruncated = false; let timedOut = false; let settled = false; @@ -147,9 +175,32 @@ export async function runCommand( durationMs: Date.now() - started, timedOut, truncated, + inputTruncated, }); }); + // A child that rejects the request — signed out, unknown model, + // rate-limited — exits without draining stdin, so an `input` larger + // than the pipe buffer (~64 KiB) cannot flush and raises EPIPE. + // Node treats an `error` on a stream with no listener as fatal and + // `installGlobalErrorHandlers` preserves that, which would tear the + // whole runtime down instead of reporting the child's own failure. + // Absorb the broken pipe — `close` still carries the exit code and + // stderr that explain it, and `inputTruncated` keeps a run that + // exits 0 over a half-delivered prompt from passing for a good one. + // Any other stdin error is a genuine local failure and rejects. + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) { + inputTruncated = true; + return; + } + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + reject(err); + }); + if (options.input) { child.stdin.write(options.input); } diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 76b11be1..ebc7e040 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1,4 +1,4 @@ -export { runCommand } from "./command-runner.js"; +export { isBrokenPipe, runCommand } from "./command-runner.js"; export type { CommandOptions, CommandResult } from "./command-runner.js"; export { buildSubshellInvocation, diff --git a/src/session/conversation-turn.ts b/src/session/conversation-turn.ts index d64399f4..449d12e5 100644 --- a/src/session/conversation-turn.ts +++ b/src/session/conversation-turn.ts @@ -282,6 +282,14 @@ export function packConversation( if (lastUserIndex !== -1 && lastUserIndex < startIndex) { startIndex = lastUserIndex; } + // A drained steer becomes the LAST user turn, which would otherwise + // carry the only pin — under token pressure the macro-turn's founding + // instruction would compress into the dropped-summary line while the + // correction stayed, and the model would continue from the correction + // alone. Pin the current macro-turn's opening user turn as well. + if (currentStart < startIndex && turns[currentStart]?.kind === "user") { + startIndex = currentStart; + } const droppedSlice = turns.slice(0, startIndex); const visibleTurns = turns.slice(startIndex); 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/tasks/task-runner.ts b/src/tasks/task-runner.ts index 56ed84f2..f7bcfb6e 100644 --- a/src/tasks/task-runner.ts +++ b/src/tasks/task-runner.ts @@ -335,6 +335,16 @@ export class TaskRunner { // surface as a `reason: "failed"` outcome instead — treat that // as the same retryable transport-class failure so the operator // sees a consistent retry curve. + // A steer accepted by this turn but never delivered must not vanish + // just because the turn belonged to the scheduler: there is no host + // to hand it to, so the structured log is the surface of record. + if (result.undelivered !== undefined && result.undelivered.length > 0) { + this.options.logger?.warn?.("steering messages stranded by a task turn", { + taskId: claimed.id, + count: result.undelivered.length, + preview: result.undelivered[0]?.slice(0, 120), + }); + } if (result.reason === "failed") { return this.handleFailure(claimed, "transport", new Error("loop reported failed")); } diff --git a/src/tools/coerce-tool-args.test.ts b/src/tools/coerce-tool-args.test.ts new file mode 100644 index 00000000..12a8d84f --- /dev/null +++ b/src/tools/coerce-tool-args.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { ToolRegistry, type ToolContext, type ToolDefinition } from "./tool-registry.js"; +import { coerceToolArgs } from "./coerce-tool-args.js"; + +const ctx: ToolContext = { + workingDir: "/w", + sessionId: "s1", + stepIndex: 0, + signal: new AbortController().signal, +}; + +/** Records the args a tool actually received after registry coercion. */ +function spyTool(name: string): { definition: ToolDefinition; seen: () => Record } { + let received: Record = {}; + return { + seen: () => received, + definition: { + name, + description: name, + readonly: true, + run: async (args) => { + received = args; + return { status: "ok", summary: "", details: {} } as never; + }, + }, + }; +} + +async function invokeWith( + name: string, + args: Record, +): Promise> { + const registry = new ToolRegistry(); + const spy = spyTool(name); + registry.register(spy.definition); + await registry.invoke(name, args, ctx); + return spy.seen(); +} + +describe("coerceToolArgs — real failing calls from the campaign", () => { + it("unwraps a stringified string[] for vision.describe (26 occurrences)", async () => { + const seen = await invokeWith("vision.describe", { + prompt: "read the numbers", + paths: '["/var/crops/num_04.png", "/var/crops/num_05.png"]', + }); + expect(seen.paths).toEqual(["/var/crops/num_04.png", "/var/crops/num_05.png"]); + expect(seen.prompt).toBe("read the numbers"); + }); + + it("unwraps stringified numbers for os.fs.read_document (15 occurrences)", async () => { + const seen = await invokeWith("os.fs.read_document", { + path: "census2011final_en.pdf", + maxBytes: "200000", + pagesFrom: "4", + pagesTo: "12", + }); + expect(seen).toEqual({ + path: "census2011final_en.pdf", + maxBytes: 200000, + pagesFrom: 4, + pagesTo: 12, + }); + }); + + it("unwraps a stringified header object for os.http.request (3 occurrences)", async () => { + const seen = await invokeWith("os.http.request", { + url: "https://example.com", + headers: '{"User-Agent": "Mozilla/5.0 (Macintosh)"}', + }); + expect(seen.headers).toEqual({ "User-Agent": "Mozilla/5.0 (Macintosh)" }); + expect(seen.url).toBe("https://example.com"); + }); + + it("unwraps a stringified number for browser.scroll (1 occurrence)", async () => { + const seen = await invokeWith("browser.scroll", { direction: "down", amount: "3000" }); + expect(seen).toEqual({ direction: "down", amount: 3000 }); + }); +}); + +describe("coerceToolArgs — the union case (browser.scroll `amount`)", () => { + // `amount` is anyOf: ["page" | "half"] | number. The enum strings are + // legal values as written and must survive; a numeric string is not. + it("leaves the enum string \"page\" untouched", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "page" })).toEqual({ + direction: "down", + amount: "page", + }); + }); + + it("leaves the enum string \"half\" untouched", () => { + expect(coerceToolArgs("browser.scroll", { direction: "up", amount: "half" })).toEqual({ + direction: "up", + amount: "half", + }); + }); + + it("converts a numeric string on the same union field", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "3000" })).toEqual({ + direction: "down", + amount: 3000, + }); + }); + + it("passes an off-schema string through for the tool to reject", () => { + expect(coerceToolArgs("browser.scroll", { direction: "down", amount: "lots" })).toEqual({ + direction: "down", + amount: "lots", + }); + }); + + it("leaves a JSON-looking string on a string|object field alone", () => { + // os.http.request.body accepts a raw string, so `{"a":1}` is a + // legitimate body rather than an over-encoded object. + const args = { url: "https://example.com", method: "POST", body: '{"a":1}' }; + expect(coerceToolArgs("os.http.request", args).body).toBe('{"a":1}'); + }); +}); + +describe("coerceToolArgs — do no harm", () => { + it("leaves a string value for a string-typed field alone", () => { + const args = { path: "1234", format: "5" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("passes an uncoercible number string through unchanged", () => { + const args = { path: "a.pdf", maxBytes: "not-a-number" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + expect(coerceToolArgs("os.fs.read_document", args).maxBytes).toBe("not-a-number"); + }); + + it("passes malformed JSON for an array field through without throwing", () => { + const args = { prompt: "p", paths: "[broken" }; + expect(() => coerceToolArgs("vision.describe", args)).not.toThrow(); + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); + + it("passes malformed JSON for an object field through without throwing", () => { + const args = { url: "https://example.com", headers: "{not json" }; + expect(() => coerceToolArgs("os.http.request", args)).not.toThrow(); + expect(coerceToolArgs("os.http.request", args).headers).toBe("{not json"); + }); + + it("does not coerce a JSON array of the wrong item type", () => { + // vision.describe.paths is string[]; numbers must not slip through. + const args = { prompt: "p", paths: "[1, 2, 3]" }; + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); + + it("leaves an already-correct array untouched (no double parsing)", () => { + const paths = ["/a.png", "/b.png"]; + const result = coerceToolArgs("vision.describe", { prompt: "p", paths }); + expect(result.paths).toBe(paths); + }); + + it("leaves already-correct numbers and objects untouched", () => { + const args = { path: "a.pdf", maxBytes: 200000, pageSeparators: true }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("passes a tool with no registered schema through completely unchanged", () => { + const args = { anything: "[1,2,3]", other: "500" }; + expect(coerceToolArgs("mcp.some.unregistered.tool", args)).toBe(args); + }); + + it("ignores unknown keys not present in the schema properties", () => { + const args = { path: "a.pdf", bogusKey: "[1,2,3]" }; + expect(coerceToolArgs("os.fs.read_document", args)).toEqual(args); + }); + + it("does not mutate the caller's object", () => { + const args = { prompt: "p", paths: '["/a.png"]' }; + const snapshot = { ...args }; + coerceToolArgs("vision.describe", args); + expect(args).toEqual(snapshot); + expect(args.paths).toBe('["/a.png"]'); + }); + + it("returns the same reference when nothing needed coercing", () => { + const args = { path: "a.pdf", maxBytes: 200 }; + expect(coerceToolArgs("os.fs.read_document", args)).toBe(args); + }); + + it("handles an empty args object", () => { + expect(coerceToolArgs("vision.describe", {})).toEqual({}); + }); + + it("leaves non-string values of the wrong type alone for the tool to reject", () => { + // Only strings are candidates for unwrapping; a bad number stays bad. + const args = { prompt: "p", paths: 42 }; + expect(coerceToolArgs("vision.describe", args)).toEqual(args); + }); +}); + +describe("ToolRegistry.invoke integration", () => { + it("still dispatches to the right tool and returns its result", async () => { + const registry = new ToolRegistry(); + registry.register({ + name: "vision.describe", + description: "d", + readonly: true, + run: async (args) => ({ status: "ok", summary: String(args.paths), details: {} }) as never, + }); + const result = await registry.invoke( + "vision.describe", + { prompt: "p", paths: '["/a.png"]' }, + ctx, + ); + expect(result.status).toBe("ok"); + }); + + it("still throws ToolNotFoundError before any coercion", async () => { + const registry = new ToolRegistry(); + await expect(registry.invoke("nope.missing", { a: "1" }, ctx)).rejects.toThrow( + /tool not registered/, + ); + }); +}); diff --git a/src/tools/coerce-tool-args.ts b/src/tools/coerce-tool-args.ts new file mode 100644 index 00000000..6a9abdff --- /dev/null +++ b/src/tools/coerce-tool-args.ts @@ -0,0 +1,83 @@ +import { + coerceJsonSchemaValue, + validateJsonSchemaValue, +} from "../llm/provider/openai/coerce-json-schema-value.js"; +import { getDefaultArgsJsonSchema } from "../prompt/default-tool-args-schemas.js"; + +type Schema = Record; + +/** + * Repairs tool arguments that arrived one level over-encoded. + * + * Models routinely emit a JSON value wrapped in a string: a number as + * `"200000"`, an array as `"[\"a.png\"]"`, an object as + * `"{\"User-Agent\":\"...\"}"`. The payload is valid JSON of the right + * shape, but the tools' strict `typeof` checks reject it and the step + * is wasted. This runs on every dispatch (see `ToolRegistry.invoke`) + * and unwraps exactly that case. + * + * The rule is do-no-harm. A string is left alone whenever the declared + * schema already accepts it as written, and any coercion failure keeps + * the original value so the tool's own validation produces its normal + * error. A tool with no registered schema passes through unchanged. + */ +export function coerceToolArgs( + name: string, + args: Record, +): Record { + const properties = argsProperties(name); + if (!properties) return args; + + let coerced: Record | null = null; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== "string") continue; + const schema = asSchema(properties[key]); + if (!schema) continue; + + const candidate = tryCoerce(value, schema); + if (candidate === undefined) continue; + + coerced ??= { ...args }; + coerced[key] = candidate; + } + return coerced ?? args; +} + +/** + * Returns the unwrapped value, or `undefined` when the string must be + * left exactly as it is. + * + * The guard that matters is the first one. `browser.scroll`'s `amount` + * is declared `anyOf: ["page" | "half", number]`, so the field accepts + * both strings and numbers: `"page"` validates as written and must stay + * a string, while `"3000"` does not and becomes `3000`. Checking the + * concrete value against the schema — rather than asking whether the + * schema mentions `string` anywhere — gets both halves right, and it + * covers `os.http.request`'s `body` (`string | object`) too, where a + * JSON-looking string is a legitimate value rather than an encoding + * mistake. + */ +function tryCoerce(value: string, schema: Schema): unknown { + try { + if (validateJsonSchemaValue(value, schema)) return undefined; + const candidate = coerceJsonSchemaValue(value, schema); + // A coercion that yields another string changed nothing worth + // rewriting; treat it as a no-op. + return typeof candidate === "string" ? undefined : candidate; + } catch { + // Unsupported schema, or the value does not fit the declared shape. + return undefined; + } +} + +/** The `properties` map of a default tool's args schema, when registered. */ +function argsProperties(name: string): Schema | null { + const schema = getDefaultArgsJsonSchema(name); + return schema ? asSchema(schema.properties) : null; +} + +function asSchema(value: unknown): Schema | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Schema) + : null; +} diff --git a/src/tools/os/expand-shell-glob-args.test.ts b/src/tools/os/expand-shell-glob-args.test.ts index 42e227c8..02b85837 100644 --- a/src/tools/os/expand-shell-glob-args.test.ts +++ b/src/tools/os/expand-shell-glob-args.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expandShellGlobArgs } from "./expand-shell-glob-args.js"; @@ -26,10 +26,53 @@ describe("expandShellGlobArgs", () => { ); }); - it("omits argv when glob matches nothing (nullglob-style)", async () => { + it("expands a real file glob for rm alongside other argv", async () => { + await writeFile(join(dir, "a.txt"), "1", "utf8"); + await writeFile(join(dir, "b.txt"), "2", "utf8"); + await writeFile(join(dir, "keep.md"), "3", "utf8"); + const out = expandShellGlobArgs("rm", ["*.txt"], dir); + expect(new Set(out)).toEqual( + new Set([join(dir, "a.txt"), join(dir, "b.txt")]), + ); + }); + + it("keeps the pattern verbatim when a glob matches nothing", async () => { await writeFile(join(dir, "c.txt"), "3", "utf8"); const out = expandShellGlobArgs("rm", ["-f", "*.png"], dir); - expect(out).toEqual(["-f"]); + expect(out).toEqual(["-f", "*.png"]); + }); + + it("keeps a path-shaped pattern that matches nothing verbatim", () => { + const out = expandShellGlobArgs("ls", ["./nope/*.png"], dir); + expect(out).toEqual(["./nope/*.png"]); + }); + + it("passes a bash -c payload containing a URL with ? and / through intact", () => { + const payload = + "curl -s 'https://en.wikipedia.org/w/api.php?action=query&prop=revisions&titles=Outer%20Wilds&format=json'"; + const args = ["-c", payload]; + const out = expandShellGlobArgs("bash", args, dir); + expect(out).toEqual(args); + }); + + it("passes a python3 -c payload containing regex metacharacters through intact", () => { + const payload = "import re; print(re.findall(r'a?b*c', 'aabbcc'))"; + const args = ["-c", payload]; + const out = expandShellGlobArgs("python3", args, dir); + expect(out).toEqual(args); + }); + + it("never drops argv for a bash -c payload, so the shell sees its command", () => { + const args = ["-c", "echo 'https://example.com/x?y=1' > /dev/null"]; + const out = expandShellGlobArgs("bash", args, dir); + expect(out.length).toBe(args.length); + expect(out[1]).toBe(args[1]); + }); + + it("does not treat a bare URL argument as a glob", () => { + const url = "https://example.com/w/api.php?action=query&x=*"; + const out = expandShellGlobArgs("curl", ["-s", url], dir); + expect(out).toEqual(["-s", url]); }); it("does not expand bare *.py for find", async () => { @@ -42,4 +85,39 @@ describe("expandShellGlobArgs", () => { const out = expandShellGlobArgs("rm", ["-f"], dir); expect(out).toEqual(["-f"]); }); + + it("still expands a real path argument for an interpreter without -c", async () => { + await writeFile(join(dir, "s1.py"), "x", "utf8"); + const out = expandShellGlobArgs("python3", ["./s1*.py"], dir); + expect(out).toEqual([join(dir, "s1.py")]); + }); + + it("leaves a -c payload alone even when it happens to match real files", async () => { + // The differential case for the exemption itself: without it, this + // payload would be rewritten to the matched path, not passed through. + await mkdir(join(dir, "x")); + await writeFile(join(dir, "x", "y.txt"), "1", "utf8"); + const args = ["-c", "x/*.txt"]; + expect(expandShellGlobArgs("bash", args, dir)).toEqual(args); + }); + + it("exempts the payload for a path-invoked shell and clustered flags", async () => { + await mkdir(join(dir, "x")); + await writeFile(join(dir, "x", "y.txt"), "1", "utf8"); + expect(expandShellGlobArgs("/bin/bash", ["-c", "x/*.txt"], dir)).toEqual([ + "-c", + "x/*.txt", + ]); + expect(expandShellGlobArgs("bash", ["-lc", "x/*.txt"], dir)).toEqual([ + "-lc", + "x/*.txt", + ]); + }); + + it("still expands -c for interpreters whose -c checks a file", async () => { + // perl -c is a syntax check on a script path, not an inline program. + await writeFile(join(dir, "s1.pl"), "x", "utf8"); + const out = expandShellGlobArgs("perl", ["-c", "./s1*.pl"], dir); + expect(out).toEqual(["-c", join(dir, "s1.pl")]); + }); }); diff --git a/src/tools/os/expand-shell-glob-args.ts b/src/tools/os/expand-shell-glob-args.ts index b9373721..308c2c7a 100644 --- a/src/tools/os/expand-shell-glob-args.ts +++ b/src/tools/os/expand-shell-glob-args.ts @@ -1,6 +1,7 @@ import { globSync } from "node:fs"; import { isAbsolute } from "node:path"; import { resolveUserPath } from "./expand-home.js"; +import { basenameCommand } from "./shell-command-guard/normalise.js"; const MAX_GLOB_MATCHES = 10_000; @@ -17,13 +18,77 @@ const RELATIVE_GLOB_CMDS = new Set([ "rmdir", ]); +/** + * Interpreters whose `-c` argument is a program, not a path. The payload + * routinely carries `?`/`*` (regexes, URLs with query strings, glob patterns + * meant for the inner program) and must reach the interpreter verbatim. + * `node`, `perl` and `ruby` are deliberately absent: their `-c` is a + * syntax check that takes a file path, so globbing it stays correct. + */ +const CODE_PAYLOAD_CMDS = new Set([ + "bash", + "sh", + "zsh", + "dash", + "ksh", + "python", + "python3", +]); + +/** Matches with the guard's view of the binary: basename, case-folded. */ +function isCodePayloadCmd(cmd: string): boolean { + const bin = basenameCommand(cmd).toLowerCase().replace(/\.exe$/, ""); + if (CODE_PAYLOAD_CMDS.has(bin)) return true; + return /^python\d+(\.\d+)*$/.test(bin); +} + +/** `-c`, a short-option cluster ending in it (`-lc`, `-ec`), or the long form. */ +function isCodePayloadFlag(arg: string): boolean { + return /^-[a-zA-Z]*c$/.test(arg) || arg === "--command"; +} + +/** + * `scheme://…` — a URL is never a filesystem glob, even with `?` and `/`. + * Two-letter minimum keeps Windows `C://…` sloppy-paths out of the rule. + */ +const URL_RE = /^[a-z][a-z0-9+.-]+:\/\//i; + function hasGlobMetachar(arg: string): boolean { return /[*?]/.test(arg); } +function isUrlLike(arg: string): boolean { + return URL_RE.test(arg); +} + +/** + * Indices of argv entries that are code payloads rather than paths — the + * token right after a `-c` (or a cluster like `-lc`) for a known + * interpreter. `bash -c ''` is the dominant shape; the scan + * stops at the first non-flag token so a later positional argument is not + * mistaken for a payload. A flag that takes a separate value (`-o + * pipefail`, `-X utf8`) ends the scan early — a known limit, and safe: + * the never-drop rule below keeps such a payload intact unless it + * collides with a really-matching file glob. + */ +function codePayloadIndices(cmd: string, args: string[]): ReadonlySet { + const marked = new Set(); + if (!isCodePayloadCmd(cmd)) return marked; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (isCodePayloadFlag(arg)) { + if (i + 1 < args.length) marked.add(i + 1); + break; + } + if (!arg.startsWith("-")) break; + } + return marked; +} + function shouldExpandGlobArg(cmd: string, arg: string): boolean { if (!hasGlobMetachar(arg)) return false; if (arg.startsWith("-")) return false; + if (isUrlLike(arg)) return false; if ( arg.startsWith("/") || arg.startsWith("~/") || @@ -46,15 +111,22 @@ function globMatches(pattern: string, cwd: string): string[] { /** * Expands `*` / `?` in argv the way a shell would for typical file commands, * before `spawn` (which does not perform glob expansion). + * + * An argument is never dropped. A pattern that matches nothing passes through + * verbatim, which is what POSIX shells do by default (bash without + * `nullglob`, zsh with `nomatch` off) — silently discarding it turned a + * correct `bash -c ''` into a bare `bash -c` and made the shell + * fail with "option requires an argument". */ export function expandShellGlobArgs( cmd: string, args: string[], cwd: string, ): string[] { + const codePayloads = codePayloadIndices(cmd, args); const out: string[] = []; - for (const arg of args) { - if (!shouldExpandGlobArg(cmd, arg)) { + for (const [index, arg] of args.entries()) { + if (codePayloads.has(index) || !shouldExpandGlobArg(cmd, arg)) { out.push(arg); continue; } @@ -70,6 +142,7 @@ export function expandShellGlobArgs( continue; } if (matches.length === 0) { + out.push(arg); continue; } out.push(...matches); diff --git a/src/tools/os/fs-grep.test.ts b/src/tools/os/fs-grep.test.ts index c88eda41..a6682c78 100644 --- a/src/tools/os/fs-grep.test.ts +++ b/src/tools/os/fs-grep.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { afterAll, beforeAll, describe, it, expect } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { CommandOptions, CommandResult, @@ -6,11 +9,23 @@ import type { import type { ToolContext } from "../tool-registry.js"; import { buildOsFsGrepTool, parseRipgrepJson } from "./fs-grep.js"; -// A platform-native absolute root: the grep runner is mocked in these tests -// so the path is never touched on disk, but it must be a valid absolute path -// for the host so `resolveUserPath` does not reject a Unix path on Windows. -const FIXTURE_ROOT = - process.platform === "win32" ? "C:\\tmp\\fixture" : "/tmp/fixture"; +// The grep runner is mocked in these tests, but the tool now stats the +// requested path to decide the child process cwd, so the fixture must exist +// on disk. A real temp directory also keeps the paths platform-native, so +// `resolveUserPath` does not reject a Unix path on Windows. +let FIXTURE_ROOT: string; +let FIXTURE_FILE: string; +const FIXTURE_FILE_NAME = "darktrace.txt"; + +beforeAll(async () => { + FIXTURE_ROOT = await mkdtemp(join(tmpdir(), "fs-grep-test-")); + FIXTURE_FILE = join(FIXTURE_ROOT, FIXTURE_FILE_NAME); + await writeFile(FIXTURE_FILE, "endopsychic\n", "utf8"); +}); + +afterAll(async () => { + await rm(FIXTURE_ROOT, { recursive: true, force: true }); +}); function makeCtx(): ToolContext { return { @@ -34,6 +49,7 @@ function makeCommandResult( durationMs: 1, timedOut: false, truncated: false, + inputTruncated: false, ...overrides, }; } @@ -309,4 +325,92 @@ describe("os.fs.grep", () => { expect(capturedArgs[before + 1]).toBe("2"); expect(capturedArgs[after + 1]).toBe("2"); }); + + // Regression coverage for #183: an absolute file path used to be passed + // through as the child process cwd, which `spawn` rejects with ENOTDIR. + it("searches an absolute file path from its parent directory", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { + pattern: "endopsychic", + path: FIXTURE_FILE, + outputMode: "content", + contextAround: 3, + caseInsensitive: true, + }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe(FIXTURE_FILE_NAME); + expect(capturedArgs[capturedArgs.length - 2]).toBe("endopsychic"); + }); + + it("searches a relative file path from its parent directory", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "endopsychic", path: FIXTURE_FILE_NAME }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe(FIXTURE_FILE_NAME); + }); + + it("still searches a directory path with '.' as the target", async () => { + let capturedArgs: string[] = []; + let capturedCwd: string | undefined; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async (_cmd, args, opts: CommandOptions) => { + capturedArgs = args; + capturedCwd = opts.cwd; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "endopsychic", path: FIXTURE_ROOT }, + makeCtx(), + ); + expect(result.status).toBe("ok"); + expect(capturedCwd).toBe(FIXTURE_ROOT); + expect(capturedArgs[capturedArgs.length - 1]).toBe("."); + }); + + it("reports a clear error for a path that does not exist", async () => { + let ran = false; + const tool = buildOsFsGrepTool({ + resolveRgPath: () => "/fake/rg", + runCommand: async () => { + ran = true; + return makeCommandResult({ stdout: "" }); + }, + }); + const result = await tool.run( + { pattern: "foo", path: join(FIXTURE_ROOT, "no-such-file.txt") }, + makeCtx(), + ); + expect(result.status).toBe("error"); + expect(result.summary).toContain("does not exist"); + expect(result.summary).not.toContain("ENOTDIR"); + expect(ran).toBe(false); + }); }); diff --git a/src/tools/os/fs-grep.ts b/src/tools/os/fs-grep.ts index 1a096d45..689156c7 100644 --- a/src/tools/os/fs-grep.ts +++ b/src/tools/os/fs-grep.ts @@ -1,3 +1,5 @@ +import { stat } from "node:fs/promises"; +import { basename, dirname } from "node:path"; import { compressToolResult } from "../../compressor/result-compressor.js"; import { resolveUserPath } from "./expand-home.js"; import { @@ -72,9 +74,22 @@ export function buildOsFsGrepTool( }); } - const rgArgs = buildRgArgs(args); + let target: SearchTarget; + try { + target = await resolveSearchTarget(args.path, ctx.workingDir); + } catch (err) { + const reason = (err as Error).message; + return compressToolResult({ + tool: "os.fs.grep", + status: "error", + output: reason, + details: { path: args.path, hint: reason }, + }); + } + + const rgArgs = buildRgArgs(args, target.searchTarget); const result = await runCommand(rgPath, rgArgs, { - cwd: args.path, + cwd: target.cwd, timeoutMs: args.timeoutMs, signal: ctx.signal, }); @@ -194,7 +209,49 @@ function parseArgs( }; } -function buildRgArgs(args: GrepArgs): string[] { +interface SearchTarget { + /** Directory the ripgrep child process is spawned in. Always a directory. */ + cwd: string; + /** Positional target handed to ripgrep, relative to `cwd`. */ + searchTarget: string; +} + +/** + * Work out where to spawn ripgrep and what to point it at. + * + * `spawn` requires `cwd` to be a directory, so passing a file path straight + * through fails with `ENOTDIR` before ripgrep ever runs. A file is therefore + * searched from its parent directory, with the file name as the positional + * target; a directory keeps the previous behaviour (`cwd` = the directory, + * target = `.`) so glob and type filters resolve the same way as before. + */ +async function resolveSearchTarget( + path: string, + workingDir: string, +): Promise { + let info; + try { + info = await stat(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new Error(`os.fs.grep: path does not exist: ${path}`); + } + throw new Error( + `os.fs.grep: cannot access path ${path}: ${(err as Error).message}`, + ); + } + if (info.isDirectory()) { + return { cwd: path, searchTarget: "." }; + } + const parent = dirname(path); + // `dirname` of a filesystem root returns the root itself; fall back to the + // working directory only when the parent is somehow unusable. + const cwd = parent.length > 0 ? parent : workingDir; + return { cwd, searchTarget: basename(path) }; +} + +function buildRgArgs(args: GrepArgs, searchTarget: string): string[] { const rg: string[] = ["--json"]; if (args.caseInsensitive) rg.push("-i"); if (args.multiline) { @@ -207,7 +264,7 @@ function buildRgArgs(args: GrepArgs): string[] { rg.push("--glob", g); } if (args.type) rg.push("--type", args.type); - rg.push("--", args.pattern, "."); + rg.push("--", args.pattern, searchTarget); return rg; } diff --git a/src/tools/os/http-request-fetch.ts b/src/tools/os/http-request-fetch.ts index 0828c0b1..8078dee8 100644 --- a/src/tools/os/http-request-fetch.ts +++ b/src/tools/os/http-request-fetch.ts @@ -181,6 +181,9 @@ function buildPinnedCurlArgs(input: BuildPinnedCurlArgs): string[] { : input.pinnedIp; const argv: string[] = [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", String(Math.ceil(input.timeoutMs / 1000)), // Hop-by-hop follow is owned by executeGuardedHttpRequest so each diff --git a/src/tools/os/http-request.test.ts b/src/tools/os/http-request.test.ts index 0648e2d0..2ae8163d 100644 --- a/src/tools/os/http-request.test.ts +++ b/src/tools/os/http-request.test.ts @@ -35,6 +35,7 @@ function makeCommandResult( durationMs: 1, timedOut: false, truncated: false, + inputTruncated: false, ...overrides, }; } @@ -633,6 +634,28 @@ describe("os.http.request", () => { expect(capture.args).not.toContain("-L"); }); + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Real failure from the field: without --globoff curl rejects the + // `[Dd]` character set with "curl: (3) bad range in URL position 158". + const url = + "http://web.archive.org/cdx/search/cdx?url=base-search.net" + + "&matchType=domain&filter=original:.*[Dd]ewey.*&collapse=urlkey"; + const capture: { cmd?: string; args?: string[] } = {}; + const tool = buildOsHttpRequestTool({ + lookup: publicLookup, + approvals: approveAll(), + approvalRequired: false, + config: makeHttpConfig({ approvalMode: "never" }), + runCommand: fakeRun(capture, { + stdout: "ok\n" + meta(200, "text/plain", 2), + }), + }); + await tool.run({ url }, makeCtx()); + expect(capture.args).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(capture.args![capture.args!.length - 1]).toContain("[Dd]ewey"); + }); + it("re-validates redirect hops and blocks a private Location target", async () => { const calls: string[][] = []; const runCommand = async ( diff --git a/src/tools/os/index.ts b/src/tools/os/index.ts index f2b6d017..cfaceb5a 100644 --- a/src/tools/os/index.ts +++ b/src/tools/os/index.ts @@ -132,7 +132,7 @@ export function registerOsTools( }), ); registry.register(buildOsWebSearchTool({ config: options.config })); - registry.register(buildOsWebFetchTool()); + registry.register(buildOsWebFetchTool({ config: options.config })); registry.register(osClipboardReadTool); registry.register(osClipboardWriteTool); registry.register(osWindowListTool); diff --git a/src/tools/os/web-fetch.test.ts b/src/tools/os/web-fetch.test.ts index a6388942..c98458ae 100644 --- a/src/tools/os/web-fetch.test.ts +++ b/src/tools/os/web-fetch.test.ts @@ -3,6 +3,7 @@ import { buildOsWebFetchTool, parseCurlMeta } from "./web-fetch.js"; import type { runCommand as RunCommandType } from "../../sandbox/command-runner.js"; import type { HostLookup } from "./web-fetch-ssrf-guard.js"; import type { ToolContext } from "../tool-registry.js"; +import { USER_CONFIG_DEFAULTS } from "../../config/index.js"; const MARKER = "__ATOMIC_WEBFETCH_META__"; @@ -19,8 +20,32 @@ function curlStdout(opts: { status: number; contentType: string; redirectUrl?: string; + /** Response headers rendered the way curl's `%{header_json}` emits them. */ + headers?: Record; }): string { - return `${opts.body}\n${MARKER}${opts.status}|${opts.contentType}|${opts.redirectUrl ?? ""}|${opts.body.length}`; + const headerJson = + opts.headers === undefined + ? "" + : JSON.stringify( + Object.fromEntries( + Object.entries(opts.headers).map(([k, v]) => [k, [v]]), + ), + ); + return `${opts.body}\n${MARKER}${opts.status}|${opts.contentType}|${opts.redirectUrl ?? ""}|${opts.body.length}|${headerJson}`; +} + +/** Collects backoff waits instead of sleeping, so retry tests run instantly. */ +function fakeSleep(): { + sleep: (ms: number, signal: AbortSignal) => Promise; + waits: number[]; +} { + const waits: number[] = []; + return { + waits, + sleep: async (ms: number) => { + waits.push(ms); + }, + }; } function makeRunCommand( @@ -66,6 +91,28 @@ describe("parseCurlMeta", () => { expect(parsed.contentType).toBe("text/html; charset=utf-8"); expect(parsed.redirectUrl).toBe("https://x/redir"); }); + + it("parses Retry-After out of the header_json field", () => { + const parsed = parseCurlMeta( + `body\n${MARKER}503|text/html|| 5|{"retry-after":["7"],"server":["x"]}`, + ); + expect(parsed.status).toBe(503); + expect(parsed.retryAfterMs).toBe(7_000); + }); + + it("keeps header_json containing pipes out of the fixed fields", () => { + const parsed = parseCurlMeta( + `body\n${MARKER}200|text/html||4|{"x-thing":["a|b"],"retry-after":["3"]}`, + ); + expect(parsed.contentType).toBe("text/html"); + expect(parsed.retryAfterMs).toBe(3_000); + }); + + it("yields no Retry-After when header_json is absent (older curl)", () => { + const parsed = parseCurlMeta(`body\n${MARKER}503|text/html||4|%{header_json}`); + expect(parsed.status).toBe(503); + expect(parsed.retryAfterMs).toBeNull(); + }); }); describe("os.web.fetch tool", () => { @@ -189,4 +236,314 @@ describe("os.web.fetch tool", () => { expect(result.summary).toContain("HTTP 500"); expect(result.details.status).toBe(500); }); + + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Real failure from the field: without --globoff curl rejects the + // `[... TO ...]` date range with "curl: (3) bad range in URL position 124". + const url = + "http://export.arxiv.org/api/query?search_query=all:%22multiwavelength%22" + + "+AND+submittedDate:[202102010000+TO+202104300000]&start=0&max_results=30"; + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "", + status: 200, + contentType: "application/atom+xml", + }), + })), + ); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup }); + await tool.run({ url }, ctx()); + expect(run).toHaveBeenCalledTimes(1); + const args = run.mock.calls[0]![1] as string[]; + expect(args).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(args[args.length - 1]).toContain("[202102010000"); + }); +}); + +// Issue #181 — 236 timeout failures each burned a fixed 30s of the task +// budget, with no connect timeout and no way to shorten the wait. +describe("os.web.fetch timeouts (#181)", () => { + function cfg(fetch: Partial<{ + timeoutMs: number; + connectTimeoutMs: number; + maxRetries: number; + retryBaseDelayMs: number; + retryMaxDelayMs: number; + }>) { + return { + web: { + search: USER_CONFIG_DEFAULTS.web.search, + fetch: { ...USER_CONFIG_DEFAULTS.web.fetch, ...fetch }, + }, + }; + } + + it("passes --connect-timeout so dead hosts fail fast", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup }); + await tool.run({ url: "https://example.com/doc" }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + // Default connect budget is 10s, well below the 30s overall budget. + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("10"); + }); + + it("uses the configured timeoutMs for --max-time", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 8_000, connectTimeoutMs: 3_000 }), + }); + await tool.run({ url: "https://example.com/doc" }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("8"); + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("3"); + }); + + it("lets a per-call timeoutMs override the configured default", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 30_000 }), + }); + await tool.run({ url: "https://example.com/doc", timeoutMs: 5_000 }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("5"); + }); + + it("never lets the connect budget exceed a smaller per-call timeout", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + })), + ); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + config: cfg({ timeoutMs: 30_000, connectTimeoutMs: 10_000 }), + }); + await tool.run({ url: "https://example.com/doc", timeoutMs: 2_000 }, ctx()); + const args = run.mock.calls[0]![1] as string[]; + expect(args[args.indexOf("--max-time") + 1]).toBe("2"); + expect(args[args.indexOf("--connect-timeout") + 1]).toBe("2"); + }); +}); + +// Issue #180 — os.web.fetch never retried. 128 of 130 real 503s came from +// web.archive.org, which serves the very same URL seconds later. +describe("os.web.fetch retries (#180)", () => { + const ARCHIVE_URL = "https://web.archive.org/web/2023/https://example.com"; + + it("retries a 503 from web.archive.org and succeeds on the second attempt", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: curlStdout({ + body: "slow down", + status: 503, + contentType: "text/html", + }), + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("ok"); + expect(result.details.status).toBe(200); + expect(run).toHaveBeenCalledTimes(2); + // First backoff is the base delay. + expect(waits).toEqual([500]); + }); + + it("returns the error once the retry budget is exhausted", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "still down", + status: 503, + contentType: "text/html", + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("error"); + expect(result.details.status).toBe(503); + // Default maxRetries: 2 → 3 attempts total, exponential 500ms then 1000ms. + expect(run).toHaveBeenCalledTimes(3); + expect(waits).toEqual([500, 1_000]); + }); + + it("does NOT retry a 404", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "not found", + status: 404, + contentType: "text/html", + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/missing" }, ctx()); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + expect(waits).toEqual([]); + }); + + it("retries a curl timeout (exit 28)", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: "", + exitCode: 28, + stderr: "curl: (28) Connection timed out after 30006 milliseconds", + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/slow" }, ctx()); + expect(result.status).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry a non-timeout curl failure", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: "", + exitCode: 6, + stderr: "curl: (6) Could not resolve host: nope.invalid", + })), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://nope.invalid/x" }, ctx()); + expect(result.status).toBe("error"); + expect(result.summary).toContain("Could not resolve host"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("honours a Retry-After header over the computed backoff", async () => { + let attempts = 0; + const run = vi.fn( + makeRunCommand(() => { + attempts += 1; + if (attempts === 1) { + return { + stdout: curlStdout({ + body: "", + status: 429, + contentType: "text/html", + headers: { "retry-after": "2" }, + }), + }; + } + return { + stdout: curlStdout({ body: ARTICLE, status: 200, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run({ url: "https://example.com/limited" }, ctx()); + expect(result.status).toBe("ok"); + // 2s from the header, not the 500ms base delay. + expect(waits).toEqual([2_000]); + }); + + it("clamps an oversized Retry-After to retryMaxDelayMs", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ + body: "", + status: 503, + contentType: "text/html", + headers: { "retry-after": "3600" }, + }), + })), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + await tool.run({ url: ARCHIVE_URL }, ctx()); + // Never parks the agent for an hour — capped at the 5s default. + expect(waits).toEqual([5_000, 5_000]); + }); + + it("respects maxRetries: 0 (retrying disabled)", async () => { + const run = vi.fn( + makeRunCommand(() => ({ + stdout: curlStdout({ body: "", status: 503, contentType: "text/html" }), + })), + ); + const { sleep } = fakeSleep(); + const tool = buildOsWebFetchTool({ + runCommand: run, + lookup: publicLookup, + sleep, + config: { + web: { + search: USER_CONFIG_DEFAULTS.web.search, + fetch: { ...USER_CONFIG_DEFAULTS.web.fetch, maxRetries: 0 }, + }, + }, + }); + const result = await tool.run({ url: ARCHIVE_URL }, ctx()); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("stops retrying once the abort signal fires", async () => { + const controller = new AbortController(); + const run = vi.fn( + makeRunCommand(() => { + // The task is cancelled while the first attempt is in flight. + controller.abort(); + return { + stdout: curlStdout({ body: "", status: 503, contentType: "text/html" }), + }; + }), + ); + const { sleep, waits } = fakeSleep(); + const tool = buildOsWebFetchTool({ runCommand: run, lookup: publicLookup, sleep }); + const result = await tool.run( + { url: ARCHIVE_URL }, + { ...ctx(), signal: controller.signal }, + ); + expect(result.status).toBe("error"); + expect(run).toHaveBeenCalledTimes(1); + expect(waits).toEqual([]); + }); }); diff --git a/src/tools/os/web-fetch.ts b/src/tools/os/web-fetch.ts index 7071cad4..63de529d 100644 --- a/src/tools/os/web-fetch.ts +++ b/src/tools/os/web-fetch.ts @@ -4,6 +4,7 @@ import { type CommandResult, } from "../../sandbox/command-runner.js"; import type { ToolDefinition } from "../tool-registry.js"; +import type { AtomicAgentConfig, WebFetchConfig } from "../../config/index.js"; import { extractWebContent, type ExtractMode } from "./web-fetch-extract.js"; import { CurlUnavailableError, isCurlMissingError } from "./ensure-curl.js"; import { @@ -21,6 +22,30 @@ const MAX_REDIRECTS = 3; const DEFAULT_MAX_CHARS = 50_000; const MAX_CHARS_CAP = 50_000; +/** + * Fallback `web.fetch` settings for callers that construct the tool without a + * config (tests, embedders). Mirrors `USER_CONFIG_DEFAULTS.web.fetch`. + */ +const DEFAULT_FETCH_CONFIG: WebFetchConfig = { + timeoutMs: DEFAULT_TIMEOUT_MS, + connectTimeoutMs: 10_000, + maxRetries: 2, + retryBaseDelayMs: 500, + retryMaxDelayMs: 5_000, +}; + +/** + * HTTP statuses worth a second attempt. 503 dominates the field data (and is + * overwhelmingly `web.archive.org` shedding load, which serves the very same + * URL seconds later); 429/502/504 are the other transient-by-contract codes. + * Everything else — notably 4xx like 404/403 — is a stable answer that would + * only burn budget on a repeat. + */ +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); + +/** curl's "operation timed out" exit. The other exits are not transient. */ +const CURL_EXIT_TIMEOUT = 28; + const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; @@ -37,12 +62,21 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); export interface OsWebFetchOptions { runCommand?: typeof defaultRunCommand; lookup?: HostLookup; + /** + * Timeout / retry tunables. Optional so existing callers and tests keep + * working — when absent `DEFAULT_FETCH_CONFIG` applies, which reproduces the + * pre-v38 30s budget. + */ + config?: Pick; + /** Injectable sleep so retry-backoff tests do not wait in real time. */ + sleep?: (ms: number, signal: AbortSignal) => Promise; } interface WebFetchArgs { url: string; mode: ExtractMode; maxChars: number; + timeoutMs: number; } interface CurlResponse { @@ -51,6 +85,8 @@ interface CurlResponse { redirectUrl: string; body: string; truncated: boolean; + /** Seconds parsed from a `Retry-After` response header, when present. */ + retryAfterMs: number | null; } interface FetchOutcome { @@ -66,6 +102,8 @@ export function buildOsWebFetchTool( options: OsWebFetchOptions = {}, ): ToolDefinition { const runCommand = options.runCommand ?? defaultRunCommand; + const fetchCfg = options.config?.web.fetch ?? DEFAULT_FETCH_CONFIG; + const sleep = options.sleep ?? defaultSleep; return { name: TOOL_NAME, description: @@ -74,12 +112,14 @@ export function buildOsWebFetchTool( "(Accept: text/markdown), then Mozilla Readability, then a basic " + "tag-stripping fallback. GET only, no auth headers, no JavaScript " + "(use browser.* for JS-heavy pages). Blocks private/internal " + - "addresses (SSRF) and re-validates each redirect hop. For raw " + - "API/JSON responses, custom headers, auth, or POST, use " + - "os.http.request instead.", + "addresses (SSRF) and re-validates each redirect hop. Retries " + + "transient failures (429/502/503/504, connection timeouts) with " + + "exponential backoff. Optional `timeoutMs` overrides the configured " + + "per-attempt budget. For raw API/JSON responses, custom headers, " + + "auth, or POST, use os.http.request instead.", readonly: true, async run(rawArgs, ctx) { - const args = parseArgs(rawArgs); + const args = parseArgs(rawArgs, fetchCfg.timeoutMs); let outcome: FetchOutcome; try { outcome = await fetchWithGuard(args.url, { @@ -87,6 +127,9 @@ export function buildOsWebFetchTool( lookup: options.lookup, cwd: ctx.workingDir, signal: ctx.signal, + fetchCfg, + timeoutMs: args.timeoutMs, + sleep, }); } catch (err) { return compressToolResult({ @@ -149,7 +192,10 @@ export function buildOsWebFetchTool( }; } -function parseArgs(rawArgs: Record): WebFetchArgs { +function parseArgs( + rawArgs: Record, + defaultTimeoutMs: number, +): WebFetchArgs { const url = rawArgs.url; if (typeof url !== "string" || url.length === 0) { throw new Error(`${TOOL_NAME}: \`url\` must be a non-empty string`); @@ -168,7 +214,14 @@ function parseArgs(rawArgs: Record): WebFetchArgs { if (typeof rawArgs.maxChars === "number" && Number.isFinite(rawArgs.maxChars)) { maxChars = Math.min(MAX_CHARS_CAP, Math.max(1, Math.trunc(rawArgs.maxChars))); } - return { url, mode, maxChars }; + // Mirrors os.http.request: a per-call `timeoutMs` overrides the configured + // default so the model can shorten the budget for a host it expects to be + // slow, instead of losing the full default on every attempt. + const timeoutMs = + typeof rawArgs.timeoutMs === "number" && Number.isFinite(rawArgs.timeoutMs) + ? Math.max(1, Math.trunc(rawArgs.timeoutMs)) + : defaultTimeoutMs; + return { url, mode, maxChars, timeoutMs }; } interface FetchWithGuardOptions { @@ -176,12 +229,32 @@ interface FetchWithGuardOptions { lookup?: HostLookup; cwd: string; signal: AbortSignal; + fetchCfg: WebFetchConfig; + /** Effective per-attempt budget (per-call arg, else `fetchCfg.timeoutMs`). */ + timeoutMs: number; + sleep: (ms: number, signal: AbortSignal) => Promise; +} + +/** Thrown by `curlOnce` when curl itself failed (non-zero exit). */ +class CurlFailedError extends Error { + constructor( + message: string, + readonly exitCode: number, + ) { + super(message); + this.name = "CurlFailedError"; + } } /** * Fetch `rawUrl`, following redirects manually (curl `--max-redirs 0`) so the * SSRF guard can re-validate every hop and pin curl to a verified IP via * `--resolve`, closing the DNS-rebinding window. + * + * Each hop is retried independently for transient failures. `os.web.fetch` is + * GET-only (no method argument exists, and curl is invoked without `-X`/`-d`), + * so every request is idempotent and safe to repeat — there is no + * non-idempotent case to exclude here. */ async function fetchWithGuard( rawUrl: string, @@ -190,8 +263,7 @@ async function fetchWithGuard( let currentUrl = parseHttpUrl(rawUrl); const chain: string[] = []; for (let hop = 0; ; hop++) { - const pinnedIp = await assertHostAllowed(currentUrl, { lookup: opts.lookup }); - const res = await curlOnce(currentUrl, pinnedIp, opts); + const res = await fetchHopWithRetry(currentUrl, opts); chain.push(currentUrl.toString()); if (REDIRECT_STATUSES.has(res.status) && res.redirectUrl.length > 0) { if (hop >= MAX_REDIRECTS) { @@ -213,17 +285,106 @@ async function fetchWithGuard( } } +/** + * One redirect hop, retried on transient failure with exponential backoff. + * + * Retryable: HTTP 429/502/503/504, and curl exit 28 (`--max-time` / + * `--connect-timeout` expiry). Everything else — a 404, a DNS failure, a TLS + * error — is returned or thrown on the first attempt, because repeating it only + * spends task budget for the same answer. + * + * The budget is deliberately small (`maxRetries`, default 2). Worst case adds + * two attempts plus backoff on top of the first, which is bounded by + * `retryMaxDelayMs` per wait rather than growing without limit. `ctx.signal` is + * honoured both during the sleep and by `runCommand`, so an aborted task stops + * immediately instead of finishing its retry ladder. + */ +async function fetchHopWithRetry( + url: URL, + opts: FetchWithGuardOptions, +): Promise { + const { maxRetries } = opts.fetchCfg; + for (let attempt = 0; ; attempt++) { + // Re-resolve on every attempt: the guard must pin a freshly verified IP + // rather than trusting one resolved before an arbitrary backoff wait. + const pinnedIp = await assertHostAllowed(url, { lookup: opts.lookup }); + + let res: CurlResponse | null = null; + let failure: unknown = null; + try { + res = await curlOnce(url, pinnedIp, opts); + } catch (err) { + // Only a curl timeout is worth another attempt; a missing curl binary or + // an aborted run must surface immediately. + if ( + !(err instanceof CurlFailedError) || + err.exitCode !== CURL_EXIT_TIMEOUT + ) { + throw err; + } + failure = err; + } + + const retryable = + failure !== null || (res !== null && RETRYABLE_STATUSES.has(res.status)); + if (!retryable || attempt >= maxRetries || opts.signal.aborted) { + if (res !== null) return res; + throw failure; + } + + await opts.sleep( + backoffDelayMs(attempt, res?.retryAfterMs ?? null, opts.fetchCfg), + opts.signal, + ); + } +} + +/** + * Delay before retry `attempt` (0-based): `retryBaseDelayMs * 2^attempt`, + * clamped to `retryMaxDelayMs`. Defaults give 500ms then 1000ms — long enough + * for a load-shedding origin like `web.archive.org` to recover, short enough + * that two retries cost ~1.5s against a 25-minute task budget. + * + * A `Retry-After` sent by the server wins over the computed delay, since the + * origin knows its own recovery window, but is still clamped to + * `retryMaxDelayMs` so a large or hostile value cannot park the agent. + */ +function backoffDelayMs( + attempt: number, + retryAfterMs: number | null, + cfg: WebFetchConfig, +): number { + const backoff = cfg.retryBaseDelayMs * 2 ** attempt; + const chosen = retryAfterMs !== null ? retryAfterMs : backoff; + return Math.min(cfg.retryMaxDelayMs, Math.max(0, chosen)); +} + +function defaultSleep(ms: number, signal: AbortSignal): Promise { + if (ms <= 0 || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish(): void { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + async function curlOnce( url: URL, pinnedIp: string, opts: FetchWithGuardOptions, ): Promise { - const curlArgs = buildCurlArgs(url, pinnedIp); + const curlArgs = buildCurlArgs(url, pinnedIp, opts); let result: CommandResult; try { result = await opts.runCommand("curl", curlArgs, { cwd: opts.cwd, - timeoutMs: DEFAULT_TIMEOUT_MS + 2_000, + // Outer guard sits just above curl's own `--max-time` so curl reports the + // timeout itself (exit 28) instead of being killed by the runner. + timeoutMs: opts.timeoutMs + 2_000, signal: opts.signal, maxOutputBytes: MAX_RESPONSE_BYTES + 1024, }); @@ -232,19 +393,40 @@ async function curlOnce( throw err; } if (result.exitCode !== 0) { - throw new Error(`${TOOL_NAME}: ${formatCurlError(result)}`); + throw new CurlFailedError( + `${TOOL_NAME}: ${formatCurlError(result)}`, + result.exitCode ?? -1, + ); } return { ...parseCurlMeta(result.stdout), truncated: result.truncated }; } -function buildCurlArgs(url: URL, pinnedIp: string): string[] { +function buildCurlArgs( + url: URL, + pinnedIp: string, + opts: Pick, +): string[] { const host = url.hostname.replace(/^\[|\]$/g, ""); const port = url.port || (url.protocol === "https:" ? "443" : "80"); const resolveTarget = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp; + // Never let the connect budget exceed the overall one — a per-call + // `timeoutMs` smaller than the configured connect timeout must still cap the + // handshake. + const connectTimeoutMs = Math.min( + opts.fetchCfg.connectTimeoutMs, + opts.timeoutMs, + ); return [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", - String(Math.ceil(DEFAULT_TIMEOUT_MS / 1000)), + String(Math.ceil(opts.timeoutMs / 1000)), + // Fail fast on hosts that never complete a handshake instead of holding + // the whole `--max-time` budget open for them. + "--connect-timeout", + String(Math.ceil(connectTimeoutMs / 1000)), "--max-redirs", "0", "--resolve", @@ -256,7 +438,11 @@ function buildCurlArgs(url: URL, pinnedIp: string): string[] { "-H", "Accept-Language: en-US,en;q=0.9", "-w", - `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|%{size_download}`, + // `%{header_json}` is last on purpose: it is multi-line JSON that can + // itself contain `|`, so every pipe-delimited field must precede it. + // Requires curl >= 7.83; older curl emits the literal token, which + // `parseCurlMeta` tolerates by yielding no Retry-After. + `\n${CURL_META_MARKER}%{http_code}|%{content_type}|%{redirect_url}|%{size_download}|%{header_json}`, "--", url.toString(), ]; @@ -267,20 +453,69 @@ export function parseCurlMeta( ): Omit { const markerIdx = stdout.lastIndexOf(CURL_META_MARKER); if (markerIdx === -1) { - return { status: 0, contentType: "", redirectUrl: "", body: stdout }; + return { + status: 0, + contentType: "", + redirectUrl: "", + body: stdout, + retryAfterMs: null, + }; } const body = stdout.slice(0, markerIdx).replace(/\n$/, ""); const meta = stdout.slice(markerIdx + CURL_META_MARKER.length).trim(); - const [statusStr = "", contentType = "", redirectUrl = ""] = meta.split("|"); + // Split off exactly the four fixed fields; whatever follows is + // `%{header_json}`, which may itself contain `|` and newlines. + const parts = meta.split("|"); + const [statusStr = "", contentType = "", redirectUrl = ""] = parts; + const headerJson = parts.slice(4).join("|"); const status = Number.parseInt(statusStr, 10); return { status: Number.isFinite(status) ? status : 0, contentType: contentType.trim(), redirectUrl: redirectUrl.trim(), body, + retryAfterMs: parseRetryAfterMs(headerJson), }; } +/** + * Pull `Retry-After` out of curl's `%{header_json}` blob and normalise it to + * milliseconds. Handles both RFC 9110 forms — delta-seconds and an HTTP-date — + * and returns `null` for anything unparseable (including older curl builds that + * do not support `%{header_json}` and emit the literal token instead), so a + * missing or malformed header simply falls back to plain exponential backoff. + */ +function parseRetryAfterMs(headerJson: string): number | null { + const trimmed = headerJson.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("{")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + // curl lowercases header names, but match case-insensitively regardless. + const entry = Object.entries(parsed as Record).find( + ([key]) => key.toLowerCase() === "retry-after", + ); + const rawValue = entry?.[1]; + const value = Array.isArray(rawValue) ? rawValue[0] : rawValue; + if (typeof value !== "string") return null; + const text = value.trim(); + if (text.length === 0) return null; + + if (/^\d+$/.test(text)) { + return Number.parseInt(text, 10) * 1000; + } + const dateMs = Date.parse(text); + if (!Number.isNaN(dateMs)) { + // Past dates clamp to 0 — retry immediately rather than not at all. + return Math.max(0, dateMs - Date.now()); + } + return null; +} + function formatCurlError(result: CommandResult): string { const stderr = result.stderr.trim(); if (stderr.length > 0) return stderr; diff --git a/src/tools/os/web-search/transport/search-http.test.ts b/src/tools/os/web-search/transport/search-http.test.ts new file mode 100644 index 00000000..fdeee981 --- /dev/null +++ b/src/tools/os/web-search/transport/search-http.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { searchHttp } from "./search-http.js"; +import type { runCommand as RunCommandType } from "../../../../sandbox/command-runner.js"; +import type { HostLookup } from "../../web-fetch-ssrf-guard.js"; + +const publicLookup: HostLookup = async () => [ + { address: "93.184.216.34", family: 4 }, +]; + +/** + * Builds the curl stdout envelope that `parseCurlMeta` expects: the response + * body followed by the trailing `__ATOMIC_WEB_SEARCH_META__status|ct|redir|size` + * block that searchHttp appends via `curl -w`. + */ +function stubCurlStdout(body: string): string { + return `${body}\n__ATOMIC_WEB_SEARCH_META__200|text/html||${body.length}`; +} + +function capturingRunCommand(calls: string[][]): typeof RunCommandType { + return (async (_command: string, args: string[]) => { + calls.push(args); + return { + command: "curl", + args, + exitCode: 0, + signal: null, + stdout: stubCurlStdout(""), + stderr: "", + durationMs: 1, + timedOut: false, + truncated: false, + }; + }) as unknown as typeof RunCommandType; +} + +describe("searchHttp curl argv", () => { + it("passes --globoff so bracketed URLs are not read as curl ranges", async () => { + // Search queries routinely carry `[`/`]`/`{`/`}`. Without --globoff curl + // reads them as its own range/set syntax and fails with "bad range in URL". + const calls: string[][] = []; + const url = + "https://search.example/search?q=filter:original:.*[Dd]ewey.*" + + "&range=[202102010000+TO+202104300000]"; + await searchHttp({ + url, + timeoutMs: 1000, + cwd: "/tmp", + signal: new AbortController().signal, + runCommand: capturingRunCommand(calls), + lookup: publicLookup, + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("--globoff"); + // The bracketed URL still reaches curl verbatim as the final operand. + expect(calls[0]![calls[0]!.length - 1]).toContain("[Dd]ewey"); + }); +}); diff --git a/src/tools/os/web-search/transport/search-http.ts b/src/tools/os/web-search/transport/search-http.ts index bdc1fd52..92ff5040 100644 --- a/src/tools/os/web-search/transport/search-http.ts +++ b/src/tools/os/web-search/transport/search-http.ts @@ -125,6 +125,9 @@ function buildCurlArgs(input: { : input.pinnedIp; const args = [ "-sS", + // Send `[`, `]`, `{`, `}` in URLs literally. Without this curl reads them + // as its own range/set glob syntax and fails with "bad range in URL". + "--globoff", "--max-time", String(Math.ceil(input.timeoutMs / 1000)), "--max-redirs", diff --git a/src/tools/tool-registry.ts b/src/tools/tool-registry.ts index 5f8e41c5..f5fce7af 100644 --- a/src/tools/tool-registry.ts +++ b/src/tools/tool-registry.ts @@ -1,4 +1,5 @@ import type { CompressedToolResult } from "../compressor/result-compressor.js"; +import { coerceToolArgs } from "./coerce-tool-args.js"; export interface ToolContext { /** Working directory for OS tools and relative path resolution. */ @@ -64,6 +65,10 @@ export class ToolRegistry { ctx: ToolContext, ): Promise { const tool = this.get(name); - return tool.run(args, ctx); + // Models sometimes emit a JSON value one level over-encoded (a + // number as "200000", an array as "[\"a.png\"]"). Unwrap those + // before dispatch; anything that cannot be coerced is passed + // through untouched so the tool reports its own error. + return tool.run(coerceToolArgs(name, args), ctx); } } diff --git a/src/tools/vision/describe.test.ts b/src/tools/vision/describe.test.ts index f54b5d7b..19a268ab 100644 --- a/src/tools/vision/describe.test.ts +++ b/src/tools/vision/describe.test.ts @@ -127,6 +127,88 @@ describe("buildVisionDescribeTool", () => { expect(call.images[0]!.mimeType).toBe("image/png"); }); + // Issue #185: the per-call image cap was enforced but documented + // nowhere the model could read, so it discovered the limit only by + // burning a step on a failed 8/12/20-image call. The cap now appears + // in the tool description, and the error names the remedy. + it("documents the image cap in the tool description", () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + expect(tool.description).toContain("At most 4 images per call"); + expect(tool.description).toMatch(/split/i); + }); + + it("reflects a reconfigured cap in the tool description", () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 7, + maxImageBytes: 1024, + }); + expect(tool.description).toContain("At most 7 images per call"); + }); + + it("rejects more images than the cap and names the split remedy", async () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run( + { + prompt: "describe", + paths: Array.from({ length: 12 }, (_, i) => `img-${i}.png`), + }, + ctx(process.cwd()), + ); + expect(result.status).toBe("error"); + expect(result.summary).toContain("at most 4 images per call (got 12)"); + // 12 / 4 = 3 calls. The remedy is the point: the model should not + // have to guess how to recover from the cap. + expect(result.summary).toContain("split into 3 calls of at most 4"); + }); + + it("rounds the suggested call count up for a partial final batch", async () => { + const tool = buildVisionDescribeTool({ + provider: fakeProvider(), + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run( + { + prompt: "describe", + paths: Array.from({ length: 13 }, (_, i) => `img-${i}.png`), + }, + ctx(process.cwd()), + ); + expect(result.status).toBe("error"); + // Math.ceil(13 / 4) === 4, not 3. + expect(result.summary).toContain("split into 4 calls of at most 4"); + }); + + it("accepts exactly the cap without erroring", async () => { + const tmp = await mkdtemp(join(tmpdir(), "vision-tool-")); + const paths: string[] = []; + for (let i = 0; i < 4; i += 1) { + const path = join(tmp, `image-${i}.png`); + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + paths.push(path); + } + const provider = fakeProvider(); + const tool = buildVisionDescribeTool({ + provider, + maxImagesPerCall: 4, + maxImageBytes: 1024, + }); + const result = await tool.run({ prompt: "describe", paths }, ctx(tmp)); + expect(result.status).toBe("ok"); + const call = (provider.describeImage as ReturnType).mock + .calls[0]![0] as VisionRequest; + expect(call.images).toHaveLength(4); + }); + it("rejects images that exceed maxImageBytes", async () => { const tmp = await mkdtemp(join(tmpdir(), "vision-tool-")); const path = join(tmp, "big.png"); diff --git a/src/tools/vision/describe.ts b/src/tools/vision/describe.ts index 61b87939..1751c9ba 100644 --- a/src/tools/vision/describe.ts +++ b/src/tools/vision/describe.ts @@ -55,8 +55,7 @@ export function buildVisionDescribeTool( ): ToolDefinition { return { name: "vision.describe", - description: - "Describe one or more images via the configured vision LLM. Use when the user attaches an image or asks what is on a screenshot.", + description: `Describe one or more images via the configured vision LLM. Use when the user attaches an image or asks what is on a screenshot. At most ${options.maxImagesPerCall} images per call; to cover more, split them across several calls.`, readonly: true, async run(rawArgs, ctx) { let parsed: ParsedArgs; @@ -66,8 +65,10 @@ export function buildVisionDescribeTool( return errorResult((error as Error).message); } if (parsed.paths.length > options.maxImagesPerCall) { + const calls = Math.ceil(parsed.paths.length / options.maxImagesPerCall); return errorResult( - `at most ${options.maxImagesPerCall} images per call (got ${parsed.paths.length})`, + `at most ${options.maxImagesPerCall} images per call (got ${parsed.paths.length})` + + ` — split into ${calls} calls of at most ${options.maxImagesPerCall}`, ); } if (!options.provider.capabilities.vision) { diff --git a/src/tui/agent-event-reducer.test.ts b/src/tui/agent-event-reducer.test.ts index d612c579..c3cba050 100644 --- a/src/tui/agent-event-reducer.test.ts +++ b/src/tui/agent-event-reducer.test.ts @@ -384,6 +384,75 @@ describe("reduceTuiState", () => { }); expect(down.session.approvalLevel).toBe(2); }); + + it("renders a mid-turn steer inline in the turn that is already running", () => { + const running = apply(createInitialTuiState(fakeSession()), [ + { type: "agent_event", event: { type: "user_message", text: "deploy" } }, + { type: "message_submitted" }, + { type: "agent_event", event: { type: "turn_started", turnIndex: 0 } }, + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "tool_call_executed", + result: { + tool: "os.fs.read", + status: "ok", + summary: "read config", + truncated: false, + }, + }, + }, + }, + { type: "agent_event", event: { type: "step_started", stepIndex: 1 } }, + ]); + const feedBefore = running.feed.length; + + const next = reduceTuiState(running, { + type: "agent_event", + event: { type: "steer_applied", text: "use the staging db", stepIndex: 1 }, + }); + + // The operator's words show up as a user message, in the same + // transcript as everything else... + const last = next.messages[next.messages.length - 1]; + expect(last?.role).toBe("user"); + expect(last?.text).toBe("use the staging db"); + // ...with a feed line tying it to the step it reached. + expect(next.feed.length).toBe(feedBefore + 1); + expect(next.feed[next.feed.length - 1]?.line).toContain("step 1"); + // ...and none of the per-turn resets a NEW turn would bring: this + // is a correction to the turn in flight, not the start of one. + expect(next.status).toBe("running"); + expect(next.currentStep).toBe(1); + expect(next.currentTurnToolSteps).toBe(running.currentTurnToolSteps); + expect(next.runStartedAt).toBe(running.runStartedAt); + }); + + it("reports a trimmed tool batch instead of swallowing it", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { + type: "agent_event", + event: { + type: "llm_event", + event: { + type: "batch_trimmed", + stepIndex: 0, + originalSize: 3, + kept: "os.fs.write", + dropped: ["os.shell.run", "os.fs.trash"], + reason: "approval-gated-batched", + }, + }, + }, + ]); + const line = next.feed[next.feed.length - 1]?.line ?? ""; + expect(line).toContain("os.fs.write"); + expect(line).toContain("2 of 3"); + }); }); describe("llm health visibility", () => { diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index b72d64b9..c14427c2 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -113,12 +113,20 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { return { ...state, activeTab: action.tab }; case "abort_requested": return { ...state, aborting: true }; - case "input_changed": + case "input_changed": { + // Moving the caret re-emits the buffer unchanged (the editor owns + // the cursor and reports it through `onChange`). That is not an + // edit, so it must not knock us out of history recall — otherwise + // a single Left/Right after Up dropped the recall position and the + // parked draft with it. + if (action.value === state.inputValue) return state; return { ...state, inputValue: action.value, inputHistoryCursor: null, + inputHistoryDraft: null, }; + } case "message_submitted": return startNewRun(state); case "quit_requested": @@ -209,6 +217,21 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { switch (event.type) { case "user_message": return appendUserMessage(state, event.text); + case "steer_applied": + // A message the operator sent mid-turn, folded into the prompt of + // the step named here. It renders INLINE in the running turn: same + // chat bubble as any user message, but none of the per-turn resets + // `user_message` implies — no `startNewRun`, no step counter reset. + // The feed line is what ties it to the step it actually reached. + return appendUserMessage( + appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: `» steering applied at step ${event.stepIndex}`, + color: "yellow", + }), + event.text, + ); case "turn_started": return { ...state, @@ -325,8 +348,23 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { { outcome: "failed", reason: event.error.message, lastRunStatus }, ); } - default: + case "loop_detected": + // Deliberately not rendered: the loop detector's own `### notice` + // changes what the model does, and the operator sees the effect + // through the tool calls that follow. Listed explicitly so the + // exhaustiveness check below stays meaningful. + return state; + default: { + // `steer_applied` shipped with a doc comment promising inline + // rendering and no case here, and a bare `default: return state` + // meant TypeScript had nothing to say about it. This makes the + // next new `AgentLoopEvent` a compile error instead of a silent + // no-op — while still returning `state` at runtime, because a UI + // reducer must never throw on an event it does not know. + const unhandled: never = event; + void unhandled; return state; + } } } @@ -498,7 +536,28 @@ function reduceStepEvent( line: ` ! [${event.category}] ${event.error.message}`, color: "red", }); - default: + case "batch_trimmed": + // Surfaced by the exhaustiveness check below: the model asked for + // `originalSize` calls and only one ran. That is worth a line — + // otherwise the dropped calls reappear one-by-one next step with + // no explanation for why the batch shrank. + return appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: ` ~ batch trimmed to ${event.kept} (${event.dropped.length} of ${event.originalSize} deferred: ${event.reason})`, + color: "yellow", + }); + case "prompt_built": + case "llm_completed": + case "llm_raw_completion": + // Raw plumbing: the whole prompt, the whole completion object, + // the unparsed text. The trace recorder wants them; the chat feed + // would drown in them. Listed so the exhaustiveness check holds. return state; + default: { + const unhandled: never = event; + void unhandled; + return state; + } } } diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index 3152f3f3..ea10f991 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Key } from "ink"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; import type { ApprovalRequest } from "../approval/approval-gate.js"; @@ -544,6 +545,160 @@ describe("handleAppKey", () => { }); expect(onSidebarTaskActivated).toHaveBeenCalledWith("task-id-42"); }); + + it("Esc while running aborts the turn when the chat is pinned to the bottom", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + }); + + it("Esc while running snaps the scrolled-back chat home instead of aborting", () => { + // Reported sequence: submit, PageUp to read back through the + // streaming answer, Esc. The scroll-reset rung documents that it + // runs "before doing anything else"; the abort claim must not eat + // the turn out from under an operator who was only scrolling. + const state = createInitialTuiState(stubSession()); + state.status = "running"; + state.chatScrollOffset = 8; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ type: "chat_scroll_reset" }); + expect(dispatch).not.toHaveBeenCalledWith({ type: "abort_requested" }); + }); + + it("Esc while running on a debug tab aborts even with a stale scroll offset", () => { + // Nothing resets `chatScrollOffset` on a mode switch, and the chat + // is off-screen in debug mode — snapping an invisible log back would + // just make Esc look dead there. + const state = createInitialTuiState(stubSession()); + state.status = "running"; + state.uiMode = "debug"; + state.activeTab = "logs"; + state.chatScrollOffset = 8; + const dispatch = vi.fn(); + const onAbort = vi.fn(); + const handled = handleAppKey("", emptyKey({ escape: true }), { + state, + dispatch, + callbacks: { onApprovalDecision: vi.fn(), onAbort, onQuit: vi.fn() }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + }); +}); + +describe("handleAppKey with the ctrl+g leader armed", () => { + function pressWhileArmed( + input: string, + key: Key, + state = createInitialTuiState(stubSession()), + ) { + const activated: MenuNode[] = []; + const dispatch = vi.fn(); + const setMenuLeaderArmed = vi.fn(); + const setCtrlCArmed = vi.fn(); + const onAbort = vi.fn(); + const onQuit = vi.fn(); + const handled = handleAppKey(input, key, { + state, + dispatch, + callbacks: { + onApprovalDecision: vi.fn(), + onAbort, + onQuit, + }, + ctrlCArmed: false, + setCtrlCArmed, + sidebarVisible: false, + menuLeaderArmed: true, + setMenuLeaderArmed, + activateMenuNode: (node) => activated.push(node), + }); + return { + handled, + activated, + dispatch, + setMenuLeaderArmed, + setCtrlCArmed, + onAbort, + onQuit, + }; + } + + it("a bare chord key activates its node", () => { + const run = pressWhileArmed("c", emptyKey()); + expect(run.activated.map((n) => n.id)).toEqual(["go.manage.mcp"]); + expect(run.handled).toBe(true); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + }); + + it("an unclaimed bare key is swallowed rather than leaked to the prompt", () => { + const run = pressWhileArmed("z", emptyKey()); + expect(run.activated).toEqual([]); + expect(run.handled).toBe(true); + }); + + it("Ctrl+C disarms and aborts the turn instead of jumping to the MCP tab", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const run = pressWhileArmed("c", emptyKey({ ctrl: true }), state); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.setCtrlCArmed).toHaveBeenCalledWith(true); + expect(run.onAbort).toHaveBeenCalled(); + expect(run.dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + expect(run.handled).toBe(true); + }); + + it("Ctrl+Q disarms without quitting the app", () => { + const run = pressWhileArmed("q", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.onQuit).not.toHaveBeenCalled(); + expect(run.dispatch).not.toHaveBeenCalledWith({ type: "quit_requested" }); + // Nothing else binds ctrl+q, so the key falls through unclaimed — + // which is the point: the leader no longer stands in the way. + expect(run.handled).toBe(false); + }); + + it("Ctrl+L disarms and falls through instead of opening the LLM tab", () => { + const run = pressWhileArmed("l", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.dispatch).not.toHaveBeenCalled(); + expect(run.handled).toBe(false); + }); + + it("Esc disarms and is swallowed, so it cancels the leader", () => { + const run = pressWhileArmed("", emptyKey({ escape: true })); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.handled).toBe(true); + }); }); describe("handlePanelEscape", () => { @@ -596,3 +751,68 @@ describe("handlePanelEscape", () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +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", + mode: "queue", + }); + 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", + }); + }); +}); + diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..2828fb1f 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -6,6 +6,14 @@ 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"; @@ -34,6 +42,10 @@ export interface AppKeyCallbacks { grant?: ApprovalGrantScope, ): void; onAbort(): void; + /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ + onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void; + /** Open a fresh OS terminal window running atomic-agent (Ctrl+N, `/window`). */ + onNewWindowRequested?(): void; onQuit(): void; /** Optional — called when Enter is pressed on the focused sidebar row. */ onSessionSwitchRequested?(sessionId: string): void; @@ -72,6 +84,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; } /** @@ -80,71 +97,13 @@ export interface AppKeyContext { * function is side-effectful (calls into `callbacks`) but the state * mutation funnels through `dispatch`, preserving reducer purity. */ -export function handleAppKey( - input: string, - key: Key, - ctx: AppKeyContext, -): boolean { - const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx; - if (state.pendingApproval) { - return handleApprovalKey(input, key, state.pendingApproval, ctx); - } - // A settled successful self-update parks the UI on a "press any key to - // restart" prompt. The first keystroke (whatever it is) re-execs the new - // binary; `quit_requested` then unmounts Ink so the restart handoff runs. - if (state.updateStatus === "done") { - callbacks.onUpdateRestart?.(); - dispatch({ type: "quit_requested" }); - return true; - } - // The update offer claims only y / n / Esc; anything else (Ctrl+C in - // particular) falls through to the normal handlers below. - if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { - return true; - } - if ( - ctx.sidebarVisible && - state.uiMode === "chat" && - state.chatFocus === "sidebar" - ) { - if (handleSidebarKey(input, key, ctx)) return true; - } - if (key.ctrl && input === "c") { - if (ctrlCArmed) { - callbacks.onAbort(); - callbacks.onQuit(); - dispatch({ type: "quit_requested" }); - return true; - } - setCtrlCArmed(true); - if (state.status === "running" || state.status === "awaiting_approval") { - callbacks.onAbort(); - dispatch({ type: "abort_requested" }); - } - return true; - } - setCtrlCArmed(false); - if ( - state.uiMode === "chat" && - !state.slashPaletteOpen && - !state.pendingApproval - ) { - if (shouldTreatArrowAsChatScroll(input, key, state)) { - dispatch({ - type: "chat_scrolled", - delta: key.upArrow ? CHAT_WHEEL_ARROW_DELTA : -CHAT_WHEEL_ARROW_DELTA, - }); - return true; - } - if (key.pageUp) { - dispatch({ type: "chat_scrolled", delta: CHAT_PAGE_DELTA }); - return true; - } - if (key.pageDown) { - dispatch({ type: "chat_scrolled", delta: -CHAT_PAGE_DELTA }); - return true; - } - } +/** + * A debug-tab surface that owns its own keys is open — a modal, a + * confirm dialog, a wizard, or a focused text field. While one is up, + * global claims (nav cycling, the running Esc-abort) must bow out so + * the surface keeps its keystrokes. + */ +export function isPanelModalOpen(state: TuiState): boolean { const tasksTabBusy = state.uiMode === "debug" && state.activeTab === "tasks" && @@ -205,7 +164,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,7 +172,170 @@ export function handleAppKey( telegramTabBusy || mcpTabBusy || providersTabBusy || - llmTabBusy; + llmTabBusy + ); +} + +export function handleAppKey( + input: string, + key: Key, + ctx: AppKeyContext, +): boolean { + const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx; + if (state.pendingApproval) { + return handleApprovalKey(input, key, state.pendingApproval, ctx); + } + // A settled successful self-update parks the UI on a "press any key to + // restart" prompt. The first keystroke (whatever it is) re-execs the new + // binary; `quit_requested` then unmounts Ink so the restart handoff runs. + if (state.updateStatus === "done") { + callbacks.onUpdateRestart?.(); + dispatch({ type: "quit_requested" }); + return true; + } + // The update offer claims only y / n / Esc; anything else (Ctrl+C in + // particular) falls through to the normal handlers below. + 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); + return true; + } + // An unclaimed *bare* key is swallowed rather than passed on: a + // mistyped leader must not leak a letter into the prompt or fire a + // panel hotkey. A modified key was never a chord, though — it means + // the operator changed their mind — so it only disarms and then falls + // through to the bindings below, where `ctrl+c` still aborts the turn. + if (!key.ctrl && !key.meta) 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" && + state.chatFocus === "sidebar" + ) { + if (handleSidebarKey(input, key, ctx)) return true; + } + if (key.ctrl && input === "c") { + if (ctrlCArmed) { + callbacks.onAbort(); + callbacks.onQuit(); + dispatch({ type: "quit_requested" }); + return true; + } + setCtrlCArmed(true); + if (state.status === "running" || state.status === "awaiting_approval") { + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + } + return true; + } + setCtrlCArmed(false); + // Ctrl+T flips what Enter does while a turn is running (steer <-> queue). + // Alt/Shift/Ctrl+Enter are all "insert newline" in the editor, so the + // mode cannot live on a Return modifier; an explicit, visible toggle is + // the honest alternative. Guarded like the other global claims so a + // panel modal or the palette never has the mode flipped under it, and + // placed after the Ctrl+C disarm so a flip cannot ride an armed quit. + if ( + key.ctrl && + !key.shift && + !key.meta && + input === "t" && + !state.pendingApproval && + !state.slashPaletteOpen && + !isPanelModalOpen(state) + ) { + const next = state.whileBusyMode === "steer" ? "queue" : "steer"; + dispatch({ type: "while_busy_mode_changed", mode: next }); + callbacks.onWhileBusyModePersistRequested?.(next); + return true; + } + // 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 && + // A panel modal / confirm / wizard / focused field owns Esc for its + // own cancel; aborting the run out from under it would make one + // keypress do two unrelated things (and some of those surfaces run + // their own useInput, which Ink fires regardless of ours). + !isPanelModalOpen(state) + ) { + // Scroll-reset keeps its precedence: Esc with the chat scrolled away + // from the bottom snaps back to the latest reply before doing + // anything else — the rung this branch now runs ahead of, and the + // reason a mid-run PageUp + Esc must not destroy the turn. Only in + // chat mode; on a debug tab the chat is off-screen, so a stale + // offset there would just make Esc look dead. + if (state.uiMode === "chat" && state.chatScrollOffset > 0) { + dispatch({ type: "chat_scroll_reset" }); + return true; + } + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + return true; + } + if ( + state.uiMode === "chat" && + !state.slashPaletteOpen && + !state.pendingApproval + ) { + if (shouldTreatArrowAsChatScroll(input, key, state)) { + dispatch({ + type: "chat_scrolled", + delta: key.upArrow ? CHAT_WHEEL_ARROW_DELTA : -CHAT_WHEEL_ARROW_DELTA, + }); + return true; + } + if (key.pageUp) { + dispatch({ type: "chat_scrolled", delta: CHAT_PAGE_DELTA }); + return true; + } + if (key.pageDown) { + dispatch({ type: "chat_scrolled", delta: -CHAT_PAGE_DELTA }); + return true; + } + } + const debugTabBusy = isPanelModalOpen(state); + // Ctrl+N opens a fresh OS terminal window running atomic-agent in the + // same working dir. The editor never sees ctrl-modified letters + // (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+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 / @@ -282,7 +404,8 @@ export function handleAppKey( * search inputs, detail views and half-typed forms consume Esc in their * own layer first and never reach here. `editorFocus` guards the tabs * that leave the chat editor focused — there the editor's own input - * hook owns Esc (abort / scroll-reset / quit) and must not double-act. + * hook owns Esc (scroll-reset / quit; abort is claimed earlier, by + * `handleAppKey`) and must not double-act. * * Returns `true` when the key was consumed. */ @@ -389,7 +512,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 { @@ -406,6 +534,7 @@ function handleUpdateKey( key: Key, ctx: AppKeyContext, ): boolean { + if (key.ctrl || key.meta) return false; const lower = input.toLowerCase(); if (lower === "y") { ctx.callbacks.onUpdateConfirmed?.(); @@ -435,65 +564,71 @@ 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, request: ApprovalRequest, ctx: AppKeyContext, ): boolean { + // A ctrl/meta-modified key was never aimed at the y/n/esc prompt — + // letting it through turns a global chord (ctrl+n) into a silent deny. + if (key.ctrl || key.meta) return false; 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..ea85bd38 --- /dev/null +++ b/src/tui/build-terminal-launch.test.ts @@ -0,0 +1,170 @@ +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("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"); + // foot has no `-e` — it takes the command bare, like kitty. + expect(launch?.args).toEqual(["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"]); + // The agent runs under `cmd /k` inside wt too: the env prefix must + // reach Windows Terminal and a startup error must stay on screen. + expect(launch?.args.slice(5, 7)).toEqual(["cmd", "/k"]); + expect(launch?.args.at(-1)).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"); + // The first `start` argument is its TITLE; unquoted text there is + // read as the program. An explicit empty title keeps cmd the program. + expect(launch?.args.slice(0, 5)).toEqual(["/c", "start", "", "cmd", "/k"]); + }); +}); diff --git a/src/tui/build-terminal-launch.ts b/src/tui/build-terminal-launch.ts new file mode 100644 index 00000000..e43423bc --- /dev/null +++ b/src/tui/build-terminal-launch.ts @@ -0,0 +1,277 @@ +/** + * 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[]; + /** `process.execArgv` — loader/inspect flags a dev run needs back. */ + readonly execArgv?: 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] }, + // foot takes the command bare, like kitty — it has no `-e` at all. + { bin: "foot", label: "foot", args: (command) => ["sh", "-c", command] }, + // terminator and tilix take `-e` as a single command string. + { + bin: "terminator", + label: "terminator", + args: (command) => ["-e", `sh -c ${shellQuote(command)}`], + }, + { + bin: "tilix", + label: "tilix", + args: (command) => ["-e", `sh -c ${shellQuote(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]; + // execArgv keeps dev runs honest: under tsx/--import loaders the + // script path alone is not runnable by plain node. + const execArgv = input.execArgv ?? []; + return scriptPath + ? [input.execPath, ...execArgv, scriptPath, "tui"] + : [input.execPath, ...execArgv, "tui"]; +} + +/** + * A freshly spawned terminal starts a login shell and does **not** + * inherit our environment, so a non-default state dir has to travel + * inside the command line — otherwise the second window silently talks + * to a different `~/.atomic-agent`. + */ +function posixCommandLine(input: TerminalLaunchInput): string { + const prefix = forwardedEnv(input.env) + .map(([k, v]) => `${k}=${shellQuote(v)} `) + .join(""); + const agent = agentArgv(input).map(shellQuote).join(" "); + return `cd ${shellQuote(input.cwd)} && ${prefix}${agent}`; +} + +/** + * Every `ATOMIC_AGENT_*` variable travels into the new window, sorted so + * the command line is deterministic. Forwarding only the state dir made + * the second window silently different whenever the parent was launched + * with a custom llama URL, grammar dir or skills dir — the exact failure + * class the state-dir forwarding was added to close. + */ +function forwardedEnv( + env: Readonly>, +): [string, string][] { + return Object.entries(env) + .filter((pair): pair is [string, string] => + pair[0].startsWith("ATOMIC_AGENT_") && typeof pair[1] === "string" && pair[1].length > 0, + ) + .sort(([a], [b]) => (a < b ? -1 : 1)); +} + +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 script = escapeAppleScript(posixCommandLine(input)); + if (input.env.TERM_PROGRAM === "iTerm.app") { + // iTerm2 has no Terminal-style `do script`: its dictionary is + // "create window with default profile" plus "write text". + return { + cmd: "osascript", + args: [ + "-e", + `tell application "iTerm" to create window with default profile`, + "-e", + `tell application "iTerm" to tell current session of current window to write text "${script}"`, + "-e", + `tell application "iTerm" to activate`, + ], + label: "iTerm", + }; + } + return { + cmd: "osascript", + args: [ + "-e", + `tell application "Terminal" to do script "${script}"`, + "-e", + `tell application "Terminal" to activate`, + ], + label: "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, + // Unknown emulator: the single-string `-e` dialect is the broadest + // (xterm, konsole, terminator and tilix all accept it; the + // multi-arg form breaks the last two). + args: known ? known.args(command) : ["-e", `sh -c ${shellQuote(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); + const prefix = forwardedEnv(input.env) + .map(([k, v]) => `set "${k}=${v}" && `) + .join(""); + // `/k` keeps the console open after the agent exits on BOTH Windows + // paths, matching the POSIX branches — a startup error must stay on + // screen, and the env prefix must reach Windows Terminal too (wt's own + // env inheritance goes through its single-instance monarch, which may + // predate this process). + const command = `${prefix}${agent.map(cmdQuote).join(" ")}`; + if (input.hasBinary("wt.exe")) { + // `-w -1` opens a new window rather than a tab in the existing one. + // wt splits its command line on unquoted `;` (its pane separator), + // so every argument that can carry one is escaped for wt. + return { + cmd: "wt.exe", + args: [ + "-w", + "-1", + "nt", + "-d", + wtEscape(input.cwd), + "cmd", + "/k", + wtEscape(command), + ], + label: "Windows Terminal", + }; + } + return { + cmd: "cmd.exe", + // The first `start` argument is its window title; unquoted, `start` + // reads the next token as the program instead. An explicit empty + // title (serialized as `""`) keeps `cmd /k` the program. + args: ["/c", "start", "", "cmd", "/k", command], + label: "Command Prompt", + }; +} + +/** Windows Terminal splits on unquoted `;` — escape it per wt's rules. */ +function wtEscape(value: string): string { + return value.replace(/;/g, "\\;"); +} + +/** POSIX single-quote quoting — safe for every byte except NUL. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function cmdQuote(value: string): string { + // Embedded quotes double inside a quoted cmd token. `%VAR%` expansion + // inside quotes is a cmd property no quoting silences — a path + // containing a defined %NAME% will still expand; acceptable residual. + const escaped = value.replace(/"/g, '""'); + return /[\s&|<>^%;=,()"]/.test(value) ? `"${escaped}"` : 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..04ba8fdd 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", + }); + + 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-steering.test.ts b/src/tui/chat-orchestrator-steering.test.ts new file mode 100644 index 00000000..b55f9df6 --- /dev/null +++ b/src/tui/chat-orchestrator-steering.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from "vitest"; + +import { ChatOrchestrator } from "./chat-orchestrator.js"; +import { makeTuiEventBus } from "./make-event-bus.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { SteeringInbox } from "../runtime/steering-inbox.js"; +import { TurnController } from "../runtime/turn-controller.js"; +import type { RunTurnResult } from "../agent/agent-loop.js"; +import { + createEmptySessionState, + type SessionState, +} from "../session/session-state.js"; +import type { TuiAction } from "./tui-action.js"; + +/** + * The TUI's half of the mid-turn steering contract (AGENTS.md + * §"Mid-turn steering"): + * - a message typed while a turn is running is offered to that turn + * first, and only falls back to the orchestrator's own pending + * queue when `steer` refuses it; + * - `RunTurnResult.undelivered` — messages the turn accepted but + * never delivered — is re-routed onto that same queue. `steer` + * already told the sender "yes"; dropping it here would lose a + * message the operator watched being accepted. + */ + +interface Harness { + chat: ChatOrchestrator; + actions: TuiAction[]; + /** Messages handed to `runtime.runTurn`, in order. */ + started: string[]; + /** Resolve the turn currently in flight. */ + finish(result?: Partial): Promise; + steerCalls: Array<{ sessionId: string; text: string }>; + setSteerable(value: boolean): void; +} + +function makeHarness(): Harness { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((action) => actions.push(action)); + + const started: string[] = []; + const steerCalls: Array<{ sessionId: string; text: string }> = []; + let steerable = true; + let session: SessionState = createEmptySessionState({ + id: "s-tui", + workingDir: "/work", + }); + let settle: ((result: RunTurnResult) => void) | null = null; + + const runtime = { + createSession: () => session, + sessionStore: { + listRecent: () => [], + load: () => session, + }, + approvals: { clearSessionGrants: () => undefined }, + steer: (sessionId: string, text: string) => { + steerCalls.push({ sessionId, text }); + return steerable; + }, + runTurn: (_session: SessionState, text: string) => { + started.push(text); + return new Promise((resolve) => { + settle = resolve; + }); + }, + } as unknown as AgentRuntime; + + const chat = new ChatOrchestrator(runtime, bus, { + maxSteps: 4, + llamaUrl: "http://127.0.0.1:8080", + }); + + return { + chat, + actions, + started, + steerCalls, + setSteerable: (value) => { + steerable = value; + }, + finish: async (result = {}) => { + const resolve = settle; + settle = null; + if (!resolve) throw new Error("no turn in flight"); + resolve({ + session, + reason: "reply", + stepCount: 1, + ...result, + }); + // Two microtask hops: one for `await runtime.runTurn`, one for the + // queue drain that follows it. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +function infoLines(actions: readonly TuiAction[]): string[] { + return actions + .filter((a): a is Extract => + a.type === "runtime_info", + ) + .map((a) => a.line); +} + +describe("ChatOrchestrator mid-turn steering", () => { + it("offers a message typed during a turn to that turn", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.chat.steerMessage("actually, check the logs first"); + + expect(h.steerCalls).toEqual([ + { sessionId: "s-tui", text: "actually, check the logs first" }, + ]); + // Steered, so it must NOT also become a queued follow-up turn. + await h.finish(); + expect(h.started).toEqual(["do the thing"]); + expect(infoLines(h.actions)).toContain( + "steering the running turn — the agent reads it at the next step", + ); + }); + + it("falls back to the pending queue when the turn refuses the steer", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.setSteerable(false); + h.chat.steerMessage("too late for this one"); + + expect(h.steerCalls).toHaveLength(1); + await h.finish(); + // Refused, so it runs as the next turn instead of vanishing. + expect(h.started).toEqual(["do the thing", "too late for this one"]); + }); + + it("re-routes undelivered steers onto the pending queue", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + // `steer` said yes, but the turn ended before a step could drain it. + await h.finish({ undelivered: ["stop, use staging"] }); + + expect(h.started).toEqual(["do the thing", "stop, use staging"]); + expect(infoLines(h.actions)).toContain( + "1 message arrived too late for that turn — sending it next", + ); + }); + + it("puts undelivered steers ahead of messages typed after the refusal", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + h.setSteerable(false); + h.chat.steerMessage("and then deploy"); + await h.finish({ undelivered: ["stop, use staging"] }); + + // "stop, use staging" was sent first (it was still accepted as a + // steer); "and then deploy" only arrived after `steer` refused. + expect(h.started).toEqual(["do the thing", "stop, use staging"]); + await h.finish(); + expect(h.started).toEqual([ + "do the thing", + "stop, use staging", + "and then deploy", + ]); + }); + + it("does nothing extra on an ordinary turn", async () => { + const h = makeHarness(); + h.chat.sendMessage("do the thing"); + await h.finish({ undelivered: [] }); + expect(h.started).toEqual(["do the thing"]); + expect(infoLines(h.actions)).toEqual([]); + }); +}); + +/** + * The span between "the orchestrator considers a turn in flight" and + * "the loop opened the steering window for it". + * + * `runOneTurn` sets `currentController` and *then* awaits + * `runtime.runTurn`; `AgentLoop.runTurn` opens the window only once the + * submission owns the per-session lock — after `turnController.enqueue` + * has parked in `waitOrAbort` behind whatever is still settling on that + * session. A message submitted in that span sees a turn in flight and a + * shut window, so `steer` refuses it. It is still a correction aimed at + * the turn the operator is watching, so it must not be demoted behind + * backlog, and the operator must still be told it was taken as one. + * + * The harness runs the real `TurnController` and the real + * `SteeringInbox`; only the loop body is stubbed, in the shape + * `AgentLoop.runTurn` actually has (`open` on entry, `closeAndDrain` on + * the way out, and a settle phase after it for the session save plus the + * controller's own `finally`). Gates, not sleeps. + */ +interface Deferred { + promise: Promise; + resolve: () => void; +} + +function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** Drain the microtask queue; every gate in this harness is a promise. */ +async function flush(): Promise { + for (let i = 0; i < 12; i += 1) await Promise.resolve(); +} + +interface GapHarness { + chat: ChatOrchestrator; + actions: TuiAction[]; + inbox: SteeringInbox; + /** Turn bodies that actually started, in order. */ + started: string[]; + /** Occupy the session lock from another entry point (scheduler/HTTP). */ + occupy(text: string): Promise; + /** Let the turn running `text` reach its final drain (window shuts). */ + drain(text: string): Promise; + /** Let that turn's promise settle, releasing the per-session lock. */ + settle(text: string): Promise; +} + +const GAP_SESSION = "s-gap"; + +function makeGapHarness(): GapHarness { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((action) => actions.push(action)); + + const session = createEmptySessionState({ + id: GAP_SESSION, + workingDir: "/work", + }); + const inbox = new SteeringInbox(); + const controller = new TurnController(); + const started: string[] = []; + const gates = new Map(); + const gateFor = (text: string): { drain: Deferred; settle: Deferred } => { + const existing = gates.get(text); + if (existing) return existing; + const fresh = { drain: deferred(), settle: deferred() }; + gates.set(text, fresh); + return fresh; + }; + + const turnBody = async (text: string): Promise => { + // `AgentLoop.runTurn`, in miniature. + inbox.open(session.id); + started.push(text); + await gateFor(text).drain.promise; + const undelivered = inbox.closeAndDrain(session.id); + // The window is shut but the submission still owns the lock — this + // is `sessionStore.save` plus the controller's `finally`, and it is + // where the next submission is parked in `waitOrAbort`. + await gateFor(text).settle.promise; + return { session, reason: "reply", stepCount: 1, undelivered }; + }; + + const runtime = { + createSession: () => session, + sessionStore: { listRecent: () => [], load: () => session }, + approvals: { clearSessionGrants: () => undefined }, + steer: (sessionId: string, text: string) => inbox.push(sessionId, text), + runTurn: ( + _session: SessionState, + text: string, + options: { signal?: AbortSignal } = {}, + ) => + controller.enqueue({ + sessionId: session.id, + origin: "tui" as const, + run: () => turnBody(text), + ...(options.signal ? { signal: options.signal } : {}), + }), + } as unknown as AgentRuntime; + + const chat = new ChatOrchestrator(runtime, bus, { + maxSteps: 4, + llamaUrl: "http://127.0.0.1:8080", + }); + + return { + chat, + actions, + inbox, + started, + occupy: (text) => + controller.enqueue({ + sessionId: session.id, + origin: "scheduler", + run: () => turnBody(text), + }), + drain: async (text) => { + gateFor(text).drain.resolve(); + await flush(); + }, + settle: async (text) => { + gateFor(text).settle.resolve(); + await flush(); + }, + }; +} + +describe("ChatOrchestrator steering into the commit-to-open gap", () => { + it("acknowledges a steer sent while the turn is parked behind another one", async () => { + const h = makeGapHarness(); + // An out-of-band turn (scheduler here, HTTP in production) owns the + // session lock. + const occupant = h.occupy("scheduled digest"); + await flush(); + expect(h.started).toEqual(["scheduled digest"]); + + // The TUI commits to a turn: `currentController` is set, but the + // submission is parked in `waitOrAbort` and its loop never ran. + h.chat.sendMessage("do the thing"); + await flush(); + expect(h.started).toEqual(["scheduled digest"]); + + // The occupant does its final drain. Now nothing on this session is + // accepting steers, and nothing will until the parked turn starts. + await h.drain("scheduled digest"); + expect(h.inbox.isOpen(GAP_SESSION)).toBe(false); + + h.chat.steerMessage("actually, check the logs first"); + // Half the defect is the silence: the operator aimed this at a turn + // the TUI shows as running and used to get told so. + expect(infoLines(h.actions)).toContain( + "steering the running turn — it cannot take this one, so it runs as the next turn", + ); + + await h.settle("scheduled digest"); + await occupant; + expect(h.started).toEqual(["scheduled digest", "do the thing"]); + + await h.drain("do the thing"); + await h.settle("do the thing"); + expect(h.started).toEqual([ + "scheduled digest", + "do the thing", + "actually, check the logs first", + ]); + }); + + it("runs a steer sent in that gap before backlog left by an earlier turn", async () => { + const h = makeGapHarness(); + h.chat.sendMessage("do the thing"); + await flush(); + expect(h.started).toEqual(["do the thing"]); + + // Turn 1's window shuts; its promise has not settled, so the TUI + // still shows a turn in flight. + await h.drain("do the thing"); + h.chat.sendMessage("backlog one"); + h.chat.sendMessage("backlog two"); + await h.settle("do the thing"); + // Re-routed in the order they were typed, not reversed. + expect(h.started).toEqual(["do the thing", "backlog one"]); + + // Same gap, one turn later. "stop, use staging" is a correction to + // the turn in flight; "backlog two" was aimed at the turn before it. + await h.drain("backlog one"); + h.chat.steerMessage("stop, use staging"); + await h.settle("backlog one"); + expect(h.started).toEqual([ + "do the thing", + "backlog one", + "stop, use staging", + ]); + + await h.drain("stop, use staging"); + await h.settle("stop, use staging"); + expect(h.started).toEqual([ + "do the thing", + "backlog one", + "stop, use staging", + "backlog two", + ]); + }); + + it("keeps undelivered steers ahead of ones re-routed after the window shut", async () => { + const h = makeGapHarness(); + h.chat.sendMessage("do the thing"); + await flush(); + + // Accepted while the window was open, but no step boundary came: + // the turn hands it back on `undelivered`. + expect(h.inbox.isOpen(GAP_SESSION)).toBe(true); + h.chat.steerMessage("wait, staging"); + expect(h.inbox.peek(GAP_SESSION)).toEqual(["wait, staging"]); + + await h.drain("do the thing"); + // Typed after the window shut, i.e. after "wait, staging". + h.chat.steerMessage("and read the logs"); + await h.settle("do the thing"); + + expect(h.started).toEqual(["do the thing", "wait, staging"]); + await h.drain("wait, staging"); + await h.settle("wait, staging"); + expect(h.started).toEqual([ + "do the thing", + "wait, staging", + "and read the logs", + ]); + }); +}); diff --git a/src/tui/chat-orchestrator.test.ts b/src/tui/chat-orchestrator.test.ts new file mode 100644 index 00000000..35543808 --- /dev/null +++ b/src/tui/chat-orchestrator.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createEmptySessionState } from "../session/session-state.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { ChatOrchestrator, MAX_QUEUED_MESSAGES } 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, opts: { signal: AbortSignal }) => Promise, +): AgentRuntime { + return { + createSession: () => session(), + // The queue tests exercise the fallback path: a steer that is always + // refused parks every mid-run submission in the orchestrator queue. + steer: () => false, + runTurn: (_s: unknown, text: string, opts: { signal: AbortSignal }) => + runTurn(text, opts), + 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(); + // The idle boundary re-syncs an (empty) queue unconditionally; what + // must not happen is a non-empty snapshot or an "aborted:" notice. + expect(queueSnapshots(actions).every((q) => q.length === 0)).toBe(true); + }); +}); + +describe("ChatOrchestrator abort", () => { + it("discards parked messages instead of draining them into the next turn", async () => { + const seen: string[] = []; + const runTurn = vi.fn((text: string, opts: { signal: AbortSignal }) => { + seen.push(text); + return abortableTurn(opts.signal); + }); + 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.abortCurrentTurn(); + await settle(); + + // One Esc stops everything: the parked messages must not become turns. + expect(seen).toEqual(["running"]); + expect(runTurn).toHaveBeenCalledTimes(1); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + const aborted = noticeLines(actions).find((l) => + l.startsWith("aborted: dropped 2 parked messages"), + ); + expect(aborted).toBeDefined(); + // The dropped texts ride along so the operator can copy them back. + expect(aborted).toContain("1. parked-a"); + expect(aborted).toContain("2. parked-b"); + }); + + it("stays quiet when the abort had nothing parked to drop", async () => { + const runTurn = vi.fn((_text: string, opts: { signal: AbortSignal }) => + abortableTurn(opts.signal), + ); + 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.abortCurrentTurn(); + await settle(); + + // The idle boundary re-syncs an (empty) queue unconditionally; what + // must not happen is a non-empty snapshot or an "aborted:" notice. + expect(queueSnapshots(actions).every((q) => q.length === 0)).toBe(true); + expect(noticeLines(actions).filter((l) => l.startsWith("aborted:"))).toEqual( + [], + ); + }); +}); + +describe("ChatOrchestrator queue bound", () => { + it("caps the queue and names how many messages it dropped", async () => { + const first = deferred("s1"); + const seen: string[] = []; + const runTurn = vi.fn((text: string) => { + seen.push(text); + return 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"); + for (let i = 0; i < MAX_QUEUED_MESSAGES + 3; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + + const queued = queueSnapshots(actions).at(-1) ?? []; + expect(queued).toHaveLength(MAX_QUEUED_MESSAGES); + // FIFO: the cap drops the newest arrivals, never the ones already parked. + expect(queued[0]).toBe("parked-0"); + expect(queued.at(-1)).toBe(`parked-${MAX_QUEUED_MESSAGES - 1}`); + expect(runTurn).toHaveBeenCalledTimes(1); + + const full = noticeLines(actions).filter((l) => l.startsWith("queue: full")); + expect(full).toHaveLength(3); + expect(full.at(-1)).toBe( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped 3 messages (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + + first.resolve(); + await settle(); + // Exactly the parked messages run — the refused ones are gone for good. + expect(seen).toEqual(["running", ...queued]); + expect(seen).not.toContain(`parked-${MAX_QUEUED_MESSAGES}`); + }); + + it("re-publishes the queue on a rejected push so an optimistic insert cannot stick", () => { + const runTurn = vi.fn(() => new Promise(() => undefined)); + 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"); + for (let i = 0; i < MAX_QUEUED_MESSAGES; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + const beforeDrop = queueSnapshots(actions).length; + + orchestrator.sendMessage("rejected"); + + const snapshots = queueSnapshots(actions); + expect(snapshots).toHaveLength(beforeDrop + 1); + expect(snapshots.at(-1)).toHaveLength(MAX_QUEUED_MESSAGES); + expect(snapshots.at(-1)).not.toContain("rejected"); + }); + + it("forgets the drop counter once the queue has room again", 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"); + for (let i = 0; i < MAX_QUEUED_MESSAGES + 2; i += 1) { + orchestrator.sendMessage(`parked-${i}`); + } + orchestrator.clearQueue(); + orchestrator.sendMessage("after-clear"); + // Refills to exactly the cap, then one more that must be refused. + for (let i = 0; i < MAX_QUEUED_MESSAGES; i += 1) { + orchestrator.sendMessage(`again-${i}`); + } + + const full = noticeLines(actions).filter((l) => l.startsWith("queue: full")); + // Two drops before the clear, then the counter restarts at 1 after it. + expect(full.at(-1)).toBe( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped 1 message (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + }); +}); + +/** + * A turn that never settles on its own and rejects the moment the + * orchestrator aborts it — what `runtime.runTurn` really does, and the + * only shape that exercises `runOneTurn`'s catch-then-drain tail. + */ +function abortableTurn(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); +} + +/** Let the orchestrator's post-turn continuation (catch → finally → drain) run. */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function noticeLines(actions: readonly TuiAction[]): readonly string[] { + return actions + .filter((a): a is Extract => + a.type === "runtime_info", + ) + .map((a) => a.line); +} + +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..3633ffe5 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -30,6 +30,22 @@ import type { SessionPickerEntry, TuiState } from "./tui-state.js"; const DEBUG_BUNDLE_TRACE_LIMIT = 10; const DEBUG_BUNDLE_DIR_NAME = "atomic-agent-debug"; +/** + * Hard cap on messages parked behind the running turn. + * + * Nothing bounded this before because nothing could reach it: the editor + * was dead for the duration of a turn, so the queue was a de-facto + * zero-length buffer. Now that typing stays live, a leaned-on Enter or a + * multi-line paste can pile up an arbitrary backlog, and every parked + * message is later replayed as a full `runTurn` — an unbounded queue is + * an unattended agent run nobody asked for. + * + * Twenty is past any backlog a human types while watching one turn + * stream, small enough that draining it stays comprehensible, and it + * keeps `emitQueue`'s whole-array copy bounded at 20 elements per push. + */ +export const MAX_QUEUED_MESSAGES = 20; + export interface ChatOrchestratorOptions { maxSteps: number; /** Initial llama-server base URL for the footer health poller. */ @@ -65,14 +81,21 @@ function formatSkillCatalogSystemMessage( * Owns the single live chat session. Each call to `sendMessage` queues a * macro-turn through `runtime.runTurn`; only one turn is in flight at any * time so the user can keep typing without racing the agent loop. Abort - * cancels the current turn but keeps the session alive — that is what - * sets chat mode apart from the legacy goal-runner. + * cancels the current turn and discards whatever is parked behind it, + * but keeps the session alive — that is what sets chat mode apart from + * the legacy goal-runner. * * The Tasks tab surface (list/detail/create/cancel/run-now) is delegated * to `TasksOrchestrator`, which is constructed here and exposed via * `tasks` so `tui-command.ts` can wire its callbacks without reaching * into runtime internals. */ +/** One-row preview for a dropped queue entry: flattened and elided. */ +function droppedPreview(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length <= 60 ? flat : `${flat.slice(0, 59)}…`; +} + export class ChatOrchestrator { private session: SessionState | null = null; private currentController: AbortController | null = null; @@ -81,6 +104,21 @@ export class ChatOrchestrator { /** Latest release version captured by `checkForUpdate`, used by `runUpdate`. */ private pendingUpdateVersion: string | null = null; private readonly queue: string[] = []; + /** + * Messages refused since the queue last had room, so a burst reads as + * one escalating counter instead of N identical lines. Reset by the + * first push that fits again. + */ + private droppedWhileFull = 0; + /** + * How many leading `queue` entries are steering re-routes for the turn + * currently in flight. New re-routes are spliced in at this index, so + * they stay ahead of ordinary backlog (they are corrections to the turn + * the operator is watching) while keeping their own typing order. Reset + * whenever a turn starts — a message aimed at the previous turn is + * ordinary backlog from the next one's point of view. + */ + private steeredAhead = 0; public exitCode = 0; public readonly tasks: TasksOrchestrator; public readonly skills: SkillsOrchestrator; @@ -199,6 +237,15 @@ export class ChatOrchestrator { * the user to relaunch. */ runUpdate(): void { + // Replacing the binary under a running turn is the one mid-run slash + // command with no safe outcome — now reachable because the editor + // stays live. Refuse it instead of racing the installer. + if (this.currentController) { + this.notify( + "update: refused while a turn is running — abort it or let it finish first", + ); + return; + } this.bus.emit({ type: "update_started" }); void (async () => { try { @@ -258,7 +305,7 @@ export class ChatOrchestrator { return; } this.session = loaded; - this.queue.length = 0; + this.clearQueue(); // 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(); @@ -348,7 +395,7 @@ export class ChatOrchestrator { return; } this.session = this.runtime.createSession(); - this.queue.length = 0; + this.clearQueue(); // A fresh session starts with no point exceptions: grants never // outlive the session that created them. this.runtime.approvals.clearSessionGrants(); @@ -370,16 +417,161 @@ export class ChatOrchestrator { if (this.quitting) return; this.ensureSession(); if (this.currentController) { + if (this.queue.length >= MAX_QUEUED_MESSAGES) { + this.droppedWhileFull += 1; + // Re-publish an unchanged queue on purpose: the reducer already + // inserted this message optimistically on `message_queued`, and + // only an authoritative `queue_changed` takes it back off the + // strip. Skipping the emit here would leave the operator looking + // at a parked message that is never going to run. + this.emitQueue(); + // The optimistic `message_queued` already cleared the editor, so + // a refusal that only warned would lose the typed text entirely. + // Hand it back to the buffer instead. + this.bus.emit({ type: "input_changed", value: text }); + this.notify( + `queue: full at ${MAX_QUEUED_MESSAGES} — dropped ${this.droppedWhileFull} message${ + this.droppedWhileFull === 1 ? "" : "s" + } (returned to the editor); Esc stops the run, /queue clear empties it`, + ); + return; + } + this.droppedWhileFull = 0; this.queue.push(text); + this.emitQueue(); return; } void this.runOneTurn(text); } + /** + * Fold a message into the turn already running on this session + * (Enter in `steer` mode, and `/steer ` one-shots). + * + * Steer first; on refusal the message must still go somewhere — and + * not behind ordinary backlog: `currentController` is set strictly + * earlier than the loop opens its window, so a refusal can mean "not + * yet" as well as "too late" or "full". `queueAsSteer` splices it + * ahead of backlog, behind steers already re-routed for the same + * turn, so typing order survives. `steer`'s answer is the only fact + * consulted — see §"Mid-turn steering" in AGENTS.md. + */ + steerMessage(text: string): void { + if (this.quitting) return; + const session = this.ensureSession(); + if (this.currentController) { + if (session !== null && this.runtime.steer(session.id, text)) { + this.bus.emit({ + type: "runtime_info", + line: "steering the running turn — the agent reads it at the next step", + }); + return; + } + if (this.queue.length >= MAX_QUEUED_MESSAGES) { + this.droppedWhileFull += 1; + this.emitQueue(); + this.bus.emit({ type: "input_changed", value: text }); + this.notify( + `queue: full at ${MAX_QUEUED_MESSAGES} — the steer could not be parked (returned to the editor)`, + ); + return; + } + this.droppedWhileFull = 0; + this.queueAsSteer(text); + this.emitQueue(); + this.bus.emit({ + type: "runtime_info", + line: "steering the running turn — it cannot take this one, so it runs as the next turn", + }); + return; + } + void this.runOneTurn(text); + } + + /** + * Queue a message that was meant as a steer but could not be folded + * into the running turn — ahead of ordinary backlog, behind steers + * already re-routed for the same turn. + */ + private queueAsSteer(text: string): void { + this.queue.splice(this.steeredAhead, 0, text); + this.steeredAhead += 1; + } + + /** + * Re-route steering messages the turn accepted but never delivered. + * + * `RunTurnResult.undelivered` carries anything pushed after the loop's + * last step boundary — during the final inference, or into a turn + * cancelled before it stepped. AGENTS.md makes re-routing the caller's + * job: `steer` already answered "yes" to whoever sent these, so + * dropping them here would lose a message the operator watched being + * accepted. They go to the FRONT of the queue — ahead of + * `queueAsSteer`'s entries too: they are corrections aimed at the turn + * that just ran, and anything already queued was typed after `steer` + * had refused it. + */ + private rerouteUndelivered(undelivered: readonly string[] | undefined): void { + if (undelivered === undefined || undelivered.length === 0) return; + this.queue.unshift(...undelivered); + this.emitQueue(); + this.notify( + `${undelivered.length} message${ + undelivered.length === 1 ? "" : "s" + } arrived too late for that turn — sending ${ + undelivered.length === 1 ? "it" : "them" + } next`, + ); + } + + /** + * 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 { + // Even an empty queue can carry a stale steer watermark. + this.steeredAhead = 0; + 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. + * + * The whole-array copy is deliberate and now bounded: the action must + * not hand subscribers a live reference to `this.queue`, and + * `MAX_QUEUED_MESSAGES` caps the copy at 20 elements per emit. Trading + * it for a push/shift/clear delta would put queue arithmetic back in + * the reducer — the exact drift this design removed. + */ + private emitQueue(): void { + this.bus.emit({ type: "queue_changed", queued: [...this.queue] }); + } + + /** + * Operator-facing notice about the queue: an event-feed line plus the + * same sentence as a warn message in the transcript, because the feed + * is not on screen in chat mode and these two events (an abort binning + * parked work, a refused submission) are things the operator typed and + * must not lose silently. + */ + private notify(line: string): void { + this.bus.emit({ type: "runtime_info", line }); + this.bus.emit({ type: "system_message", text: line, variant: "warn" }); + } + private async runOneTurn(text: string): Promise { if (!this.session) return; const controller = new AbortController(); this.currentController = controller; + // A new turn is in flight: whatever is still queued was aimed at an + // earlier one and is ordinary backlog now. + this.steeredAhead = 0; try { const result = await this.runtime.runTurn(this.session, text, { maxSteps: this.options.maxSteps, @@ -387,6 +579,24 @@ export class ChatOrchestrator { origin: "tui", }); this.session = result.session; + // A cancelled turn means the operator stopped the agent — Esc, + // Ctrl+C or /abort. Re-queueing its undelivered steers here would + // make the post-abort drain START a turn out of them: the exact + // "Esc launches the next parked message" trap the abort path + // exists to close. Announce the drop instead, like the queue drop. + if (result.reason === "cancelled") { + const dropped = result.undelivered ?? []; + if (dropped.length > 0) { + this.notify( + [ + `aborted: dropped ${dropped.length} undelivered steer${dropped.length === 1 ? "" : "s"}`, + ...dropped.map((text, i) => ` ${i + 1}. ${droppedPreview(text)}`), + ].join("\n"), + ); + } + } else { + this.rerouteUndelivered(result.undelivered); + } if (isFailedSessionStatus(this.session.status)) this.exitCode = 1; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -401,6 +611,10 @@ export class ChatOrchestrator { if (this.currentController === controller) this.currentController = null; } const next = this.queue.shift(); + // Unconditional: the idle boundary re-syncs the strip even when + // nothing drained, so an optimistic UI insert can never outlive the + // turn it was parked behind. + this.emitQueue(); if (next !== undefined && !this.quitting) { void this.runOneTurn(next); } @@ -471,14 +685,41 @@ export class ChatOrchestrator { return ids.slice(0, DEBUG_BUNDLE_TRACE_LIMIT); } + /** + * Esc / Ctrl+C / `/abort` — stop the agent, not merely this turn. + * + * Discarding the queue is the whole point. `runOneTurn` catches the + * abort rejection and falls straight through to `this.queue.shift()`, + * so an intact backlog turned Esc into "start the next parked + * message"; stopping a wrong run cost one Esc per parked message. + * Clear first, then abort — the same order `quit()` uses below. + * + * The drop is announced: the operator typed those messages, so binning + * N of them silently is worse than one line in the transcript. + */ abortCurrentTurn(): void { + const dropped = [...this.queue]; + if (dropped.length > 0) { + this.queue.length = 0; + this.droppedWhileFull = 0; + this.emitQueue(); + // The operator typed those messages; a bare count would bin their + // words with no way back. The transcript line carries a preview of + // each so anything worth keeping can be copied out. + this.notify( + [ + `aborted: dropped ${dropped.length} parked message${dropped.length === 1 ? "" : "s"}`, + ...dropped.map((text, i) => ` ${i + 1}. ${droppedPreview(text)}`), + ].join("\n"), + ); + } this.currentController?.abort(); } quit(): void { if (this.quitting) return; this.quitting = true; - this.queue.length = 0; + this.clearQueue(); this.currentController?.abort(); } diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts index 98563a41..b556db4b 100644 --- a/src/tui/commands/slash-command-handler.test.ts +++ b/src/tui/commands/slash-command-handler.test.ts @@ -86,6 +86,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); @@ -372,4 +384,43 @@ describe("dispatchSlashCommand", () => { expect(result.analyticsVerb).toBe("disable"); expect(result.approvalLevelSet).toBeUndefined(); }); + + it("asks for the new default to be persisted on bare /steer and /queue", () => { + const steer = dispatchSlashCommand("/steer"); + expect(steer.setWhileBusyMode).toBe("steer"); + expect(steer.actions).toEqual([ + { type: "while_busy_mode_changed", mode: "steer" }, + ]); + + // Bare /queue stays a side-effect-free listing — the menu node and + // the parked chip both invite running it just to look. + const queue = dispatchSlashCommand("/queue"); + expect(queue.setWhileBusyMode).toBeUndefined(); + expect(queue.queueVerb).toBe("list"); + expect(queue.actions).toEqual([]); + + const queueMode = dispatchSlashCommand("/queue mode"); + expect(queueMode.setWhileBusyMode).toBe("queue"); + expect(queueMode.actions).toEqual([ + { type: "while_busy_mode_changed", mode: "queue" }, + ]); + }); + + it("leaves the persisted default alone for the message-carrying forms", () => { + const steer = dispatchSlashCommand("/steer use the staging db"); + expect(steer.submitWhileBusy).toEqual({ + mode: "steer", + text: "use the staging db", + }); + expect(steer.setWhileBusyMode).toBeUndefined(); + + const queue = dispatchSlashCommand("/queue then deploy"); + expect(queue.submitWhileBusy).toEqual({ + mode: "queue", + text: "then deploy", + }); + expect(queue.setWhileBusyMode).toBeUndefined(); + + expect(dispatchSlashCommand("/queue clear").setWhileBusyMode).toBeUndefined(); + }); }); diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..59762447 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -1,3 +1,4 @@ +import type { WhileBusySubmitMode } from "../../config/index.js"; import type { TuiAction } from "../tui-action.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; import { isThemeName, THEME_NAMES } from "../theme/theme.js"; @@ -28,6 +29,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. */ @@ -82,6 +85,28 @@ 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 }; + /** + * Bare `/steer` / `/queue`: the new Enter-while-busy default. The + * `while_busy_mode_changed` action flips the live state; this asks the + * caller to also write it to `tui.whileBusySubmit` through + * `onWhileBusyModePersistRequested` — the same callback Ctrl+T uses, so + * every route to the setting shares one persist path. Deliberately + * unset by the message-carrying form, which is a one-off. + */ + readonly setWhileBusyMode?: WhileBusySubmitMode; /** * `/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 +114,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 +139,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, @@ -124,6 +157,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, @@ -142,12 +176,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, @@ -204,6 +244,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 +297,59 @@ function formatSlashCommandHelp(): string { return ["slash commands:", ...lines].join("\n"); } +/** + * `/queue` — bare switches the Enter-while-busy mode to `queue`, persists + * it, 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 (verb === "mode" || verb === "default") { + return pureActions([{ type: "while_busy_mode_changed", mode: "queue" }], { + setWhileBusyMode: "queue", + systemMessage: "Enter now queues behind the running turn", + }); + } + if (raw.length > 0) { + return pureActions([], { + submitWhileBusy: { mode: "queue", text: raw }, + }); + } + // Bare `/queue` stays a side-effect-free listing: the menu node and + // the `/queue N parked` chip both invite running it just to look, so + // looking must not silently persist a mode change. + return pureActions([], { queueVerb: "list" }); +} + +/** + * `/steer` — bare switches the Enter-while-busy mode to `steer` and + * persists it; `/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" }], { + setWhileBusyMode: "steer", + systemMessage: + "Enter now steers the running turn (Ctrl+T or /queue switches back)", + }); +} + function pureActions( actions: readonly TuiAction[], overrides: Partial< @@ -268,6 +363,7 @@ function pureActions( triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, @@ -283,6 +379,9 @@ function pureActions( setThemeName: undefined, telegramVerb: undefined, analyticsVerb: undefined, + queueVerb: undefined, + submitWhileBusy: undefined, + setWhileBusyMode: undefined, approvalLevelSet: undefined, ...overrides, }; @@ -295,6 +394,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-log.test.tsx b/src/tui/components/chat-log.test.tsx index 8cd25f51..aebef8fd 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"); }); diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index 09f0e081..bc020054 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -1,6 +1,7 @@ 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"; @@ -16,15 +17,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 +81,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 diff --git a/src/tui/components/cloud-provider-onboarding.test.tsx b/src/tui/components/cloud-provider-onboarding.test.tsx new file mode 100644 index 00000000..f90697f1 --- /dev/null +++ b/src/tui/components/cloud-provider-onboarding.test.tsx @@ -0,0 +1,323 @@ +/** + * First-run onboarding, from the angle the sibling wizard already has + * covered: what a cancelled key check is allowed to do afterwards. + * + * `verifyProviderKey` samples the abort signal at the top of each probe + * and in the fetch catch, so an abort that lands while the response body + * is being read produces an ordinary verdict, not `"cancelled"`. Every + * test here drives that interleaving through real key bindings and the + * real verify path, stubbing only the disk write, the config read and + * the network. The one exception is the gate rejection, which no real + * provider answer produces — `verifyProviderKey` returns a verdict for + * every transport failure it meets. + */ + +import { render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AtomicAgentConfig } from "../../config/index.js"; +import type { WizardVerifyGate } from "../providers/verify-wizard-before-save.js"; +import { CloudProviderOnboarding } from "./cloud-provider-onboarding.js"; +import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; +import { saveProviderWizardToConfig } from "../providers/save-provider-wizard.js"; + +vi.mock("../../config/index.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, getConfig: () => currentConfig }; +}); + +vi.mock("../providers/save-provider-wizard.js", () => ({ + saveProviderWizardToConfig: vi.fn(() => ({ + entry: { id: "openrouter", kind: "openrouter" }, + })), +})); + +/** + * Answers queued here stand in for the next checks; anything not queued + * runs the real gate against the stubbed fetch. `verifyProviderKey` + * turns every transport failure into a verdict rather than a rejection, + * so a rejected gate is the only way to reach the component's `catch`. + */ +const gateOverrides: ((signal?: AbortSignal) => Promise)[] = + []; + +vi.mock("../providers/verify-wizard-before-save.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../providers/verify-wizard-before-save.js") + >(); + return { + ...original, + verifyWizardBeforeSave: ( + wizard: Parameters[0], + opts: Parameters[1] = {}, + ) => { + const queued = gateOverrides.shift(); + return queued + ? queued(opts.signal) + : original.verifyWizardBeforeSave(wizard, opts); + }, + }; +}); + +const currentConfig = { + llm: { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [], + }, +} as unknown as AtomicAgentConfig; + +const saveMock = vi.mocked(saveProviderWizardToConfig); + +/** Written out so an editor cannot quietly eat the control character. */ +const ESC = "\u001b"; +const CTRL_C = "\u0003"; + +/** One in-flight probe, with its body read parked until the test says so. */ +interface ProbeGate { + /** Resolves once `verifyProviderKey` has started reading the body. */ + readonly bodyRequested: Promise; + /** Hands the body over, which lets `classifyVerifyResponse` run. */ + releaseBody(body: string): void; + readonly calls: () => number; +} + +/** + * A fetch that answers instantly but hands its body over only on demand. + * The window between the two is where the reviewer's race lives: the + * response has arrived, so the abort no longer reaches any of the + * signal checks inside `verifyProviderKey`. + */ +function stubGatedProbe(status = 429): ProbeGate { + let requested = () => {}; + const bodyRequested = new Promise((resolve) => { + requested = resolve; + }); + let release: (body: string) => void = () => {}; + const bodyReady = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown) => { + // Model-catalog reads share this stub; only the probe completions + // say anything about how many checks were started. + if (String(url).includes("/chat/completions")) calls += 1; + return { + ok: false, + status, + text: () => { + requested(); + return bodyReady; + }, + } as unknown as Response; + }), + ); + return { + bodyRequested, + releaseBody: (body: string) => { + release(body); + }, + calls: () => calls, + }; +} + +async function flush(times = 6): Promise { + for (let i = 0; i < times; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +/** Long enough for Ink's 20 ms pending-escape flush to fire. */ +async function settleInput(): Promise { + await new Promise((resolve) => setTimeout(resolve, 40)); + await flush(); +} + +/** + * Walks the wizard from the provider list to the last screen before the + * save: OpenRouter → key → chat model → embedding. The next Enter is the + * one that starts the credential check. + */ +async function mountAtSubmitPoint(): Promise<{ + stdin: { write(data: string): void }; + onFinished: ReturnType; + frame: () => string; + unmount: () => void; +}> { + const onFinished = vi.fn(); + const { stdin, lastFrame, unmount } = render( + {}} />, + ); + await settleInput(); + // The CLI-backed rows sit at the head of the list; walk down to + // OpenRouter by its registry position instead of assuming row 0. One + // settle per keypress: the component reads the wizard from a state + // closure, so two arrows in one tick would collapse into one step. + for (let i = 0; i < KIND_ROW_ORDER.indexOf("openrouter"); i += 1) { + stdin.write("\u001b[B"); + await settleInput(); + } + stdin.write("\r"); // OpenRouter + await settleInput(); + stdin.write("sk-onboarding-test-key"); + await settleInput(); + stdin.write("\r"); // key accepted → chat model list + await settleInput(); + stdin.write("\r"); // chat model → embedding list + await settleInput(); + return { + stdin, + onFinished, + frame: () => (lastFrame() ?? "").replace(/\[[0-9;]*m/g, ""), + unmount, + }; +} + +describe("CloudProviderOnboarding cancellation", () => { + beforeEach(() => { + saveMock.mockClear(); + gateOverrides.length = 0; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("drops a verdict that arrives after Esc cancelled the check", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); // Enter on the embedding list starts the check + await probe.bodyRequested; + await flush(); + expect(frame()).toContain("checking the key with the provider"); + + stdin.write(ESC); // Esc cancels while the body is still unread + await settleInput(); + expect(frame()).toContain("Key check cancelled"); + + // The response was already on the wire, so the check finishes with an + // ordinary verdict rather than "cancelled". + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).not.toHaveBeenCalled(); + // The screen the operator was handed back is still the one on show. + expect(frame()).toContain("Key check cancelled"); + unmount(); + }); + + it("keeps the cancelled check from overwriting the retry started after it", async () => { + const first = stubGatedProbe(); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await first.bodyRequested; + await flush(); + + stdin.write(ESC); + await settleInput(); + + // Enter after Esc: the operator takes the screen's own advice. + const second = stubGatedProbe(200); + stdin.write("\r"); + await second.bodyRequested.catch(() => {}); + await flush(); + expect(frame()).toContain("checking the key with the provider"); + + // The abandoned check answers last, and its verdict would have saved. + first.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).not.toHaveBeenCalled(); + + // The retry is still the live one, and it is the one that saves. + second.releaseBody("{}"); + await flush(12); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + expect(onFinished.mock.calls[0]?.[0]).toBe("saved_cloud"); + unmount(); + }); + + it("starts one check when two Enters are drained from stdin in one turn", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, unmount } = await mountAtSubmitPoint(); + + // Two key events in the same turn — what a buffered stdin hands Ink + // in one `readable` drain. Neither is cancelled, so the post-await + // abort check cannot separate them; only the in-flight ref can. + stdin.write("\r"); + stdin.write("\r"); + await probe.bodyRequested; + await flush(); + expect(probe.calls()).toBe(1); + + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + // One check, one save, one exit — not two of each. + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("keeps a cancelled check's failure off the retry that replaced it", async () => { + let failFirst: (err: Error) => void = () => {}; + gateOverrides.push( + () => + new Promise((_resolve, reject) => { + failFirst = reject; + }), + ); + const { stdin, onFinished, frame, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await settleInput(); + stdin.write(ESC); // Esc + await settleInput(); + expect(frame()).toContain("Key check cancelled"); + + const retry = stubGatedProbe(200); + stdin.write("\r"); + await retry.bodyRequested; + await flush(); + + // The abandoned run blows up after the retry took the screen. Its + // message must not land there, and it must not free `submitting`. + failFirst(new Error("openrouter provider network error: socket hang up")); + await flush(12); + expect(frame()).not.toContain("socket hang up"); + expect(frame()).toContain("checking the key with the provider"); + expect(saveMock).not.toHaveBeenCalled(); + + retry.releaseBody("{}"); + await flush(12); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("drops a verdict that arrives after Ctrl+C left onboarding", async () => { + const probe = stubGatedProbe(); + const { stdin, onFinished, unmount } = await mountAtSubmitPoint(); + + stdin.write("\r"); + await probe.bodyRequested; + await flush(); + + stdin.write(CTRL_C); // Ctrl+C + await settleInput(); + expect(onFinished).toHaveBeenCalledWith("aborted"); + + probe.releaseBody('{"error":{"message":"rate limited"}}'); + await flush(12); + expect(saveMock).not.toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledTimes(1); + unmount(); + }); +}); diff --git a/src/tui/components/cloud-provider-onboarding.tsx b/src/tui/components/cloud-provider-onboarding.tsx index fbb57fb3..40e5d31e 100644 --- a/src/tui/components/cloud-provider-onboarding.tsx +++ b/src/tui/components/cloud-provider-onboarding.tsx @@ -1,41 +1,102 @@ 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(); + }; + }, []); + + /** + * Whether the check this controller belongs to may still touch the + * screen or the config. Asked again after every await, at both exits. + * + * `alive` alone answers a different question: Esc and Ctrl+C both end + * a check without unmounting anything, so a mounted component says + * nothing about whether its operator still wants the answer. Nor does + * a verdict — `verifyProviderKey` samples the signal at the top of + * each probe and in the fetch catch, so an abort landing between the + * response arriving and `classifyVerifyResponse` returning still comes + * back as an ordinary `ok`/`rate_limited`. The Providers-tab twin + * carries this guard in `completeWizard` for the same reason. + * + * A run superseded by the operator's retry is covered because + * `cancelSubmit` is the only thing that frees the wizard for a second + * check and it aborts first: no cancel, no second run. + */ + const checkStillWanted = useCallback( + (abort: AbortController): boolean => + alive.current && !abort.signal.aborted, + [], + ); const submit = useCallback( - (nextWizard: ProvidersWizardState) => { - if (submitting) return; + async (nextWizard: ProvidersWizardState) => { + // Re-entry is guarded on the ref, not on `submitting`: that state + // is captured in this closure, and the cancel handler resets it + // while the check it cancelled is still resolving, so Enter after + // Esc read a stale `false`. So did two key events drained from + // stdin in one turn, which started two checks racing to save the + // same wizard — and neither was cancelled, so no post-await check + // could tell them apart. The ref is written before the first await + // and cleared only by the run that owns it, or by a cancel. + if (verifyAbort.current) 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 (!checkStillWanted(abort)) 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) { + // An abandoned run does not get to report a failure either: it + // would paint over the screen the operator was handed back, and + // free `submitting` under a check that is still running. + if (!checkStillWanted(abort)) 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], + [checkStillWanted, props], ); useInput((input, key) => { if (key.ctrl && input === "c") { + verifyAbort.current?.abort(); props.onFinished("aborted"); return; } @@ -46,8 +107,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..fdfc7ff3 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -1,3 +1,4 @@ +import { Box } from "ink"; import { render } from "ink-testing-library"; import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react"; @@ -16,8 +17,24 @@ const MAC_SCROLL_KEY = "fn+↑↓"; const OTHER_SCROLL_KEY = "pgup/pgdn"; const SCROLL_KEY_PATTERN = /fn\+↑↓|pgup\/pgdn/; -function renderHint(state: TuiState): string { - const { lastFrame, unmount } = render(); +/** + * Wide enough that no chip is shed — these cases assert *which* chips a + * state offers, not how the row degrades. Narrow behaviour has its own + * describe block below. + */ +const WIDE = 200; + +/** + * The strip is rendered inside a column-direction Box exactly as + * `TuiApp` renders it, so Ink resolves the same width and the frame we + * assert on is the frame the operator sees at `columns`. + */ +function renderHint(state: TuiState, columns: number = WIDE): string { + const { lastFrame, unmount } = render( + + + , + ); const out = (lastFrame() ?? "").replace(ANSI, ""); unmount(); return out; @@ -31,6 +48,15 @@ function chatState(overrides: Partial = {}): TuiState { }; } +/** Chips are `[key] label`; counting the brackets counts the chips. */ +function chipCount(frame: string): number { + return (frame.match(/\[/g) ?? []).length; +} + +function widest(frame: string): number { + return Math.max(0, ...frame.split("\n").map((line) => line.length)); +} + function fakeApproval(): ApprovalRequest { return { approvalId: "appr-1", @@ -71,6 +97,92 @@ describe("HotkeyHint scroll chip", () => { }); }); +// Renders are the expensive part of this file (~2s each under Ink), so +// each case below is one render and asserts everything it can from it. +describe("HotkeyHint draft chips", () => { + it("adds an esc / clear-draft chip once a draft exists", () => { + // The ctrl+p chip is not inert with a draft (the menu opens either + // way), so nothing is swapped out: the affordance rides as a seventh + // chip on a wide surface and the shed ranks pay for it when the row + // narrows. + const out = renderHint(chatState({ inputValue: "half a thought" })); + expect(out).toContain("[esc]"); + expect(out).toContain("clear draft"); + expect(out).toContain("[ctrl+p]"); + expect(chipCount(out)).toBe(7); + }); + + it("keeps the empty idle footer free of the clear-draft chip", () => { + const out = renderHint(chatState()); + expect(out).not.toContain("clear draft"); + expect(out).toContain("[ctrl+p]"); + expect(out).toContain("menu"); + expect(chipCount(out)).toBe(6); + }); + + it("tells the operator the draft survives an abort mid-turn", () => { + const out = renderHint( + chatState({ status: "running", inputValue: "half a thought" }), + ); + expect(out).toContain("[esc]"); + expect(out).toContain("abort, draft kept"); + // Clearing is not on offer while a turn is in flight — abort wins. + expect(out).not.toContain("clear draft"); + }); + + it("leaves the running label plain when there is no draft to keep", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).toContain("[esc]"); + expect(out).toContain("abort"); + expect(out).not.toContain("draft"); + }); +}); + +/** + * Ink wraps an over-wide row instead of clipping it, which both costs a + * row `debug-pane` budgeted away (`APP_CHROME_ROWS` counts the strip as + * 1) and smears chips across two lines with their separators stranded. + * The strip must therefore stay exactly one row, shedding whole chips + * rather than letting Yoga chop them. + */ +describe("HotkeyHint narrow-width degradation", () => { + it("sheds the scroll hint before send / clear-draft / quit at 80 columns", () => { + const out = renderHint(chatState({ inputValue: "half a thought" }), 80); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).not.toMatch(SCROLL_KEY_PATTERN); + expect(out).toContain("send"); + expect(out).toContain("clear draft"); + expect(out).toContain("quit"); + }); + + it("keeps the running strip and its abort label on one row at 80 columns", () => { + const out = renderHint( + chatState({ status: "running", inputValue: "half a thought" }), + 80, + ); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).toContain("abort, draft kept"); + }); + + it("keeps the debug footer on one row at 80 columns", () => { + const out = renderHint(chatState({ uiMode: "debug" }), 80); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(80); + expect(out).toContain("back to Run"); + }); + + it("clips instead of wrapping once only essential chips are left", () => { + // 40 columns cannot hold even the essentials; `truncate-end` must + // take the overflow rather than Ink adding a second row. + const out = renderHint(chatState({ inputValue: "half a thought" }), 40); + expect(out.split("\n")).toHaveLength(1); + expect(widest(out)).toBeLessThanOrEqual(40); + expect(out).toContain("[enter]"); + }); +}); + describe("HotkeyHint debug footer", () => { it("advertises the way back to Run and drops the duplicate ctrl+b chip", () => { const out = renderHint(chatState({ uiMode: "debug" })); @@ -84,6 +196,33 @@ describe("HotkeyHint debug footer", () => { }); }); +describe("HotkeyHint pending ctrl+g leader", () => { + it("says the leader is waiting instead of showing the idle chips", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("ctrl+g"); + expect(out).toContain("waiting for a chord"); + expect(out).toContain("[esc]"); + expect(out).toContain("cancel"); + // The armed leader unfocuses the editor and eats the next key, so the + // strip must not keep advertising chips that no longer apply. + expect(out).not.toContain("send"); + }); + + it("keeps the approval footer, which outranks the leader on keys", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("approve"); + expect(out).not.toContain("waiting for a chord"); + }); +}); + describe("HotkeyHint scroll key spelling per platform", () => { const realPlatform = process.platform; @@ -98,7 +237,11 @@ describe("HotkeyHint scroll key spelling per platform", () => { Object.defineProperty(process, "platform", { value: platform }); vi.resetModules(); const fresh = await import("./hotkey-hint.js"); - const { lastFrame, unmount } = render(); + const { lastFrame, unmount } = render( + + + , + ); const out = (lastFrame() ?? "").replace(ANSI, ""); unmount(); return out; @@ -116,3 +259,56 @@ describe("HotkeyHint scroll key spelling per platform", () => { expect(out).not.toContain(MAC_SCROLL_KEY); }); }); + +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("parked"); + 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..63ce8e9e 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -1,5 +1,14 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MENU_LEADER_LABEL } from "../menu/menu-keys.js"; +import { applyNavSlot, decideApproval } 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 +16,35 @@ interface HotkeyHintProps { state: TuiState; /** Whether a Ctrl+C was recently pressed and is armed for exit. */ ctrlCArmed?: boolean; + /** Whether a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed?: boolean; + /** + * Columns the strip may occupy. This is the **chat column**, not the + * terminal: the caller subtracts the root gutter and the sidebar, + * because the strip shares a flex row with them. Required so a new + * call site cannot forget it and silently reintroduce the wrap. + */ + width: number; } interface HotkeyChip { readonly key: string; readonly label: string; + /** + * Position in the shedding queue when the row does not fit `width`: + * chip `1` is dropped first, then `2`, and so on. A chip with no rank + * is essential — it stays even if the row still overflows (and is then + * clipped by `truncate-end` rather than wrapped). + */ + readonly shed?: number; + + /** + * What a click on this chip does. Only chips with one unambiguous + * meaning get one — "alt+enter 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; } /** @@ -23,20 +56,30 @@ const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgd /** * Bottom hint strip: surfaces the keybindings that are meaningful in - * the current state so the user never has to guess. We cap to ~6 chips - * to fit one terminal row and let slash commands take care of the long - * tail. + * the current state so the user never has to guess. + * + * The strip is budgeted to **one row**. Ink does not clip an over-wide + * row, it wraps it — and a wrapped strip both costs a row the debug + * pane already budgeted away (`APP_CHROME_ROWS`) and splits chips from + * their separators into an unreadable two-line smear. So chips are shed + * in a declared order until the row fits, and `truncate-end` clips the + * essential remainder on a terminal too narrow even for those. */ -export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement { - const chips = resolveChips(state, ctrlCArmed ?? false); +export function HotkeyHint({ + state, + ctrlCArmed, + menuLeaderArmed, + width, +}: HotkeyHintProps): ReactElement { + const chips = fitChips( + resolveChips(state, ctrlCArmed ?? false, menuLeaderArmed ?? false), + width, + ); return ( - + {chips.map((chip, idx) => ( - - - [{chip.key}] - - {chip.label} + + {idx < chips.length - 1 ? ( {" "} @@ -44,20 +87,70 @@ 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} + + ); +} + +function resolveChips( + state: TuiState, + ctrlCArmed: boolean, + menuLeaderArmed: boolean, +): HotkeyChip[] { + const hasDraft = state.inputValue.length > 0; 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" }, ]; } + // An armed leader owns the very next keystroke and unfocuses the editor + // while it waits, so it takes the whole strip: the row the operator is + // already looking at is where "the app is mid-gesture" belongs. Ordered + // to match key precedence — a pending approval still outranks it. + if (menuLeaderArmed) { + return [ + { key: MENU_LEADER_LABEL, label: "waiting for a chord" }, + { key: "ctrl+p", label: "full menu" }, + { key: "esc", label: "cancel" }, + ]; + } if (state.slashPaletteOpen) { return [ { key: "↑↓", label: "select" }, @@ -66,26 +159,69 @@ 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 [ - { key: SCROLL_KEY, label: "scroll" }, - { key: "esc", label: "abort" }, + // Esc has exactly one meaning during a turn — abort — because abort + // deliberately wins over clear-draft (`handleAppKey` claims the key; + // see `onEscape` in `tui-app.tsx`). Say so when a draft exists: an + // operator who typed while the agent worked otherwise has nothing on + // screen telling him whether Esc also eats what he typed. The editor + // stays live during a run, so the strip also advertises what Enter + // does now — and how many messages are already parked behind the + // turn. Scroll sheds first (the wheel already does it), then the + // parked counter, then the Enter hint. + // An armed Ctrl+C is the one state where a mispress quits the whole + // app — it takes the row for itself so nothing dilutes the warning. + if (ctrlCArmed) { + return [{ key: "ctrl+c", label: "press again to quit" }]; + } + const steering = state.whileBusyMode === "steer"; + const chips: HotkeyChip[] = [ + { key: SCROLL_KEY, label: "scroll", shed: 1 }, + { key: "⏎", label: steering ? "steer" : "queue message", shed: 3 }, + { + key: "ctrl+t", + label: steering ? "queue mode" : "steer mode", + shed: 4, + }, + { key: "esc", label: hasDraft ? "abort, draft kept" : "abort" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "abort", }, ]; + if (state.queuedMessages.length > 0) { + chips.push({ + key: "/queue", + label: `${state.queuedMessages.length} parked`, + shed: 2, + }); + } + 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. + // panels actually lacked — the way back to Run. Shift+Tab sheds + // first because "prev panel" is guessable from "next panel". 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", + shed: 1, + 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", shed: 2 }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -94,9 +230,9 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { } if (state.chatFocus === "sidebar") { return [ - { key: "↑↓", label: "select" }, + { key: "↑↓", label: "select", shed: 2 }, { key: "enter", label: "open" }, - { key: "tab", label: "next pane" }, + { key: "tab", label: "next pane", shed: 1 }, { key: "esc", label: "back to editor" }, { key: "ctrl+c", @@ -104,18 +240,74 @@ 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. + // The strip fits one row by shedding, not by a fixed cap. `ctrl+p` + // holds the slot `/` used to: the menu contains every slash command + // as well as every destination, and `/` keeps working for anyone who + // already reaches for it. Shedding order: scroll (the wheel already + // does it), then the sidebar (narrow terminals collapse it anyway — + // see `SIDEBAR_MIN_COLUMNS`), then the newline key, then the menu + // chip. A draft adds an `esc / clear draft` chip so the affordance is + // on screen exactly when it applies — `/` no longer opens the palette + // with a non-empty buffer, so nothing usable is displaced. return [ { key: "enter", label: "send" }, - { key: "alt+enter", label: "newline" }, - { key: "tab", label: "sidebar" }, - { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, + { key: "alt+enter", label: "newline", shed: 3 }, + { + key: "tab", + label: "sidebar", + shed: 2, + onClick: (mouse) => + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }), + }, + { key: SCROLL_KEY, label: "scroll", shed: 1 }, + { key: "ctrl+p", label: "menu", shed: 4 }, + ...(hasDraft ? [{ key: "esc", label: "clear draft" }] : []), { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", }, ]; } + +/** + * Drop chips — lowest `shed` rank first — until the row fits `width`. + * Stops once only essential (rank-less) chips remain; those overflow + * into `truncate-end` rather than silently disappearing. + */ +function fitChips(chips: HotkeyChip[], width: number): HotkeyChip[] { + let kept = chips; + while (stripWidth(kept) > width) { + const next = nextToShed(kept); + if (next < 0) break; + kept = kept.filter((_, idx) => idx !== next); + } + return kept; +} + +function nextToShed(chips: readonly HotkeyChip[]): number { + let best = -1; + let bestRank = Number.POSITIVE_INFINITY; + chips.forEach((chip, idx) => { + if (chip.shed === undefined || chip.shed >= bestRank) return; + best = idx; + bestRank = chip.shed; + }); + return best; +} + +/** + * Rendered columns of the whole strip. Every key and label we ship is + * single-width (ASCII plus `↑`, `↓`, `·`), so `String.length` is the + * rendered width and we do not need a `string-width` dependency here — + * keep new chips inside that alphabet. + */ +function stripWidth(chips: readonly HotkeyChip[]): number { + if (chips.length === 0) return 0; + const separator = 4 + theme.glyphs.dotSeparator.length; + const chipWidths = chips.reduce( + // "[" + key + "] " + label + (acc, chip) => acc + chip.key.length + chip.label.length + 3, + 0, + ); + return chipWidths + (chips.length - 1) * separator; +} diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index d58699a5..4a21cecd 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -4,9 +4,12 @@ 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 { 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 +353,23 @@ 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 +384,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..97bdc004 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -36,6 +36,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 +68,12 @@ export function LlmPanelModals({ maxRows?: number; }): ReactElement | null { if (state.providersPanel.wizard) { - return ; + return ( + + ); } if (state.providersPanel.removeConfirm) { return ( 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..c07aba8e 100644 --- a/src/tui/components/llm-panel.tsx +++ b/src/tui/components/llm-panel.tsx @@ -9,7 +9,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 +47,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. */} 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..a1aae8d3 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,119 @@ 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 }), +}; + +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 +131,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..334f817a 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -142,6 +142,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 +174,13 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { paddingX={1} flexDirection="column" > - + ); } @@ -302,7 +323,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/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..99ac3118 100644 --- a/src/tui/components/providers-wizard.test.tsx +++ b/src/tui/components/providers-wizard.test.tsx @@ -1,11 +1,15 @@ 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 { OPENAI_COMPAT_DEFAULT_CHAT_MODEL } from "../providers/providers-model-options.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 { @@ -89,6 +93,34 @@ describe("ProvidersWizard chat model step", () => { expect(countRows(text, "model-")).toBeLessThanOrEqual(12); }); + it("shows a rejected-submit error alongside the discovered model list", async () => { + // A rejected submit (empty or non-ASCII key) leaves the wizard on the + // chat-model step with `error` set, but the pick list has no error slot + // of its own. Without surfacing it here, the operator's Enter reads as + // doing nothing. + const ids = ["model-a", "model-b"]; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: ids.map((id) => ({ id })) }), + })), + ); + + const wizard = { + ...chatModelStep("https://listed.example/v1"), + error: "API key contains non-ASCII characters. Use a plain ASCII key.", + }; + const { lastFrame } = render(); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + // The model list still renders... + expect(text).toContain("model-a"); + // ...and the submit error is visible under it. + expect(text).toContain("non-ASCII characters"); + }); + it("explains a refused key instead of showing the raw status", async () => { vi.stubGlobal( "fetch", @@ -127,6 +159,57 @@ describe("ProvidersWizard chat model step", () => { }); }); +describe("ProvidersWizard CLI-backed configure step", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens a claude-cli row on its model, not on a key screen", async () => { + // What `c` now reaches. A CLI-backed provider has no key and no + // endpoint, so anything but the model id would be a dead end — and + // the openai-compat placeholder would name a model `claude` rejects. + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const { lastFrame } = render( + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("Chat model id — claude CLI"); + expect(text).toContain("opus"); + expect(text).toContain("the CLI uses its own session"); + // The key screen's own copy, absent because that phase is skipped. + expect(text).not.toContain("Saved to"); + expect(text).not.toContain(OPENAI_COMPAT_DEFAULT_CHAT_MODEL); + // No endpoint exists behind the CLI; listing must not be attempted. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("tells a codex-cli operator that an empty line is the answer", async () => { + const { lastFrame } = render( + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("Chat model id — codex CLI"); + expect(text).toContain("the CLI resolves the model"); + }); +}); + describe("ProvidersWizard pick list counter", () => { it("shows a clear Gemini provider row", () => { const { lastFrame } = render( @@ -287,3 +370,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..92262a6b 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"; + emptyKeyMeaningForWizard, + envHintForWizard, +} from "../providers/providers-wizard-target.js"; import { theme } from "../theme/theme.js"; import { findProviderPreset } from "../providers/provider-presets.js"; import { @@ -29,6 +31,8 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL, OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "../providers/providers-model-options.js"; +import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; +import { subscriptionCliForWizardKind } from "../providers/providers-wizard-state.js"; import type { ProvidersWizardKind, ProvidersWizardState, @@ -36,6 +40,10 @@ import type { import { renderPickList } from "./wizard-pick-list.js"; 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 +68,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 +90,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 +154,7 @@ function renderLineField(props: { function CompatChatModelStep(props: { wizard: ProvidersWizardState; + maxRows?: number; }): ReactElement { const w = props.wizard; const baseUrl = baseUrlForWizard(w); @@ -157,9 +173,13 @@ function CompatChatModelStep(props: { let alive = true; setStatus({ loading: true, error: null }); const apiKey = apiKeyForWizard(w); + // A preset knows how its service wants credentials presented; without + // it this probe would 401 for a vendor that is not Bearer-authenticated + // and the operator would be told their valid key was rejected. + const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; const fetchModels = isGemini ? fetchGeminiModels(apiKey) - : fetchOpenAiCompatModels(baseUrl, apiKey); + : fetchOpenAiCompatModels(baseUrl, apiKey, preset); fetchModels.then( () => { if (alive) setStatus({ loading: false, error: null }); @@ -180,6 +200,24 @@ function CompatChatModelStep(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseUrl, isCompat, isGemini]); + // A CLI-backed provider has no endpoint to list and no key screen + // behind it, so this is the whole configure flow for one: the id the + // CLI's own `--model` accepts. Naming the openai-compat placeholder + // here would suggest `gpt-5.4-mini` is a valid answer for `claude`. + const cli = w.kind ? subscriptionCliForWizardKind(w.kind) : null; + if (cli) { + return renderLineField({ + title: `Chat model id — ${cli} CLI`, + value: w.chatModelLine, + placeholder: + cli === "claude" + ? CLAUDE_CLI_DEFAULT_CHAT_MODEL + : "(empty — the CLI resolves the model)", + hint: "Enter to save · Esc back · no API key: the CLI uses its own session", + error: w.error, + }); + } + const picks = listCompatChatModelPicks(w); if (picks.length > 0) { const source = isGemini @@ -190,12 +228,21 @@ 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 }), + // A rejected submit (empty or non-ASCII key) leaves the wizard on + // this step with `error` set; the pick list renders it inside the + // box, so Enter never reads as doing nothing. + error: w.error, }); } - const hint = !canList + const hint = w.submitting + ? CHECKING_KEY_HINT + : !canList ? "Enter to save · Esc back" : status.loading ? isGemini @@ -230,6 +277,7 @@ function CompatChatModelStep(props: { function CatalogChatModelStep(props: { wizard: ProvidersWizardState; kind: "openrouter" | "aimlapi"; + maxRows?: number; }): ReactElement { const { wizard: w, kind } = props; const getCached = @@ -274,14 +322,28 @@ 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 }), + 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,14 @@ export function ProvidersWizard(props: { cursor: w.cursor, moveHint: "j/k move", actionsHint: "Enter pick · Esc cancel", + ...maxRows, + 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 = emptyKeyMeaningForWizard(w); return ( Enter to continue · Esc back · Backspace edit - {w.submitting ? " · saving…" : ""} + {w.submitting ? ` · ${CHECKING_KEY_HINT}` : ""} ); @@ -338,26 +396,29 @@ export function ProvidersWizard(props: { w.phase === "pick_chat_model" && (w.kind === "openrouter" || w.kind === "aimlapi") ) { - return ; + 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", - }); - } - - 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, + error: w.error, }); } @@ -372,7 +433,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/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-fit.test.tsx b/src/tui/components/sidebar-fit.test.tsx new file mode 100644 index 00000000..d358d24e --- /dev/null +++ b/src/tui/components/sidebar-fit.test.tsx @@ -0,0 +1,117 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { + computeSidebarRowBudget, + isSidebarVisible, + SIDEBAR_CHROME_ROWS, + SIDEBAR_MIN_COLUMNS, + SIDEBAR_MIN_ROWS, +} from "../layout.js"; +import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; +import type { SessionPickerEntry } from "../tui-state.js"; +import { Sidebar } from "./sidebar.js"; + +/** Colour codes never carry a newline, so the raw frame counts fine. */ +function frameRows(frame: string): number { + return frame.split("\n").length; +} + +/** Long enough that both panes always hide a tail and draw a footer. */ +const SESSIONS: readonly SessionPickerEntry[] = Array.from( + { length: 40 }, + (_, idx) => ({ + sessionId: `s-${idx}`, + workingDir: "/tmp/fit", + turnCount: 1, + stepCount: 1, + updatedAt: 0, + preview: `session ${idx}`, + }), +); + +const TASKS: readonly TaskSummaryRow[] = Array.from( + { length: 40 }, + (_, idx) => ({ + id: `t-${idx}`, + status: "pending", + origin: "tui", + triggerSource: "user", + sessionId: null, + userMessage: `task ${idx}`, + scheduleKind: null, + scheduleLabel: "-", + recurring: false, + scheduledFor: null, + createdAt: 0, + updatedAt: 0, + startedAt: null, + completedAt: null, + attempts: 0, + maxAttempts: 3, + lastError: null, + }), +); + +function renderRail(rows: number): number { + const budget = computeSidebarRowBudget(rows); + const { lastFrame } = render( + , + ); + return frameRows(lastFrame() ?? ""); +} + +/** + * Regression guard for "a wide but short terminal garbles the rail", + * the sibling of `splash-fit.render.test.tsx`. Ink 7 does NOT clip a + * frame taller than the terminal — it overlaps earlier lines — so the + * row budget has to be a promise the rendered component keeps, and a + * window too short to keep it must lose the rail entirely. + * + * The rail is drawn under a one-row status bar, so its own frame gets + * `rows - 1` at most. + */ +describe("Sidebar fit", () => { + it("renders inside every terminal height that still draws it", () => { + // 24 rows is where the budget saturates at its 10/5 caps, so every + // distinct split the arithmetic can produce is covered here. The + // exact cost — two section headers, the blank row between the + // panes and a "↓ N more" footer per pane — is asserted alongside + // the fit so `SIDEBAR_CHROME_ROWS` cannot drift away from the + // component it describes. The left border is the only border edge + // the rail draws, so it costs columns, not rows. + for (let rows = SIDEBAR_MIN_ROWS; rows <= 24; rows += 1) { + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS, rows)).toBe(true); + const budget = computeSidebarRowBudget(rows); + const rendered = renderRail(rows); + expect(rendered).toBe( + budget.sessions + budget.tasks + SIDEBAR_CHROME_ROWS, + ); + expect(rendered).toBeLessThanOrEqual(rows - 1); + } + }); + + it("is dropped rather than squeezed in a wide but short window", () => { + // 100x8 is a split tmux pane; 100x5 is a terminal docked under an + // editor. Both used to budget three list rows into a rail that + // rendered ten rows deep. + for (const [columns, rows] of [ + [100, 8], + [100, 5], + [1, 1], + [0, 0], + ] as const) { + expect(isSidebarVisible(columns, rows)).toBe(false); + } + }); +}); diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx index 89ec7b05..2e7a20e6 100644 --- a/src/tui/components/sidebar.test.tsx +++ b/src/tui/components/sidebar.test.tsx @@ -147,4 +147,83 @@ 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 + blank row. + expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(10); + }); + + 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..c82bfce6 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -1,5 +1,12 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { computeRowWindow } from "../row-window.js"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; @@ -17,10 +24,26 @@ export interface SidebarProps { activeSection: SidebarSection; /** Whether the sidebar owns keyboard focus right now. */ focused: boolean; + /** + * 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; + +/** + * 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; /** * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and @@ -29,6 +52,8 @@ const MAX_TASK_ROWS = 5; * `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. + * Its width and per-pane row budgets arrive as props from `TuiApp`, + * which owns the terminal measurement. * * Focus is layered: `focused` toggles the section header colour for * the active pane, and `activeSection` decides which pane gets the @@ -45,12 +70,36 @@ export function Sidebar(props: SidebarProps): ReactElement { tasksCursor, activeSection, focused, + maxSessionRows = DEFAULT_MAX_SESSION_ROWS, + maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + 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 ( @@ -75,6 +126,8 @@ export function Sidebar(props: SidebarProps): ReactElement { tasks={tasks} cursor={tasksCursor} focused={tasksActive} + maxRows={maxTaskRows} + previewWidth={previewWidth} /> ); @@ -98,6 +151,8 @@ interface SessionsListProps { cursor: number; focused: boolean; currentSessionId: string | null; + maxRows: number; + previewWidth: number; } function SessionsList({ @@ -105,30 +160,41 @@ function SessionsList({ cursor, focused, currentSessionId, + maxRows, + previewWidth, }: 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,10 +203,16 @@ interface SessionRowProps { entry: SessionPickerEntry; selected: boolean; current: boolean; + previewWidth: number; } -function SessionRow({ entry, selected, current }: SessionRowProps): ReactElement { - const preview = truncate(entry.preview, 28); +function SessionRow({ + entry, + selected, + current, + previewWidth, +}: SessionRowProps): ReactElement { + const preview = truncate(entry.preview, previewWidth); const marker = current ? theme.glyphs.assistantMarker : " "; const chevron = selected ? theme.glyphs.chevronRight : " "; return ( @@ -158,24 +230,48 @@ interface TasksListProps { tasks: readonly TaskSummaryRow[]; cursor: number; focused: boolean; + maxRows: number; + previewWidth: number; } -function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { +function TasksList({ + tasks, + cursor, + focused, + maxRows, + previewWidth, +}: 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,10 +279,11 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { interface TaskRowProps { row: TaskSummaryRow; selected: boolean; + previewWidth: number; } -function TaskRow({ row, selected }: TaskRowProps): ReactElement { - const preview = truncate(row.userMessage, 24); +function TaskRow({ row, selected, previewWidth }: TaskRowProps): ReactElement { + const preview = truncate(row.userMessage, previewWidth); const chevron = selected ? theme.glyphs.chevronRight : " "; const badge = statusBadge(row); return ( @@ -200,6 +297,16 @@ function TaskRow({ row, selected }: TaskRowProps): ReactElement { ); } +/** "↓ N more" footer, or nothing at all when the tail is visible. */ +function MoreRow({ hidden }: { hidden: number }): ReactElement | null { + if (hidden <= 0) return null; + return ( + + ↓ {hidden} more + + ); +} + /** * One-glyph status indicator: ● running, ○ pending, ↻ recurring (when * the task is not currently running or queued), · for the rare other @@ -216,11 +323,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..b88565b3 100644 --- a/src/tui/components/splash-banner.test.tsx +++ b/src/tui/components/splash-banner.test.tsx @@ -1,17 +1,22 @@ import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; +import { toSlashCommands } from "../menu/menu-registry.js"; 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 +26,72 @@ 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"); + }); + + // The banner is a short tip-list, not the full command catalogue — which + // slash commands it picks is a copy decision that changes freely. What must + // not drift is that every command it prints is a real one, so a renamed or + // deleted command cannot leave the welcome screen advertising a dead verb. + it("only advertises slash commands that exist in the menu registry", () => { + const frame = frameAt(96, 40); + // Aliases count as real: `/run` resolves to `/chat` at dispatch, so + // advertising an alias is not a dead verb. + const registered = new Set( + toSlashCommands().flatMap((c) => [c.name, ...(c.aliases ?? [])]), + ); + const advertised = [...frame.matchAll(/\/([a-z][a-z0-9-]*)/g)].map( + (m) => m[1]!, + ); + expect(advertised.length).toBeGreaterThan(0); + for (const name of advertised) { + expect(registered, `/${name} is advertised but not registered`).toContain( + name, + ); + } + }); }); diff --git a/src/tui/components/splash-banner.tsx b/src/tui/components/splash-banner.tsx index 64fe5ddc..d1c5dfbe 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, terminal.rows), + 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..c1c00f22 --- /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, terminal.rows), + 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..faff907b 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 { getCurrentSection, type TuiSection } from "../section.js"; +import { menuPlaceByTab } from "../menu/menu-registry.js"; +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 { 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,57 @@ const SECTION_LABELS: Record = { manage: "Manage", }; -function SectionPills({ active }: { active: TuiSection }): ReactElement { - return ( +/** + * Where you are: `Section › Tab`. + * + * #165 originally made a Run / Observe / Manage pill strip clickable, but + * #170 replaced that strip with this breadcrumb — the menu is now the one + * navigation surface, and re-adding pills would give the same job two + * competing controls. So the breadcrumb itself takes the click and opens + * the menu, which is exactly what `ctrl+p` does. Clicking where you + * already are is still meaningful here: the menu is a destination list, + * not a reset. + */ +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; + // Open at the top of the list, the same state `ctrl+p` produces, + // so the keyboard and the mouse land on one menu rather than two + // subtly different ones. + mouse.dispatch({ type: "menu_path_set", path: null }); + mouse.dispatch({ type: "menu_cursor_set", cursor: 0 }); + 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..ec1140b4 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-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 { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; import type { TaskSummaryRow, TasksPanelState, @@ -46,12 +48,20 @@ export function TasksList(props: TasksListProps): ReactElement { ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "tasks_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleTasksTabKey)} + > + + ))} {hiddenAfter > 0 ? ( 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..65ce7536 100644 --- a/src/tui/components/wizard-pick-list.tsx +++ b/src/tui/components/wizard-pick-list.tsx @@ -3,12 +3,66 @@ import type { ReactElement } from "react"; 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 +84,24 @@ 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; }): 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 ( ); })} - + {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..8262837f --- /dev/null +++ b/src/tui/escape-abort-running.test.tsx @@ -0,0 +1,143 @@ +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 PAGE_UP = `${String.fromCharCode(27)}[5~`; +const FLUSH_MS = 60; +/** Comfortably past the 24-row `ink-testing-library` default viewport. */ +const TALL_CHAT_LINES = 40; + +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. Exactly once: + // the editor's own Esc handler no longer carries a second copy of + // the abort, which would double-fire once it stays live during a run. + expect(counts.abort).toBe(1); + 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).toBe(1); + expect(counts.quit).toBe(0); + unmount(); + }); + + it("snaps a scrolled-back chat home first and keeps the turn alive", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "message_submitted" }); + // A chat taller than the viewport, or `ChatLog` clamps the scroll + // straight back to 0 and PageUp is a no-op. + for (let i = 0; i < TALL_CHAT_LINES; i++) { + bus.emit({ type: "system_message", text: `line ${i}` }); + } + await settle(); + + // Read back through the streaming answer, then press the Esc the + // scroll-reset rung documents as "snap to the latest reply before + // doing anything else". + stdin.write(PAGE_UP); + await settle(); + stdin.write(ESC); + await settle(); + + expect(counts.abort).toBe(0); + + // The offset is back at 0, so the next Esc means abort — which also + // proves the first one consumed the scroll rather than falling + // through and leaving the chat pinned mid-history. + stdin.write(ESC); + await settle(); + + expect(counts.abort).toBe(1); + 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..78aaa0a6 --- /dev/null +++ b/src/tui/escape-chat-editor.test.tsx @@ -0,0 +1,154 @@ +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("aborts the turn and keeps the draft when both are on the table", async () => { + // The precedence this branch commits to: while a turn is in flight + // Esc aborts and leaves the buffer alone; the *next* Esc, now idle, + // clears it. Abort is the destructive, time-critical action and a + // draft is cheap to keep, so it wins. + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await settle(); + stdin.write("draft message"); + await settle(); + bus.emitAgentEvent({ type: "turn_started", turnIndex: 0 }); + await settle(); + expect(strip(lastFrame() ?? "")).toContain("draft message"); + + stdin.write(ESC); + await settle(); + + expect(counts.abort).toBe(1); + expect(counts.quit).toBe(0); + // Untouched: the run stopped, the half-typed message did not. + expect(strip(lastFrame() ?? "")).toContain("draft message"); + + bus.emitAgentEvent({ + type: "turn_finished", + turnIndex: 0, + reason: "cancelled", + stepCount: 0, + durationMs: 1, + }); + await settle(); + + stdin.write(ESC); + await settle(); + + // Second Esc, this time idle: now the draft goes and nothing else. + expect(strip(lastFrame() ?? "")).not.toContain("draft message"); + expect(counts.abort).toBe(1); + 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(); + expect(strip(lastFrame() ?? "")).toContain("Run"); + 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..280e02cd --- /dev/null +++ b/src/tui/escape-import-tab.test.tsx @@ -0,0 +1,64 @@ +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("Manage ▸"); + + 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. + expect(strip(lastFrame() ?? "")).toContain("Run"); + 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..e54d496e --- /dev/null +++ b/src/tui/escape-observe-tabs.test.tsx @@ -0,0 +1,91 @@ +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(); + expect(strip(lastFrame() ?? "")).toContain("Observe ▸"); + + 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("Run"); + 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/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..ae58c97e 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"; diff --git a/src/tui/input-history-keys.test.tsx b/src/tui/input-history-keys.test.tsx new file mode 100644 index 00000000..1361ba61 --- /dev/null +++ b/src/tui/input-history-keys.test.tsx @@ -0,0 +1,104 @@ +import { render } from "ink-testing-library"; +import { createElement, useReducer, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { reduceTuiState } from "./agent-event-reducer.js"; +import { MultiLineEditor } from "./components/multi-line-editor.js"; +import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "s1", + workingDir: "/tmp", + llamaUrl: "http://127.0.0.1:19091", + browserChannel: "chromium", + browserHeadless: true, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +const UP = "\u001b[A"; +const DOWN = "\u001b[B"; +const LEFT = "\u001b[D"; +const RIGHT = "\u001b[C"; + +/** + * Renders the real editor against the real reducer, wired exactly as + * `tui-app` wires them, so these assertions exercise the true keystroke + * path rather than a hand-rolled stand-in. + */ +function Harness(): ReactElement { + const [state, dispatch] = useReducer(reduceTuiState, undefined, () => ({ + ...createInitialTuiState(SESSION), + inputHistory: ["first", "second"], + })); + return createElement(MultiLineEditor, { + value: state.inputValue, + focus: true, + onChange: (next: string) => dispatch({ type: "input_changed", value: next }), + onSubmit: () => {}, + onHistoryPrev: () => dispatch({ type: "input_history_navigated", delta: -1 }), + onHistoryNext: () => dispatch({ type: "input_history_navigated", delta: 1 }), + }); +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe("editor arrow keys vs input history", () => { + it("restores the typed draft after Up then Down", async () => { + const { stdin, lastFrame } = render(createElement(Harness)); + await settle(); + stdin.write("hello draft"); + await settle(); + expect(lastFrame()).toContain("hello draft"); + + stdin.write(UP); + await settle(); + expect(lastFrame()).toContain("second"); + + stdin.write(DOWN); + await settle(); + expect(lastFrame()).toContain("hello draft"); + }); + + it("does not wipe the draft when Left/Right move the caret", async () => { + const { stdin, lastFrame } = render(createElement(Harness)); + await settle(); + stdin.write("keep me"); + await settle(); + + stdin.write(LEFT); + await settle(); + expect(lastFrame()).toContain("keep me"); + + stdin.write(RIGHT); + await settle(); + expect(lastFrame()).toContain("keep me"); + }); + + it("keeps walking further back when Left is pressed mid-recall", async () => { + const { stdin, lastFrame } = render(createElement(Harness)); + await settle(); + stdin.write("draft"); + await settle(); + + stdin.write(UP); + await settle(); + expect(lastFrame()).toContain("second"); + + // Caret movement must not drop the recall position. + stdin.write(LEFT); + await settle(); + stdin.write(UP); + await settle(); + expect(lastFrame()).toContain("first"); + + stdin.write(DOWN); + await settle(); + expect(lastFrame()).toContain("second"); + stdin.write(DOWN); + await settle(); + expect(lastFrame()).toContain("draft"); + }); +}); diff --git a/src/tui/layout.test.ts b/src/tui/layout.test.ts new file mode 100644 index 00000000..658669cc --- /dev/null +++ b/src/tui/layout.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + computeChatViewportRows, + computeChatWidth, + computeSidebarRowBudget, + computeSidebarWidth, + isSidebarVisible, + SIDEBAR_CHROME_ROWS, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_COLUMNS, + SIDEBAR_MIN_ROWS, + SIDEBAR_MIN_WIDTH, +} from "./layout.js"; + +/** Comfortably taller than anything the rail needs. */ +const TALL = 40; + +describe("isSidebarVisible", () => { + it("collapses the rail one column below the threshold", () => { + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS - 1, TALL)).toBe(false); + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS, TALL)).toBe(true); + }); + + it("collapses the rail one row below the threshold", () => { + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS, SIDEBAR_MIN_ROWS - 1)).toBe( + false, + ); + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS, SIDEBAR_MIN_ROWS)).toBe(true); + }); + + it("hides the rail in a wide but short window", () => { + // A split tmux pane, or a terminal docked under an editor: wide + // enough for the rail and nowhere near tall enough for it. + expect(isSidebarVisible(100, 8)).toBe(false); + expect(isSidebarVisible(100, 5)).toBe(false); + expect(isSidebarVisible(200, 4)).toBe(false); + }); + + it("hides the rail in a degenerate window", () => { + expect(isSidebarVisible(1, 1)).toBe(false); + expect(isSidebarVisible(0, 0)).toBe(false); + }); +}); + +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, TALL)).toBe(78); + expect(computeChatWidth(100, TALL)).toBe(100 - 2 - 25); + expect(computeChatWidth(120, TALL)).toBe(120 - 2 - 30); + }); + + it("hands the chat column the full width when the rail is too short to draw", () => { + expect(computeChatWidth(120, 8)).toBe(118); + }); + + it("grows monotonically with the terminal", () => { + let previous = 0; + for (let columns = 40; columns <= 400; columns += 1) { + const width = computeChatWidth(columns, TALL); + 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 at every height the rail is drawn at", () => { + for (let rows = SIDEBAR_MIN_ROWS; rows <= 12; rows += 1) { + const budget = computeSidebarRowBudget(rows); + expect(budget.sessions).toBeGreaterThanOrEqual(1); + expect(budget.tasks).toBeGreaterThanOrEqual(1); + } + }); + + it("never budgets more rows than the window has", () => { + // The rail renders `sessions + tasks + SIDEBAR_CHROME_ROWS` rows, + // under a status bar that takes one more. Ink 7 overlaps rather + // than clips, so overshooting here is what garbles the frame. + for (let rows = 0; rows <= 60; rows += 1) { + if (!isSidebarVisible(SIDEBAR_MIN_COLUMNS, rows)) continue; + const budget = computeSidebarRowBudget(rows); + expect( + budget.sessions + budget.tasks + SIDEBAR_CHROME_ROWS + 1, + ).toBeLessThanOrEqual(rows); + } + }); + + 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..80f5ba32 --- /dev/null +++ b/src/tui/layout.ts @@ -0,0 +1,177 @@ +/** + * 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 itself spends before a single list row is drawn: the + * two section headers, the blank row between the panes and a "↓ N + * more" footer per pane. Counted off the rendered component rather + * than estimated — the left border costs no rows because `sidebar.tsx` + * turns the top and bottom edges off — and pinned by + * `sidebar-fit.test.tsx`, which renders the rail at a budget and + * asserts it comes to exactly `sessions + tasks + SIDEBAR_CHROME_ROWS` + * rows. + */ +export const SIDEBAR_CHROME_ROWS = 5; + +/** + * Rows the rail costs outside its own frame: the status bar above it + * (one row at any width that carries the rail) plus one row of slack, + * so the frame lands short of the terminal height rather than exactly + * on it. + */ +const SIDEBAR_OUTER_ROWS = 2; + +/** Everything the row budget has to leave alone. */ +const SIDEBAR_RESERVED_ROWS = SIDEBAR_CHROME_ROWS + SIDEBAR_OUTER_ROWS; + +/** + * Shortest terminal that still fits the reserved rows plus one list + * row in each pane. Below this the rail is not drawn at all: Ink 7 + * overlaps rather than clips (see `row-window.ts`), so a rail that + * does not fit garbles the whole frame instead of losing its tail. + */ +export const SIDEBAR_MIN_ROWS = SIDEBAR_RESERVED_ROWS + 2; + +/** + * 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 both wide and tall enough to carry the right + * rail. Height matters as much as width: a 100×8 split pane is wide + * enough for the rail and far too short for it, and an over-tall rail + * garbles the frame rather than being clipped. + */ +export function isSidebarVisible(columns: number, rows: number): boolean { + return columns >= SIDEBAR_MIN_COLUMNS && rows >= SIDEBAR_MIN_ROWS; +} + +/** + * 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, rows: number): number { + const rail = isSidebarVisible(columns, rows) + ? 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, and 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. Nothing here floors the split + * above what is actually available: `sessions + tasks` never exceeds + * `usable`, and a window too short to seat one row in each pane is + * handled by `isSidebarVisible` hiding the rail outright, not by + * handing back a budget that does not fit. + */ +export function computeSidebarRowBudget(rows: number): SidebarRowBudget { + const usable = Math.max(0, rows - SIDEBAR_RESERVED_ROWS); + // Capping at `usable - 1` leaves the last row to Tasks, so its + // header is never left dangling over an empty pane while sessions + // still take the larger share of anything above two rows. + const sessions = clamp( + Math.min(Math.ceil((usable * 2) / 3), Math.max(1, usable - 1)), + 0, + SIDEBAR_MAX_SESSION_ROWS, + ); + const tasks = clamp(usable - sessions, 0, 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-primary-actions.ts b/src/tui/llm-panel/llm-panel-primary-actions.ts index 65adf491..21008e57 100644 --- a/src/tui/llm-panel/llm-panel-primary-actions.ts +++ b/src/tui/llm-panel/llm-panel-primary-actions.ts @@ -5,7 +5,8 @@ import type { import type { TuiAction } from "../tui-action.js"; import type { TuiAppCallbacks } from "../tui-app.js"; import type { TuiState } from "../tui-state.js"; -import { isCloudProviderKind } from "../providers/providers-orchestrator.js"; +import { configureWizardKindForRow } from "../providers/providers-orchestrator.js"; +import type { ProviderRow } from "../providers/providers-panel-state.js"; import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import type { LlmPanelRow } from "./llm-panel-selectors.js"; import { isLocalTextActive } from "./llm-panel-selectors.js"; @@ -59,10 +60,11 @@ export function openProviderConfig( state: TuiState, dispatch: (action: TuiAction) => void, ): void { + const configurable = (row: ProviderRow): boolean => + configureWizardKindForRow(row) !== null; const provider = - state.providersPanel.rows.find( - (row) => isCloudProviderKind(row.kind) && row.isActiveText, - ) ?? state.providersPanel.rows.find((row) => isCloudProviderKind(row.kind)); + state.providersPanel.rows.find((row) => configurable(row) && row.isActiveText) ?? + state.providersPanel.rows.find(configurable); if (provider) openProviderConfigFor(provider, dispatch); else { dispatch({ @@ -213,16 +215,18 @@ function triggerCloudEmbeddingModel( } function openProviderConfigFor( - provider: { id: string; kind: string; baseUrl?: string | null }, + provider: ProviderRow, dispatch: (action: TuiAction) => void, ): void { - if (!isCloudProviderKind(provider.kind)) return; + const kind = configureWizardKindForRow(provider); + if (!kind) return; dispatch({ type: "providers_wizard_opened", wizard: createProvidersWizardState("configure", { providerId: provider.id, - kind: provider.kind, + kind, ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}), + ...(provider.chatModel ? { chatModel: provider.chatModel } : {}), }), }); } 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..3cd70c08 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( 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..14064a6f --- /dev/null +++ b/src/tui/menu/menu-behaviour.test.ts @@ -0,0 +1,161 @@ +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"]); + const go = rows.filter((r) => r.kind === "item" && r.node.group === "go"); + expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([ + "Run", + "Toggle debug pane", + "Observe", + "Manage", + ]); + }); + + 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", () => { + const atManage = open({ menuCursor: 2 }); + expect(drive(atManage, "", { 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(); + }); + + it("resolves nothing while a modifier is held, so ctrl+c stays reachable", () => { + // Ink reports Ctrl+C as input "c" with `key.ctrl` — the same letter the + // MCP tab claims as its chord. Reading it as a chord would navigate + // instead of aborting; ctrl+q would quit and ctrl+l would leave the + // conventional clear-screen unreachable. + for (const input of ["c", "q", "l", "t"]) { + expect(resolveLeaderChord(input, { ...KEY, ctrl: true } as never)).toBeNull(); + expect(resolveLeaderChord(input, { ...KEY, meta: 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..de6bda9b --- /dev/null +++ b/src/tui/menu/menu-keys.ts @@ -0,0 +1,165 @@ +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. + * + * A chord is a *bare* key, same as the sibling predicates above insist on + * an unmodified `ctrl`. Ink reports `ctrl+c` as `input === "c"` with + * `key.ctrl`, so without this guard the armed leader would read the abort + * key as the MCP chord — and `ctrl+q` as quit, `ctrl+l` as the LLM tab. + * Held modifiers mean the operator changed their mind, not that they typed + * a chord. + */ +export function resolveLeaderChord(input: string, key: Key): MenuNode | null { + if (key.escape || input.length === 0) return null; + if (key.ctrl || key.meta) 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..5deff17a --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,205 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { chromeTheme } from "../theme/theme.js"; +import type { TuiState } from "../tui-state.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; +} + +/** + * 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. + * + * 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, +}: 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); + + // Anchor to the bottom of the pane so the menu sits just above the prompt, + // the way a dropdown hangs off the control that opened it. + const height = visible.length + CHROME_ROWS; + const offsetTop = Math.max(0, availableRows - height); + + 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, +}: { + row: MenuItemRow; + inner: number; + selected: boolean; +}): ReactElement { + 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, + ); + return ( + + + {label} + + {detail} + {chord} + + ); +} + +/** + * 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..c7898250 --- /dev/null +++ b/src/tui/menu/menu-registry.test.ts @@ -0,0 +1,291 @@ +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 exactly as it shipped in v0.2.2, before the registry + * refactor. `SLASH_COMMANDS` is now derived from `MENU`; this snapshot is + * what makes "no visible change" a claim the suite can check rather than + * a promise in a PR description. + * + * Deliberate additions since v0.2.2 are appended here as they land, so the + * guard keeps catching *accidental* drift: `/mouse` (the mouse layer) is + * the only entry that was not in the v0.2.2 palette. + */ +const V0_2_2_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: "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`", + }, + // Added after v0.2.2: the mid-run message queue (#156) and steering (#159). + { + name: "queue", + description: + "parked messages: `/queue` list | `/queue ` park one | `/queue clear` | `/queue mode` make Enter queue", + }, + { + name: "steer", + description: + "steer the running turn: `/steer ` one-shot | bare `/steer` makes Enter steer", + }, + { + name: "window", + description: + "open a new terminal window running atomic-agent (ctrl+n)", + aliases: ["newwindow"], + }, + { + name: "mouse", + description: + "mouse support on/off/status (off restores the terminal's drag-to-select)", + }, +]; + +describe("menu registry", () => { + it("derives the slash palette from the registry — same commands, same order", () => { + expect(SLASH_COMMANDS).toEqual(V0_2_2_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..4a943b80 --- /dev/null +++ b/src/tui/menu/menu-registry.ts @@ -0,0 +1,637 @@ +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; + /** 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", + aliases: ["run"], + rank: 8, + }, + section: "run", + }, + { + kind: "action", + id: "go.debug", + label: "Toggle debug pane", + group: "go", + slash: { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + rank: 7, + }, + }, + { + 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.queue", + label: "Queued messages", + group: "run", + slash: { + name: "queue", + description: + "parked messages: `/queue` list | `/queue ` park one | `/queue clear` | `/queue mode` make Enter queue", + rank: 32, + }, + }, + { + 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: 34, + }, + }, + { + kind: "action", + id: "run.steer", + label: "Steer the running turn", + group: "run", + slash: { + name: "steer", + description: + "steer the running turn: `/steer ` one-shot | bare `/steer` makes Enter steer", + rank: 33, + }, + }, + { + 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.mouse", + label: "Mouse…", + group: "setup", + slash: { + name: "mouse", + description: + "mouse support on/off/status (off restores the terminal's drag-to-select)", + rank: 35, + }, + }, + { + 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.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, + }, + }, +]; + +/** 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..c502dcde --- /dev/null +++ b/src/tui/menu/menu-selectors.ts @@ -0,0 +1,166 @@ +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 "session.switch": + return countLabel(state.recentSessions.length, "recent"); + default: + return ""; + } +} + +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..76a879fb --- /dev/null +++ b/src/tui/mouse/index.ts @@ -0,0 +1,41 @@ +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 { 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..7e83cca4 --- /dev/null +++ b/src/tui/mouse/mouse-app.test.tsx @@ -0,0 +1,285 @@ +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"; + +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; + 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, + }, + ], + }); + }, + unmount, + }; +} + +describe("TuiApp mouse", () => { + // #165 was written against the Run / Observe / Manage pill strip and the + // sub-tab strip. #170 replaced both with a breadcrumb plus one menu, so + // the click target that used to switch sections now *opens the menu* — + // the same thing ctrl+p does. Navigating from there is the menu's own + // job and is covered by `menu-behaviour.test.ts`. + it("opens the menu when the breadcrumb is clicked", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("Run"), "the Run screen"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Run"), + () => app.frame().includes("Observe"), + "click on the breadcrumb", + ); + // The menu lists every destination, so Observe/Manage become visible + // only once it is open. + expect(app.frame()).toContain("Observe"); + expect(app.frame()).toContain("Manage"); + app.unmount(); + }); + + it("ignores a click that lands on no target", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("Run"), "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("Run"), "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("Run"), "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(); + const marker = (name: string): string => { + const line = app + .frame() + .split("\n") + .find((candidate) => candidate.includes(name)); + return line?.trimStart().slice(0, 1) ?? ""; + }; + await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows"); + for (let attempt = 0; attempt < 40; attempt += 1) { + app.mouse.emit(wheel("down", 10, 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(); + const marker = (name: string): string => { + const line = app + .frame() + .split("\n") + .find((candidate) => candidate.includes(name)); + return line?.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(); + }); +}); 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(null); + +export interface MouseProviderProps extends MouseContextValue { + readonly children: ReactNode; +} + +export function MouseProvider({ + children, + ...value +}: MouseProviderProps): ReactElement { + const memo = useMemo( + () => value, + [value.registry, value.dispatch, value.callbacks, value.getState], + ); + return ( + {children} + ); +} + +/** + * Access to `dispatch` / `callbacks` / `getState` for click handlers. + * `null` when rendered outside `MouseProvider` (unit tests). + */ +export function useMouseCommands(): MouseContextValue | null { + return useContext(MouseContext); +} + +export interface UseMouseTargetOptions { + readonly layer?: number; + /** Set false to keep the element inert without changing the tree. */ + readonly enabled?: boolean; +} + +/** + * Registers the returned ref as a click target. Attach it to a ``; + * the handler receives the click position relative to that box. + */ +export function useMouseTarget( + handler: MouseTargetHandler, + options: UseMouseTargetOptions = {}, +): RefObject { + const { layer = MOUSE_LAYER_BASE, enabled = true } = options; + const ref = useRef(null); + const context = useContext(MouseContext); + // The handler is re-created on every render; keeping it in a ref means + // registration survives without re-subscribing on each keystroke. + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => { + if (!context || !enabled) return; + return context.registry.register({ + ref, + layer, + handler: (hit) => handlerRef.current(hit), + }); + }, [context, enabled, layer]); + return ref; +} + +export interface MouseTargetProps extends UseMouseTargetOptions { + readonly onMouse: MouseTargetHandler; + /** + * Pass `0` for inline markers on a text row: without it Yoga squeezes + * the wrapper when the row overflows and the label loses characters. + */ + readonly flexShrink?: number; + readonly children: ReactNode; +} + +/** + * Layout-neutral clickable wrapper: an unstyled `` around + * `children`. In a column it occupies the same single row its content + * did; in a row it hugs its content. + */ +export function MouseTarget({ + onMouse, + children, + flexShrink, + ...options +}: MouseTargetProps): ReactElement { + const ref = useMouseTarget(onMouse, options); + return ( + + {children} + + ); +} diff --git a/src/tui/mouse/mouse-event.ts b/src/tui/mouse/mouse-event.ts new file mode 100644 index 00000000..5b204c51 --- /dev/null +++ b/src/tui/mouse/mouse-event.ts @@ -0,0 +1,45 @@ +/** + * Terminal mouse event model. + * + * The TUI decodes xterm mouse reports itself (see + * `parse-mouse-events.ts`) instead of leaning on a library, because Ink + * has no mouse layer at all: it parses stdin as keystrokes only. Keeping + * the event shape terminal-agnostic here means the hit-testing and the + * per-component handlers never touch escape sequences. + * + * Coordinates are **0-based** and measured in terminal cells from the + * top-left of the screen — the same space Yoga computes the Ink layout + * in, so a hit test is a plain rectangle containment check. + */ + +export type MouseButton = "left" | "middle" | "right" | "none"; + +export type MouseEventKind = "press" | "release" | "wheel"; + +export type WheelDirection = "up" | "down"; + +export interface TuiMouseEvent { + readonly kind: MouseEventKind; + /** Which button changed state. `"none"` for wheel and for release-without-button reports. */ + readonly button: MouseButton; + /** Set only when `kind === "wheel"`. */ + readonly wheel: WheelDirection | null; + /** 0-based terminal column. */ + readonly x: number; + /** 0-based terminal row. */ + readonly y: number; + readonly shift: boolean; + readonly alt: boolean; + readonly ctrl: boolean; +} + +/** True for a plain (unmodified) left-button press — the "click" gesture. */ +export function isPrimaryPress(event: TuiMouseEvent): boolean { + return ( + event.kind === "press" && + event.button === "left" && + !event.shift && + !event.alt && + !event.ctrl + ); +} diff --git a/src/tui/mouse/mouse-list-row.tsx b/src/tui/mouse/mouse-list-row.tsx new file mode 100644 index 00000000..0acc5c6f --- /dev/null +++ b/src/tui/mouse/mouse-list-row.tsx @@ -0,0 +1,95 @@ +import type { Key } from "ink"; +import type { ReactElement, ReactNode } from "react"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { returnKey } from "./synthetic-key.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "./mouse-context.js"; +import { isPrimaryPress } from "./mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "./mouse-registry.js"; + +export interface MouseListRowProps { + /** Whether this row currently holds the panel's cursor. */ + readonly selected: boolean; + /** Move the cursor here. Called on a click on an unselected row. */ + readonly onSelect: (mouse: MouseContextValue) => void; + /** + * Open / run the row. Called on a click on the row that is already + * selected. Omit for lists where selection is the whole interaction. + */ + readonly onActivate?: (mouse: MouseContextValue) => void; + readonly layer?: number; + readonly children: ReactNode; +} + +/** + * Click behaviour for every cursor-driven list in the TUI: **the first + * click selects, a second click on the selected row activates.** + * + * The two-step is deliberate. A double-click needs a timing window that + * is unreliable over SSH and invisible to the user, and select-and-open + * on a single click makes a mis-click destructive in lists where Enter + * starts a download or opens a session. Two plain clicks mirror what + * the keyboard already does — arrow to the row, then Enter — and reuse + * each panel's own activation path rather than duplicating it. + * + * Without the mouse context (component tests, `--no-mouse`) this is a + * transparent pass-through and the row renders exactly as before. + */ +export function MouseListRow({ + selected, + onSelect, + onActivate, + layer = MOUSE_LAYER_PANEL, + children, +}: MouseListRowProps): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (selected) { + onActivate?.(mouse); + return true; + } + onSelect(mouse); + return true; + }} + > + {children} + + ); +} + +/** + * Adapter that turns "the operator clicked the selected row" into the + * Enter keypress the owning panel already knows how to handle. Reusing + * `*-key-bindings.ts` means the mouse cannot drift from the keyboard: + * whatever Enter opens, downloads or confirms today, a second click + * does too. + */ +export function pressEnter( + handler: ( + input: string, + key: Key, + ctx: { + state: TuiState; + dispatch: (action: TuiAction) => void; + callbacks: TuiAppCallbacks; + }, + ) => boolean | null, +): (mouse: MouseContextValue) => void { + return (mouse) => { + handler("", returnKey(), { + state: mouse.getState(), + dispatch: mouse.dispatch, + callbacks: mouse.callbacks, + }); + }; +} diff --git a/src/tui/mouse/mouse-registry.test.ts b/src/tui/mouse/mouse-registry.test.ts new file mode 100644 index 00000000..323921fd --- /dev/null +++ b/src/tui/mouse/mouse-registry.test.ts @@ -0,0 +1,224 @@ +import type { DOMElement } from "ink"; +import { describe, expect, it } from "vitest"; +import { + absoluteRect, + MOUSE_LAYER_BASE, + MOUSE_LAYER_MODAL, + MouseTargetRegistry, +} from "./mouse-registry.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +interface FakeLayout { + left: number; + top: number; + width: number; + height: number; +} + +/** + * Minimal stand-in for an Ink node: the registry only ever reads + * `yogaNode.getComputedLayout()`, `parentNode` and `style`. + */ +function node( + layout: FakeLayout, + parent?: DOMElement, + style: Record = {}, +): DOMElement { + return { + nodeName: "ink-box", + attributes: {}, + childNodes: [], + style, + parentNode: parent, + yogaNode: { + getComputedLayout: () => layout, + }, + } as unknown as DOMElement; +} + +function press(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +describe("absoluteRect", () => { + it("sums the offsets of every ancestor", () => { + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + const column = node({ left: 2, top: 1, width: 78, height: 20 }, root); + const row = node({ left: 0, top: 3, width: 78, height: 1 }, column); + expect(absoluteRect(row)).toEqual({ + left: 2, + top: 4, + width: 78, + height: 1, + }); + }); + + it("returns null for an unmounted ref", () => { + expect(absoluteRect(null)).toBeNull(); + }); + + it("clips against an ancestor that hides overflow", () => { + const viewport = node({ left: 0, top: 0, width: 40, height: 5 }, undefined, { + overflowY: "hidden", + }); + const scrolled = node({ left: 0, top: 3, width: 40, height: 4 }, viewport); + expect(absoluteRect(scrolled)).toEqual({ + left: 0, + top: 3, + width: 40, + height: 2, + }); + }); + + it("drops a row scrolled fully out of a clipping viewport", () => { + const viewport = node({ left: 0, top: 0, width: 40, height: 5 }, undefined, { + overflowY: "hidden", + }); + const offscreen = node({ left: 0, top: -4, width: 40, height: 1 }, viewport); + expect(absoluteRect(offscreen)).toBeNull(); + }); +}); + +describe("MouseTargetRegistry", () => { + it("routes a click to the target under the pointer", () => { + const registry = new MouseTargetRegistry(); + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + const hits: string[] = []; + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 10, height: 1 }, root) }, + handler: () => { + hits.push("first"); + return true; + }, + }); + registry.register({ + ref: { current: node({ left: 0, top: 2, width: 10, height: 1 }, root) }, + handler: () => { + hits.push("second"); + return true; + }, + }); + expect(registry.dispatch(press(3, 2))).toBe(true); + expect(hits).toEqual(["second"]); + }); + + it("reports the click position relative to the target", () => { + const registry = new MouseTargetRegistry(); + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + let seen = { x: -1, y: -1 }; + registry.register({ + ref: { current: node({ left: 5, top: 4, width: 20, height: 3 }, root) }, + handler: (hit) => { + seen = { x: hit.localX, y: hit.localY }; + return true; + }, + }); + registry.dispatch(press(9, 6)); + expect(seen).toEqual({ x: 4, y: 2 }); + }); + + it("prefers the innermost target when boxes nest", () => { + const registry = new MouseTargetRegistry(); + const container = node({ left: 0, top: 0, width: 40, height: 10 }); + const row = node({ left: 0, top: 2, width: 40, height: 1 }, container); + const claimed: string[] = []; + registry.register({ + ref: { current: container }, + handler: () => { + claimed.push("container"); + return true; + }, + }); + registry.register({ + ref: { current: row }, + handler: () => { + claimed.push("row"); + return true; + }, + }); + registry.dispatch(press(1, 2)); + expect(claimed).toEqual(["row"]); + }); + + it("falls through to the next candidate when a handler declines", () => { + const registry = new MouseTargetRegistry(); + const container = node({ left: 0, top: 0, width: 40, height: 10 }); + const row = node({ left: 0, top: 0, width: 40, height: 1 }, container); + const claimed: string[] = []; + registry.register({ + ref: { current: container }, + handler: () => { + claimed.push("container"); + return true; + }, + }); + registry.register({ + ref: { current: row }, + handler: () => { + claimed.push("row"); + return false; + }, + }); + registry.dispatch(press(1, 0)); + expect(claimed).toEqual(["row", "container"]); + }); + + it("ignores clicks outside every target", () => { + const registry = new MouseTargetRegistry(); + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 4, height: 1 }) }, + handler: () => true, + }); + expect(registry.dispatch(press(9, 9))).toBe(false); + }); + + it("lets a modal layer lock out the surfaces behind it", () => { + const registry = new MouseTargetRegistry(); + const claimed: string[] = []; + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 40, height: 10 }) }, + layer: MOUSE_LAYER_BASE, + handler: () => { + claimed.push("background"); + return true; + }, + }); + registry.register({ + ref: { current: node({ left: 0, top: 5, width: 40, height: 2 }) }, + layer: MOUSE_LAYER_MODAL, + handler: () => { + claimed.push("modal"); + return true; + }, + }); + registry.setMinLayer(MOUSE_LAYER_MODAL); + expect(registry.dispatch(press(1, 1))).toBe(false); + registry.dispatch(press(1, 5)); + expect(claimed).toEqual(["modal"]); + }); + + it("stops routing to an unregistered target", () => { + const registry = new MouseTargetRegistry(); + let calls = 0; + const unregister = registry.register({ + ref: { current: node({ left: 0, top: 0, width: 4, height: 1 }) }, + handler: () => { + calls += 1; + return true; + }, + }); + registry.dispatch(press(0, 0)); + unregister(); + registry.dispatch(press(0, 0)); + expect(calls).toBe(1); + }); +}); diff --git a/src/tui/mouse/mouse-registry.ts b/src/tui/mouse/mouse-registry.ts new file mode 100644 index 00000000..56e7bf9d --- /dev/null +++ b/src/tui/mouse/mouse-registry.ts @@ -0,0 +1,185 @@ +/** + * Hit-testing registry: turns a screen coordinate into the component + * that owns that cell. + * + * Ink exposes no absolute positions — `measureElement` returns a size + * only — but every rendered node keeps its Yoga node, and Yoga computes + * each box's offset relative to its parent's border box. Ink's own + * renderer walks the tree the same way (`render-node-to-output.ts` + * accumulates `offsetX/offsetY` from `getComputedLeft/Top`), so summing + * the chain up to the root reproduces exactly the cell the renderer + * painted the node into. That equivalence is what makes clicking + * reliable instead of a table of hardcoded row numbers that rots the + * next time a panel gains a header line. + * + * Targets register a ref plus a handler; a click is offered to the + * candidates whose rectangle contains the point, innermost first, until + * one claims it. `minLayer` is the modal gate: while a modal owns the + * keyboard, only targets registered at the modal layer are eligible, so + * a click cannot reach the list rendered behind it. + */ +import type { DOMElement } from "ink"; +import type { RefObject } from "react"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** Chat log, status bar, sidebar, prompt — the resting UI. */ +export const MOUSE_LAYER_BASE = 0; +/** Observe / Manage panel bodies. */ +export const MOUSE_LAYER_PANEL = 1; +/** Modals, confirms and pickers — claim clicks exclusively while open. */ +export const MOUSE_LAYER_MODAL = 2; + +export interface MouseRect { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +export interface MouseHit { + readonly event: TuiMouseEvent; + /** Click column relative to the target's left edge. */ + readonly localX: number; + /** Click row relative to the target's top edge. */ + readonly localY: number; + readonly rect: MouseRect; +} + +/** Return `true` to claim the event; `false` lets it fall through. */ +export type MouseTargetHandler = (hit: MouseHit) => boolean; + +export interface MouseTargetOptions { + readonly ref: RefObject; + readonly handler: MouseTargetHandler; + readonly layer?: number; +} + +interface RegisteredTarget extends MouseTargetOptions { + readonly id: number; + readonly layer: number; +} + +export class MouseTargetRegistry { + private readonly targets = new Map(); + private nextId = 1; + private minLayer = MOUSE_LAYER_BASE; + + /** Registers a target and returns its unregister function. */ + register(options: MouseTargetOptions): () => void { + const id = this.nextId++; + this.targets.set(id, { + ...options, + id, + layer: options.layer ?? MOUSE_LAYER_BASE, + }); + return () => { + this.targets.delete(id); + }; + } + + /** + * Raises the floor for eligible targets. Set to `MOUSE_LAYER_MODAL` + * while a modal is open so background surfaces stop responding. + */ + setMinLayer(layer: number): void { + this.minLayer = layer; + } + + /** Offers `event` to the matching targets; `true` when one claimed it. */ + dispatch(event: TuiMouseEvent): boolean { + const hits: Array<{ target: RegisteredTarget; rect: MouseRect }> = []; + for (const target of this.targets.values()) { + if (target.layer < this.minLayer) continue; + const rect = absoluteRect(target.ref.current); + if (!rect || !containsPoint(rect, event.x, event.y)) continue; + hits.push({ target, rect }); + } + // Innermost wins: higher layer first, then the smaller box, then the + // more recently mounted node (later siblings paint over earlier ones). + hits.sort( + (a, b) => + b.target.layer - a.target.layer || + area(a.rect) - area(b.rect) || + b.target.id - a.target.id, + ); + for (const hit of hits) { + const claimed = hit.target.handler({ + event, + localX: event.x - hit.rect.left, + localY: event.y - hit.rect.top, + rect: hit.rect, + }); + if (claimed) return true; + } + return false; + } +} + +/** + * Absolute screen rectangle of `node`, in terminal cells, or `null` + * when the node is unmounted or has not been through a layout pass. + * Ancestors that clip (`overflow: hidden`) trim the result, so a chat + * row scrolled out of the viewport is not clickable where it would + * have been painted. + */ +export function absoluteRect(node: DOMElement | null): MouseRect | null { + const yoga = node?.yogaNode; + if (!node || !yoga) return null; + const own = yoga.getComputedLayout(); + // `rect` is always expressed in the coordinate space of the ancestor + // currently being visited, then translated one level up per step. + let rect: MouseRect = { + left: own.left, + top: own.top, + width: own.width, + height: own.height, + }; + let parent = node.parentNode; + while (parent?.yogaNode) { + const layout = parent.yogaNode.getComputedLayout(); + if (clipsOverflow(parent)) { + const clipped = intersect(rect, { + left: 0, + top: 0, + width: layout.width, + height: layout.height, + }); + if (!clipped) return null; + rect = clipped; + } + rect = { + ...rect, + left: rect.left + layout.left, + top: rect.top + layout.top, + }; + parent = parent.parentNode; + } + return rect; +} + +function clipsOverflow(node: DOMElement): boolean { + const style = node.style as { overflowX?: string; overflowY?: string }; + return style.overflowX === "hidden" || style.overflowY === "hidden"; +} + +function intersect(a: MouseRect, b: MouseRect): MouseRect | null { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.left + a.width, b.left + b.width); + const bottom = Math.min(a.top + a.height, b.top + b.height); + if (right <= left || bottom <= top) return null; + return { left, top, width: right - left, height: bottom - top }; +} + +function containsPoint(rect: MouseRect, x: number, y: number): boolean { + return ( + x >= rect.left && + x < rect.left + rect.width && + y >= rect.top && + y < rect.top + rect.height + ); +} + +function area(rect: MouseRect): number { + return rect.width * rect.height; +} diff --git a/src/tui/mouse/mouse-source.ts b/src/tui/mouse/mouse-source.ts new file mode 100644 index 00000000..84f5f99a --- /dev/null +++ b/src/tui/mouse/mouse-source.ts @@ -0,0 +1,29 @@ +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** Read side of the mouse pipe, handed to `TuiApp` as a prop. */ +export interface MouseSource { + subscribe(listener: (event: TuiMouseEvent) => void): () => void; +} + +export interface MouseSourceEmitter extends MouseSource { + emit(event: TuiMouseEvent): void; +} + +/** + * Tiny pub/sub bridging the stdin decoder (plain Node, outside React) + * to the Ink tree — the same shape as `makeTuiEventBus`, for the same + * reason: the app shell subscribes, the process-level plumbing emits, + * and tests can drive clicks without a terminal. + */ +export function makeMouseSource(): MouseSourceEmitter { + const listeners = new Set<(event: TuiMouseEvent) => void>(); + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit(event) { + for (const listener of listeners) listener(event); + }, + }; +} diff --git a/src/tui/mouse/mouse-stdin.test.ts b/src/tui/mouse/mouse-stdin.test.ts new file mode 100644 index 00000000..ddf22ef4 --- /dev/null +++ b/src/tui/mouse/mouse-stdin.test.ts @@ -0,0 +1,96 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { createMouseStdin } from "./mouse-stdin.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +const ESC = "\u001B"; + +interface FakeTty extends PassThrough { + isTTY?: boolean; + rawModeCalls?: boolean[]; +} + +function makeSource(): FakeTty { + const stream = new PassThrough() as FakeTty; + stream.isTTY = true; + stream.rawModeCalls = []; + (stream as unknown as { setRawMode: (mode: boolean) => void }).setRawMode = ( + mode: boolean, + ) => { + stream.rawModeCalls?.push(mode); + }; + return stream; +} + +async function collect(stream: NodeJS.ReadStream): Promise { + await new Promise((resolve) => setImmediate(resolve)); + const chunks: string[] = []; + let chunk: unknown; + while ((chunk = stream.read()) !== null) { + chunks.push(String(chunk)); + } + return chunks.join(""); +} + +describe("createMouseStdin", () => { + it("keeps mouse reports away from the keyboard stream", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + source.write(`a${ESC}[<0;5;2Mb`); + expect(await collect(stdin)).toBe("ab"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "press", x: 4, y: 1 }); + }); + + it("reassembles a report split across two reads", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + source.write(`${ESC}[<64;3`); + source.write(";9M"); + expect(await collect(stdin)).toBe(""); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "wheel", wheel: "up", y: 8 }); + }); + + it("forwards ordinary keystrokes untouched", async () => { + const source = makeSource(); + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + ); + source.write(`hi${ESC}[A${ESC}`); + expect(await collect(stdin)).toBe(`hi${ESC}[A${ESC}`); + }); + + it("proxies TTY-ness and raw mode to the real stdin", () => { + const source = makeSource(); + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + ); + expect(stdin.isTTY).toBe(true); + stdin.setRawMode(true); + expect(source.rawModeCalls).toEqual([true]); + }); + + it("stops listening after dispose", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin, dispose } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + dispose(); + source.write(`x${ESC}[<0;1;1M`); + expect(await collect(stdin)).toBe(""); + expect(events).toEqual([]); + }); +}); diff --git a/src/tui/mouse/mouse-stdin.ts b/src/tui/mouse/mouse-stdin.ts new file mode 100644 index 00000000..f331e04e --- /dev/null +++ b/src/tui/mouse/mouse-stdin.ts @@ -0,0 +1,82 @@ +/** + * Keyboard/mouse demultiplexer for the TUI's stdin. + * + * Ink parses stdin as keystrokes and has no mouse layer, so a raw mouse + * report reaching it is decoded as a stray Escape plus a handful of + * literal characters typed into the chat buffer. Rather than fight that + * downstream, we hand Ink a *different* stream: this module reads the + * real TTY, pulls the mouse reports out, and forwards everything else + * to a `PassThrough` that Ink treats as its stdin. + * + * The wrapper has to look enough like `process.stdin` for Ink's raw + * mode plumbing — `isTTY`, `setRawMode`, `ref`/`unref` — so those are + * delegated to the real stream. Ink also `unshift()`s bytes back during + * its kitty-keyboard probe; a `PassThrough` supports that natively. + */ +import { PassThrough } from "node:stream"; +import { decodeMouseEvents } from "./parse-mouse-events.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +export interface MouseStdin { + /** Stream to hand to Ink's `render({ stdin })` — mouse bytes removed. */ + readonly stdin: NodeJS.ReadStream; + /** Detaches from the real stdin. Call during TUI teardown. */ + dispose(): void; +} + +/** + * Wraps `source` so mouse reports are delivered to `onMouseEvent` and + * every other byte flows through to the returned stream. + */ +export function createMouseStdin( + source: NodeJS.ReadStream, + onMouseEvent: (event: TuiMouseEvent) => void, +): MouseStdin { + const passthrough = new PassThrough(); + // Ink asks its stdin for raw mode and for TTY-ness; both questions + // are really about the underlying terminal, so proxy them. + const proxy = passthrough as unknown as NodeJS.ReadStream; + Object.defineProperty(proxy, "isTTY", { + configurable: true, + get: () => source.isTTY, + }); + Object.defineProperty(proxy, "isRaw", { + configurable: true, + get: () => source.isRaw, + }); + proxy.setRawMode = (mode: boolean): NodeJS.ReadStream => { + source.setRawMode?.(mode); + return proxy; + }; + // `ref`/`unref` are TTY/socket concerns — a PassThrough has neither, + // so they belong to the real stdin and only to it. + proxy.ref = (): NodeJS.ReadStream => { + source.ref?.(); + return proxy; + }; + proxy.unref = (): NodeJS.ReadStream => { + source.unref?.(); + return proxy; + }; + + // A report can straddle two reads; `pending` holds the head of a + // truncated sequence until the rest of it arrives. + let pending = ""; + const onData = (chunk: Buffer | string): void => { + const decoded = decodeMouseEvents( + pending + (typeof chunk === "string" ? chunk : chunk.toString("utf8")), + ); + pending = decoded.rest; + for (const event of decoded.events) onMouseEvent(event); + if (decoded.text.length > 0) passthrough.write(decoded.text); + }; + source.on("data", onData); + + return { + stdin: proxy, + dispose: () => { + source.off("data", onData); + pending = ""; + }, + }; +} diff --git a/src/tui/mouse/mouse-tracking.test.ts b/src/tui/mouse/mouse-tracking.test.ts new file mode 100644 index 00000000..760864fc --- /dev/null +++ b/src/tui/mouse/mouse-tracking.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { enableMouseTracking } from "./mouse-tracking.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; + }, + }; +} + +const ENABLE_TRACKING = "\u001B[?1000h"; +const DISABLE_TRACKING = "\u001B[?1000l"; +const ENABLE_SGR = "\u001B[?1006h"; +const DISABLE_SGR = "\u001B[?1006l"; + +describe("enableMouseTracking", () => { + it("requests button tracking with SGR reports on a TTY", () => { + const stdout = makeStdout(true); + enableMouseTracking({ stdout: stdout as unknown as NodeJS.WriteStream }); + expect(stdout.writes).toEqual([ENABLE_TRACKING, ENABLE_SGR]); + }); + + it("never asks for motion tracking", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes.join("")).not.toContain("1002"); + expect(stdout.writes.join("")).not.toContain("1003"); + }); + + it("hands selection back to the terminal on disable", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes).toEqual([ + ENABLE_TRACKING, + ENABLE_SGR, + DISABLE_SGR, + DISABLE_TRACKING, + ]); + }); + + it("is idempotent — a second disable writes nothing", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + controller.disable(); + expect(stdout.writes).toHaveLength(4); + }); + + it("is a no-op when stdout is not a TTY", () => { + const stdout = makeStdout(false); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes).toEqual([]); + }); + + it("detaches its exit hook when disabled explicitly", () => { + const before = process.listenerCount("exit"); + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + expect(process.listenerCount("exit")).toBe(before + 1); + controller.disable(); + expect(process.listenerCount("exit")).toBe(before); + }); +}); diff --git a/src/tui/mouse/mouse-tracking.ts b/src/tui/mouse/mouse-tracking.ts new file mode 100644 index 00000000..e98a56ca --- /dev/null +++ b/src/tui/mouse/mouse-tracking.ts @@ -0,0 +1,75 @@ +/** + * Mouse reporting mode manager — the terminal-side half of TUI mouse + * support. Deliberately shaped like `alt-screen.ts`: a single + * `enable → controller.disable()` pair, silent on non-TTY streams, and + * a `process.on("exit")` safety net so a crash never leaves the host + * terminal in reporting mode (where every click would print garbage + * into the user's shell). + * + * We request **1000 (normal tracking)** plus **1006 (SGR encoding)** + * only. 1002/1003 (drag / any-motion) are intentionally left off: the + * app has no hover or drag affordance, and motion reports are a + * constant stream of wakeups for a UI that does not use them. + * + * The trade-off this mode forces — the terminal stops doing its own + * drag-to-select while reporting is on — is why mouse support is a + * toggle (`tui.mouse`, `--no-mouse`, `/mouse`) rather than a + * hard-wired behaviour. `disable()` restores native selection + * instantly, without restarting the TUI. + */ +import type { Writable } from "node:stream"; + +/** Normal tracking: press + release, no motion. */ +const ENABLE_BUTTON_TRACKING = "\u001B[?1000h"; +const DISABLE_BUTTON_TRACKING = "\u001B[?1000l"; +/** SGR extended reports — required past column 223. */ +const ENABLE_SGR_REPORTS = "\u001B[?1006h"; +const DISABLE_SGR_REPORTS = "\u001B[?1006l"; + +export interface MouseTrackingController { + /** Stops mouse reporting and hands selection back to the terminal. Safe to call twice. */ + disable(): void; +} + +export interface MouseTrackingOptions { + readonly stdout?: NodeJS.WriteStream; +} + +/** + * Turns on mouse reporting for `stdout` and returns a controller whose + * `disable()` turns it back off. On a non-TTY stream (pipes, CI, the + * test harness) both halves are no-ops, exactly like `enterAltScreen`. + */ +export function enableMouseTracking( + options: MouseTrackingOptions = {}, +): MouseTrackingController { + const stdout = options.stdout ?? process.stdout; + if (!streamIsTty(stdout)) { + return { disable: () => {} }; + } + stdout.write(ENABLE_BUTTON_TRACKING); + stdout.write(ENABLE_SGR_REPORTS); + let disabled = false; + const disable = (): void => { + if (disabled) return; + disabled = true; + // Reverse order: stop the extended encoding first so a terminal + // that only understood 1000 still sees a clean disable. + stdout.write(DISABLE_SGR_REPORTS); + stdout.write(DISABLE_BUTTON_TRACKING); + }; + // Last-chance cleanup. Without it an uncaught exception leaves the + // terminal reporting clicks as escape sequences into the shell. + const onExit = (): void => disable(); + process.once("exit", onExit); + return { + disable: () => { + process.off("exit", onExit); + disable(); + }, + }; +} + +function streamIsTty(stream: Writable): boolean { + return (stream as NodeJS.WriteStream).isTTY === true; +} diff --git a/src/tui/mouse/parse-mouse-events.test.ts b/src/tui/mouse/parse-mouse-events.test.ts new file mode 100644 index 00000000..ec982a5f --- /dev/null +++ b/src/tui/mouse/parse-mouse-events.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { decodeMouseEvents } from "./parse-mouse-events.js"; + +const ESC = "\u001B"; + +/** SGR press/release report for the given button code at 1-based col/row. */ +function sgr(code: number, column: number, row: number, press = true): string { + return `${ESC}[<${code};${column};${row}${press ? "M" : "m"}`; +} + +describe("decodeMouseEvents", () => { + it("decodes a left-button press into 0-based coordinates", () => { + const { events, text, rest } = decodeMouseEvents(sgr(0, 12, 3)); + expect(text).toBe(""); + expect(rest).toBe(""); + expect(events).toEqual([ + { + kind: "press", + button: "left", + wheel: null, + x: 11, + y: 2, + shift: false, + alt: false, + ctrl: false, + }, + ]); + }); + + it("distinguishes release reports from presses", () => { + const { events } = decodeMouseEvents(sgr(0, 1, 1, false)); + expect(events[0]?.kind).toBe("release"); + expect(events[0]?.button).toBe("none"); + }); + + it("decodes middle and right buttons", () => { + const { events } = decodeMouseEvents(sgr(1, 2, 2) + sgr(2, 2, 2)); + expect(events.map((event) => event.button)).toEqual(["middle", "right"]); + }); + + it("decodes wheel up and wheel down", () => { + const { events } = decodeMouseEvents(sgr(64, 5, 5) + sgr(65, 5, 5)); + expect(events.map((event) => event.kind)).toEqual(["wheel", "wheel"]); + expect(events.map((event) => event.wheel)).toEqual(["up", "down"]); + }); + + it("decodes modifier bits", () => { + const { events } = decodeMouseEvents(sgr(0 + 4 + 8 + 16, 1, 1)); + expect(events[0]).toMatchObject({ shift: true, alt: true, ctrl: true }); + }); + + it("handles coordinates past the 223-column legacy ceiling", () => { + const { events } = decodeMouseEvents(sgr(0, 400, 260)); + expect(events[0]).toMatchObject({ x: 399, y: 259 }); + }); + + it("keeps the keyboard bytes around a report intact", () => { + const { events, text } = decodeMouseEvents(`a${sgr(0, 2, 2)}b`); + expect(text).toBe("ab"); + expect(events).toHaveLength(1); + }); + + it("reassembles a report split across two chunks", () => { + const whole = sgr(0, 30, 7); + const first = decodeMouseEvents(whole.slice(0, 6)); + expect(first.events).toEqual([]); + expect(first.text).toBe(""); + expect(first.rest).toBe(whole.slice(0, 6)); + const second = decodeMouseEvents(first.rest + whole.slice(6)); + expect(second.events).toHaveLength(1); + expect(second.events[0]).toMatchObject({ x: 29, y: 6 }); + expect(second.rest).toBe(""); + }); + + it("passes a lone Escape through instead of buffering it", () => { + const { events, text, rest } = decodeMouseEvents(ESC); + expect(events).toEqual([]); + expect(text).toBe(ESC); + expect(rest).toBe(""); + }); + + it("leaves non-mouse CSI sequences untouched", () => { + const arrows = `${ESC}[A${ESC}[B${ESC}[Z`; + const { events, text } = decodeMouseEvents(arrows); + expect(events).toEqual([]); + expect(text).toBe(arrows); + }); + + it("decodes the legacy X10 encoding so it is never typed as text", () => { + const x10 = `${ESC}[M${String.fromCharCode(32, 32 + 10, 32 + 4)}`; + const { events, text } = decodeMouseEvents(x10); + expect(text).toBe(""); + expect(events[0]).toMatchObject({ + kind: "press", + button: "left", + x: 9, + y: 3, + }); + }); + + it("buffers a truncated X10 report", () => { + const partial = `${ESC}[M${String.fromCharCode(32)}`; + const { events, rest } = decodeMouseEvents(partial); + expect(events).toEqual([]); + expect(rest).toBe(partial); + }); +}); diff --git a/src/tui/mouse/parse-mouse-events.ts b/src/tui/mouse/parse-mouse-events.ts new file mode 100644 index 00000000..4ef1a16a --- /dev/null +++ b/src/tui/mouse/parse-mouse-events.ts @@ -0,0 +1,148 @@ +import type { MouseButton, TuiMouseEvent } from "./mouse-event.js"; + +/** + * Incremental decoder for xterm mouse reports. + * + * Two encodings are understood: + * + * - **SGR / 1006** — `ESC [ < b ; col ; row (M|m)`. What we ask for + * (`\u001B[?1006h`) and what every modern terminal answers with. + * `M` is a press, `m` a release; columns/rows are 1-based and + * unbounded, which is why 1006 exists at all (the legacy encoding + * tops out at column 223). + * - **X10 / legacy** — `ESC [ M b col row` with each field a single + * byte offset by 32. Terminals that ignore the 1006 request fall + * back to this; decoding it costs ten lines and stops the raw bytes + * from being typed into the chat buffer as mojibake. + * + * The decoder is a pure function so the interesting part — a chunk + * boundary splitting a report in half — is unit-testable without a + * terminal. Everything that is not a mouse report comes back verbatim + * in `text` and must reach Ink's key parser untouched. + */ + +const ESC = "\u001B"; +/** Bit 2 of the button byte. */ +const SHIFT_BIT = 4; +/** Bit 3 ("meta" in the spec, Alt/Option in practice). */ +const ALT_BIT = 8; +/** Bit 4. */ +const CTRL_BIT = 16; +/** Bit 6 — wheel reports arrive as buttons 64 (up) / 65 (down). */ +const WHEEL_BIT = 64; + +const SGR_MOUSE = /^\u001B\[<(\d{1,6});(\d{1,6});(\d{1,6})([Mm])/; +const TRUNCATED_SGR = /^\u001B\[<\d{0,6}(;\d{0,6}){0,2}$/; +const TRUNCATED_X10 = /^\u001B\[M[\s\S]{0,2}$/; + +export interface DecodedMouseChunk { + /** Mouse reports found in this chunk, in arrival order. */ + readonly events: TuiMouseEvent[]; + /** Everything that was not a mouse report — forward this to Ink. */ + readonly text: string; + /** + * Trailing bytes that *might* be the head of a mouse report split + * across a read boundary. Prepend to the next chunk. + */ + readonly rest: string; +} + +/** + * Split `buffer` into mouse events plus the keyboard bytes around them. + * + * A lone trailing `ESC` is deliberately **not** buffered: that is how + * the Escape key itself arrives, and holding it back would make Esc + * respond only after the next keystroke. An incomplete `ESC [` (or a + * truncated report) is buffered — neither is a complete key sequence + * Ink could act on anyway. + */ +export function decodeMouseEvents(buffer: string): DecodedMouseChunk { + const events: TuiMouseEvent[] = []; + let text = ""; + let index = 0; + while (index < buffer.length) { + const esc = buffer.indexOf(ESC, index); + if (esc === -1) { + text += buffer.slice(index); + return { events, text, rest: "" }; + } + text += buffer.slice(index, esc); + const tail = buffer.slice(esc); + // Lone ESC at the very end, or an ESC followed by something that is + // not a CSI introducer: not ours, pass it through. + if (tail.length === 1 || tail[1] !== "[") { + text += ESC; + index = esc + 1; + continue; + } + const sgr = SGR_MOUSE.exec(tail); + if (sgr) { + events.push(decodeSgr(sgr)); + index = esc + sgr[0].length; + continue; + } + if (tail.startsWith(`${ESC}[M`)) { + if (tail.length < 6) return { events, text, rest: tail }; + events.push(decodeX10(tail)); + index = esc + 6; + continue; + } + if ( + tail === `${ESC}[` || + TRUNCATED_SGR.test(tail) || + TRUNCATED_X10.test(tail) + ) { + return { events, text, rest: tail }; + } + // A CSI that is not a mouse report (arrows, Shift+Tab, kitty + // protocol replies…). Emit the introducer and resume scanning after + // it so the rest of the sequence flows to Ink unchanged. + text += `${ESC}[`; + index = esc + 2; + } + return { events, text, rest: "" }; +} + +function decodeSgr(match: RegExpExecArray): TuiMouseEvent { + const code = Number.parseInt(match[1] ?? "0", 10); + const column = Number.parseInt(match[2] ?? "1", 10); + const row = Number.parseInt(match[3] ?? "1", 10); + return buildEvent(code, column, row, match[4] === "m"); +} + +function decodeX10(tail: string): TuiMouseEvent { + const code = (tail.codePointAt(3) ?? 32) - 32; + const column = (tail.codePointAt(4) ?? 33) - 32; + const row = (tail.codePointAt(5) ?? 33) - 32; + // The legacy encoding has no dedicated release code: low bits `3` + // mean "some button came up" and never say which one. + return buildEvent(code, column, row, (code & 3) === 3); +} + +function buildEvent( + code: number, + column: number, + row: number, + released: boolean, +): TuiMouseEvent { + const wheeling = (code & WHEEL_BIT) !== 0; + const low = code & 3; + return { + kind: wheeling ? "wheel" : released ? "release" : "press", + button: wheeling || released ? "none" : buttonFromLowBits(low), + wheel: wheeling ? (low === 0 ? "up" : "down") : null, + // Terminals report 1-based cells; the layout engine is 0-based. + x: Math.max(0, column - 1), + y: Math.max(0, row - 1), + shift: (code & SHIFT_BIT) !== 0, + alt: (code & ALT_BIT) !== 0, + ctrl: (code & CTRL_BIT) !== 0, + }; +} + +function buttonFromLowBits(low: number): MouseButton { + if (low === 0) return "left"; + if (low === 1) return "middle"; + if (low === 2) return "right"; + return "none"; +} diff --git a/src/tui/mouse/synthetic-key.ts b/src/tui/mouse/synthetic-key.ts new file mode 100644 index 00000000..298d806d --- /dev/null +++ b/src/tui/mouse/synthetic-key.ts @@ -0,0 +1,47 @@ +import type { Key } from "ink"; + +/** + * Ink `Key` objects synthesised from mouse gestures. + * + * The wheel and "click the already-selected row" gestures mean exactly + * what ↑/↓/Enter mean, and every panel already owns a key handler with + * its own clamping, windowing and activation rules + * (`*-key-bindings.ts`). Feeding those handlers a synthetic key reuses + * that logic wholesale instead of duplicating a second, drifting copy + * of "what does moving the cursor mean in the Skills panel". + */ + +const NO_KEY: Key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, +}; + +/** A bare ↑ or ↓ press. */ +export function arrowKey(direction: "up" | "down"): Key { + return direction === "up" + ? { ...NO_KEY, upArrow: true } + : { ...NO_KEY, downArrow: true }; +} + +/** A bare Enter press — the "activate what is selected" gesture. */ +export function returnKey(): Key { + return { ...NO_KEY, return: true }; +} diff --git a/src/tui/open-terminal-window.test.ts b/src/tui/open-terminal-window.test.ts new file mode 100644 index 00000000..5e09c467 --- /dev/null +++ b/src/tui/open-terminal-window.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + openAgentTerminalWindow, + openTerminalWindow, + isOnPath, + type SpawnedTerminal, + type TerminalSpawn, +} from "./open-terminal-window.js"; +import type { TerminalLaunchInput } from "./build-terminal-launch.js"; + +/** Child stub that replays one lifecycle event on `once`. */ +function fakeChild( + event: "spawn" | "error" | "exit" | "none", + payload?: Error | number, +) { + const unref = vi.fn(); + const child: SpawnedTerminal = { + once(name: string, listener: (...args: never[]) => void) { + if (name === event) { + // Deliver asynchronously, like the real emitter. + queueMicrotask(() => + (listener as unknown as (arg?: Error | number) => void)(payload), + ); + } + return child; + }, + unref, + }; + return { child, unref }; +} + +const LAUNCH = { cmd: "osascript", args: ["-e", "…"], label: "Terminal" }; + +describe("openTerminalWindow", () => { + it("reports success when the launcher exits 0, detached and unref'd", async () => { + const { child, unref } = fakeChild("exit", 0); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { cwd: "/w", spawn }); + expect(result).toEqual({ ok: true, label: "Terminal" }); + expect(spawn).toHaveBeenCalledWith("osascript", ["-e", "…"], { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + cwd: "/w", + }); + // Detached + unref'd: quitting this agent must not kill the new window. + expect(unref).toHaveBeenCalledTimes(1); + }); + + it("treats a launcher that keeps running as an open window", async () => { + // Direct emulators (xterm, kitty) live as long as the window itself. + const { child, unref } = fakeChild("none"); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn, settleMs: 10 }); + expect(result).toEqual({ ok: true, label: "Terminal" }); + expect(unref).toHaveBeenCalledTimes(1); + }); + + it("reports a launcher that spawned fine and then failed", async () => { + // The osascript shape: process starts, AppleScript errors, exit 1 — + // the old spawn-event reading called this "opened". + const { child, unref } = fakeChild("exit", 1); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn }); + expect(result).toEqual({ + ok: false, + reason: "osascript exited with 1", + }); + expect(unref).not.toHaveBeenCalled(); + }); + + it("returns the spawn error instead of throwing", async () => { + const { child } = fakeChild("error", new Error("spawn osascript ENOENT")); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn }); + expect(result).toEqual({ + ok: false, + reason: "osascript: spawn osascript ENOENT", + }); + }); + + it("survives a synchronous throw from spawn", async () => { + const spawn = vi.fn(() => { + throw new Error("EACCES"); + }) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn }); + expect(result).toEqual({ ok: false, reason: "osascript: EACCES" }); + }); +}); + +describe("openAgentTerminalWindow", () => { + const base: TerminalLaunchInput = { + platform: "linux", + execPath: "/usr/bin/node", + argv: ["/usr/bin/node", "/opt/a.js"], + isSea: false, + cwd: "/w", + env: {}, + hasBinary: () => false, + }; + + it("explains itself when the box has no terminal emulator", async () => { + const spawn = vi.fn() as unknown as TerminalSpawn; + const result = await openAgentTerminalWindow(base, { spawn }); + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toContain( + "ATOMIC_AGENT_TERMINAL", + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("spawns the resolved emulator in the working directory", async () => { + const { child } = fakeChild("spawn"); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openAgentTerminalWindow( + { ...base, hasBinary: (n) => n === "xterm" }, + { spawn }, + ); + expect(result).toEqual({ ok: true, label: "xterm" }); + expect(spawn).toHaveBeenCalledWith( + "xterm", + expect.arrayContaining(["-e", "sh", "-c"]), + expect.objectContaining({ cwd: "/w", detached: true }), + ); + }); +}); + +describe("isOnPath", () => { + it("finds a binary that exists on PATH", () => { + expect(isOnPath("sh", { PATH: "/nope:/bin:/usr/bin" })).toBe(true); + }); + + it("misses one that does not", () => { + expect(isOnPath("definitely-not-a-real-binary", { PATH: "/bin" })).toBe(false); + }); + + it("treats an empty PATH as a miss rather than an error", () => { + expect(isOnPath("sh", {})).toBe(false); + }); +}); diff --git a/src/tui/open-terminal-window.ts b/src/tui/open-terminal-window.ts new file mode 100644 index 00000000..e5aecf7a --- /dev/null +++ b/src/tui/open-terminal-window.ts @@ -0,0 +1,184 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { + buildTerminalLaunch, + type TerminalLaunch, + type TerminalLaunchInput, +} from "./build-terminal-launch.js"; + +/** + * Opens a detached OS terminal window running a fresh `atomic-agent tui` + * (Ctrl+N / `/window`). The spawn is injectable so the unit tests never + * pop a window, and every failure comes back as a value — a broken + * emulator must not take the render loop down with it. + */ + +export type OpenTerminalWindowResult = + | { readonly ok: true; readonly label: string } + | { readonly ok: false; readonly reason: string }; + +/** Structural slice of `ChildProcess` this module actually uses. */ +export interface SpawnedTerminal { + once(event: string, listener: (...args: never[]) => void): unknown; + unref(): void; + /** Present when stderr is piped; absent on fakes that never fail. */ + readonly stderr?: { + on(event: "data", listener: (chunk: unknown) => void): unknown; + } | null; +} + +export type TerminalSpawn = ( + cmd: string, + args: readonly string[], + options: { + detached: boolean; + stdio: readonly ["ignore", "ignore", "pipe"]; + cwd?: string; + }, +) => SpawnedTerminal; + +export interface OpenTerminalWindowOptions { + readonly cwd?: string; + readonly spawn?: TerminalSpawn; + /** + * How long a launcher may keep running before it counts as success. + * osascript / gnome-terminal / wt exit quickly — code 0 on success, + * non-zero with stderr on failure; direct emulators (xterm, kitty) + * stay alive for the window's lifetime, which the timeout treats as + * the window being genuinely open. + */ + readonly settleMs?: number; +} + +const DEFAULT_SETTLE_MS = 1_500; + +export async function openTerminalWindow( + launch: TerminalLaunch, + options: OpenTerminalWindowOptions = {}, +): Promise { + const spawnFn = options.spawn ?? (spawn as unknown as TerminalSpawn); + let child: SpawnedTerminal; + try { + child = spawnFn(launch.cmd, launch.args, { + detached: true, + // stderr is piped: a launcher that spawns fine and THEN fails + // (an AppleScript error, a TCC denial, an unsupported flag) exits + // non-zero with its reason there — the old spawn-event-as-success + // reading reported "opened" for every one of those. + stdio: ["ignore", "ignore", "pipe"] as const, + ...(options.cwd ? { cwd: options.cwd } : {}), + }); + } catch (err) { + return { ok: false, reason: `${launch.cmd}: ${errorMessage(err)}` }; + } + const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS; + return await new Promise((resolve) => { + let settled = false; + let stderrTail = ""; + let timer: ReturnType | null = null; + const settle = (result: OpenTerminalWindowResult): void => { + if (settled) return; + settled = true; + if (timer !== null) clearTimeout(timer); + if (result.ok) { + // Detached + unref'd: the new window outlives this process, so + // quitting the parent agent does not kill the one we just opened. + try { + child.unref(); + } catch { + // A fake/limited child without unref is not a failure. + } + } + resolve(result); + }; + child.stderr?.on("data", (chunk: unknown) => { + stderrTail = `${stderrTail}${String(chunk)}`.slice(-400); + }); + child.once("error", ((err: unknown) => { + settle({ ok: false, reason: `${launch.cmd}: ${errorMessage(err)}` }); + }) as (...args: never[]) => void); + child.once("exit", ((code: unknown) => { + if (code === 0) { + settle({ ok: true, label: launch.label }); + return; + } + const detail = stderrTail.trim(); + settle({ + ok: false, + reason: `${launch.cmd} exited with ${String(code)}${detail ? `: ${detail}` : ""}`, + }); + }) as (...args: never[]) => void); + // A launcher still running after the window is treated as a + // successfully opened window (direct emulators live as long as it). + timer = setTimeout(() => settle({ ok: true, label: launch.label }), settleMs); + }); +} + +/** Build + open in one call. Returns the "nothing to open" reason as a value. */ +export async function openAgentTerminalWindow( + input: TerminalLaunchInput, + options: OpenTerminalWindowOptions = {}, +): Promise { + const launch = buildTerminalLaunch(input); + if (launch === null) { + return { + ok: false, + reason: + "no terminal emulator found — set $ATOMIC_AGENT_TERMINAL to the one you use", + }; + } + return await openTerminalWindow(launch, { cwd: input.cwd, ...options }); +} + +/** + * Snapshot of the running process in the shape `buildTerminalLaunch` + * wants. `isSeaBuild` is passed in rather than read from `node:sea` + * here: that module is unresolvable under vitest's bundler, and this + * file must stay unit-testable. + */ +export function currentTerminalLaunchInput( + cwd: string, + isSeaBuild: boolean, +): TerminalLaunchInput { + return { + platform: process.platform, + execPath: process.execPath, + argv: process.argv, + execArgv: process.execArgv, + isSea: isSeaBuild, + cwd, + env: process.env, + hasBinary: isOnPath, + }; +} + +/** + * PATH probe without shelling out to `which` (which does not exist on + * Windows and would cost a process per candidate emulator). An absolute + * or relative path is checked as given. + */ +export function isOnPath( + name: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (name.includes("/") || name.includes("\\")) return isExecutableFile(name); + const entries = (env.PATH ?? "").split(delimiter).filter((p) => p.length > 0); + return entries.some((dir) => isExecutableFile(join(dir, name))); +} + +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + // POSIX: a non-executable shadow file on PATH must not win the + // probe. Windows has no X bit worth checking. + if (process.platform !== "win32") accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/tui/persist-embedding-hybrid-recall.test.ts b/src/tui/persist-embedding-hybrid-recall.test.ts index 5103767c..7e3f634c 100644 --- a/src/tui/persist-embedding-hybrid-recall.test.ts +++ b/src/tui/persist-embedding-hybrid-recall.test.ts @@ -37,10 +37,14 @@ describe("persistEmbeddingHybridRecall", () => { localModels: { embeddings: { enabled: boolean; modelId: string } }; memory: { embeddings: { enabled: boolean } }; }; + // `url` is derived from the port and written alongside the model id + // whenever hybrid recall is switched on — see + // `persistEmbeddingHybridRecall`. expect(written.localModels.embeddings).toEqual({ enabled: true, modelId: "bge-m3", port: 19092, + url: "http://127.0.0.1:19092", }); expect(written.memory.embeddings.enabled).toBe(true); expect(getConfig().memory.embeddings.enabled).toBe(true); diff --git a/src/tui/persist-user-local-models-config.test.ts b/src/tui/persist-user-local-models-config.test.ts index 9322551b..8f7d31c5 100644 --- a/src/tui/persist-user-local-models-config.test.ts +++ b/src/tui/persist-user-local-models-config.test.ts @@ -8,6 +8,7 @@ import { getUserConfigPath, writeUserConfigFileSync } from "../config/config-fil import { USER_CONFIG_DEFAULTS } from "../config/config-schema.js"; import { getConfig } from "../config/index.js"; import { + isLoopbackBaseUrl, normalizeLocalLlmBaseUrl, persistUserLocalModelsConfig, persistUserLocalLlmUrl, @@ -155,6 +156,37 @@ describe("persistUserLocalLlmUrl", () => { }); }); +describe("isLoopbackBaseUrl", () => { + it("is true for every on-machine host at any port", () => { + for (const url of [ + "http://127.0.0.1:9931", + "http://0.0.0.0:8080", + "http://localhost:1234", + "http://LOCALHOST:1234", + "http://box.localhost:9931", + "http://[::1]:9931", + "http://[::1]", + "localhost:9931", // typed without a scheme + "127.0.0.1:9931", + ]) { + expect(isLoopbackBaseUrl(url)).toBe(true); + } + }); + + it("is false for remote hosts and unparseable input", () => { + for (const url of [ + "https://api.example.com", + "http://192.168.1.50:8000", + "http://notlocalhost.com", + "http://localhost.evil.com", // suffix trick must not pass + "", + " ", + ]) { + expect(isLoopbackBaseUrl(url)).toBe(false); + } + }); +}); + describe("pointsAtManagedDaemon", () => { it("matches the managed port on every loopback spelling", () => { for (const host of ["127.0.0.1", "localhost", "[::1]"]) { diff --git a/src/tui/persist-user-local-models-config.ts b/src/tui/persist-user-local-models-config.ts index 8b76cd80..751551e1 100644 --- a/src/tui/persist-user-local-models-config.ts +++ b/src/tui/persist-user-local-models-config.ts @@ -7,6 +7,7 @@ import { } from "../config/index.js"; import type { LocalLlmMode, UserConfigFile } from "../config/config-schema.js"; import { DEFAULT_EMBEDDING_MODEL_ID } from "../local-llm/index.js"; +import { isLocalProviderUrl } from "./providers/is-local-provider-url.js"; /** * Normalise a user-typed local LLM (llama-server) base URL: trim and add @@ -27,6 +28,23 @@ export function normalizeLocalLlmBaseUrl(raw: string): string { return withScheme; } +/** + * True when `url`'s host is a loopback / on-machine address, at any port. + * The host list itself lives in `isLocalProviderUrl` — one set of loopback + * spellings for every caller. This wrapper only absorbs the raw typed + * form: a URL without a scheme (`localhost:9931`) parses with the host in + * the wrong slot, so it is normalized to `http://` first, matching how + * the wizard stores base URLs. + */ +export function isLoopbackBaseUrl(url: string): boolean { + const trimmed = url.trim(); + if (trimmed.length === 0) return false; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; + return isLocalProviderUrl(withScheme); +} + /** * True when `url` resolves to the managed daemon's own loopback address. * Pointing external mode at the managed port is legal — it is how you @@ -37,12 +55,7 @@ export function normalizeLocalLlmBaseUrl(raw: string): string { export function pointsAtManagedDaemon(url: string, managedPort: number): boolean { try { const parsed = new URL(url); - const loopback = - parsed.hostname === "127.0.0.1" || - parsed.hostname === "localhost" || - // `new URL` keeps IPv6 hosts bracketed. - parsed.hostname === "[::1]"; - return loopback && parsed.port === String(managedPort); + return isLoopbackBaseUrl(url) && parsed.port === String(managedPort); } catch { return false; } diff --git a/src/tui/persist-user-tui-config.ts b/src/tui/persist-user-tui-config.ts index fac398ba..3b46c38e 100644 --- a/src/tui/persist-user-tui-config.ts +++ b/src/tui/persist-user-tui-config.ts @@ -4,6 +4,7 @@ import { parseUserConfigFile, resetConfigCache, writeUserConfigFileSync, + type WhileBusySubmitMode, } from "../config/index.js"; /** @@ -23,3 +24,32 @@ export function persistUserTuiTheme(theme: string): void { writeUserConfigFileSync(path, validated); resetConfigCache(); } + +/** + * Persist what Enter does while a turn is running (`steer` / `queue`) + * into `tui.whileBusySubmit`. Same read → merge → validate → write → + * reset shape as {@link persistUserTuiTheme}; the live `TuiState` flip + * is the caller's job, this only makes it survive a restart. + */ +export function persistUserWhileBusySubmit(mode: WhileBusySubmitMode): void { + const path = getConfig().paths.userConfigFile; + const prev = ensureUserConfigFileSync(path); + const draft = { ...prev, tui: { ...prev.tui, whileBusySubmit: mode } }; + const validated = parseUserConfigFile(draft); + writeUserConfigFileSync(path, validated); + resetConfigCache(); +} + +/** + * Persist the mouse-support toggle into `tui.mouse`. Same read → merge → + * validate → write → reset cycle as the theme; the caller owns turning + * the terminal's reporting mode on or off for the running session. + */ +export function persistUserTuiMouse(mouse: boolean): void { + const path = getConfig().paths.userConfigFile; + const prev = ensureUserConfigFileSync(path); + const draft = { ...prev, tui: { ...prev.tui, mouse } }; + const validated = parseUserConfigFile(draft); + writeUserConfigFileSync(path, validated); + resetConfigCache(); +} diff --git a/src/tui/providers/describe-verify-outcome.ts b/src/tui/providers/describe-verify-outcome.ts new file mode 100644 index 00000000..90904aae --- /dev/null +++ b/src/tui/providers/describe-verify-outcome.ts @@ -0,0 +1,40 @@ +/** + * One sentence per verification outcome, in the same voice the cloud + * error path already uses (`humanizeOpenAiHttpError`): who answered, + * what happened, what to do about it. A raw `http 402` on the key screen + * reads as a product failure when it is in fact an empty account. + */ + +import type { ProviderVerifyResult } from "../../llm/provider/verify/index.js"; + +export function describeProviderVerifyOutcome( + result: ProviderVerifyResult, + label: string, +): string { + const who = `"${label}"`; + const model = result.probedModel ? ` (tested with ${result.probedModel})` : ""; + switch (result.status) { + case "ok": + return `${who} accepted the key${model}.`; + case "invalid_key": + return `${who} rejected this key${statusSuffix(result)}. Check it belongs to ${label}, then paste it again.`; + case "no_balance": + return `${who} accepted the key but has no usable balance${statusSuffix(result)}. Top up the account or enable billing, then try again.`; + case "rate_limited": + return `${who} is rate-limiting this key right now — the key itself works. Saved without a completed test.`; + case "model_unavailable": + return `${who} has no model this check could run${model}. Saved without a live test.`; + case "timeout": + return `${who} did not answer the key check in time. Saved unverified — the key was not tested.`; + case "unreachable": + return `Could not reach ${who} to test the key. Saved unverified — check the connection or the base URL.`; + case "cancelled": + return `Key check cancelled. Nothing was saved.`; + default: + return `${who} failed the key check${statusSuffix(result)}. Saved unverified — this looks like the provider, not your key.`; + } +} + +function statusSuffix(result: ProviderVerifyResult): string { + return result.httpStatus === null ? "" : ` (${result.httpStatus})`; +} diff --git a/src/tui/providers/is-local-provider-url.ts b/src/tui/providers/is-local-provider-url.ts new file mode 100644 index 00000000..393a3efb --- /dev/null +++ b/src/tui/providers/is-local-provider-url.ts @@ -0,0 +1,22 @@ +/** + * Whether a base URL points at the operator's own machine. Local servers + * have no API key and no account behind them, so every check that exists + * to protect a cloud account has to step aside for them. + */ +export function isLocalProviderUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + let host: string; + try { + host = new URL(baseUrl).hostname; + } catch { + return false; + } + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host === "[::1]" || + host.endsWith(".localhost") + ); +} diff --git a/src/tui/providers/provider-presets.test.ts b/src/tui/providers/provider-presets.test.ts index 47f7276d..c7fd93ed 100644 --- a/src/tui/providers/provider-presets.test.ts +++ b/src/tui/providers/provider-presets.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseLlmProviderEntry } from "../../config/llm-config.js"; +import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { getProviderFactory } from "../../llm/provider/registry/provider-types.js"; +import { registerBuiltInProviderKinds } from "../../llm/provider/registry/register-built-in-providers.js"; import { findProviderPreset, presetForEntryId, PROVIDER_PRESETS, suggestPresetEntryId, } from "./provider-presets.js"; +import { buildProviderEntryFromWizard } from "./providers-wizard-build-entry.js"; describe("PROVIDER_PRESETS", () => { it("has unique ids", () => { @@ -40,6 +45,56 @@ describe("PROVIDER_PRESETS", () => { expect(findProviderPreset("nous")).toBeDefined(); }); + it("offers Anthropic as a first-class preset", () => { + // Claude was the one major vendor with no route into the agent at + // all: no preset, and both aggregator catalogs filtered it out. + // Anthropic's OpenAI-compatible endpoint needs no new provider kind. + const preset = findProviderPreset("anthropic"); + expect(preset?.baseUrl).toBe("https://api.anthropic.com"); + expect(preset?.envVar).toBe("ANTHROPIC_API_KEY"); + expect(preset?.local).toBeUndefined(); + }); + + it("declares the header contract for the one non-Bearer service", () => { + // `api.anthropic.com` reads `Authorization: Bearer` as an OAuth + // token — an `sk-ant-…` key sent that way is rejected with "Invalid + // bearer token" on every path. Only `x-api-key` reaches the real key + // check, and `anthropic-version` is mandatory on every request. + expect(findProviderPreset("anthropic")?.apiKeyHeader).toBe("x-api-key"); + expect(findProviderPreset("anthropic")?.headers).toEqual({ + "anthropic-version": "2023-06-01", + }); + }); + + it("leaves every other preset on the OpenAI Bearer convention", () => { + // The override is opt-in per service; a stray one would silently + // break a vendor that only accepts Bearer. + for (const preset of PROVIDER_PRESETS) { + if (preset.id === "anthropic") continue; + expect(preset.apiKeyHeader, preset.id).toBeUndefined(); + expect(preset.headers, preset.id).toBeUndefined(); + } + }); + + it("names every hosted vendor preset after its own service", () => { + // Each of these answers `/v1/models` — 200 with a `data` + // array, or a 401 that rejects the *key* rather than naming a header + // we do not send — while the same host 404s a bogus sibling path. + // See the admission bar in `provider-presets.ts`. + const expected: Record = { + anthropic: "https://api.anthropic.com", + dashscope: "https://dashscope-intl.aliyuncs.com/compatible-mode", + hyperbolic: "https://api.hyperbolic.xyz", + moonshot: "https://api.moonshot.ai", + novita: "https://api.novita.ai/openai", + perplexity: "https://api.perplexity.ai", + sambanova: "https://api.sambanova.ai", + }; + for (const [id, baseUrl] of Object.entries(expected)) { + expect(findProviderPreset(id)?.baseUrl, id).toBe(baseUrl); + } + }); + it("marks LM Studio as local", () => { expect(findProviderPreset("lmstudio")?.local).toBe(true); }); @@ -75,6 +130,8 @@ describe("PROVIDER_PRESETS", () => { ); expect(keyless).toContain("nous"); expect(keyless).toContain("ollama-cloud"); + expect(keyless).toContain("novita"); + expect(keyless).toContain("sambanova"); }); it("returns undefined for an unknown id", () => { @@ -136,3 +193,102 @@ describe("presetForEntryId", () => { expect(presetForEntryId("my-vllm")).toBeUndefined(); }); }); + +/** + * The blocker this suite exists for. Asserting `baseUrl` and `envVar` + * string equality — which is all this file used to do for Anthropic — + * cannot see that the preset resolves to a kind whose only auth mode is + * `Authorization: Bearer`, which `api.anthropic.com` never accepts for an + * API key. These tests pin the bytes that actually leave the process, on + * both request paths, after the entry has been through `config.json`. + */ +describe("Anthropic preset — outgoing request headers", () => { + const KEY = "sk-ant-test-not-a-real-key"; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** The saved entry, exactly as it looks after a wizard save + restart. */ + function entryAfterRestart() { + const built = buildProviderEntryFromWizard({ + kind: "openai-compatible", + presetId: "anthropic", + chatModelId: "", + embeddingChoiceId: "local", + customChatModel: "claude-opus-4-5", + baseUrl: findProviderPreset("anthropic")!.baseUrl, + }); + // Round-trip through the serializer/parser pair that owns + // `config.json`: a header contract the file cannot express would be + // silently dropped here and the fix would last until restart. + return parseLlmProviderEntry( + JSON.parse(JSON.stringify(built.entry)) as unknown, + "llm.providers[0]", + ); + } + + it("survives the wizard save and the config.json round trip", () => { + const entry = entryAfterRestart(); + expect(entry.apiKeyHeader).toBe("x-api-key"); + expect(entry.headers).toEqual({ "anthropic-version": "2023-06-01" }); + // The key itself must NOT be in the entry — it stays in the env var + // so it never lands in a config file. + expect(entry.apiKeyEnvVar).toBe("ANTHROPIC_API_KEY"); + expect(entry.apiKey).toBeUndefined(); + }); + + it("sends x-api-key and anthropic-version on model discovery", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "claude-opus-4-5" }] }), + })); + vi.stubGlobal("fetch", fetchMock); + + const preset = findProviderPreset("anthropic")!; + // Distinct host: the module-level cache is keyed by base URL, and a + // sibling test in this run must not serve this one a cached list. + await fetchOpenAiCompatModels("https://anthropic-discovery.invalid", KEY, preset); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers["x-api-key"]).toBe(KEY); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + expect(headers.authorization).toBeUndefined(); + }); + + it("sends x-api-key and anthropic-version on every chat turn", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + model: "claude-opus-4-5", + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + // Stub before constructing: OpenAiProvider captures `fetch` in its + // constructor when no fetchImpl is injected, which is what the + // registry factory does in production. + vi.stubGlobal("fetch", fetchMock); + + registerBuiltInProviderKinds(); + const factory = getProviderFactory("openai-compatible"); + expect(factory).toBeDefined(); + const provider = await factory!({ + entry: { ...entryAfterRestart(), apiKey: KEY }, + config: {} as never, + logger: {} as never, + }); + + await provider.complete({ prompt: "hi" }); + + const sent = new Headers( + (fetchMock.mock.calls[0]?.[1] as RequestInit).headers as HeadersInit, + ); + expect(sent.get("x-api-key")).toBe(KEY); + expect(sent.get("anthropic-version")).toBe("2023-06-01"); + expect(sent.get("authorization")).toBeNull(); + }); +}); diff --git a/src/tui/providers/provider-presets.ts b/src/tui/providers/provider-presets.ts index 011eb992..bae9bb97 100644 --- a/src/tui/providers/provider-presets.ts +++ b/src/tui/providers/provider-presets.ts @@ -7,9 +7,34 @@ * lists still come from the server's own `/v1/models` (#31, #41), so * nothing here needs updating when a vendor ships a new model. * - * Every URL below was verified live: each answers `/v1/models` with an - * OpenAI-shaped payload (200 with a `data` array, or 401/403 asking for - * a key, which confirms the path exists). + * ## Admission bar for a new preset + * + * Probe `/v1/models` **with the exact headers this preset will + * send** — the `apiKeyHeader` and `headers` below, not a bare + * `Authorization: Bearer` — and accept only one of: + * + * 1. **200 with a `data` array.** Keyless listing; nothing left to prove. + * 2. **401/403 that rejects the *credential*,** with a bogus key of the + * right shape in place. The body must complain about the key + * (`API key is invalid`), never about the request's shape. + * + * A 401 whose body names a header the preset does not send — Anthropic's + * `x-api-key header is required`, a missing mandatory version header, an + * "invalid bearer token" for what is an API key and not an OAuth token — + * is a **failing** probe, not a passing one. It proves the path exists + * and the auth is wrong, which is the exact condition that must keep a + * preset out. Fix `apiKeyHeader`/`headers` until the body talks about the + * key instead, or drop the preset. (Anthropic shipped under the old + * wording, which read any 401 as "asking for a key": it was asking for a + * header we never sent, and the preset could not authenticate at all.) + * + * Both branches additionally require the same host to answer **404 for a + * bogus sibling path** — gateways that reject every request before + * routing (z.ai, Cohere's compatibility root) prove nothing about + * `/v1/models` and are deliberately absent. So is anything whose model + * list does not live under `/v1/models`: DeepInfra serves it at + * `/v1/openai/models`, which this convention cannot express. + * Re-verified 2026-08-20. */ export interface ProviderPreset { /** Stable id used as the provider entry id when adding. */ @@ -28,6 +53,22 @@ export interface ProviderPreset { * which is what a shared `OPENAI_COMPAT_API_KEY` did. */ readonly envVar: string; + /** + * Header that carries the API key, for services that do not accept + * `Authorization: Bearer`. Absent means the OpenAI convention, which is + * every preset but Anthropic. Rides onto the saved config entry as + * `apiKeyHeader`, so the next non-Bearer vendor is a data change here + * rather than a code change. + */ + readonly apiKeyHeader?: string; + /** + * Static headers the service requires on every request (Anthropic's + * mandatory `anthropic-version`). Copied onto the saved entry's + * `headers`, which the operator can then edit in `config.json`. Never + * put a secret here — the key travels in `apiKeyHeader` so it can keep + * coming from `envVar` instead of being written to config. + */ + readonly headers?: Readonly>; /** * `true` for endpoints that serve a model list without credentials. * Saving without a key is allowed for these: the operator can browse @@ -53,6 +94,22 @@ export interface ProviderPreset { * list reads predictably; a test enforces this. */ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ + { + id: "anthropic", + label: "Anthropic (Claude)", + baseUrl: "https://api.anthropic.com", + envVar: "ANTHROPIC_API_KEY", + // The one preset that is not Bearer-authenticated. `api.anthropic.com` + // reads `Authorization: Bearer` as an OAuth token and answers + // "Invalid bearer token" to an `sk-ant-…` API key on every path, + // including `/v1/models`; the key only ever authenticates as + // `x-api-key`. `anthropic-version` is mandatory on every request and + // is the header the API dates its wire format by — pinned, not + // floating, so a future default cannot silently reshape responses. + apiKeyHeader: "x-api-key", + headers: { "anthropic-version": "2023-06-01" }, + note: "Claude models through Anthropic's OpenAI-compatible endpoint", + }, { id: "cerebras", label: "Cerebras", @@ -81,6 +138,13 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ envVar: "GROQ_API_KEY", note: "very fast inference on open-weight models", }, + { + id: "hyperbolic", + label: "Hyperbolic", + baseUrl: "https://api.hyperbolic.xyz", + envVar: "HYPERBOLIC_API_KEY", + note: "open-weight models on rented GPUs", + }, { id: "lmstudio", label: "LM Studio (local)", @@ -96,6 +160,13 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ envVar: "MISTRAL_API_KEY", note: "Mistral models direct from the vendor", }, + { + id: "moonshot", + label: "Moonshot AI (Kimi)", + baseUrl: "https://api.moonshot.ai", + envVar: "MOONSHOT_API_KEY", + note: "Kimi models direct from the vendor", + }, { id: "nous", label: "Nous Research", @@ -104,6 +175,14 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ listsModelsWithoutKey: true, note: "open-weight models, 350+ ids listed without a key", }, + { + id: "novita", + label: "Novita AI", + baseUrl: "https://api.novita.ai/openai", + envVar: "NOVITA_API_KEY", + listsModelsWithoutKey: true, + note: "hosted open-weight catalog, models listed without a key", + }, { id: "ollama", label: "Ollama (local)", @@ -120,6 +199,28 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ listsModelsWithoutKey: true, note: "hosted Ollama, models listed without a key", }, + { + id: "perplexity", + label: "Perplexity", + baseUrl: "https://api.perplexity.ai", + envVar: "PERPLEXITY_API_KEY", + note: "Sonar models with live web grounding", + }, + { + id: "dashscope", + label: "Qwen (DashScope)", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode", + envVar: "DASHSCOPE_API_KEY", + note: "Qwen models direct from Alibaba Cloud (international endpoint)", + }, + { + id: "sambanova", + label: "SambaNova", + baseUrl: "https://api.sambanova.ai", + envVar: "SAMBANOVA_API_KEY", + listsModelsWithoutKey: true, + note: "open-weight models on custom silicon; models listed without a key", + }, { id: "together", label: "Together AI", diff --git a/src/tui/providers/providers-actions.ts b/src/tui/providers/providers-actions.ts index c4497784..cb931347 100644 --- a/src/tui/providers/providers-actions.ts +++ b/src/tui/providers/providers-actions.ts @@ -14,6 +14,8 @@ export type ProvidersAction = | { type: "providers_set_active_embedding"; id: string } | { type: "providers_cursor_down" } | { type: "providers_cursor_up" } + /** Put the provider-list cursor on an absolute row (mouse click). */ + | { type: "providers_cursor_set"; row: number } | { type: "providers_status"; line: string | null } | { type: "providers_busy"; busy: boolean } | { type: "providers_wizard_opened"; wizard: ProvidersWizardState } @@ -81,6 +83,7 @@ export type ProvidersAction = | { type: "providers_wizard_closed" } | { type: "providers_wizard_submit_started" } | { type: "providers_wizard_failed"; error: string } + | { type: "providers_wizard_verify_cancelled" } | { type: "providers_wizard_succeeded" } | { type: "providers_remove_opened"; id: string } | { type: "providers_remove_closed" } diff --git a/src/tui/providers/providers-inline-models-flow.test.ts b/src/tui/providers/providers-inline-models-flow.test.ts index 7f0366fb..6b8206b9 100644 --- a/src/tui/providers/providers-inline-models-flow.test.ts +++ b/src/tui/providers/providers-inline-models-flow.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; import type { AtomicAgentConfig } from "../../config/index.js"; import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { OPENROUTER_MODELS_CATALOG } from "../../llm/provider/openrouter/openrouter-models-catalog.js"; import { reduceTuiState } from "../agent-event-reducer.js"; import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; import { selectCloudModelSection } from "../llm-panel/llm-panel-row-builders.js"; @@ -77,6 +78,25 @@ function configWithNous(baseUrl: string): AtomicAgentConfig { } as AtomicAgentConfig; } +function configWithOpenRouter(): AtomicAgentConfig { + return { + llm: { + activeTextProvider: "or", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [ + { + id: "or", + kind: "openrouter", + apiKey: "sk-or-test", + model: "openrouter/auto", + defaultChatModel: "openrouter/auto", + }, + ], + }, + } as AtomicAgentConfig; +} + function configWithTwoProviders( nousBaseUrl: string, xaiBaseUrl: string, @@ -246,7 +266,7 @@ describe("inline list open flow: /model (cold /v1/models cache)", () => { ); // The provider's key must ride along on the fetch. expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - headers: { Authorization: "Bearer sk-nous-test" }, + headers: { authorization: "Bearer sk-nous-test" }, }); releaseFetch(); @@ -486,3 +506,34 @@ describe("inline list flow: bare /model end to end", () => { ]); }); }); + +describe("multi-term search in the Cloud pane", () => { + it("narrows a curated catalog on capability terms the id does not contain", () => { + // The bundled OpenRouter catalog backs this pane, so every row has + // metadata: `qwen vision` has to match on the entry, not the id. + currentConfig = configWithOpenRouter(); + const h = makeHarness(); + h.orchestrator.refresh(); + openLlmCloudTab(h, 0); + + expect(h.press("f")).toBe(true); + for (const ch of "qwen") h.press(ch); + const qwenOnly = modelRowIds(h.store.state); + expect(qwenOnly.length).toBeGreaterThan(1); + for (const id of qwenOnly) expect(id).toMatch(/qwen/); + + for (const ch of " vision") h.press(ch); + expect(h.store.state.llmPanel.cloudModelFilter).toBe("qwen vision"); + const narrowed = modelRowIds(h.store.state); + expect(narrowed.length).toBeGreaterThan(0); + expect(narrowed.length).toBeLessThan(qwenOnly.length); + for (const id of narrowed) { + expect(id).toMatch(/qwen/); + expect(OPENROUTER_MODELS_CATALOG.get(id)?.supportsVision).toBe(true); + } + + // A term nothing satisfies empties the list rather than ignoring it. + for (const ch of " free") h.press(ch); + expect(modelRowIds(h.store.state)).toEqual([]); + }); +}); diff --git a/src/tui/providers/providers-key-bindings.test.ts b/src/tui/providers/providers-key-bindings.test.ts new file mode 100644 index 00000000..0c1cc83e --- /dev/null +++ b/src/tui/providers/providers-key-bindings.test.ts @@ -0,0 +1,140 @@ +import type { Key } from "ink"; +import { describe, expect, it, vi } from "vitest"; + +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { createInitialTuiState } from "../tui-state.js"; +import { handleProvidersTabKey } from "./providers-key-bindings.js"; +import type { ProviderRow } from "./providers-panel-state.js"; + +function emptyKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides, + }; +} + +function row(overrides: Partial): ProviderRow { + return { + id: "x", + kind: "openrouter", + isActiveText: false, + isActiveEmbedding: false, + hasApiKey: true, + baseUrl: null, + subscriptionCli: null, + chatModel: null, + embeddingModel: null, + ...overrides, + }; +} + +/** The panel only reads `uiMode`, `activeTab` and `providersPanel`. */ +function stateWith(rows: readonly ProviderRow[]): TuiState { + const base = createInitialTuiState({ id: "s1", workingDir: "/tmp" }); + return { + ...base, + uiMode: "debug", + activeTab: "providers", + providersPanel: { ...base.providersPanel, rows, cursor: 0 }, + }; +} + +function pressC(rows: readonly ProviderRow[]) { + const dispatch = vi.fn<(action: TuiAction) => void>(); + const handled = handleProvidersTabKey("c", emptyKey(), { + state: stateWith(rows), + dispatch, + callbacks: {} as TuiAppCallbacks, + }); + return { handled, dispatch }; +} + +describe("handleProvidersTabKey — c on a subscription-cli row", () => { + it("opens the configure wizard on the claude-cli row", () => { + // The row's stored kind is `subscription-cli` for both CLIs, so only + // the CLI name on the entry can say which wizard row to reopen. + // Before this, `c` matched no branch, was swallowed by the handler + // and did nothing at all. + const { handled, dispatch } = pressC([ + row({ + id: "claude-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "claude" }, + chatModel: "opus", + }), + ]); + + expect(handled).toBe(true); + expect(dispatch).toHaveBeenCalledTimes(1); + const action = dispatch.mock.calls[0]![0] as Extract< + TuiAction, + { type: "providers_wizard_opened" } + >; + expect(action.wizard.kind).toBe("claude-cli"); + expect(action.wizard.mode).toBe("configure"); + expect(action.wizard.providerId).toBe("claude-cli"); + // Straight to the only editable field: a CLI-backed provider has no + // key to paste, so the api_key screen would be a dead end. + expect(action.wizard.phase).toBe("chat_model_line"); + // Prefilled, so Enter keeps the pinned model instead of resetting it. + expect(action.wizard.chatModelLine).toBe("opus"); + }); + + it("opens the codex-cli row on its own wizard kind", () => { + const { dispatch } = pressC([ + row({ + id: "codex-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "codex" }, + }), + ]); + + const action = dispatch.mock.calls[0]![0] as Extract< + TuiAction, + { type: "providers_wizard_opened" } + >; + expect(action.wizard.kind).toBe("codex-cli"); + expect(action.wizard.phase).toBe("chat_model_line"); + // Codex resolves the model server-side; an empty line saves that. + expect(action.wizard.chatModelLine).toBe(""); + }); + + it("still opens key-based cloud rows on the API-key screen", () => { + const { dispatch } = pressC([ + row({ id: "gemini", kind: "gemini", chatModel: "gemini-2.5-flash" }), + ]); + + const action = dispatch.mock.calls[0]![0] as Extract< + TuiAction, + { type: "providers_wizard_opened" } + >; + expect(action.wizard.kind).toBe("gemini"); + expect(action.wizard.phase).toBe("api_key"); + }); + + it("opens nothing on the local llama row", () => { + const { handled, dispatch } = pressC([ + row({ id: "local-llama", kind: "llama-server", hasApiKey: false }), + ]); + + expect(handled).toBe(true); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/providers/providers-key-bindings.ts b/src/tui/providers/providers-key-bindings.ts index 30b56c11..ed910ad6 100644 --- a/src/tui/providers/providers-key-bindings.ts +++ b/src/tui/providers/providers-key-bindings.ts @@ -4,7 +4,7 @@ import type { TuiAppCallbacks } from "../tui-app.js"; import type { TuiState } from "../tui-state.js"; import { createProvidersWizardState } from "./providers-wizard-state.js"; import { handleProvidersWizardKey } from "./providers-wizard-key-bindings.js"; -import { isCloudProviderKind } from "./providers-orchestrator.js"; +import { configureWizardKindForRow } from "./providers-orchestrator.js"; export interface ProvidersTabKeyContext { state: TuiState; @@ -31,6 +31,10 @@ export function handleProvidersTabKey( 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; @@ -75,13 +79,15 @@ export function handleProvidersTabKey( } if (input === "c") { const row = panel.rows[panel.cursor]; - if (row && isCloudProviderKind(row.kind)) { + const kind = row ? configureWizardKindForRow(row) : null; + if (row && kind) { dispatch({ type: "providers_wizard_opened", wizard: createProvidersWizardState("configure", { providerId: row.id, - kind: row.kind, + kind, ...(row.baseUrl ? { baseUrl: row.baseUrl } : {}), + ...(row.chatModel ? { chatModel: row.chatModel } : {}), }), }); } diff --git a/src/tui/providers/providers-model-options.ts b/src/tui/providers/providers-model-options.ts index b25cc273..ccee9d8c 100644 --- a/src/tui/providers/providers-model-options.ts +++ b/src/tui/providers/providers-model-options.ts @@ -6,7 +6,14 @@ import { import { listOpenRouterChatPicks } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; import { OPENROUTER_MODELS_CATALOG } from "../../llm/provider/openrouter/openrouter-models-catalog.js"; import type { ModelCatalogEntry } from "../../llm/provider/model-resolver.js"; +import type { ModelEntryLookup } from "../../llm/provider/model-search.js"; import { GEMINI_DEFAULT_CHAT_MODEL } from "../../llm/provider/gemini/gemini-provider.js"; +import { + formatCapabilitySummary, + formatContextWindow, + formatEmbeddingTokenPrice, + formatTokenPrice, +} from "../../llm/provider/format-model-details.js"; export type ProviderModelOption = { id: string; @@ -145,6 +152,21 @@ const liveOpenRouterEntryById = createCatalogEntryResolver( listOpenRouterChatPicks, ); +/** + * Catalog lookup for a provider kind, so the Cloud pane can search on + * capabilities and price and not just on the id. Curated kinds resolve + * through the live-or-static resolvers above; `openai-compatible` and + * `gemini` point at operator-chosen endpoints with no bundled catalog, + * so they get `undefined` and search stays id-only for them. + */ +export function catalogEntryLookupForKind( + kind: string, +): ModelEntryLookup | undefined { + if (kind === "openrouter") return resolveOpenRouterCatalogEntry; + if (kind === "aimlapi") return resolveAimlapiCatalogEntry; + return undefined; +} + function resolveAimlapiCatalogEntry(modelId: string): ModelCatalogEntry | undefined { return liveAimlapiEntryById(modelId) ?? AIMLAPI_MODELS_CATALOG.get(modelId); } @@ -152,47 +174,3 @@ function resolveAimlapiCatalogEntry(modelId: string): ModelCatalogEntry | undefi function resolveOpenRouterCatalogEntry(modelId: string): ModelCatalogEntry | undefined { return liveOpenRouterEntryById(modelId) ?? OPENROUTER_MODELS_CATALOG.get(modelId); } - -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}`; -} - -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)}`; -} - -function formatEmbeddingTokenPrice(pricing: ModelCatalogEntry["pricing"]): string { - if (!pricing) return "$?"; - if (pricing.input === 0) return "free"; - return `$${formatPrice(pricing.input)}`; -} - -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); -} - -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/tui/providers/providers-orchestrator.test.ts b/src/tui/providers/providers-orchestrator.test.ts index 36e85c75..a4268c1b 100644 --- a/src/tui/providers/providers-orchestrator.test.ts +++ b/src/tui/providers/providers-orchestrator.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; import type { AtomicAgentConfig } from "../../config/index.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { ProvidersWizardState } from "./providers-wizard-state.js"; vi.mock("../../config/index.js", async (importOriginal) => { const original = await importOriginal(); @@ -170,6 +172,57 @@ describe("isCloudProviderKind", () => { expect(isCloudProviderKind("gemini")).toBe(true); }); + + it("stays false for subscription-cli, which is not a key-based cloud kind", async () => { + const { isCloudProviderKind } = await importFreshOrchestrator(); + + expect(isCloudProviderKind("subscription-cli")).toBe(false); + }); +}); + +describe("configureWizardKindForRow", () => { + it("recovers the wizard row behind a stored subscription-cli entry", async () => { + const { configureWizardKindForRow } = await importFreshOrchestrator(); + + // Two wizard rows collapse onto one config kind, so the CLI name on + // the entry is the only thing that can tell them apart. + expect( + configureWizardKindForRow({ + kind: "subscription-cli", + subscriptionCli: { cli: "claude" }, + }), + ).toBe("claude-cli"); + expect( + configureWizardKindForRow({ + kind: "subscription-cli", + subscriptionCli: { cli: "codex" }, + }), + ).toBe("codex-cli"); + }); + + it("passes key-based cloud kinds through unchanged", async () => { + const { configureWizardKindForRow } = await importFreshOrchestrator(); + + expect(configureWizardKindForRow({ kind: "gemini" })).toBe("gemini"); + expect(configureWizardKindForRow({ kind: "openai-compatible" })).toBe( + "openai-compatible", + ); + }); + + it("has no wizard for the local daemon or an unknown CLI", async () => { + const { configureWizardKindForRow } = await importFreshOrchestrator(); + + expect(configureWizardKindForRow({ kind: "llama-server" })).toBeNull(); + expect( + configureWizardKindForRow({ kind: "subscription-cli", subscriptionCli: null }), + ).toBeNull(); + expect( + configureWizardKindForRow({ + kind: "subscription-cli", + subscriptionCli: { cli: "not-a-cli" }, + }), + ).toBeNull(); + }); }); describe("ProvidersOrchestrator.ensureInlineModels", () => { @@ -236,3 +289,114 @@ describe("ProvidersOrchestrator.ensureInlineModels", () => { ); }); }); + +describe("ProvidersOrchestrator.completeWizard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + function wizardFor(kind: "openrouter" | "aimlapi"): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + apiKeyBuffer: "sk-wizard-key", + }; + } + + function fakeRuntime() { + return { + providerRegistry: { + setActive: vi.fn(async () => {}), + listIds: () => [] as string[], + }, + reloadLlmProvider: vi.fn(async () => {}), + reloadLlmProviders: vi.fn(async () => {}), + } as unknown as AgentRuntime; + } + + it("refuses to save a key the provider will not honour", async () => { + currentConfig = configWithGemini(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: "Insufficient credits" }), { + status: 402, + }), + ), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const runtime = fakeRuntime(); + const orchestrator = new ProvidersOrchestrator(runtime, bus as never); + + await orchestrator.completeWizard(wizardFor("openrouter")); + + const types = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(types).toContain("providers_wizard_failed"); + expect(types).not.toContain("providers_wizard_succeeded"); + // Nothing reloaded means nothing was written: the save never ran. + expect(runtime.reloadLlmProviders).not.toHaveBeenCalled(); + expect(runtime.reloadLlmProvider).not.toHaveBeenCalled(); + }); + + it("reports the failure in words the operator can act on", async () => { + currentConfig = configWithGemini(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: "No auth credentials found" }), { + status: 401, + }), + ), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const orchestrator = new ProvidersOrchestrator(fakeRuntime(), bus as never); + + await orchestrator.completeWizard(wizardFor("aimlapi")); + + const failure = bus.emit.mock.calls + .map((call) => call[0] as { type: string; error?: string }) + .find((action) => action.type === "providers_wizard_failed"); + expect(failure?.error).toContain("rejected this key"); + }); + + it("hands the cancel back to the wizard while a check is in flight", async () => { + currentConfig = configWithGemini(); + let releaseFetch: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseFetch = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + await gate; + return new Response("{}", { status: 401 }); + }), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const runtime = fakeRuntime(); + const orchestrator = new ProvidersOrchestrator(runtime, bus as never); + + const running = orchestrator.completeWizard(wizardFor("openrouter")); + await flush(); + // Still waiting on the provider: submitting is on, nothing saved. + const midTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(midTypes).toContain("providers_wizard_submit_started"); + expect(midTypes).not.toContain("providers_wizard_succeeded"); + + orchestrator.cancelWizardVerification(); + const cancelTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(cancelTypes).toContain("providers_wizard_verify_cancelled"); + + releaseFetch(); + await running; + expect(runtime.reloadLlmProviders).not.toHaveBeenCalled(); + // The late answer from the abandoned check stays quiet: the wizard is + // already back under the operator's hands. + const finalTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(finalTypes).not.toContain("providers_wizard_failed"); + }); +}); diff --git a/src/tui/providers/providers-orchestrator.ts b/src/tui/providers/providers-orchestrator.ts index 9fe38615..0a4eefa1 100644 --- a/src/tui/providers/providers-orchestrator.ts +++ b/src/tui/providers/providers-orchestrator.ts @@ -1,5 +1,9 @@ import { getConfig } from "../../config/index.js"; import type { UserLlmProviderEntry } from "../../config/index.js"; +import { + SUBSCRIPTION_CLI_KIND, + usesExternalCliAuth, +} from "../../config/provider-auth-mode.js"; import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; import { resolveLlmConfig } from "../../llm/provider/registry/index.js"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; @@ -26,6 +30,8 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; import { isProvidersAction } from "./providers-actions.js"; import type { ProviderRow } from "./providers-panel-state.js"; import { saveProviderWizardToConfig } from "./save-provider-wizard.js"; +import { verifyWizardBeforeSave } from "./verify-wizard-before-save.js"; +import { wizardKindForSubscriptionCli } from "./providers-wizard-state.js"; import type { ProvidersWizardKind, ProvidersWizardState, @@ -42,6 +48,9 @@ export class ProvidersOrchestrator { /** Backs the inline model list's stale-response guard; see `ensureInlineModels`. */ private inlineModelsGeneration = 0; + /** Aborts the pre-save key check when the operator presses Esc. */ + private wizardVerifyAbort: AbortController | null = null; + constructor( private readonly runtime: AgentRuntime, private readonly bus: TuiEventBus & { emit(action: unknown): void }, @@ -130,7 +139,10 @@ export class ProvidersOrchestrator { }); try { const apiKey = resolveLlmProviderApiKey(provider) ?? undefined; - const models = await fetchOpenAiCompatModels(baseUrl, apiKey); + // `fileEntry` carries this service's header contract + // (`apiKeyHeader` / `headers`) when it came from a preset, so + // discovery authenticates exactly the way chat turns will. + const models = await fetchOpenAiCompatModels(baseUrl, apiKey, fileEntry); this.bus.emit({ type: "providers_chat_model_picker_loaded", generation, @@ -181,7 +193,7 @@ export class ProvidersOrchestrator { const models = provider.kind === "gemini" ? await fetchGeminiModels(apiKey) - : await fetchOpenAiCompatModels(baseUrl, apiKey); + : await fetchOpenAiCompatModels(baseUrl, apiKey, fileEntry); this.bus.emit({ type: "providers_inline_models_loaded", providerId: id, @@ -210,8 +222,14 @@ export class ProvidersOrchestrator { kind: p.kind, isActiveText: p.id === resolved.activeTextProvider, isActiveEmbedding: p.id === resolved.activeEmbeddingProvider, - hasApiKey: Boolean(resolveLlmProviderApiKey(p)?.length), + // A CLI-backed entry has no key by design. Without this the row + // renders unavailable and Enter is a silent no-op. + hasApiKey: + Boolean(resolveLlmProviderApiKey(p)?.length) || usesExternalCliAuth(p), baseUrl: fileEntry?.baseUrl ?? null, + subscriptionCli: fileEntry?.subscriptionCli + ? { cli: fileEntry.subscriptionCli.cli } + : null, chatModel: fileEntry?.defaultChatModel ?? fileEntry?.model ?? null, chatModelOptions: listChatModelOptionsForEntry(fileEntry), embeddingModel: fileEntry?.defaultEmbeddingModel ?? null, @@ -323,9 +341,37 @@ export class ProvidersOrchestrator { } } + /** + * Abandon the key check a `completeWizard` call is waiting on. The + * wizard reopens for editing; nothing has been written by this point, + * because the check runs before the save. + */ + cancelWizardVerification(): void { + if (!this.wizardVerifyAbort) return; + this.wizardVerifyAbort.abort(); + this.wizardVerifyAbort = null; + this.bus.emit({ type: "providers_wizard_verify_cancelled" }); + } + async completeWizard(wizard: ProvidersWizardState): Promise { this.bus.emit({ type: "providers_wizard_submit_started" }); + const abort = new AbortController(); + this.wizardVerifyAbort = abort; try { + // The key is checked against the service before anything reaches + // disk: a dead or unfunded key used to be written to .env and made + // the active provider, and only failed on the first real message. + const gate = await verifyWizardBeforeSave(wizard, { signal: abort.signal }); + // A cancel already put the wizard back in an editable state; a + // late verdict from the abandoned check must not overwrite it. + if (abort.signal.aborted) return; + // The check is over; from here Esc has nothing to cancel and must + // not interrupt the save that follows. + this.wizardVerifyAbort = null; + if (!gate.proceed) { + this.bus.emit({ type: "providers_wizard_failed", error: gate.error }); + return; + } const built = saveProviderWizardToConfig(wizard); const exists = this.runtime.providerRegistry .listIds() @@ -339,16 +385,40 @@ export class ProvidersOrchestrator { await this.setActiveText(built.entry.id); this.bus.emit({ type: "providers_wizard_succeeded" }); + if (gate.warning) { + // Saved, but the key was never proven. Say so where the operator + // will see it rather than letting the first chat message find out. + this.bus.emit({ type: "providers_status", line: gate.warning }); + this.bus.emit({ type: "runtime_info", line: gate.warning }); + } this.bus.emit({ type: "runtime_info", line: `Active text provider: ${built.entry.id} (${built.entry.defaultChatModel ?? "default model"}). Chat uses cloud native tools now.`, }); + // The whole point of a subscription CLI is no per-token billing — + // but vendor credential precedence usually puts an exported API + // key ABOVE the CLI's own login, silently inverting that promise. + // Say so once, where the operator is already looking. + const cli = built.entry.subscriptionCli?.cli; + const conflictVar = + cli === "claude" + ? "ANTHROPIC_API_KEY" + : cli === "codex" + ? "OPENAI_API_KEY" + : null; + if (conflictVar && process.env[conflictVar]) { + const line = `${conflictVar} is exported in this environment — the ${cli} CLI may bill the API per-token instead of your subscription. Unset it before trusting the no-per-token setup.`; + this.bus.emit({ type: "providers_status", line }); + this.bus.emit({ type: "runtime_info", line }); + } this.refresh(); } catch (err) { this.bus.emit({ type: "providers_wizard_failed", error: wrapLlmConfigError(err), }); + } finally { + if (this.wizardVerifyAbort === abort) this.wizardVerifyAbort = null; } } @@ -375,6 +445,11 @@ export class ProvidersOrchestrator { } } +/** + * Key-based cloud kinds, whose config `kind` is the wizard row verbatim. + * Use `configureWizardKindForRow` to decide whether a row can be + * configured — `subscription-cli` can, and is not one of these. + */ export function isCloudProviderKind(kind: string): kind is ProvidersWizardKind { return ( kind === "openrouter" || @@ -384,6 +459,27 @@ export function isCloudProviderKind(kind: string): kind is ProvidersWizardKind { ); } +/** + * The wizard row `c` (and the LLM tab's configure action) opens for a + * provider row, or `null` when the row has nothing to configure. + * + * `subscription-cli` needs the indirection the cloud kinds do not: two + * wizard rows collapse onto one config kind, so the stored `kind` alone + * cannot say whether the entry drives `claude` or `codex` — only the CLI + * name on the entry can. Without it the key fell through every branch, + * was swallowed by the panel handler, and did nothing. + */ +export function configureWizardKindForRow(row: { + kind: string; + subscriptionCli?: { cli: string } | null; +}): ProvidersWizardKind | null { + if (isCloudProviderKind(row.kind)) return row.kind; + if (row.kind === SUBSCRIPTION_CLI_KIND && row.subscriptionCli) { + return wizardKindForSubscriptionCli(row.subscriptionCli.cli); + } + return null; +} + function listChatModelOptionsForEntry( entry: UserLlmProviderEntry | undefined, ): readonly string[] { diff --git a/src/tui/providers/providers-panel-state.ts b/src/tui/providers/providers-panel-state.ts index 50f81a32..2a280036 100644 --- a/src/tui/providers/providers-panel-state.ts +++ b/src/tui/providers/providers-panel-state.ts @@ -1,3 +1,7 @@ +import { + searchModelIds, + type ModelEntryLookup, +} from "../../llm/provider/model-search.js"; import type { ProvidersWizardState } from "./providers-wizard-state.js"; export type ProvidersPanelMode = "list"; @@ -7,6 +11,10 @@ export type ProviderRow = { kind: string; isActiveText: boolean; isActiveEmbedding: boolean; + /** + * Credentials are resolved for this entry — an API key, or the vendor + * CLI's own session for `subscription-cli` entries. + */ hasApiKey: boolean; /** * Stored base URL for `openai-compatible` entries (`null` for curated @@ -15,6 +23,12 @@ export type ProviderRow = { * silently resets a custom endpoint to the OpenAI default. */ baseUrl: string | null; + /** + * Which vendor CLI a `subscription-cli` entry drives, `null` for every + * other kind. The panes need it because the CLIs differ in what they + * can offer — Claude publishes a model list, Codex does not. + */ + subscriptionCli: { cli: string } | null; chatModel: string | null; chatModelOptions?: readonly string[]; embeddingModel: string | null; @@ -66,17 +80,21 @@ export function filteredPickerModels( } /** - * Case-insensitive substring filter over model ids. Shared by the modal - * picker and the inline Cloud-pane model list so both surfaces match the - * same rows for the same query. + * Ranked model search over ids and, when a catalog lookup is supplied, + * their metadata. Shared by the modal picker and the inline Cloud-pane + * model list so both surfaces match the same rows for the same query. + * + * Was a single case-insensitive `includes` over the id. `searchModelIds` + * keeps that behaviour for a one-word query and adds multi-term AND, + * vendor and capability matching, subsequence fallback, and relevance + * ordering — see `src/llm/provider/model-search.ts`. */ export function filterModelIds( models: readonly string[], query: string, + lookup?: ModelEntryLookup, ): readonly string[] { - const q = query.trim().toLowerCase(); - if (q.length === 0) return models; - return models.filter((id) => id.toLowerCase().includes(q)); + return searchModelIds(models, query, lookup); } /** diff --git a/src/tui/providers/providers-reducer.ts b/src/tui/providers/providers-reducer.ts index 39acf506..cc035907 100644 --- a/src/tui/providers/providers-reducer.ts +++ b/src/tui/providers/providers-reducer.ts @@ -46,6 +46,15 @@ export function reduceProvidersPanel( cursor: (panel.cursor + 1) % panel.rows.length, }, }; + case "providers_cursor_set": + if (panel.rows.length === 0) return state; + return { + ...state, + providersPanel: { + ...panel, + cursor: Math.min(panel.rows.length - 1, Math.max(0, action.row)), + }, + }; case "providers_cursor_up": if (panel.rows.length === 0) return state; return { @@ -104,6 +113,21 @@ export function reduceProvidersPanel( }, }, }; + case "providers_wizard_verify_cancelled": + // Back to an editable screen rather than a closed wizard: the + // operator abandoned the check, not the provider they were adding. + if (panel.wizard === null) return state; + return { + ...state, + providersPanel: { + ...panel, + wizard: { + ...panel.wizard, + submitting: false, + error: "Key check cancelled — press Enter to try again.", + }, + }, + }; case "providers_wizard_succeeded": return { ...state, diff --git a/src/tui/providers/providers-wizard-build-entry.test.ts b/src/tui/providers/providers-wizard-build-entry.test.ts index 2934b873..fb0ede01 100644 --- a/src/tui/providers/providers-wizard-build-entry.test.ts +++ b/src/tui/providers/providers-wizard-build-entry.test.ts @@ -150,4 +150,56 @@ describe("buildProviderEntryFromWizard", () => { expect(built.entry.id).toBe("my-vllm"); expect(built.entry.apiKeyEnvVar).toBeUndefined(); }); + + it("maps the claude-cli row onto a keyless subscription-cli entry", () => { + const built = buildProviderEntryFromWizard({ + kind: "claude-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: "opus", + }); + expect(built.entry).toEqual({ + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "opus", + subscriptionCli: { cli: "claude" }, + }); + // No endpoint and no env var: the CLI authenticates itself, and a + // stray apiKeyEnvVar would make resolveLlmProviderApiKey look for a + // key that is never meant to exist. + expect(built.entry.baseUrl).toBeUndefined(); + expect(built.entry.apiKeyEnvVar).toBeUndefined(); + // There is no embedding endpoint behind the CLI. + expect(built.useLocalEmbedding).toBe(true); + expect(built.activateEmbeddingProviderId).toBe("local-llama"); + }); + + it("falls back to the default model when nothing was typed", () => { + const built = buildProviderEntryFromWizard({ + kind: "claude-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: " ", + }); + expect(built.entry.defaultChatModel).toBe("sonnet"); + }); + + it("omits defaultChatModel for codex, which resolves the model itself", () => { + const built = buildProviderEntryFromWizard({ + kind: "codex-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: "", + }); + expect(built.entry).toEqual({ + id: "codex-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "codex" }, + }); + // Writing "" would fail config validation (parseOptionalString + // rejects the empty string), and any pinned id is rejected by Codex + // under a ChatGPT login. + expect(built.entry.defaultChatModel).toBeUndefined(); + }); + }); diff --git a/src/tui/providers/providers-wizard-build-entry.ts b/src/tui/providers/providers-wizard-build-entry.ts index bc413eea..c2c797f8 100644 --- a/src/tui/providers/providers-wizard-build-entry.ts +++ b/src/tui/providers/providers-wizard-build-entry.ts @@ -10,7 +10,15 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL, OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "./providers-model-options.js"; -import type { ProvidersWizardKind } from "./providers-wizard-state.js"; +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, +} from "./providers-wizard-state.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, +} from "../../llm/provider/subscription-cli/index.js"; export type BuiltWizardProvider = { entry: UserLlmProviderEntry; @@ -22,6 +30,8 @@ export type BuiltWizardProvider = { /** The fixed entry id each wizard kind maps to when no preset is involved. */ function baseIdForKind(kind: ProvidersWizardKind): string { + if (kind === "claude-cli") return "claude-cli"; + if (kind === "codex-cli") return "codex-cli"; if (kind === "openrouter") return "openrouter"; if (kind === "aimlapi") return "aimlapi"; if (kind === "gemini") return "gemini"; @@ -75,6 +85,31 @@ export function buildProviderEntryFromWizard(input: { takenProviderIds?: readonly string[]; }): BuiltWizardProvider { const id = providerIdForWizardSave(input); + const subscriptionCli = subscriptionCliForWizardKind(input.kind); + if (subscriptionCli) { + // Several wizard rows, one config kind: the CLI name is the only + // thing that differs, and it rides in the entry rather than in the + // kind so a new vendor CLI never needs a new provider kind. + // Codex resolves the model server-side under a ChatGPT login and + // rejects explicit ids, so its descriptor default is empty and the + // field is omitted rather than written as "". + registerBuiltInCliAdapters(); + const model = + input.customChatModel?.trim() || + resolveCliAdapter(subscriptionCli).defaultChatModel; + return { + entry: { + id, + kind: SUBSCRIPTION_CLI_KIND, + ...(model ? { defaultChatModel: model } : {}), + subscriptionCli: { cli: subscriptionCli }, + }, + // No embedding endpoint exists behind these CLIs, so notes keep + // being embedded by the local daemon. + useLocalEmbedding: true, + activateEmbeddingProviderId: "local-llama", + }; + } const preset = input.presetId ? findProviderPreset(input.presetId) : undefined; const chatModel = isCuratedCatalogKind(input.kind) ? input.chatModelId @@ -108,10 +143,23 @@ export function buildProviderEntryFromWizard(input: { OPENAI_COMPAT_DEFAULT_BASE_URL, } : {}), - // The preset's own env var rides on the entry, so two services never - // resolve one shared key (see `resolveLlmProviderApiKey`). + // The preset's env var and header contract both ride on the entry. + // The env var so two services never resolve one shared key (see + // `resolveLlmProviderApiKey`); the headers because the entry — not + // the build-time preset table — is what every later request is built + // from. Copying them here is what makes the override survive a + // restart: `parseLlmProviderEntry` reads both back out of + // `config.json`, and `registerBuiltInProviderKinds` hands them to the + // provider. `headers` is cloned so the frozen preset literal cannot + // be mutated through the saved entry. ...(input.kind === "openai-compatible" && preset - ? { apiKeyEnvVar: preset.envVar } + ? { + apiKeyEnvVar: preset.envVar, + ...(preset.apiKeyHeader + ? { apiKeyHeader: preset.apiKeyHeader } + : {}), + ...(preset.headers ? { headers: { ...preset.headers } } : {}), + } : {}), }; diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index b50c4862..74a3f721 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -1,7 +1,12 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Key } from "ink"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetConfigCache } from "../../config/index.js"; import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { upsertLlmProvider } from "../persist-llm-provider.js"; import { PICK_WINDOW } from "../components/wizard-pick-list.js"; import { PROVIDER_PRESETS } from "./provider-presets.js"; import { LOCAL_EMBEDDING_CHOICE_ID } from "./providers-model-options.js"; @@ -70,6 +75,28 @@ describe("createProvidersWizardState configure prefill", () => { expect(wizard.providerId).toBe("groq"); }); + it("skips the key screen for a CLI-backed entry and prefills its model", () => { + // No key exists for a subscription CLI, so `api_key` would be a dead + // end; the model id is the only thing configure can change. + const wizard = createProvidersWizardState("configure", { + providerId: "claude-cli", + kind: "claude-cli", + chatModel: "opus", + }); + expect(wizard.phase).toBe("chat_model_line"); + expect(wizard.chatModelLine).toBe("opus"); + }); + + it("saves a CLI-backed model line straight from the model step", () => { + const wizard = createProvidersWizardState("configure", { + providerId: "claude-cli", + kind: "claude-cli", + chatModel: "opus", + }); + const result = handleProvidersWizardKey("", emptyKey({ return: true }), wizard); + expect(result).toMatchObject({ handled: true, submit: true }); + }); + it("recovers the preset behind a numbered entry id", () => { const wizard = createProvidersWizardState("configure", { providerId: "groq-2", @@ -89,13 +116,17 @@ describe("createProvidersWizardState configure prefill", () => { }); describe("KIND_ROW_ORDER", () => { - it("lists catalogs first, presets alphabetically by label, manual last", () => { - expect(KIND_ROW_ORDER[0]).toBe("openrouter"); - expect(KIND_ROW_ORDER[1]).toBe("aimlapi"); - expect(KIND_ROW_ORDER[2]).toBe("gemini"); + it("lists the subscription CLI first, then catalogs, presets alphabetically by label, manual last", () => { + // A CLI-backed provider needs no key and no endpoint, so it is the + // shortest path from a fresh install to a working agent. + expect(KIND_ROW_ORDER[0]).toBe("claude-cli"); + expect(KIND_ROW_ORDER[1]).toBe("codex-cli"); + expect(KIND_ROW_ORDER[2]).toBe("openrouter"); + expect(KIND_ROW_ORDER[3]).toBe("aimlapi"); + expect(KIND_ROW_ORDER[4]).toBe("gemini"); expect(KIND_ROW_ORDER[KIND_ROW_ORDER.length - 1]).toBe("openai-compatible"); - const presetRows = KIND_ROW_ORDER.slice(3, -1); + const presetRows = KIND_ROW_ORDER.slice(5, -1); expect(presetRows).toEqual( PROVIDER_PRESETS.map((preset) => ({ presetId: preset.id })), ); @@ -106,12 +137,36 @@ describe("KIND_ROW_ORDER", () => { }); describe("handleProvidersWizardKey", () => { + it("takes the Claude CLI row straight past the API-key screen", () => { + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("claude-cli") }; + wizard = next(wizard, "", emptyKey({ return: true })); + // There is no key to paste — the CLI authenticates from its own + // session — so stopping on `api_key` would be a dead end. + expect(wizard).toMatchObject({ + kind: "claude-cli", + phase: "chat_model_line", + }); + expect(wizard.presetId).toBeNull(); + }); + + it("takes the Codex CLI row straight past the API-key screen too", () => { + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("codex-cli") }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ + kind: "codex-cli", + phase: "chat_model_line", + }); + }); + it("takes Gemini from API key directly to model selection", () => { let wizard = createProvidersWizardState("add"); wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("gemini") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard).toMatchObject({ kind: "gemini", phase: "api_key" }); + for (const ch of "gk") wizard = next(wizard, ch, emptyKey()); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.phase).toBe("chat_model_line"); expect(wizard.phase).not.toBe("base_url"); @@ -120,7 +175,9 @@ describe("handleProvidersWizardKey", () => { it("walks the aimlapi onboarding flow when the cursor lands on it", () => { let wizard = createProvidersWizardState("add"); - wizard = next(wizard, "", emptyKey({ downArrow: true })); + // Addressed by name so inserting a row above it does not silently + // repoint this flow at a different provider. + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("aimlapi") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("aimlapi"); expect(wizard.phase).toBe("api_key"); @@ -149,7 +206,9 @@ describe("handleProvidersWizardKey", () => { wizard = next(wizard, "", emptyKey({ upArrow: true })); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("openai-compatible"); - expect(wizard.phase).toBe("api_key"); + // The endpoint comes before the key: the key screen consults the + // base URL (a loopback server is keyless), so it must exist first. + expect(wizard.phase).toBe("base_url"); }); describe("openai-compatible chat model step", () => { @@ -172,6 +231,8 @@ describe("handleProvidersWizardKey", () => { }); wizard = { ...wizard, phase: "base_url" }; for (const ch of baseUrl) wizard = next(wizard, ch, emptyKey()); + // URL first, then the key screen — satisfied here by the env key. + wizard = next(wizard, "", emptyKey({ return: true })); return next(wizard, "", emptyKey({ return: true })); } @@ -354,6 +415,7 @@ describe("handleProvidersWizardKey", () => { it("walks the OpenRouter onboarding flow through model and embedding picks", () => { let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("openrouter") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("openrouter"); expect(wizard.phase).toBe("api_key"); @@ -515,17 +577,229 @@ describe("handleProvidersWizardKey", () => { expect(wizard.phase).not.toBe("base_url"); }); - it("still shows the URL screen for the manual openai-compatible row", () => { + it("walks the manual row URL-first, then the key, then the model line", () => { let wizard = createProvidersWizardState("add"); // Manual entry is the last row. wizard = next(wizard, "", emptyKey({ upArrow: true })); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("openai-compatible"); expect(wizard.presetId).toBeNull(); - // No key typed, Enter through the key screen: a hand-added compat - // endpoint still has to declare its base URL. + expect(wizard.phase).toBe("base_url"); + // An empty URL line falls back to the default (remote) base, so the + // key screen that follows still demands a key. + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + for (const ch of "ck") wizard = next(wizard, ch, emptyKey()); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("chat_model_line"); + }); + + it("configure still reaches the URL screen after the key", () => { + // Reconfiguring opens on the key screen; the URL step must follow it, + // or a mistyped port could never be corrected without re-adding. + let wizard = createProvidersWizardState("configure", { + providerId: "my-llama", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:9931", + }); + expect(wizard.phase).toBe("api_key"); + // Loopback endpoint: the empty key screen passes. + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("base_url"); + expect(wizard.baseUrlLine).toBe("http://127.0.0.1:9931"); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("chat_model_line"); + }); + + it("lets a loopback custom URL through the key screen with no key", () => { + // The user report behind #187: a raw llama-server on the operator's + // machine has no key, and the wizard used to refuse the empty screen. + let wizard = createProvidersWizardState("add"); + wizard = next(wizard, "", emptyKey({ upArrow: true })); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.phase).toBe("base_url"); + for (const ch of "localhost:9931") wizard = next(wizard, ch, emptyKey()); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("chat_model_line"); + expect(wizard.error).toBeNull(); + }); + + describe("empty API key", () => { + // The gate reads the environment, so a key left over from the host + // shell would silently satisfy the screen under test. + const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "ATOMIC_AGENT_OPENAI_API_KEY", + ] as const; + const saved = new Map(); + beforeEach(() => { + for (const key of ENV_KEYS) { + saved.set(key, process.env[key]); + delete process.env[key]; + } + }); + afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + saved.clear(); + }); + + it("keeps Enter on the key screen when nothing was typed", () => { + let wizard = createProvidersWizardState("add"); + // The CLI rows sit at the head of the list now; aim at OpenRouter + // by name rather than assuming it is row 0. + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("openrouter") }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ kind: "openrouter", phase: "api_key" }); + + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + expect(wizard.error).toContain("API key required"); + expect(wizard.error).toContain("OPENROUTER_API_KEY"); + }); + + it("refuses a whitespace-only key", () => { + let wizard = createProvidersWizardState("add", { kind: "aimlapi" }); + wizard = { ...wizard, phase: "api_key" }; + for (const ch of " ") wizard = next(wizard, ch, emptyKey()); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + expect(wizard.error).toContain("API key required"); + }); + + it("clears the message on the next keystroke", () => { + let wizard = createProvidersWizardState("add", { kind: "openrouter" }); + wizard = { ...wizard, phase: "api_key" }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.error).not.toBeNull(); + + wizard = next(wizard, "s", emptyKey()); + expect(wizard.error).toBeNull(); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("pick_chat_model"); + }); + + it("lets a keyless local preset through with no key at all", () => { + // LM Studio has no key to type; the wizard skips the screen + // entirely and the gate must not reintroduce it. + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: presetRowIndex("lmstudio") }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ + presetId: "lmstudio", + phase: "chat_model_line", + }); + }); + + it("accepts an empty screen when the service's key is already in .env", () => { + process.env.OPENROUTER_API_KEY = "sk-or-env"; + let wizard = createProvidersWizardState("add", { kind: "openrouter" }); + wizard = { ...wizard, phase: "api_key" }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("pick_chat_model"); + }); + }); + + describe("reconfiguring a saved provider", () => { + // Every other test here starts from `createProvidersWizardState("add", …)`, + // which is how a gate stricter than the save path reached review: a + // configure run opens on the key screen with an empty buffer, and the + // key it should find is in `config.json`, not `.env`. + let stateDir: string; + let previousStateDir: string | undefined; + + beforeEach(() => { + previousStateDir = process.env.ATOMIC_AGENT_STATE_DIR; + stateDir = mkdtempSync(join(tmpdir(), "wizard-configure-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + delete process.env.OPENROUTER_API_KEY; + resetConfigCache(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + if (previousStateDir === undefined) { + delete process.env.ATOMIC_AGENT_STATE_DIR; + } else { + process.env.ATOMIC_AGENT_STATE_DIR = previousStateDir; + } + delete process.env.OPENROUTER_API_KEY; + resetConfigCache(); + }); + + it("Enter leaves the key screen when the key is already saved", () => { + upsertLlmProvider({ + id: "openrouter", + kind: "openrouter", + apiKey: "sk-or-stored", + }); + const wizard = createProvidersWizardState("configure", { + providerId: "openrouter", + kind: "openrouter", + }); + expect(wizard.phase).toBe("api_key"); + const next1 = next(wizard, "", emptyKey({ return: true })); + expect(next1.phase).toBe("pick_chat_model"); + expect(next1.error).toBeNull(); + }); + + it("Enter still refuses when the entry has no key anywhere", () => { + upsertLlmProvider({ id: "openrouter", kind: "openrouter" }); + const wizard = createProvidersWizardState("configure", { + providerId: "openrouter", + kind: "openrouter", + }); + const next1 = next(wizard, "", emptyKey({ return: true })); + expect(next1.phase).toBe("api_key"); + expect(next1.error).toContain("API key required"); + }); + + it("Esc on the key screen closes the wizard", () => { + // The key screen is where a configure run opens, so there is no + // screen behind it. Stepping "back" built a provider list this run + // never showed and dropped the entry's kind and base URL with it. + upsertLlmProvider({ + id: "my-vllm", + kind: "openai-compatible", + baseUrl: "http://192.168.1.50:8000/v1", + }); + const wizard = createProvidersWizardState("configure", { + providerId: "my-vllm", + kind: "openai-compatible", + baseUrl: "http://192.168.1.50:8000/v1", + }); + const result = handleProvidersWizardKey( + "", + emptyKey({ escape: true }), + wizard, + ); + expect("closed" in result && result.closed).toBe(true); + }); + + it("Esc past the key screen still steps back one screen", () => { + const wizard = { + ...createProvidersWizardState("configure", { + providerId: "openrouter", + kind: "openrouter", + }), + phase: "pick_chat_model" as const, + }; + const result = handleProvidersWizardKey( + "", + emptyKey({ escape: true }), + wizard, + ); + expect("closed" in result && result.closed).toBeFalsy(); + expect("wizard" in result && result.wizard.phase).toBe("pick_kind"); + }); }); it("Esc from a preset returns to the provider list, not out of the wizard", () => { diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index 3cb5084a..cf2e872d 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,11 +1,8 @@ import type { Key } from "ink"; -import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; import { getCachedOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { getCachedGeminiModels } from "../../llm/provider/gemini/fetch-gemini-models.js"; -import { normalizeOpenAiBaseUrl } from "../../llm/provider/openai/normalize-openai-base-url.js"; import { PICK_WINDOW } from "../components/wizard-pick-list.js"; import { findProviderPreset } from "./provider-presets.js"; -import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; import { advanceWizardPhase, clampCursor, @@ -13,41 +10,20 @@ import { isCuratedCatalogKind, isLinePhase, isListPhase, + isWizardFirstScreen, kindRowAtCursor, listEmbeddingModelsForKind, listLengthForPhase, presetNeedsKeyScreen, } from "./providers-wizard-phases.js"; import { createProvidersWizardState } from "./providers-wizard-state.js"; +import { + apiKeyForWizard, + apiKeyPhaseError, + baseUrlForWizard, +} from "./providers-wizard-target.js"; import type { ProvidersWizardState } from "./providers-wizard-state.js"; -/** Normalized so the fetch, the cache key and the displayed URL always agree. */ -export function baseUrlForWizard(wizard: ProvidersWizardState): string { - return ( - normalizeOpenAiBaseUrl(wizard.baseUrlLine) || OPENAI_COMPAT_DEFAULT_BASE_URL - ); -} - -/** - * Typed key wins; otherwise a key already in the environment needs no - * retyping. The fallback probe resolves the preset's own variable when - * one is selected — reading the shared compat variable for Groq would - * hand the wrong service's key to the model-list fetch. - */ -export function apiKeyForWizard( - wizard: ProvidersWizardState, -): string | undefined { - const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; - return ( - wizard.apiKeyBuffer.trim() || - resolveLlmProviderApiKey({ - id: "openai-compatible", - kind: "openai-compatible", - ...(preset ? { apiKeyEnvVar: preset.envVar } : {}), - }) - ); -} - /** * Chat model ids discovered from `{baseUrl}/v1/models`. Empty once the operator * types anything — a typed id is a deliberate override, so the picker steps @@ -106,6 +82,7 @@ function nextListCursor( export type ProvidersWizardKeyResult = | { handled: true; wizard: ProvidersWizardState; submit?: false } | { handled: true; wizard: ProvidersWizardState; submit: true } + | { handled: true; wizard: ProvidersWizardState; cancelSubmit: true } | { handled: true; closed: true } | { handled: false }; @@ -115,15 +92,21 @@ export function handleProvidersWizardKey( wizard: ProvidersWizardState, ): ProvidersWizardKeyResult { if (wizard.submitting) { + // Esc is the one key that survives the lockout. Submitting now waits + // on the provider answering a key check, and a service that has gone + // quiet must not hold the wizard until it times out. + if (key.escape) { + return { handled: true, wizard, cancelSubmit: true }; + } return { handled: true, wizard }; } if (key.escape) { // Esc steps back one screen rather than abandoning the whole wizard: // picking the wrong service should not cost the operator the flow. - // Only the first screen (the provider list) closes it. Stepping back + // Only the screen the run opened at closes it. Stepping back // rebuilds a clean pick_kind state so the previous pick does not // leak into the next one, then restores the cursor to that row. - if (wizard.phase !== "pick_kind") { + if (!isWizardFirstScreen(wizard)) { return { handled: true, wizard: { @@ -140,6 +123,15 @@ export function handleProvidersWizardKey( if (wizard.phase === "api_key") { if (key.return) { + // An empty key is refused here rather than at the end of the + // wizard: leaving this screen blank used to cost the operator the + // model picker and the save round-trip before anything said so. + // Services that genuinely have no key (local servers, keyless + // listing) opt out through `apiKeyPhaseError`. + const missing = apiKeyPhaseError(wizard); + if (missing) { + return { handled: true, wizard: { ...wizard, error: missing } }; + } return { handled: true, wizard: advanceWizardPhase(wizard), diff --git a/src/tui/providers/providers-wizard-phases.ts b/src/tui/providers/providers-wizard-phases.ts index a3c3ec5b..ad362aee 100644 --- a/src/tui/providers/providers-wizard-phases.ts +++ b/src/tui/providers/providers-wizard-phases.ts @@ -5,10 +5,11 @@ import { listOpenRouterChatModels, listOpenRouterEmbeddingModels, } from "./providers-model-options.js"; -import type { - ProvidersWizardKind, - ProvidersWizardPhase, - ProvidersWizardState, +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, + type ProvidersWizardPhase, + type ProvidersWizardState, } from "./providers-wizard-state.js"; /** @@ -28,6 +29,10 @@ export type ProvidersWizardKindRow = * never disagree. */ export const KIND_ROW_ORDER: readonly ProvidersWizardKindRow[] = [ + // Subscription CLIs first: they need no key and no endpoint, so they + // are the shortest path from a fresh install to a working agent. + "claude-cli", + "codex-cli", "openrouter", "aimlapi", "gemini", @@ -67,12 +72,14 @@ function nextPhaseAfterApiKey( ): ProvidersWizardPhase { const kind = wizard.kind; if (kind && isCuratedCatalogKind(kind)) return "pick_chat_model"; - if (kind === "gemini") return "chat_model_line"; - // A preset already knows its endpoint, so showing the URL step would - // ask the operator to confirm something they never typed (#69). Only - // the manual openai-compatible row still needs it. - if (wizard.presetId) return "chat_model_line"; - return "base_url"; + // A reconfigure run opens on the key screen, so for the manual compat + // row the URL step still follows it — that is the only screen where a + // stored endpoint can be corrected. The add flow collected the URL + // before the key instead. Presets and Gemini know their endpoint (#69). + if (kind === "openai-compatible" && !wizard.presetId && wizard.mode === "configure") { + return "base_url"; + } + return "chat_model_line"; } /** @@ -87,6 +94,27 @@ export function presetNeedsKeyScreen(presetId: string): boolean { return !preset.listsModelsWithoutKey && !preset.local; } +/** + * `true` on the screen the wizard opened at, which has no "back" inside + * the run. Adding starts on the provider list; reconfiguring starts on + * the key screen, having been opened from a row the operator already + * chose. Stepping back from there used to build a `pick_kind` screen + * that run never showed, dropping the entry's kind and base URL with it. + */ +export function isWizardFirstScreen(wizard: ProvidersWizardState): boolean { + if (wizard.phase === "pick_kind") return true; + if (wizard.mode !== "configure") return false; + if (wizard.phase === "api_key") return true; + // A CLI-backed configure run opens straight on the model line (there + // is no key screen); Esc there must close the wizard, not rebuild a + // pick_kind screen the run never showed. + return ( + wizard.phase === "chat_model_line" && + wizard.kind !== null && + subscriptionCliForWizardKind(wizard.kind) !== null + ); +} + /** * Where the cursor lands when Esc returns to the provider list: on the * row the operator came from, so stepping back and forth is stable. @@ -122,6 +150,18 @@ export function advanceWizardPhase( ): ProvidersWizardState { const { phase, kind } = wizard; if (phase === "pick_kind" && kind) { + // A CLI-backed provider has no key to paste — it authenticates from + // the CLI's own session — so the key screen would be a dead end. + if (subscriptionCliForWizardKind(kind)) { + return { ...wizard, phase: "chat_model_line", cursor: 0, error: null }; + } + // The manual compat row describes its endpoint before authenticating + // to it: the key screen consults the base URL (a loopback server is + // keyless — `wizardKeyIsOptional`), so the URL has to exist first. + // Every other kind already knows its endpoint and goes straight to + // the key. + if (kind === "openai-compatible" && !wizard.presetId) { + return { ...wizard, phase: "base_url", cursor: 0, error: null }; } return { ...wizard, phase: "api_key", cursor: 0, error: null }; } if (phase === "api_key" && kind) { @@ -147,7 +187,10 @@ export function advanceWizardPhase( }; } if (phase === "base_url" && kind === "openai-compatible") { - return { ...wizard, phase: "chat_model_line", cursor: 0, error: null }; + // Adding walks URL → key; a reconfigure run opened on the key screen + // and edits the URL after it, so from there it proceeds to the model. + const next = wizard.mode === "configure" ? "chat_model_line" : "api_key"; + return { ...wizard, phase: next, cursor: 0, error: null }; } // `chat_model_line` is the last step for the compat/preset path: the // embedding screen is gone from the flow, embeddings stay on the local diff --git a/src/tui/providers/providers-wizard-state.ts b/src/tui/providers/providers-wizard-state.ts index 19d31ad5..f8cf4ca8 100644 --- a/src/tui/providers/providers-wizard-state.ts +++ b/src/tui/providers/providers-wizard-state.ts @@ -1,11 +1,49 @@ +import type { SubscriptionCliName } from "../../config/llm-config.js"; import { presetForEntryId } from "./provider-presets.js"; export type ProvidersWizardKind = + | "claude-cli" + | "codex-cli" | "openrouter" | "aimlapi" | "gemini" | "openai-compatible"; +/** + * Wizard rows that map onto the single `subscription-cli` config kind. + * One row per vendor CLI keeps the choice on the screen the operator is + * already looking at, instead of adding a wizard phase whose only job is + * to ask "which CLI?". + */ +const SUBSCRIPTION_CLI_WIZARD_KINDS: Partial< + Record +> = { + "claude-cli": "claude", + "codex-cli": "codex", +}; + +/** The vendor CLI this row drives, or null for a key-based provider. */ +export function subscriptionCliForWizardKind( + kind: ProvidersWizardKind, +): SubscriptionCliName | null { + return SUBSCRIPTION_CLI_WIZARD_KINDS[kind] ?? null; +} + +/** + * The wizard row a stored `subscription-cli` entry came from. The map + * above only runs one way, and a saved entry keeps the CLI name rather + * than the row it was picked on, so reconfiguring one has to walk back. + * `null` for a CLI name no wizard row offers. + */ +export function wizardKindForSubscriptionCli( + cli: string, +): ProvidersWizardKind | null { + for (const [kind, name] of Object.entries(SUBSCRIPTION_CLI_WIZARD_KINDS)) { + if (name === cli) return kind as ProvidersWizardKind; + } + return null; +} + export type ProvidersWizardPhase = | "pick_kind" | "api_key" @@ -52,10 +90,26 @@ export function createProvidersWizardState( * endpoint instead of silently resetting it to the OpenAI default. */ baseUrl?: string; + /** + * Stored chat model of the entry being reconfigured. Prefills the + * model step so Enter keeps the pinned model instead of silently + * resetting it to the kind's default. + */ + chatModel?: string; }, ): ProvidersWizardState { const configure = mode === "configure"; const kind = opts?.kind ?? null; + // A CLI-backed provider has no key to paste — it authenticates from + // the CLI's own session. Opening configure on the key screen would be + // the dead end `advanceWizardPhase` already skips on the add path, so + // reconfiguring lands on the one thing that is editable: the model. + const cliBacked = kind !== null && subscriptionCliForWizardKind(kind) !== null; + const phase: ProvidersWizardPhase = !configure + ? "pick_kind" + : cliBacked + ? "chat_model_line" + : "api_key"; // Reconfiguring an entry that was created from a preset must keep the // preset identity: the key screen then names the service's own env // var, and saving keeps the entry id instead of minting an @@ -66,14 +120,14 @@ export function createProvidersWizardState( : null; return { mode, - phase: configure ? "api_key" : "pick_kind", + phase, kind, providerId: opts?.providerId ?? null, presetId, cursor: 0, apiKeyBuffer: "", baseUrlLine: opts?.baseUrl ?? "", - chatModelLine: "", + chatModelLine: cliBacked ? (opts?.chatModel ?? "") : "", embeddingModelLine: "", selectedChatModelId: null, selectedEmbeddingChoiceId: null, diff --git a/src/tui/providers/providers-wizard-target.test.ts b/src/tui/providers/providers-wizard-target.test.ts new file mode 100644 index 00000000..27ba5037 --- /dev/null +++ b/src/tui/providers/providers-wizard-target.test.ts @@ -0,0 +1,326 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetConfigCache } from "../../config/index.js"; +import { upsertLlmProvider } from "../persist-llm-provider.js"; +import { + apiKeyForWizard, + apiKeyPhaseError, + emptyKeyMeaningForWizard, + envHintForWizard, + verifyTargetForWizard, + wizardKeyIsOptional, +} from "./providers-wizard-target.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; + +const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "ATOMIC_AGENT_OPENAI_API_KEY", + "GROQ_API_KEY", + "LMSTUDIO_API_KEY", + "OLLAMA_API_KEY", + "NOUS_API_KEY", + "OLLAMA_CLOUD_API_KEY", + "VLLM_API_KEY", +] as const; + +function wizardFor( + kind: ProvidersWizardKind, + presetId?: string, +): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + ...(presetId ? { presetId } : {}), + }; +} + +describe("apiKeyPhaseError", () => { + beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("refuses an empty key for every service that needs one", () => { + for (const wizard of [ + wizardFor("openrouter"), + wizardFor("aimlapi"), + wizardFor("gemini"), + wizardFor("openai-compatible"), + wizardFor("openai-compatible", "groq"), + ]) { + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + } + }); + + it("names the service's own env var in the message", () => { + expect(apiKeyPhaseError(wizardFor("openrouter"))).toContain( + "OPENROUTER_API_KEY", + ); + expect(apiKeyPhaseError(wizardFor("openai-compatible", "groq"))).toContain( + "GROQ_API_KEY", + ); + }); + + it("treats a whitespace-only buffer as empty", () => { + // What a mis-paste leaves behind. Accepting it would write " " to + // .env and present it to the provider as a key. + const wizard = { ...wizardFor("openrouter"), apiKeyBuffer: " " }; + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + }); + + it("treats a hand-added loopback endpoint as keyless", () => { + // A raw llama-server on the operator's machine has no preset and no + // key. Any loopback host, at any port, opts out of the key screen. + for (const baseUrlLine of [ + "http://127.0.0.1:9931", + "http://localhost:8080", + "http://0.0.0.0:1234", + "http://[::1]:9931", + "localhost:9931", // no scheme, as typed + "http://my-box.localhost:9931", + ]) { + const wizard = { ...wizardFor("openai-compatible"), baseUrlLine }; + expect(wizardKeyIsOptional(wizard)).toBe(true); + expect(apiKeyPhaseError(wizard)).toBeNull(); + } + }); + + it("still requires a key for a non-loopback custom URL", () => { + const wizard = { + ...wizardFor("openai-compatible"), + baseUrlLine: "https://api.example.com", + }; + expect(wizardKeyIsOptional(wizard)).toBe(false); + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + }); + + it("refuses a non-ASCII key with a clear message", () => { + // A stray Cyrillic character cannot go into an Authorization header; + // the message names the problem instead of the raw ByteString crash. + const wizard = { ...wizardFor("openrouter"), apiKeyBuffer: "sk-т" }; + expect(apiKeyPhaseError(wizard)).toContain("non-ASCII"); + }); + + it("accepts a typed key", () => { + const wizard = { ...wizardFor("openrouter"), apiKeyBuffer: "sk-or-typed" }; + expect(apiKeyPhaseError(wizard)).toBeNull(); + }); + + it("accepts an empty buffer when the service's key is already in .env", () => { + process.env.OPENROUTER_API_KEY = "sk-or-from-env"; + expect(apiKeyPhaseError(wizardFor("openrouter"))).toBeNull(); + }); + + it("does not let another service's key satisfy the screen", () => { + // The lookup used to run as a fixed openai-compatible entry, so an + // unrelated OPENAI_API_KEY answered for OpenRouter, AI/ML API and + // Gemini alike — an empty key screen that looked satisfied. + process.env.OPENAI_API_KEY = "sk-openai"; + expect(apiKeyPhaseError(wizardFor("openrouter"))).toContain( + "API key required", + ); + expect(apiKeyPhaseError(wizardFor("aimlapi"))).toContain("API key required"); + expect(apiKeyPhaseError(wizardFor("gemini"))).toContain("API key required"); + expect(apiKeyForWizard(wizardFor("openrouter"))).toBeUndefined(); + }); + + it("still resolves the shared variable for the manual compat entry", () => { + process.env.OPENAI_API_KEY = "sk-openai"; + expect(apiKeyPhaseError(wizardFor("openai-compatible"))).toBeNull(); + expect(apiKeyForWizard(wizardFor("openai-compatible"))).toBe("sk-openai"); + }); + + it("leaves keyless services alone", () => { + for (const presetId of ["lmstudio", "ollama", "nous", "ollama-cloud"]) { + const wizard = wizardFor("openai-compatible", presetId); + expect(wizardKeyIsOptional(wizard)).toBe(true); + expect(apiKeyPhaseError(wizard)).toBeNull(); + } + }); +}); + +describe("apiKeyPhaseError in configure mode", () => { + // A key the operator typed into the wizard once is stored in + // `config.json` by `upsertLlmProvider`, with nothing written to `.env`. + // The gate has to see it exactly where `saveProviderWizardToConfig` + // does, or reconfiguring a provider's model demands the key again. + let stateDir: string; + let previousStateDir: string | undefined; + + beforeEach(() => { + previousStateDir = process.env.ATOMIC_AGENT_STATE_DIR; + stateDir = mkdtempSync(join(tmpdir(), "wizard-target-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + for (const key of ENV_KEYS) delete process.env[key]; + resetConfigCache(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + if (previousStateDir === undefined) { + delete process.env.ATOMIC_AGENT_STATE_DIR; + } else { + process.env.ATOMIC_AGENT_STATE_DIR = previousStateDir; + } + for (const key of ENV_KEYS) delete process.env[key]; + resetConfigCache(); + }); + + function configureWizard( + kind: ProvidersWizardKind, + providerId: string, + ): ProvidersWizardState { + return createProvidersWizardState("configure", { providerId, kind }); + } + + it("accepts an empty screen when the entry's key is in config.json", () => { + upsertLlmProvider({ + id: "openrouter", + kind: "openrouter", + apiKey: "sk-or-stored", + }); + const wizard = configureWizard("openrouter", "openrouter"); + expect(apiKeyForWizard(wizard)).toBe("sk-or-stored"); + expect(apiKeyPhaseError(wizard)).toBeNull(); + }); + + it("reads the entry's own env var, as the save path does", () => { + // A hand-added compat entry naming its own variable: no preset to + // supply it, and the per-kind fallbacks would answer with a + // different service's key or nothing at all. + upsertLlmProvider({ + id: "my-vllm", + kind: "openai-compatible", + baseUrl: "http://192.168.1.50:8000/v1", + apiKeyEnvVar: "VLLM_API_KEY", + }); + process.env.VLLM_API_KEY = "vllm-from-env"; + const wizard = configureWizard("openai-compatible", "my-vllm"); + expect(apiKeyForWizard(wizard)).toBe("vllm-from-env"); + expect(apiKeyPhaseError(wizard)).toBeNull(); + }); + + it("still refuses when the entry has no key anywhere", () => { + upsertLlmProvider({ id: "openrouter", kind: "openrouter" }); + const wizard = configureWizard("openrouter", "openrouter"); + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + }); + + it("refuses for an id that is not stored yet", () => { + const wizard = configureWizard("openrouter", "openrouter"); + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + }); + + it("does not let a stored key satisfy an `add` run", () => { + // Adding a second OpenRouter entry must still ask for its own key, + // whatever the first one has saved. + upsertLlmProvider({ + id: "openrouter", + kind: "openrouter", + apiKey: "sk-or-stored", + }); + expect(apiKeyPhaseError(wizardFor("openrouter"))).toContain( + "API key required", + ); + expect(apiKeyForWizard(wizardFor("openrouter"))).toBeUndefined(); + }); + + it("tells the operator the saved key is what an empty screen keeps", () => { + upsertLlmProvider({ + id: "openrouter", + kind: "openrouter", + apiKey: "sk-or-stored", + }); + expect( + emptyKeyMeaningForWizard(configureWizard("openrouter", "openrouter")), + ).toBe("Leave empty to keep the key already saved."); + }); +}); + +describe("emptyKeyMeaningForWizard", () => { + it("points at .env when there is no saved key to keep", () => { + expect(emptyKeyMeaningForWizard(wizardFor("openrouter"))).toContain(".env"); + }); + + it("calls the key optional for a keyless service", () => { + expect( + emptyKeyMeaningForWizard(wizardFor("openai-compatible", "lmstudio")), + ).toContain("Optional for this service"); + }); +}); + +describe("envHintForWizard", () => { + it("names the preset's variable, not the shared compat one", () => { + expect(envHintForWizard(wizardFor("openai-compatible", "groq"))).toBe( + "GROQ_API_KEY", + ); + expect(envHintForWizard(wizardFor("gemini"))).toBe("GEMINI_API_KEY"); + expect(envHintForWizard(wizardFor("openai-compatible"))).toBe( + "OPENAI_COMPAT_API_KEY", + ); + }); +}); + +describe("verifyTargetForWizard", () => { + beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + function withKey( + kind: ProvidersWizardKind, + presetId?: string, + ): ProvidersWizardState { + return { ...wizardFor(kind, presetId), apiKeyBuffer: "sk-test" }; + } + + it("bills the check as this app on OpenRouter", () => { + const target = verifyTargetForWizard(withKey("openrouter")); + expect(target).not.toBeNull(); + expect(target?.baseUrl).toBe("https://openrouter.ai/api"); + expect(target?.extraHeaders?.["X-Title"]).toBe("Atomic Agent"); + // Never the free router: it answers on a key with no credit. + expect(target?.probeModels[0]).not.toBe("openrouter/auto"); + }); + + it("knows Gemini's compatibility prefix", () => { + const target = verifyTargetForWizard(withKey("gemini")); + expect(target?.apiPathPrefix).toBe("/v1beta/openai"); + }); + + it("has nothing to check for keyless and local providers", () => { + expect(verifyTargetForWizard(withKey("openai-compatible", "lmstudio"))).toBeNull(); + expect( + verifyTargetForWizard({ + ...withKey("openai-compatible"), + baseUrlLine: "http://localhost:1234", + }), + ).toBeNull(); + expect(verifyTargetForWizard(wizardFor("openrouter"))).toBeNull(); + }); + + it("probes the endpoint and model the operator is about to save", () => { + const target = verifyTargetForWizard({ + ...withKey("openai-compatible"), + baseUrlLine: "https://vllm.example", + chatModelLine: "my-model", + }); + expect(target?.baseUrl).toBe("https://vllm.example"); + expect(target?.probeModels).toEqual(["my-model"]); + }); +}); diff --git a/src/tui/providers/providers-wizard-target.ts b/src/tui/providers/providers-wizard-target.ts new file mode 100644 index 00000000..f1b906e4 --- /dev/null +++ b/src/tui/providers/providers-wizard-target.ts @@ -0,0 +1,296 @@ +/** + * What the wizard's current state says about the *service* being + * configured: which endpoint it points at, which key it would use, and + * whether that key is allowed to be missing. + * + * Split out of `providers-wizard-key-bindings.ts`, which sits on the + * 300-line limit: the key screen needs these answers before it can + * refuse an empty key, and so does every other surface that renders the + * wizard. + */ + +import { getConfig } from "../../config/index.js"; +import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; +import type { UserLlmProviderEntry } from "../../config/llm-config.js"; +import { DEFAULT_AIMLAPI_BASE } from "../../llm/provider/aimlapi/aimlapi-provider.js"; +import { + DEFAULT_GEMINI_BASE, + GEMINI_API_PATH_PREFIX, +} from "../../llm/provider/gemini/gemini-provider.js"; +import { getCachedOpenAiCompatModelsForBaseUrl } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { isAsciiOnly } from "../../llm/provider/openai/ascii-header-guard.js"; +import { normalizeOpenAiBaseUrl } from "../../llm/provider/openai/normalize-openai-base-url.js"; +import { + DEFAULT_OPENROUTER_BASE, + OPENROUTER_APP_CATEGORIES, + OPENROUTER_APP_REFERER, + OPENROUTER_APP_TITLE, +} from "../../llm/provider/openrouter/openrouter-provider.js"; +import { pickProbeModels } from "../../llm/provider/verify/index.js"; +import type { ProviderVerifyTarget } from "../../llm/provider/verify/index.js"; +import { isLoopbackBaseUrl } from "../persist-user-local-models-config.js"; +import { isLocalProviderUrl } from "./is-local-provider-url.js"; +import { findProviderPreset } from "./provider-presets.js"; +import { subscriptionCliForWizardKind } from "./providers-wizard-state.js"; +import { + AIMLAPI_DEFAULT_CHAT_MODEL, + GEMINI_DEFAULT_CHAT_MODEL, + OPENAI_COMPAT_DEFAULT_BASE_URL, + OPENAI_COMPAT_DEFAULT_CHAT_MODEL, + OPENROUTER_DEFAULT_CHAT_MODEL, +} from "./providers-model-options.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; + +/** Normalized so the fetch, the cache key and the displayed URL always agree. */ +export function baseUrlForWizard(wizard: ProvidersWizardState): string { + return ( + normalizeOpenAiBaseUrl(wizard.baseUrlLine) || OPENAI_COMPAT_DEFAULT_BASE_URL + ); +} + +/** + * The config entry this wizard run would produce, reduced to what key + * resolution needs. Built from the selected kind — not a fixed + * `openai-compatible` shape — so `resolveLlmProviderApiKey` reads the + * variable this service actually uses. Probing with the compat entry + * made an unrelated `OPENAI_API_KEY` answer for OpenRouter, AI/ML API + * and Gemini alike: the wrong service's key, presented as this one's. + */ +function keyLookupEntryForWizard( + wizard: ProvidersWizardState, +): UserLlmProviderEntry { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + const kind = wizard.kind ?? "openai-compatible"; + return { + id: wizard.providerId ?? preset?.id ?? kind, + kind, + ...(preset ? { apiKeyEnvVar: preset.envVar } : {}), + }; +} + +/** + * The `config.json` entry a `configure` run would overwrite, or + * `undefined` while adding. `saveProviderWizardToConfig` keeps this + * entry's `apiKey` when the key screen is left blank, so every question + * about "does this wizard have a key" has to see it: a key the operator + * typed once lives in `config.json` with nothing in `.env` to find. + */ +function storedEntryForWizard( + wizard: ProvidersWizardState, +): UserLlmProviderEntry | undefined { + const { mode, providerId } = wizard; + if (mode !== "configure" || !providerId) return undefined; + return getConfig().llm?.providers.find( + (provider) => provider.id === providerId, + ); +} + +/** + * Typed key wins; then the key the entry being reconfigured already has + * (stored, or under its own env var); then a key already in the + * environment for this kind. Same union `saveProviderWizardToConfig` + * accepts, so the key screen never refuses what the save would take. + */ +export function apiKeyForWizard( + wizard: ProvidersWizardState, +): string | undefined { + const typed = wizard.apiKeyBuffer.trim(); + if (typed) return typed; + const stored = storedEntryForWizard(wizard); + return ( + (stored && resolveLlmProviderApiKey(stored)) ?? + resolveLlmProviderApiKey(keyLookupEntryForWizard(wizard)) + ); +} + +/** + * 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. + */ +export function envHintForWizard(wizard: ProvidersWizardState): string { + // CLI-backed providers read no env var; the key screen is skipped + // entirely, so there is no variable to name. + if (wizard.kind && subscriptionCliForWizardKind(wizard.kind)) return ""; + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + if (preset) return preset.envVar; + if (wizard.kind === "openrouter") return "OPENROUTER_API_KEY"; + if (wizard.kind === "aimlapi") return "AIMLAPI_API_KEY"; + if (wizard.kind === "gemini") return "GEMINI_API_KEY"; + return "OPENAI_COMPAT_API_KEY"; +} + +/** + * `true` when saving without any key is a legitimate outcome: servers on + * the operator's own machine have no key at all, and keyless-listing + * services work before one is entered. Both save with an empty key and + * send requests without an Authorization header. + */ +export function wizardKeyIsOptional(wizard: ProvidersWizardState): boolean { + // A CLI-backed provider authenticates from the CLI's own session — + // there is no key by construction, not merely an optional one. + if (wizard.kind && subscriptionCliForWizardKind(wizard.kind)) return true; + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + if (preset && (preset.local || preset.listsModelsWithoutKey)) return true; + // A hand-added compat endpoint pointing at a loopback address is a + // local server too, even without a matching preset. A raw + // `llama-server` on `http://127.0.0.1:9931` needs no key, so an empty + // one is valid there. The wizard collects the base URL before the key + // screen for this kind, so the URL is known by the time this runs. + return wizard.kind === "openai-compatible" && isLoopbackBaseUrl(wizard.baseUrlLine); +} + +/** + * What leaving the key screen blank means for this run, in the operator's + * words. Local servers and keyless-listing services save without a key; + * a reconfigure keeps the one already in `config.json` — telling that + * operator the key must be "in .env" describes a file it was never + * written to. Everyone else does need one there. + */ +export function emptyKeyMeaningForWizard(wizard: ProvidersWizardState): string { + if (wizardKeyIsOptional(wizard)) { + return "Optional for this service — leave empty to connect without a key."; + } + if (storedEntryForWizard(wizard)?.apiKey) { + return "Leave empty to keep the key already saved."; + } + return "Leave empty only if the key is already in .env."; +} + +/** + * Why the key screen cannot be left yet, or `null` when it can. + * + * The check used to happen only at the end of the wizard, in + * `saveProviderWizardToConfig`: the operator picked a model, waited for + * the save, and only then learned the first screen was blank. Refusing + * here costs one keystroke instead. Whitespace counts as empty — it is + * what a mis-paste leaves behind, and it would otherwise be written to + * `.env` as a real key. + * + * Never stricter than the save it fronts: `apiKeyForWizard` consults the + * stored entry the same way, so reconfiguring a provider whose key is + * already saved passes with a blank screen instead of demanding it again. + */ +export function apiKeyPhaseError( + wizard: ProvidersWizardState, +): string | null { + const typed = wizard.apiKeyBuffer.trim(); + // A non-ASCII key cannot go into an HTTP header. Refusing it here + // names the problem on the key screen rather than letting the first + // model-list fetch crash with an opaque ByteString error. + if (typed && !isAsciiOnly(typed)) { + return "API key contains non-ASCII characters. Use a plain ASCII key."; + } + if (wizardKeyIsOptional(wizard)) return null; + if (apiKeyForWizard(wizard)) return null; + return `API key required — paste the key, or set ${envHintForWizard(wizard)} in .env first`; +} + +/** + * How each built-in kind is named in prose. The raw kind is a config + * token, not a service name: `"openrouter" rejected this key` in the + * middle of a screen whose own row says "OpenRouter" reads as a + * different, lower-case product. + */ +const KIND_SERVICE_LABELS: Record = { + openrouter: "OpenRouter", + aimlapi: "AI/ML API", + gemini: "Gemini", + "openai-compatible": "this endpoint", + "claude-cli": "Claude Code", + "codex-cli": "Codex", +}; + +/** Service name for headings and for every sentence about a failure. */ +export function providerLabelForWizard(wizard: ProvidersWizardState): string { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + if (preset) return preset.label; + return wizard.kind ? KIND_SERVICE_LABELS[wizard.kind] : "provider"; +} + +/** The model this wizard run is about to save, before any defaulting. */ +function chosenModelForWizard(wizard: ProvidersWizardState): string { + const typed = wizard.chatModelLine.trim(); + if (wizard.selectedChatModelId) return wizard.selectedChatModelId; + if (typed.length > 0) return typed; + if (wizard.kind === "openrouter") return OPENROUTER_DEFAULT_CHAT_MODEL; + if (wizard.kind === "aimlapi") return AIMLAPI_DEFAULT_CHAT_MODEL; + if (wizard.kind === "gemini") return GEMINI_DEFAULT_CHAT_MODEL; + return OPENAI_COMPAT_DEFAULT_CHAT_MODEL; +} + +function endpointForKind( + kind: ProvidersWizardKind, + wizard: ProvidersWizardState, +): { baseUrl: string; apiPathPrefix: string; extraHeaders?: Record } { + if (kind === "openrouter") { + return { + baseUrl: DEFAULT_OPENROUTER_BASE, + apiPathPrefix: "/v1", + // The same attribution the real provider sends, so the check is + // billed and rate-limited as this app rather than as a stranger. + extraHeaders: { + "HTTP-Referer": OPENROUTER_APP_REFERER, + "X-Title": OPENROUTER_APP_TITLE, + "X-OpenRouter-Categories": OPENROUTER_APP_CATEGORIES, + }, + }; + } + if (kind === "aimlapi") { + return { baseUrl: DEFAULT_AIMLAPI_BASE, apiPathPrefix: "/v1" }; + } + if (kind === "gemini") { + return { + baseUrl: DEFAULT_GEMINI_BASE, + apiPathPrefix: GEMINI_API_PATH_PREFIX, + }; + } + return { baseUrl: baseUrlForWizard(wizard), apiPathPrefix: "/v1" }; +} + +/** + * What to send the credential check, or `null` when there is nothing + * worth checking: a service that legitimately has no key, a screen with + * no key resolved (the key-screen gate already refused that), or a + * server on this machine, which has no account to be wrong about. + */ +export function verifyTargetForWizard( + wizard: ProvidersWizardState, +): ProviderVerifyTarget | null { + const kind = wizard.kind; + if (!kind) return null; + // CLI-backed providers have no key and no HTTP endpoint to probe — + // the verify gate skips them by design (a bounded liveness probe is a + // possible follow-up), explicitly rather than by the accident of an + // unresolvable key. Spelled as literal checks so the compiler narrows + // `kind` to the verifiable union below. + if (kind === "claude-cli" || kind === "codex-cli") return null; + if (wizardKeyIsOptional(wizard)) return null; + const apiKey = apiKeyForWizard(wizard)?.trim(); + if (!apiKey) return null; + + const endpoint = endpointForKind(kind, wizard); + if (isLocalProviderUrl(endpoint.baseUrl)) return null; + + const listed = + kind === "openai-compatible" + ? getCachedOpenAiCompatModelsForBaseUrl(endpoint.baseUrl) + : undefined; + const probeModels = pickProbeModels({ + kind, + selectedModelId: chosenModelForWizard(wizard), + ...(listed ? { listedModelIds: listed } : {}), + }); + + return { + label: providerLabelForWizard(wizard), + baseUrl: endpoint.baseUrl, + apiPathPrefix: endpoint.apiPathPrefix, + apiKey, + probeModels, + ...(endpoint.extraHeaders ? { extraHeaders: endpoint.extraHeaders } : {}), + }; +} diff --git a/src/tui/providers/save-provider-wizard.test.ts b/src/tui/providers/save-provider-wizard.test.ts index ad847c7c..293d9c33 100644 --- a/src/tui/providers/save-provider-wizard.test.ts +++ b/src/tui/providers/save-provider-wizard.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -197,6 +197,71 @@ describe("saveProviderWizardToConfig", () => { expect(getConfig().llm?.activeTextProvider).toBe("lmstudio"); }); + it("saves a hand-added loopback endpoint with no key", () => { + // A raw llama-server on the operator's own machine: no preset, no key, + // just a loopback base URL. An empty key is valid here. + const built = saveProviderWizardToConfig({ + ...createProvidersWizardState("add"), + kind: "openai-compatible" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "", + baseUrlLine: "http://127.0.0.1:9931", + chatModelLine: "qwen3-30b", + }); + + expect(built.entry.id).toBe("openai-compatible"); + expect(built.entry.apiKey).toBeUndefined(); + expect(process.env.OPENAI_COMPAT_API_KEY).toBeUndefined(); + expect(getConfig().llm?.activeTextProvider).toBe("openai-compatible"); + }); + + it("still refuses an empty key for a non-loopback custom URL", () => { + // A remote OpenAI-compatible host with no preset needs a key: the + // loopback exception must not weaken the check for real endpoints. + expect(() => + saveProviderWizardToConfig({ + ...createProvidersWizardState("add"), + kind: "openai-compatible" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "", + baseUrlLine: "https://api.example.com", + chatModelLine: "some-model", + }), + ).toThrow(/API key is empty/); + }); + + it("accepts a key whose only non-ASCII is a trimmable paste artifact", () => { + // U+00A0 from a web-page copy is non-ASCII, but the persisted value + // is trimmed — the save judges what it stores, not the raw buffer, + // so the key screen and the save agree. + saveProviderWizardToConfig({ + ...createProvidersWizardState("add"), + kind: "openai-compatible" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "sk-clean\u00a0", + baseUrlLine: "https://api.example.com", + chatModelLine: "some-model", + }); + expect(process.env.OPENAI_COMPAT_API_KEY).toBe("sk-clean"); + }); + + it("refuses a non-ASCII key before it can reach a header", () => { + // A stray Cyrillic character would otherwise crash the first request + // with an opaque ByteString error; catch it at save time instead. + expect(() => + saveProviderWizardToConfig({ + ...createProvidersWizardState("add"), + kind: "openai-compatible" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "sk-т", // Cyrillic "т", code point 1090 + baseUrlLine: "https://api.example.com", + chatModelLine: "some-model", + }), + ).toThrow(/non-ASCII/); + // Nothing was written to .env for a rejected key. + expect(process.env.OPENAI_COMPAT_API_KEY).toBeUndefined(); + }); + it("gives a second entry for the same service a numbered id", () => { saveProviderWizardToConfig(groqWizard("gsk-groq")); const second = saveProviderWizardToConfig({ @@ -242,4 +307,28 @@ describe("saveProviderWizardToConfig", () => { // stole the selection. expect(getConfig().llm?.activeTextProvider).toBe("groq"); }); + + it("saves a claude-cli provider with an empty key and writes no .env", () => { + const wizard = { + ...createProvidersWizardState("add"), + kind: "claude-cli" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "", + chatModelLine: "opus", + selectedEmbeddingChoiceId: LOCAL_EMBEDDING_CHOICE_ID, + }; + + // Would throw "API key is empty" for any key-based kind. + const built = saveProviderWizardToConfig(wizard); + + expect(built.entry.kind).toBe("subscription-cli"); + expect(built.entry.subscriptionCli).toEqual({ cli: "claude" }); + expect(built.entry.defaultChatModel).toBe("opus"); + + const cfg = getConfig(); + expect(cfg.llm?.activeTextProvider).toBe("claude-cli"); + expect(cfg.llm?.activeEmbeddingProvider).toBe("local-llama"); + expect(existsSync(join(stateDir, ".env"))).toBe(false); + }); + }); diff --git a/src/tui/providers/save-provider-wizard.ts b/src/tui/providers/save-provider-wizard.ts index 43156714..8d65e972 100644 --- a/src/tui/providers/save-provider-wizard.ts +++ b/src/tui/providers/save-provider-wizard.ts @@ -1,11 +1,13 @@ import { getConfig } from "../../config/index.js"; import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; +import { isAsciiOnly } from "../../llm/provider/openai/ascii-header-guard.js"; import { setActiveTextProviderInConfig, upsertLlmProvider, writeProviderApiKeyToDotenv, } from "../persist-llm-provider.js"; import { findProviderPreset } from "./provider-presets.js"; +import { wizardKeyIsOptional } from "./providers-wizard-target.js"; import { AIMLAPI_DEFAULT_CHAT_MODEL, GEMINI_DEFAULT_CHAT_MODEL, @@ -16,12 +18,22 @@ import { buildProviderEntryFromWizard, type BuiltWizardProvider, } from "./providers-wizard-build-entry.js"; -import type { - ProvidersWizardKind, - ProvidersWizardState, +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, + type ProvidersWizardState, } from "./providers-wizard-state.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, +} from "../../llm/provider/subscription-cli/index.js"; function defaultChatModelForKind(kind: ProvidersWizardKind): string { + const cli = subscriptionCliForWizardKind(kind); + if (cli) { + registerBuiltInCliAdapters(); + return resolveCliAdapter(cli).defaultChatModel; + } if (kind === "aimlapi") return AIMLAPI_DEFAULT_CHAT_MODEL; if (kind === "gemini") return GEMINI_DEFAULT_CHAT_MODEL; return OPENROUTER_DEFAULT_CHAT_MODEL; @@ -54,12 +66,42 @@ export function saveProviderWizardToConfig( (provider) => provider.id === built.entry.id, ); let entry = built.entry; + // Reconfiguring must not wipe what the wizard has no screen for: a + // subscription-cli entry can carry a hand-set binPath / extraArgs / + // streaming flag / spend ceiling, and any entry a request timeout. + // `upsertLlmProvider` replaces the row wholesale, so carry them over + // the same way the stored apiKey is carried below. + if (existing?.subscriptionCli && entry.subscriptionCli) { + entry = { + ...entry, + subscriptionCli: { + ...existing.subscriptionCli, + cli: entry.subscriptionCli.cli, + }, + }; + } + if (existing?.requestTimeoutMs !== undefined && entry.requestTimeoutMs === undefined) { + entry = { ...entry, requestTimeoutMs: existing.requestTimeoutMs }; + } const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; - // Local servers have no key at all, and keyless-listing services work - // before one is entered, so an empty key is a valid state for both: - // nothing is written to .env and requests go out without Authorization. - const keyOptional = Boolean(preset && (preset.local || preset.listsModelsWithoutKey)); + // Local servers (including a hand-added loopback endpoint) have no key + // at all, and keyless-listing services work before one is entered, so + // an empty key is a valid state for both: nothing is written to .env + // and requests go out without Authorization. + const keyOptional = wizardKeyIsOptional(wizard); if (wizard.apiKeyBuffer.trim().length > 0) { + // Validate the value that is actually persisted: the dotenv writer + // trims, and `trim()` strips U+00A0/U+FEFF paste artifacts that are + // non-ASCII themselves. Checking the raw buffer here would refuse a + // key whose stored form is pure ASCII — after the key screen (which + // trims) already accepted it. + if (!isAsciiOnly(wizard.apiKeyBuffer.trim())) { + // A non-ASCII key cannot go into an Authorization header and would + // otherwise crash the first request with an opaque ByteString error. + throw new Error( + "API key contains non-ASCII characters. Use a plain ASCII key.", + ); + } writeProviderApiKeyToDotenv(kind, wizard.apiKeyBuffer, preset?.envVar); } else if (existing?.apiKey) { entry = { ...entry, apiKey: existing.apiKey }; diff --git a/src/tui/providers/verify-wizard-before-save.test.ts b/src/tui/providers/verify-wizard-before-save.test.ts new file mode 100644 index 00000000..6e28f7d9 --- /dev/null +++ b/src/tui/providers/verify-wizard-before-save.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { verifyWizardBeforeSave } from "./verify-wizard-before-save.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; + +const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "LMSTUDIO_API_KEY", +] as const; + +function wizard( + kind: ProvidersWizardKind, + overrides: Partial = {}, +): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + apiKeyBuffer: "sk-test-key", + ...overrides, + }; +} + +function stubChatCompletions(status: number, body: unknown): ReturnType { + const fetchMock = vi.fn(async () => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; +}); +afterEach(() => { + vi.unstubAllGlobals(); + for (const key of ENV_KEYS) delete process.env[key]; +}); + +describe("verifyWizardBeforeSave", () => { + it("lets a working key through with nothing to report", async () => { + stubChatCompletions(200, { choices: [{ message: { content: "" } }] }); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate).toEqual({ proceed: true, warning: null }); + }); + + it("stops a key the provider rejects", async () => { + stubChatCompletions(401, { error: "No auth credentials found" }); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate.proceed).toBe(false); + if (!gate.proceed) { + expect(gate.error).toContain("rejected this key"); + } + }); + + it("stops a key with no balance behind it", async () => { + // The whole reason the probe spends a token instead of listing + // models: a drained account answers every listing perfectly well. + stubChatCompletions(402, { error: "Insufficient credits" }); + const gate = await verifyWizardBeforeSave(wizard("aimlapi")); + expect(gate.proceed).toBe(false); + if (!gate.proceed) { + expect(gate.error).toContain("no usable balance"); + } + }); + + it("saves with a warning when the provider cannot be reached", async () => { + // An offline laptop or a corporate proxy must still be able to + // finish the wizard; the key is simply unproven. + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed"); + }), + ); + const gate = await verifyWizardBeforeSave(wizard("gemini")); + expect(gate.proceed).toBe(true); + if (gate.proceed) { + expect(gate.warning).toContain("Saved unverified"); + } + }); + + it("saves with a warning when the key is merely throttled", async () => { + stubChatCompletions(429, "slow down"); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate.proceed).toBe(true); + if (gate.proceed) { + expect(gate.warning).toContain("rate-limiting"); + } + }); + + it("never calls out for a keyless local provider", async () => { + const fetchMock = stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave( + wizard("openai-compatible", { + presetId: "lmstudio", + apiKeyBuffer: "", + baseUrlLine: "http://localhost:1234", + }), + ); + expect(gate).toEqual({ proceed: true, warning: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never calls out for a hand-typed endpoint on this machine", async () => { + const fetchMock = stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave( + wizard("openai-compatible", { baseUrlLine: "http://127.0.0.1:8000" }), + ); + expect(gate).toEqual({ proceed: true, warning: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends the key to the service the operator picked", async () => { + const fetchMock = stubChatCompletions(200, { choices: [] }); + await verifyWizardBeforeSave(wizard("gemini")); + // Gemini's OpenAI-compatible surface lives under /v1beta/openai. + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + ); + }); + + it("stops when the operator cancels the check", async () => { + const controller = new AbortController(); + controller.abort(); + stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave(wizard("openrouter"), { + signal: controller.signal, + }); + expect(gate.proceed).toBe(false); + }); +}); diff --git a/src/tui/providers/verify-wizard-before-save.ts b/src/tui/providers/verify-wizard-before-save.ts new file mode 100644 index 00000000..f713fece --- /dev/null +++ b/src/tui/providers/verify-wizard-before-save.ts @@ -0,0 +1,51 @@ +/** + * The gate every provider save passes through. + * + * Both save paths — the Providers/LLM panel wizard and the first-run + * onboarding screen — call this before `saveProviderWizardToConfig`, so + * neither can quietly persist a key the other would have refused. + * + * Only a dead key and an empty account stop a save. A timeout, an + * unreachable host or a throttled key say nothing about whether the key + * is good, and refusing them would leave an operator behind a proxy, or + * on a plane, with no way to configure the agent at all — so those save + * and say so. + */ + +import { + isBlockingVerifyStatus, + verifyProviderKey, +} from "../../llm/provider/verify/index.js"; +import { describeProviderVerifyOutcome } from "./describe-verify-outcome.js"; +import { verifyTargetForWizard } from "./providers-wizard-target.js"; +import type { ProvidersWizardState } from "./providers-wizard-state.js"; + +export type WizardVerifyGate = + | { readonly proceed: true; readonly warning: string | null } + | { readonly proceed: false; readonly error: string }; + +export async function verifyWizardBeforeSave( + wizard: ProvidersWizardState, + opts: { signal?: AbortSignal } = {}, +): Promise { + const target = verifyTargetForWizard(wizard); + // Nothing to check: a keyless local server, or a service that saves + // without a key on purpose. Both keep the behaviour they had before. + if (!target) return { proceed: true, warning: null }; + + const result = await verifyProviderKey(target, { + ...(opts.signal ? { signal: opts.signal } : {}), + }); + const sentence = describeProviderVerifyOutcome(result, target.label); + + if (result.status === "cancelled") { + return { proceed: false, error: sentence }; + } + if (isBlockingVerifyStatus(result.status)) { + return { proceed: false, error: sentence }; + } + return { + proceed: true, + warning: result.status === "ok" ? null : sentence, + }; +} diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index e02f93a9..25c2f715 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { reduceTuiState } from "./agent-event-reducer.js"; import { reduceUiAction } from "./reduce-ui-actions.js"; import { THEME_NAMES } from "./theme/theme.js"; import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; @@ -85,3 +86,135 @@ describe("reduceUiAction theme picker", () => { expect(closed?.themePickerOriginal).toBe(""); }); }); + +describe("input history navigation", () => { + const withHistory = (draft: string) => ({ + ...createInitialTuiState(SESSION), + inputHistory: ["first", "second"], + inputValue: draft, + }); + + it("preserves the in-progress draft when Up recalls history", () => { + const state = withHistory("draft I am typing"); + const up = reduceTuiState(state, { type: "input_history_navigated", delta: -1 }); + expect(up.inputValue).toBe("second"); + const back = reduceTuiState(up, { type: "input_history_navigated", delta: 1 }); + expect(back.inputValue).toBe("draft I am typing"); + }); + + it("keeps the history cursor when the caret moves without editing", () => { + const state = withHistory("draft"); + const up = reduceTuiState(state, { type: "input_history_navigated", delta: -1 }); + expect(up.inputHistoryCursor).toBe(1); + const caret = reduceTuiState(up, { type: "input_changed", value: "second" }); + expect(caret.inputHistoryCursor).toBe(1); + const older = reduceTuiState(caret, { type: "input_history_navigated", delta: -1 }); + expect(older.inputValue).toBe("first"); + }); + + it("drops the stashed draft once the recalled entry is edited", () => { + const state = withHistory("draft"); + const up = reduceTuiState(state, { type: "input_history_navigated", delta: -1 }); + const edited = reduceTuiState(up, { type: "input_changed", value: "second!" }); + expect(edited.inputHistoryCursor).toBeNull(); + const down = reduceTuiState(edited, { type: "input_history_navigated", delta: 1 }); + expect(down.inputValue).toBe("second!"); + }); +}); + +describe("reduceUiAction message_queued", () => { + it("parks the message and clears the editor", () => { + const state = createInitialTuiState(SESSION); + const next = reduceUiAction( + { ...state, inputValue: "draft" }, + { type: "message_queued", text: "draft" }, + ); + expect(next?.queuedMessages).toEqual(["draft"]); + expect(next?.inputValue).toBe(""); + }); + + it("appends in submission order", () => { + const state = createInitialTuiState(SESSION); + const first = reduceUiAction(state, { type: "message_queued", text: "a" }); + const second = reduceUiAction(first!, { type: "message_queued", text: "b" }); + expect(second?.queuedMessages).toEqual(["a", "b"]); + }); + + it("leaves the running turn's state alone", () => { + // The whole point of a separate action: `message_submitted` calls + // startNewRun, which would blank the feed of the turn in flight. + const state = { + ...createInitialTuiState(SESSION), + status: "running" as const, + currentStep: 3, + runStartedAt: 1234, + }; + const next = reduceUiAction(state, { type: "message_queued", text: "x" }); + expect(next?.status).toBe("running"); + expect(next?.currentStep).toBe(3); + expect(next?.runStartedAt).toBe(1234); + }); + + it("mirrors the orchestrator queue on queue_changed", () => { + const state = createInitialTuiState(SESSION); + const seeded = reduceUiAction(state, { type: "message_queued", text: "a" }); + const next = reduceUiAction(seeded!, { + type: "queue_changed", + queued: [], + }); + expect(next?.queuedMessages).toEqual([]); + }); + + it("drops the queue when the session is switched", () => { + const state = createInitialTuiState(SESSION); + const seeded = reduceUiAction(state, { type: "message_queued", text: "a" }); + const next = reduceUiAction(seeded!, { + type: "session_switched", + sessionId: "s2", + workingDir: "/tmp", + messages: [], + }); + expect(next?.queuedMessages).toEqual([]); + }); +}); + +describe("reduceUiAction while_busy_mode_changed", () => { + it("toggles when no explicit mode is given", () => { + const state = createInitialTuiState(SESSION); + expect(state.whileBusyMode).toBe("steer"); + const toQueue = reduceUiAction(state, { type: "while_busy_mode_changed" }); + expect(toQueue?.whileBusyMode).toBe("queue"); + const backToSteer = reduceUiAction(toQueue!, { + type: "while_busy_mode_changed", + }); + expect(backToSteer?.whileBusyMode).toBe("steer"); + }); + + it("sets an explicit mode idempotently", () => { + const state = createInitialTuiState(SESSION); + const once = reduceUiAction(state, { + type: "while_busy_mode_changed", + mode: "queue", + }); + const twice = reduceUiAction(once!, { + type: "while_busy_mode_changed", + mode: "queue", + }); + expect(twice?.whileBusyMode).toBe("queue"); + }); + + it("message_steered clears the editor without parking the message", () => { + const state = { ...createInitialTuiState(SESSION), inputValue: "draft" }; + const next = reduceUiAction(state, { + type: "message_steered", + text: "draft", + }); + expect(next?.inputValue).toBe(""); + // The bubble arrives with `steer_applied`, so nothing is queued and + // nothing is rendered yet — a steer that misses the turn must not + // show up twice when it falls back to the queue. + expect(next?.queuedMessages).toEqual([]); + expect(next?.messages).toEqual([]); + }); +}); + diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..aebbdd2a 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -1,3 +1,4 @@ +import { clampMenuCursor } from "./menu/menu-selectors.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { THEME_NAMES } from "./theme/theme.js"; @@ -44,6 +45,14 @@ export function reduceUiAction( ); return { ...state, themePickerCursor: next }; } + case "theme_picker_cursor_set": { + if (!state.themePickerOpen) return state; + const max = THEME_NAMES.length - 1; + return { + ...state, + themePickerCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "tool_expand_toggled": { const current = state.toolsExpandedById[action.toolCardId] ?? false; return { @@ -62,6 +71,37 @@ export function reduceUiAction( for (const card of state.streamingToolCards) next[card.id] = action.expanded; return { ...state, toolsExpandedById: next }; } + case "menu_opened": + // Always reopen at the root with an empty query: a menu that resumes + // where it was last left makes the same keypress mean different + // things on different days. + return { + ...state, + menuOpen: true, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_closed": + return { + ...state, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_query_changed": + // A query flattens the tree, so any open submenu is dropped with it. + return { ...state, menuQuery: action.query, menuPath: null }; + case "menu_path_set": + return { ...state, menuPath: action.path }; + case "menu_cursor_set": + return { ...state, menuCursor: clampMenuCursor(state, action.cursor) }; + case "menu_cursor_moved": + return { + ...state, + menuCursor: clampMenuCursor(state, state.menuCursor + action.delta), + }; case "slash_palette_opened": return { ...state, @@ -92,8 +132,38 @@ export function reduceUiAction( } case "input_history_navigated": return navigateInputHistory(state, action.delta); - case "input_history_reset": - return { ...state, inputHistoryCursor: null }; + case "message_queued": + return { + ...state, + queuedMessages: [...state.queuedMessages, action.text], + inputValue: "", + inputHistoryCursor: null, + // A queued submit is still a submit: the parked history draft + // must not resurface on the next Down. + inputHistoryDraft: null, + slashPaletteOpen: false, + slashQuery: "", + slashPaletteCursor: 0, + }; + case "queue_changed": + return { ...state, queuedMessages: [...action.queued] }; + case "while_busy_mode_changed": { + const next = + action.mode ?? (state.whileBusyMode === "steer" ? "queue" : "steer"); + return { ...state, whileBusyMode: next }; + } + case "message_steered": + return { + ...state, + inputValue: "", + inputHistoryCursor: null, + // A steered submit is still a submit: the parked history draft + // must not resurface on the next Down. + inputHistoryDraft: null, + slashPaletteOpen: false, + slashQuery: "", + slashPaletteCursor: 0, + }; case "chat_cleared": return { ...state, @@ -123,6 +193,13 @@ export function reduceUiAction( ); return { ...state, sessionPickerCursor: next }; } + case "session_picker_cursor_set": { + const max = Math.max(0, state.sessionPickerList.length - 1); + return { + ...state, + sessionPickerCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "llama_url_changed": return { ...state, @@ -153,6 +230,13 @@ export function reduceUiAction( ); return { ...state, sidebarCursor: next }; } + case "sidebar_cursor_set": { + const max = Math.max(0, state.recentSessions.length - 1); + return { + ...state, + sidebarCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "sidebar_tasks_cursor_moved": { // Upper bound here is the **rendered** sidebar tasks list size, // capped by SIDEBAR_TASKS_LIMIT and the number of active/recurring @@ -167,6 +251,13 @@ export function reduceUiAction( ); return { ...state, sidebarTasksCursor: next }; } + case "sidebar_tasks_cursor_set": { + const max = Math.max(0, selectSidebarTasks(state.tasksPanel.rows).length - 1); + return { + ...state, + sidebarTasksCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "chat_scrolled": { // `chatScrollOffset` is in **lines** since the line-by-line // scroll refactor — the unit changed but the field name is @@ -210,6 +301,7 @@ export function reduceUiAction( sidebarSection: "sessions", sidebarCursor: 0, sidebarTasksCursor: 0, + queuedMessages: [], }; default: return null; @@ -224,13 +316,23 @@ function navigateInputHistory(state: TuiState, delta: 1 | -1): TuiState { // buffer (Down). Cursor value equals the history index shown. let cursor: number | null; if (state.inputHistoryCursor === null) { - cursor = delta === -1 ? max : null; + // Down on the live buffer has nowhere to go — leave the draft alone. + if (delta === 1) return state; + cursor = max; } else { const candidate = state.inputHistoryCursor + delta; if (candidate < 0) return state; if (candidate > max) cursor = null; else cursor = candidate; } - const value = cursor === null ? "" : (history[cursor] ?? ""); - return { ...state, inputHistoryCursor: cursor, inputValue: value }; + // Entering recall parks the live draft; stepping back past the newest + // entry hands it back verbatim instead of clearing the editor. + const draft = state.inputHistoryCursor === null ? state.inputValue : state.inputHistoryDraft; + const value = cursor === null ? (draft ?? "") : (history[cursor] ?? ""); + return { + ...state, + inputHistoryCursor: cursor, + inputHistoryDraft: cursor === null ? null : draft, + inputValue: value, + }; } diff --git a/src/tui/reducer-helpers.ts b/src/tui/reducer-helpers.ts index 6e8d07f0..52a4045f 100644 --- a/src/tui/reducer-helpers.ts +++ b/src/tui/reducer-helpers.ts @@ -124,10 +124,17 @@ export function appendChatMessage( export function appendUserMessage(state: TuiState, text: string): TuiState { const withMessage = appendChatMessage(state, { role: "user", text }); const history = pushRing(state.inputHistory, text, state.ringBufferSize); + // History-navigation state is deliberately left alone: this event also + // fires in the BACKGROUND when a parked queue message drains into a + // turn, possibly while the operator is walking history in the live + // editor — resetting the cursor or the parked draft here would eat + // what they are doing. The submit actions (`message_submitted`, + // `message_queued`) already reset both at submit time. Appending to + // the END of `inputHistory` keeps an active cursor index pointing at + // the same entry. return { ...withMessage, inputHistory: history, - inputHistoryCursor: null, }; } @@ -176,6 +183,7 @@ export function startNewRun(state: TuiState): TuiState { lastRunStatus: null, inputValue: "", inputHistoryCursor: null, + inputHistoryDraft: null, slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, diff --git a/src/tui/run-local-models-config-wizard.test.ts b/src/tui/run-local-models-config-wizard.test.ts index 7dccd47c..1aaceefb 100644 --- a/src/tui/run-local-models-config-wizard.test.ts +++ b/src/tui/run-local-models-config-wizard.test.ts @@ -129,6 +129,59 @@ describe("isCloudTextProviderReady", () => { expect(isCloudTextProviderReady()).toBe(true); }); + it("treats a subscription-cli entry as ready with no key and no base URL", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "sonnet", + subscriptionCli: { cli: "claude" }, + }, + ], + }, + }); + resetConfigCache(); + + // Neither existing check can see this entry: there is no API key and + // no loopback base URL. Without the CLI-auth branch the user would + // land in the local-model wizard on every single start. + expect(isCloudTextProviderReady()).toBe(true); + }); + + it("still refuses a keyless remote entry that is not CLI-backed", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "remote-compat", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "remote-compat", + kind: "openai-compatible", + baseUrl: "https://api.example.invalid", + defaultChatModel: "some-model", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(false); + }); + it("treats a manual keyless entry with a loopback base URL as ready", () => { const cfg = getConfig(); const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); diff --git a/src/tui/run-local-models-config-wizard.ts b/src/tui/run-local-models-config-wizard.ts index 47d5b671..c0c80b63 100644 --- a/src/tui/run-local-models-config-wizard.ts +++ b/src/tui/run-local-models-config-wizard.ts @@ -2,6 +2,7 @@ import { render } from "ink"; import React from "react"; import { getConfig, USER_CONFIG_DEFAULTS } from "../config/index.js"; import type { UserLlmProviderEntry } from "../config/llm-config.js"; +import { usesExternalCliAuth } from "../config/provider-auth-mode.js"; import { resolveLlmProviderApiKey } from "../config/resolve-llm-api-key.js"; import { getLocalModelDef, @@ -14,6 +15,7 @@ import { LocalModelsConfigWizard, type LocalModelsWizardOutcome, } from "./components/local-models-config-wizard.js"; +import { isLocalProviderUrl } from "./providers/is-local-provider-url.js"; import { presetForEntryId } from "./providers/provider-presets.js"; export type LocalModelsStartupGateResult = @@ -55,19 +57,25 @@ export async function runLocalModelsStartupGateIfNeeded(options: { ); const outcome = { value: "skipped" as LocalModelsWizardOutcome }; + // A cloud key that saved without a completed check has something to + // say; it is printed after the Ink tree is torn down so the message + // survives the redraw. + let notice: string | undefined; const ink = render( React.createElement(LocalModelsConfigWizard, { initialUrl: getConfig().localModels.url, probeError: probe.error, hadConfiguredBackend: isLocalBackendConfigured(), - onFinished: (o) => { + onFinished: (o, n) => { outcome.value = o; + notice = n; }, }), { stdout: process.stdout, stderr: process.stderr, exitOnCtrlC: false }, ); await ink.waitUntilExit(); ink.clear(); + if (notice) process.stderr.write(`[atomic-agent] ${notice}\n`); if (outcome.value === "aborted") return "aborted"; if (outcome.value === "saved_managed") return "saved_managed"; @@ -82,7 +90,12 @@ export function isCloudTextProviderReady(): boolean { const entry = cfg.llm?.providers.find((provider) => provider.id === active); if (!entry) return false; if (resolveLlmProviderApiKey(entry)) return true; - return isKeylessLocalProviderEntry(entry); + if (isKeylessLocalProviderEntry(entry)) return true; + // A subscription CLI carries no key and no base URL, so both checks + // above miss it. Reachable only for a kind that config validation + // rejected outright before this existed, so no pre-existing config + // changes behaviour here. + return usesExternalCliAuth(entry); } /** @@ -95,21 +108,7 @@ export function isCloudTextProviderReady(): boolean { */ function isKeylessLocalProviderEntry(entry: UserLlmProviderEntry): boolean { if (presetForEntryId(entry.id)?.local) return true; - if (!entry.baseUrl) return false; - let host: string; - try { - host = new URL(entry.baseUrl).hostname; - } catch { - return false; - } - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "0.0.0.0" || - host === "::1" || - host === "[::1]" || - host.endsWith(".localhost") - ); + return isLocalProviderUrl(entry.baseUrl); } /** diff --git a/src/tui/skills/skills-actions.ts b/src/tui/skills/skills-actions.ts index 0101a6f1..34046189 100644 --- a/src/tui/skills/skills-actions.ts +++ b/src/tui/skills/skills-actions.ts @@ -40,6 +40,8 @@ export type SkillsAction = error: string | null; } | { type: "skills_hub_cursor_moved"; delta: 1 | -1 | number } + /** Put the Skills Hub cursor on an absolute row (mouse click). */ + | { type: "skills_hub_cursor_set"; row: number } | { type: "skills_hub_search_focus"; editing: boolean } | { type: "skills_hub_query_changed"; query: string } | { type: "skills_install_loading"; loading: boolean } diff --git a/src/tui/skills/skills-reducer.ts b/src/tui/skills/skills-reducer.ts index e5447677..6027b654 100644 --- a/src/tui/skills/skills-reducer.ts +++ b/src/tui/skills/skills-reducer.ts @@ -121,6 +121,11 @@ function reducePanel( hubLoading: false, hubCursor: clampCursor(panel.hubCursor, action.rows.length), }; + case "skills_hub_cursor_set": + return { + ...panel, + hubCursor: clampCursor(action.row, panel.hubRows.length), + }; case "skills_hub_cursor_moved": { const total = panel.hubRows.length; const nextCursor = Math.max( diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 86a178e6..20e7d600 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -128,3 +128,289 @@ describe("handleEditorSubmit", () => { expect(onApprovalLevelSetRequested).toHaveBeenCalledTimes(3); }); }); + +describe("handleEditorSubmit while a turn is running", () => { + function runningState(): TuiState { + return { ...createInitialTuiState(fakeSession()), status: "running" }; + } + + it("queues the message instead of dropping it", () => { + const dispatched: Array<{ type: string; text?: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "and also check the logs", + runningState(), + ((a: { type: string; text?: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("and also check the logs"); + expect(dispatched).toContainEqual({ + type: "message_queued", + text: "and also check the logs", + }); + }); + + it("never dispatches message_submitted mid-run (it would wipe the live turn)", () => { + const dispatched: Array<{ type: string }> = []; + handleEditorSubmit( + "second thought", + runningState(), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks(), + ); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(false); + }); + + it("drops the submit entirely once the app is quitting", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "too late", + { ...createInitialTuiState(fakeSession()), status: "quitting" }, + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).not.toHaveBeenCalled(); + expect(dispatched).toHaveLength(0); + }); + + it("still starts a turn immediately when idle", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "go", + createInitialTuiState(fakeSession()), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(true); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(false); + expect(onMessageSubmitted).toHaveBeenCalledWith("go"); + }); +}); + +describe("/queue", () => { + it("lists the parked messages in the transcript", () => { + const state: TuiState = { + ...createInitialTuiState(fakeSession()), + status: "running", + queuedMessages: ["first", "second"], + }; + const messages: string[] = []; + handleEditorSubmit( + "/queue", + state, + ((a: { type: string; text?: string }) => { + if (a.type === "system_message" && a.text) messages.push(a.text); + }) as never, + stubCallbacks(), + ); + const joined = messages.join("\n"); + expect(joined).toContain("queue (2 messages)"); + expect(joined).toContain("1. first"); + expect(joined).toContain("2. second"); + }); + + it("clear empties the reducer slice and tells the orchestrator", () => { + const state: TuiState = { + ...createInitialTuiState(fakeSession()), + status: "running", + queuedMessages: ["first"], + }; + const dispatched: Array<{ type: string; queued?: readonly string[] }> = []; + const onQueueClearRequested = vi.fn(); + handleEditorSubmit( + "/queue clear", + state, + ((a: { type: string; queued?: readonly string[] }) => + dispatched.push(a)) as never, + stubCallbacks({ onQueueClearRequested }), + ); + expect(onQueueClearRequested).toHaveBeenCalledTimes(1); + expect(dispatched).toContainEqual({ type: "queue_changed", queued: [] }); + }); + + it("says so when there is nothing parked", () => { + const messages: string[] = []; + handleEditorSubmit( + "/queue", + createInitialTuiState(fakeSession()), + ((a: { type: string; text?: string }) => { + if (a.type === "system_message" && a.text) messages.push(a.text); + }) as never, + stubCallbacks(), + ); + expect(messages.join("\n")).toContain("queue: (empty)"); + }); +}); + +describe("steer vs queue while a turn is running", () => { + function busy(mode: "steer" | "queue"): TuiState { + return { + ...createInitialTuiState(fakeSession()), + status: "running", + whileBusyMode: mode, + }; + } + + it("steers when the mode says steer", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "no, use the staging db", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered, onMessageSubmitted }), + ); + expect(onMessageSteered).toHaveBeenCalledWith("no, use the staging db"); + expect(onMessageSubmitted).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_steered")).toBe(true); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(false); + }); + + it("queues when the mode says queue", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "afterwards, run the tests", + busy("queue"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered, onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("afterwards, run the tests"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(true); + }); + + it("falls back to queueing when the host wired no steer callback", () => { + // Steering is optional on the callback surface; a host that does not + // implement it must still not drop the message. + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "still needs to land", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("still needs to land"); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(true); + }); + + it("/steer lands one message without changing the mode", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + const onWhileBusyModePersistRequested = vi.fn(); + handleEditorSubmit( + "/steer drop what you are doing", + busy("queue"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered, onWhileBusyModePersistRequested }), + ); + expect(onMessageSteered).toHaveBeenCalledWith("drop what you are doing"); + expect( + dispatched.some((a) => a.type === "while_busy_mode_changed"), + ).toBe(false); + // The message-carrying form is a one-off: nothing reaches the config file. + expect(onWhileBusyModePersistRequested).not.toHaveBeenCalled(); + }); + + it("/queue parks one message without changing the mode", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + const onMessageSteered = vi.fn(); + const onWhileBusyModePersistRequested = vi.fn(); + handleEditorSubmit( + "/queue and then deploy", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ + onMessageSubmitted, + onMessageSteered, + onWhileBusyModePersistRequested, + }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("and then deploy"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect( + dispatched.some((a) => a.type === "while_busy_mode_changed"), + ).toBe(false); + expect(onWhileBusyModePersistRequested).not.toHaveBeenCalled(); + }); + + it("bare /steer and /queue mode flip the persisted mode", () => { + // Same contract as Ctrl+T in `app-key-bindings.test.ts`: the reducer + // action moves the live mode, the callback makes it survive a restart. + // Without the second half the operator sees the confirmation and finds + // the old mode back on the next launch. + const steerDispatched: Array<{ type: string; mode?: string }> = []; + const onSteerPersist = vi.fn(); + handleEditorSubmit( + "/steer", + busy("queue"), + ((a: { type: string; mode?: string }) => steerDispatched.push(a)) as never, + stubCallbacks({ onWhileBusyModePersistRequested: onSteerPersist }), + ); + expect(steerDispatched).toContainEqual({ + type: "while_busy_mode_changed", + mode: "steer", + }); + expect(onSteerPersist).toHaveBeenCalledWith("steer"); + + const queueDispatched: Array<{ type: string; mode?: string }> = []; + const onQueuePersist = vi.fn(); + handleEditorSubmit( + "/queue mode", + busy("steer"), + ((a: { type: string; mode?: string }) => queueDispatched.push(a)) as never, + stubCallbacks({ onWhileBusyModePersistRequested: onQueuePersist }), + ); + expect(queueDispatched).toContainEqual({ + type: "while_busy_mode_changed", + mode: "queue", + }); + expect(onQueuePersist).toHaveBeenCalledWith("queue"); + }); + + it("bare /queue only lists — looking must not persist a mode change", () => { + const onWhileBusyModePersistRequested = vi.fn(); + handleEditorSubmit( + "/queue", + createInitialTuiState(fakeSession()), + (() => {}) as never, + stubCallbacks({ onWhileBusyModePersistRequested }), + ); + expect(onWhileBusyModePersistRequested).not.toHaveBeenCalled(); + }); + + it("/queue clear drops the parked messages without touching the default", () => { + const onQueueClearRequested = vi.fn(); + const onWhileBusyModePersistRequested = vi.fn(); + handleEditorSubmit( + "/queue clear", + busy("steer"), + (() => {}) as never, + stubCallbacks({ onQueueClearRequested, onWhileBusyModePersistRequested }), + ); + expect(onQueueClearRequested).toHaveBeenCalled(); + expect(onWhileBusyModePersistRequested).not.toHaveBeenCalled(); + }); + + it("/steer on an idle session just sends it", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + const onMessageSteered = vi.fn(); + handleEditorSubmit( + "/steer go", + createInitialTuiState(fakeSession()), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted, onMessageSteered }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("go"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(true); + }); +}); + diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index e7542555..f91da7ce 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -11,7 +11,12 @@ import type { TuiAction } from "./tui-action.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; import { isThemeName, setActiveTheme, THEME_NAMES, THEMES } from "./theme/theme.js"; import type { TuiAppCallbacks } from "./tui-app.js"; -import { canAcceptMessage, type TuiState } from "./tui-state.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; +import { + canAcceptMessage, + canTypeMessage, + type TuiState, +} from "./tui-state.js"; type Dispatch = (action: TuiAction) => void; @@ -80,7 +85,16 @@ export function handleEditorSubmit( return; } - if (!canAcceptMessage(state)) return; + // A turn is already in flight. Two ways to land the message, chosen + // by `whileBusyMode` (Ctrl+T, or `/steer` / `/queue` for one message): + // steer — fold it into the running turn at its next step boundary + // queue — park it and run it as its own turn afterwards + // Either way it is never dropped, which is the whole point. + if (!canAcceptMessage(state)) { + if (!canTypeMessage(state)) return; + submitWhileBusy(trimmed, state.whileBusyMode, dispatch, callbacks); + return; + } dispatch({ type: "message_submitted" }); callbacks.onMessageSubmitted(trimmed); } @@ -137,6 +151,14 @@ export function runSlashCommand( setActiveTheme(THEMES[result.setThemeName]); callbacks.onThemePersistRequested?.(result.setThemeName); } + // Bare `/steer` / `/queue` are the persisting form, so they take the same + // callback as Ctrl+T (`app-key-bindings.ts`) and land in the same + // `persistUserWhileBusySubmit` helper — one write path, one error path. + // `/steer ` sets `submitWhileBusy` instead and never reaches here, + // which is what keeps a one-off from moving the default. + if (result.setWhileBusyMode) { + callbacks.onWhileBusyModePersistRequested?.(result.setWhileBusyMode); + } for (const action of result.actions) { if (action.type === "providers_chat_model_picker_requested") { // A state no-op as a reducer action: the orchestrator that owns @@ -162,6 +184,25 @@ export function runSlashCommand( } if (result.clearBuffer) dispatch({ type: "input_changed", value: "" }); dispatch({ type: "slash_palette_closed" }); + if (result.submitWhileBusy) { + const { mode, text } = result.submitWhileBusy; + // `/steer foo` / `/queue foo` on an idle session is just "send foo". + if (canAcceptMessage(state)) { + dispatch({ type: "message_submitted" }); + callbacks.onMessageSubmitted(text); + } else if (canTypeMessage(state)) { + submitWhileBusy(text, mode, dispatch, callbacks); + } else { + // Quitting: nothing can run this message any more. Say so instead + // of clearing the buffer over a silent drop. + dispatch({ + type: "system_message", + text: `quitting — "${text}" was not sent`, + }); + } + } + if (result.queueVerb) runQueueVerb(result.queueVerb, state, dispatch, callbacks); + if (result.triggerNewWindow) callbacks.onNewWindowRequested?.(); if (result.triggerAbort) callbacks.onAbort(); if (result.triggerQuit) { callbacks.onAbort(); @@ -212,11 +253,75 @@ export function runSlashCommand( break; } } + if (result.mouseVerb) { + callbacks.onMouseSupportRequested?.( + result.mouseVerb === "status" ? null : result.mouseVerb === "on", + ); + } if (result.approvalLevelSet !== undefined) { void callbacks.onApprovalLevelSetRequested?.(result.approvalLevelSet); } } +/** + * Land a message that was submitted while a turn was running, in the + * requested mode. Split out so `/steer` and `/queue` can reuse it for a + * one-off override without flipping the persisted default. + */ +export function submitWhileBusy( + text: string, + mode: WhileBusySubmitMode, + dispatch: Dispatch, + callbacks: TuiAppCallbacks, +): void { + if (mode === "steer" && callbacks.onMessageSteered) { + dispatch({ type: "message_steered", text }); + callbacks.onMessageSteered(text); + return; + } + dispatch({ type: "message_queued", text }); + callbacks.onMessageSubmitted(text); +} + +/** + * `/queue` needs the live queue to render, which `dispatchSlashCommand` + * (a pure buffer -> result function) cannot see. The listing is built + * here from `TuiState`; `clear` also tells the orchestrator to drop its + * own copy so the two never diverge. + */ +function runQueueVerb( + verb: NonNullable, + state: TuiState, + dispatch: Dispatch, + callbacks: TuiAppCallbacks, +): void { + if (verb === "clear") { + callbacks.onQueueClearRequested?.(); + const line = + state.queuedMessages.length === 0 + ? "queue: already empty" + : `queue: dropped ${state.queuedMessages.length} parked message${ + state.queuedMessages.length === 1 ? "" : "s" + }`; + dispatch({ type: "runtime_info", line }); + dispatch({ type: "system_message", text: line }); + return; + } + const text = formatQueueListing(state.queuedMessages); + dispatch({ type: "runtime_info", line: text.split("\n")[0] ?? text }); + dispatch({ type: "system_message", text }); +} + +/** Multi-line `/queue` listing for the chat transcript. */ +export function formatQueueListing(queued: readonly string[]): string { + if (queued.length === 0) { + return "queue: (empty) \u2014 messages sent while a turn is running are parked here"; + } + const header = `queue (${queued.length} message${queued.length === 1 ? "" : "s"})`; + const lines = queued.map((text, i) => ` ${i + 1}. ${text.replace(/\s+/g, " ").trim()}`); + return [header, ...lines].join("\n"); +} + function runTelegramVerb( verb: NonNullable, callbacks: TuiAppCallbacks, diff --git a/src/tui/theme/theme-palettes.ts b/src/tui/theme/theme-palettes.ts index 39edae31..0a5e2ad3 100644 --- a/src/tui/theme/theme-palettes.ts +++ b/src/tui/theme/theme-palettes.ts @@ -41,6 +41,7 @@ export const GITHUB_DARK_COLORS: TuiColors = { warnStrong: "#db6d28", success: "#3fb950", info: "#4493f8", + brandMark: "#a5c9ff", }; // GitHub Primer "light (default)" — @primer/primitives functional tokens @@ -62,6 +63,7 @@ export const GITHUB_LIGHT_COLORS: TuiColors = { warnStrong: "#bc4c00", success: "#1a7f37", info: "#0969da", + brandMark: "#54a3ff", }; // Catppuccin Mocha — official palette (catppuccin/palette palette.json). @@ -84,6 +86,7 @@ export const CATPPUCCIN_MOCHA_COLORS: TuiColors = { warnStrong: "#fab387", success: "#a6e3a1", info: "#89b4fa", + brandMark: "#b4cffa", }; // Catppuccin Latte — official palette (light flavour). @@ -104,6 +107,7 @@ export const CATPPUCCIN_LATTE_COLORS: TuiColors = { warnStrong: "#fe640b", success: "#40a02b", info: "#1e66f5", + brandMark: "#5f9bf5", }; // Dracula — official spec (draculatheme.com). accent=purple, green, @@ -126,6 +130,7 @@ export const DRACULA_COLORS: TuiColors = { warnStrong: "#ffb86c", success: "#50fa7b", info: "#bd93f9", + brandMark: "#b9c9ff", }; // Nord — official spec (nordtheme.com). accent=nord8 frost, nord14 green, @@ -148,6 +153,7 @@ export const NORD_COLORS: TuiColors = { warnStrong: "#d08770", success: "#a3be8c", info: "#88c0d0", + brandMark: "#b3ccec", }; // Tokyo Night ("Night" variant) — official spec @@ -170,6 +176,7 @@ export const TOKYO_NIGHT_COLORS: TuiColors = { warnStrong: "#ff9e64", success: "#9ece6a", info: "#7aa2f7", + brandMark: "#a9c7ff", }; // Gruvbox Dark — official "bright" palette (morhetz/gruvbox). accent=blue, @@ -192,6 +199,7 @@ export const GRUVBOX_DARK_COLORS: TuiColors = { warnStrong: "#fe8019", success: "#b8bb26", info: "#83a598", + brandMark: "#b3d1e6", }; // Gruvbox Light — official "faded" palette on light bg (morhetz/gruvbox). @@ -212,6 +220,7 @@ export const GRUVBOX_LIGHT_COLORS: TuiColors = { warnStrong: "#af3a03", success: "#79740e", info: "#076678", + brandMark: "#5a9ec9", }; // Solarized Dark — official spec (Ethan Schoonover). Accents are shared @@ -234,6 +243,7 @@ export const SOLARIZED_DARK_COLORS: TuiColors = { warnStrong: "#cb4b16", success: "#859900", info: "#268bd2", + brandMark: "#9fc7e8", }; // Solarized Light — same accent set, light base. base1(muted), base2(border). @@ -254,4 +264,5 @@ export const SOLARIZED_LIGHT_COLORS: TuiColors = { warnStrong: "#cb4b16", success: "#859900", info: "#268bd2", + brandMark: "#4a95c9", }; diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 7578c3fa..7f3d3390 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -42,6 +42,13 @@ export interface TuiColors { readonly toolError: string; readonly accent: string; readonly accentSoft: string; + /** + * The brand mark's own blue — deliberately lighter and whiter than + * `accent`. The mark is not a control, and painting it in the same + * blue as every accented control made the start page read as one big + * highlighted widget. + */ + readonly brandMark: string; readonly border: string; readonly muted: string; readonly error: string; @@ -209,12 +216,70 @@ export function getActiveThemeName(): ThemeName { return "github-dark"; } +/** + * Backdrop dimming. While the operator menu is open the whole app behind it + * fades, so the popup reads as the foreground rather than as one more panel + * competing with the chat log. + * + * Implemented here rather than by threading a `dimmed` prop through every + * component because {@link theme} is already a read-at-render proxy — the + * same machinery that makes `/theme` live-preview repaint the whole UI. One + * flag flips every colour; the menu itself reads {@link chromeTheme}, which + * ignores the flag, so it stays at full contrast. + * + * Every colour collapses to the active theme's `muted`: a real terminal has + * no alpha channel, so "faded" has to mean "one low-contrast tone" rather + * than "the same colours, weaker". + */ +let backdropDimmed = false; +let dimmedColorsFor: TuiColors | null = null; +let dimmedColorsCache: TuiColors | null = null; + +export function setBackdropDimmed(next: boolean): void { + backdropDimmed = next; +} + +export function isBackdropDimmed(): boolean { + return backdropDimmed; +} + +function dimColors(colors: TuiColors): TuiColors { + if (dimmedColorsFor === colors && dimmedColorsCache) return dimmedColorsCache; + const flat = Object.fromEntries( + Object.keys(colors).map((key) => [key, colors.muted]), + ) as unknown as TuiColors; + dimmedColorsFor = colors; + dimmedColorsCache = flat; + return flat; +} + /** * The themed palette consumed across the TUI. A `Proxy` that always forwards * to the current {@link activeTheme}, so `theme.colors.X` reflects the active * theme at read time even after a `setActiveTheme` swap. */ export const theme: TuiTheme = new Proxy({} as TuiTheme, { + get(_target, prop: string | symbol): unknown { + if (prop === "colors" && backdropDimmed) return dimColors(activeTheme.colors); + return activeTheme[prop as keyof TuiTheme]; + }, + has(_target, prop: string | symbol): boolean { + return prop in activeTheme; + }, + ownKeys(): ArrayLike { + return Reflect.ownKeys(activeTheme); + }, + getOwnPropertyDescriptor(_target, prop: string | symbol) { + return Reflect.getOwnPropertyDescriptor(activeTheme, prop); + }, +}); + +/** + * The palette for chrome that must stay legible while the backdrop is dimmed — + * i.e. the operator menu. Identical to {@link theme} except that it ignores + * {@link setBackdropDimmed}. + */ +export const chromeTheme: TuiTheme = new Proxy({} as TuiTheme, { get(_target, prop: string | symbol): unknown { return activeTheme[prop as keyof TuiTheme]; }, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..46a00fc9 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -13,6 +13,7 @@ import type { PrivacyAction } from "./privacy/privacy-actions.js"; import type { ProvidersAction } from "./providers/providers-actions.js"; import type { LlmPanelAction } from "./llm-panel/llm-panel-actions.js"; import type { FallbackPanelAction } from "./llm-panel/fallback/fallback-panel-actions.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { ChatMessage, SessionPickerEntry, TuiTab, TuiUiMode } from "./tui-state.js"; /** @@ -58,6 +59,30 @@ export type TuiAction = * `user_message` agent event, so the action carries no payload. */ | { type: "message_submitted" } + /** + * The operator pressed Enter while a turn was still running. Unlike + * `message_submitted` this must NOT reset the run state — the turn in + * flight owns `feed` / `reasoning` / `streamingToolCards` and wiping + * them mid-run would blank the screen the operator is reading. We only + * clear the editor and record the message as parked; the orchestrator + * confirms with `queue_changed` once it has actually buffered it. + */ + | { type: "message_queued"; text: string } + /** Orchestrator re-published its pending-message queue (push/drain/clear). */ + | { type: "queue_changed"; queued: readonly string[] } + /** + * Flip (or set) what Enter does while a turn is running. `mode` + * omitted toggles; the persist side-effect lives in the caller, like + * `theme_set`. + */ + | { type: "while_busy_mode_changed"; mode?: WhileBusySubmitMode } + /** + * The operator submitted a message in `steer` mode. Clears the editor + * only — the user bubble is appended when the agent loop confirms + * delivery (`steer_applied`), so a steer that arrives too late and + * falls back to the queue is not rendered twice. + */ + | { type: "message_steered"; text: string } | { type: "quit_requested" } | { type: "loaded_skill"; @@ -93,14 +118,18 @@ export type TuiAction = | { type: "slash_palette_queried"; query: string } /** Close the slash palette without committing a selection. */ | { type: "slash_palette_closed" } + | { type: "menu_opened" } + | { type: "menu_closed" } + | { type: "menu_query_changed"; query: string } + | { type: "menu_cursor_moved"; delta: number } + | { type: "menu_cursor_set"; cursor: number } + | { type: "menu_path_set"; path: string | null } /** Move the highlight in the open slash palette by delta rows. */ | { type: "slash_palette_cursor_moved"; delta: 1 | -1 } /** Reset the slash palette highlight to a specific row. */ | { type: "slash_palette_cursor_set"; row: number } /** Navigate input history by delta (up = older, down = newer). */ | { type: "input_history_navigated"; delta: 1 | -1 } - /** Restore the live editor buffer, exiting history navigation. */ - | { type: "input_history_reset" } /** Clear the chat transcript (slash `/clear`). */ | { type: "chat_cleared" } /** Populate + show the session picker overlay. */ @@ -109,6 +138,8 @@ export type TuiAction = | { type: "session_picker_closed" } /** Move the highlight in the open session picker by delta rows. */ | { type: "session_picker_cursor_moved"; delta: 1 | -1 } + /** Put the session picker highlight on an absolute row (mouse click). */ + | { type: "session_picker_cursor_set"; row: number } /** * Open the interactive theme picker. The reducer seeds the cursor from the * current `themeName` and records it in `themePickerOriginal` so Esc can @@ -119,6 +150,8 @@ export type TuiAction = | { type: "theme_picker_closed" } /** Move the theme picker highlight by delta rows (clamped). */ | { type: "theme_picker_cursor_moved"; delta: 1 | -1 } + /** Put the theme picker highlight on an absolute row (mouse click). */ + | { type: "theme_picker_cursor_set"; row: number } /** * Hard-switch the TUI transcript to an already-loaded session. The * orchestrator performs the SessionStore load + swap, then dispatches @@ -172,8 +205,12 @@ export type TuiAction = | { type: "sidebar_section_focused"; section: "sessions" | "tasks" } /** Move the sidebar's session-list cursor by N rows (clamped). */ | { type: "sidebar_cursor_moved"; delta: 1 | -1 } + /** Put the sidebar's session-list cursor on an absolute row (mouse click). */ + | { type: "sidebar_cursor_set"; row: number } /** Move the sidebar's tasks-list cursor by N rows (clamped). */ | { type: "sidebar_tasks_cursor_moved"; delta: 1 | -1 } + /** Put the sidebar's tasks-list cursor on an absolute row (mouse click). */ + | { type: "sidebar_tasks_cursor_set"; row: number } /** Scroll the chat history by N messages (positive = older). Clamped to [0, total]. */ | { type: "chat_scrolled"; delta: number } /** Snap the chat scroll back to the bottom (newest message). */ diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 480215dc..30994713 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -33,6 +33,25 @@ function strip(value: string): string { .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); } +/** + * Poll the rendered frame until it satisfies `match`, then return it. Ink + * renders on its own schedule and a loaded runner stretches it, so a fixed + * sleep is a coin flip for anything that also waits on a timer. + */ +async function waitForFrame( + lastFrame: () => string | undefined, + match: (text: string) => boolean, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + let text = strip(lastFrame() ?? ""); + while (!match(text) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 20)); + text = strip(lastFrame() ?? ""); + } + return text; +} + describe("TuiApp (smoke)", () => { it("renders the chat surface with the compact operator status bar", () => { const bus = makeTuiEventBus(); @@ -41,10 +60,16 @@ describe("TuiApp (smoke)", () => { ); const text = strip(lastFrame() ?? ""); expect(text).toContain("atomic-agent"); + // The status bar shows where you are, not a menu of where you could go — + // the three-section pill row moved into the ctrl+p menu. expect(text).toContain("Run"); - expect(text).toContain("Observe"); - expect(text).toContain("Manage"); - expect(text).toContain("Local AI-First Agent"); + expect(text).not.toContain("Observe"); + expect(text).not.toContain("Manage"); + // The splash mark scales with the window; ink-testing-library's + // 100-column stdout reports no rows, so the fallback 80x24 surface + // gets the compact mark rather than the wordmark + tagline. Assert + // on what every size keeps. See `components/splash-fit.render.test.tsx`. + expect(text).toContain(":::"); expect(text).toContain("commands"); unmount(); }); @@ -146,20 +171,19 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("▸ Run"); + expect(before).toContain("Run"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); if (before.includes("Sessions")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("▸ Run"); - expect(after).not.toContain("▸ Observe"); + expect(after).toContain("Run"); + expect(after).not.toContain("Observe \u25b8"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("▸ Observe"); - expect(after).toContain("▸ Feed"); + expect(after).toContain("Observe \u25b8 Feed"); } unmount(); }); @@ -173,8 +197,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Observe"); - expect(text).toContain("▸ Feed"); + expect(text).toContain("Observe \u25b8 Feed"); unmount(); }); @@ -188,7 +211,7 @@ describe("TuiApp (smoke)", () => { await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). - expect(text).toContain("▸ Manage"); + expect(text).toContain("Manage \u25b8"); expect(text).toContain("▸ Telegram"); unmount(); }); @@ -335,13 +358,130 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); - expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + expect(strip(lastFrame() ?? "")).toContain("Manage \u25b8"); stdin.write("\u001b"); await new Promise((r) => setTimeout(r, 60)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Run"); - expect(text).not.toContain("▸ Manage"); + expect(text).toContain("Run"); + expect(text).not.toContain("Manage \u25b8"); + unmount(); + }); + + it("ctrl+p opens the operator menu over the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Menu"); + expect(text).toContain("GO"); + expect(text).toContain("Manage"); + expect(text).toContain("esc close"); + unmount(); + }); + + it("typing in the menu searches instead of reaching the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + stdin.write("privacy"); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Privacy"); + // The query lives in the menu, never in the editor buffer underneath. + expect(text).not.toContain("> privacy"); + unmount(); + }); + + it("ctrl+g then a chord jumps straight to a panel, and the chord letter never reaches the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + await new Promise((r) => setTimeout(r, 30)); + stdin.write("t"); + await new Promise((r) => setTimeout(r, 30)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Manage"); + expect(text).toContain("Tasks"); + // Ink delivers every key to every useInput, child first — so the editor + // sees the chord letter too. If the leader did not disable it, a stray + // "t" would be sitting in the prompt right now. + expect(text).not.toMatch(/[>\u276f]\s+t\s*$/m); + unmount(); + }); + + it("an armed ctrl+g is visible in the hint strip and disarms itself when no chord follows", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + const armed = await waitForFrame(lastFrame, (t) => + t.includes("waiting for a chord"), + ); + expect(armed).toContain("waiting for a chord"); + + // Nothing follows the leader. It must disarm on its own — while it is + // armed the editor is unfocused and the next keystroke is swallowed. + // No key is pressed here on purpose: only the timer can end this state. + const idle = await waitForFrame( + lastFrame, + (t) => !t.includes("waiting for a chord"), + ); + expect(idle).not.toContain("waiting for a chord"); + // Idle chips are back, and no chord fired on the way out. Matched on the + // chip key, not its label: a narrow runner wraps the strip and can split + // "menu" off its own chip. + expect(idle).toContain("[ctrl+p]"); + expect(idle).not.toContain("Manage ▸"); + unmount(); + }); + + it("esc closes the menu and leaves the screen it was opened over", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + expect(strip(lastFrame() ?? "")).toContain("esc close"); + stdin.write(String.fromCharCode(27)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).not.toContain("esc close"); + expect(text).toContain("Run"); + unmount(); + }); + + it("floats over the UI without moving it — the frame below is unchanged", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + const before = strip(lastFrame() ?? "").split("\n"); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 25)); + const after = strip(lastFrame() ?? "").split("\n"); + + // A popup composites on top; it must not add rows or push the prompt and + // the hint strip down the way an inline panel would. + expect(after.length).toBe(before.length); + expect(after.at(-1)).toBe(before.at(-1)); + expect(after.some((line) => line.includes("Menu"))).toBe(true); unmount(); }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..f4905bab 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useApp, useInput } from "ink"; +import { Box, Text, useApp, useInput, type DOMElement, type Key } from "ink"; import { useCallback, useEffect, @@ -9,19 +9,29 @@ import { } from "react"; import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { TuiAction } from "./tui-action.js"; -import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { + handleAppKey, + handlePanelEscape, + isPanelModalOpen, +} from "./app-key-bindings.js"; +import { APP_CHROME_ROWS } from "./components/debug-pane.js"; +import { MenuPopup } from "./menu/menu-popup.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; import { HotkeyHint } from "./components/hotkey-hint.js"; import { LlmHealthBadge } from "./components/llm-health-badge.js"; import { PromptShell } from "./components/prompt-shell.js"; +import { QueuedMessages } from "./components/queued-messages.js"; import { SessionPicker } from "./components/session-picker.js"; import { ThemePicker } from "./components/theme-picker.js"; import { isThemeName, setActiveTheme, + setBackdropDimmed, theme, THEME_NAMES, THEMES, @@ -35,13 +45,19 @@ import { UpdateModal } from "./components/update-modal.js"; import { UpdateIndicator } from "./components/update-indicator.js"; import { UpdateRestartPrompt } from "./components/update-restart-prompt.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; +import { + computeSidebarRowBudget, + computeSidebarWidth, + isSidebarVisible, +} from "./layout.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; -import { handleEditorSubmit } from "./submit-handler.js"; +import { handleEditorSubmit, runSlashCommand } from "./submit-handler.js"; import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { canAcceptMessage, + canTypeMessage, createInitialTuiState, DEFAULT_RING_BUFFER_SIZE, type InitialTuiLayoutOptions, @@ -63,6 +79,15 @@ import type { ImportFormState } from "./import/import-panel-state.js"; import { handleProvidersTabKey } from "./providers/providers-key-bindings.js"; import { handleTelegramTabKey } from "./telegram/telegram-key-bindings.js"; import { handlePrivacyTabKey } from "./privacy/privacy-key-bindings.js"; +import { MouseProvider } from "./mouse/mouse-context.js"; +import { + MOUSE_LAYER_BASE, + MOUSE_LAYER_MODAL, + MouseTargetRegistry, + type MouseHit, +} from "./mouse/mouse-registry.js"; +import type { MouseSource } from "./mouse/mouse-source.js"; +import { arrowKey } from "./mouse/synthetic-key.js"; export { makeTuiEventBus } from "./make-event-bus.js"; @@ -79,6 +104,16 @@ export interface TuiAppCallbacks { onAbort(): void; onQuit(): void; onMessageSubmitted(message: string): void; + /** Drop every message parked behind the running turn (`/queue clear`). */ + onQueueClearRequested?(): void; + /** + * Fold a message into the turn already running (`steer` mode). The + * orchestrator falls back to the queue when the runtime refuses — + * the turn may have ended between the keypress and the dispatch. + */ + onMessageSteered?(message: string): void; + /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ + onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void; /** Ask the orchestrator to emit the recent-sessions list to the bus. */ onSessionPickerRequested?(): void; /** Ask the orchestrator to swap to an existing persisted session. */ @@ -93,6 +128,12 @@ export interface TuiAppCallbacks { onPersistLlamaUrl?(url: string): void; /** Persist the chosen TUI theme name into the user config (`/theme`). */ onThemePersistRequested?(themeName: string): void; + /** + * `/mouse on|off` — flip terminal mouse reporting live. `null` asks + * for the current state to be reported without changing it. The + * handler owns the escape sequences and the config write. + */ + onMouseSupportRequested?(enabled: boolean | null): void; /** Start the Tasks-tab auto-refresh loop (first entry only). */ onTasksAutoRefreshStart?(): void; /** Perform a one-shot refresh of the tasks list. */ @@ -261,6 +302,8 @@ export interface TuiAppCallbacks { onMcpRemoveServer?(name: string): void; /** Providers tab: finish the add/configure wizard. */ onProvidersWizardSubmit?(wizard: import("./providers/providers-wizard-state.js").ProvidersWizardState): void; + /** Providers tab: abandon a running pre-save key check. */ + onProvidersWizardSubmitCancel?(): void; /** Providers tab: remove a provider by id from config + registry. */ onProvidersRemove?(id: string): void; /** Slash-command surface: enable a skill explicitly (`/skill enable `). */ @@ -331,6 +374,11 @@ export interface TuiAppCallbacks { onUpdateConfirmed?(): void; /** Self-update settled: user pressed a key to re-exec the new binary. */ onUpdateRestart?(): void; + /** + * Ctrl+N / `/window`: open a new OS terminal window running a fresh + * `atomic-agent tui` in the same working directory. + */ + onNewWindowRequested?(): void; } export interface TuiAppProps { @@ -340,19 +388,36 @@ export interface TuiAppProps { maxVisibleRows?: number; /** Optional initial debug tab / mode (e.g. after managed-mode wizard). */ initialLayout?: InitialTuiLayoutOptions; + /** + * Decoded terminal mouse reports. `tui-command.ts` always passes it, + * whatever `tui.mouse` says at startup: whether reports actually flow + * is decided upstream by the tracking controller, which `/mouse + * on|off` flips live, and this prop is fixed at mount. Omitted only in + * tests, where the app is keyboard-only and every clickable surface + * simply never fires. + */ + mouse?: MouseSource; } const DEFAULT_MAX_VISIBLE_ROWS = 14; const CTRL_C_WINDOW_MS = 1500; +/** + * How long a `ctrl+g` leader waits for its chord before disarming itself. + * The same window as Ctrl+C on purpose — both are "you started a two-key + * gesture, finish it" timers, and an armed leader is not free to leave + * pending: it unfocuses the editor and eats the next keystroke. + */ +const MENU_LEADER_WINDOW_MS = CTRL_C_WINDOW_MS; +/** Left gutter of the whole app frame — see the root `paddingLeft`. */ +const ROOT_PADDING_COLUMNS = 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. + * Rows the chat transcript moves per wheel notch. Three keeps a flick + * of the wheel useful on a long transcript without overshooting the + * reply the operator is reading; the keyboard's own ±2 arrow scroll is + * deliberately finer. */ -const SIDEBAR_MIN_COLUMNS = 100; -const SIDEBAR_WIDTH = 30; +const WHEEL_SCROLL_LINES = 3; /** * Rotating placeholder pool shown in the prompt's empty state. Phrasing @@ -374,16 +439,34 @@ export function TuiApp({ callbacks, maxVisibleRows = DEFAULT_MAX_VISIBLE_ROWS, initialLayout, + mouse, }: TuiAppProps): ReactElement { const [state, dispatch] = useReducer(reduceTuiState, { session, initialLayout }, (init) => createInitialTuiState(init.session, DEFAULT_RING_BUFFER_SIZE, init.initialLayout), ); const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); + const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); + const menuLeaderTimer = useRef(null); + const registryRef = useRef(null); + registryRef.current ??= new MouseTargetRegistry(); + const registry = registryRef.current; + // Click handlers run outside React's render pass, so they read state + // through a ref rather than a closure that may be a frame stale. + const stateRef = useRef(state); + stateRef.current = state; + const getState = useCallback(() => stateRef.current, []); useEffect(() => bus.subscribe(dispatch), [bus]); + useEffect(() => { + if (!mouse) return; + return mouse.subscribe((event) => { + registry.dispatch(event); + }); + }, [mouse, registry]); + useEffect(() => { callbacks.onProvidersTabRefresh?.(); }, [callbacks]); @@ -461,6 +544,19 @@ export function TuiApp({ }; }, [ctrlCArmed]); + // A leader that is never followed by a chord must not stay armed: it + // holds the editor unfocused and swallows whatever is typed next. + useEffect(() => { + if (!menuLeaderArmed) return; + menuLeaderTimer.current = setTimeout( + () => setMenuLeaderArmed(false), + MENU_LEADER_WINDOW_MS, + ); + return () => { + if (menuLeaderTimer.current) clearTimeout(menuLeaderTimer.current); + }; + }, [menuLeaderArmed]); + const tasksTabActive = state.uiMode === "debug" && state.activeTab === "tasks"; const skillsTabActive = @@ -482,9 +578,28 @@ export function TuiApp({ state.uiMode === "debug" && state.activeTab === "privacy"; const terminalSize = useTerminalSize(); const sidebarVisible = - state.uiMode === "chat" && terminalSize.columns >= SIDEBAR_MIN_COLUMNS; + state.uiMode === "chat" && + isSidebarVisible(terminalSize.columns, terminalSize.rows); + // The rail takes a share of the terminal rather than a flat 30 + // columns, and its two panes get a row budget cut from the terminal + // height — Ink 7 overlaps rather than clips an over-tall frame, so + // an unbudgeted rail garbles short windows. A window too short for + // even one row per pane drops the rail entirely. + const sidebarWidth = computeSidebarWidth(terminalSize.columns); + const sidebarRows = computeSidebarRowBudget(terminalSize.rows); + // Columns left for the main column once the frame gutter and the + // right rail have taken their cut — what the one-row hint strip has + // to fit inside. + const mainColumnWidth = Math.max( + 0, + terminalSize.columns - + ROOT_PADDING_COLUMNS - + (sidebarVisible ? sidebarWidth : 0), + ); const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = + !state.menuOpen && + !menuLeaderArmed && !state.pendingApproval && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update @@ -513,15 +628,106 @@ export function TuiApp({ state.localModelsPanel.removeConfirmId !== null) ))); - // When the sidebar collapses below the width threshold (terminal - // resized smaller), focus must follow back to the editor so Tab does - // not strand the operator on an invisible surface. + // When the sidebar collapses below the width or height threshold + // (terminal resized smaller), focus must follow back to the editor so + // Tab does not strand the operator on an invisible surface. useEffect(() => { if (!sidebarVisible && state.chatFocus === "sidebar") { dispatch({ type: "chat_focus_set", focus: "editor" }); } }, [sidebarVisible, state.chatFocus]); + const activateMenuNode = useCallback( + (node: MenuNode) => { + // A node that carries a slash name is *run as that command*, so the + // menu never grows a second dispatch path beside the slash handler. + if (node.slash) { + runSlashCommand(`/${node.slash.name}`, state, dispatch, callbacks); + return; + } + if (node.kind === "place") { + if (node.tab) { + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: node.tab }); + } else { + dispatch({ type: "ui_mode_set", mode: "chat" }); + } + } + }, + [state, callbacks], + ); + + /** + * Routes a key to whichever Observe / Manage panel is on screen. + * Returns `null` when no panel owns the surface (chat mode), `true` / + * `false` for handled / declined. Shared by the keyboard hook and the + * mouse wheel, so a wheel notch means exactly what an arrow key means + * on every panel — including the clamping each panel does itself. + */ + const routePanelKey = (input: string, key: Key): boolean | null => { + const ctx = { state, dispatch, callbacks }; + if (tasksTabActive) return handleTasksTabKey(input, key, ctx); + if (skillsTabActive) return handleSkillsTabKey(input, key, ctx); + if (memoryTabActive) return handleMemoryTabKey(input, key, ctx); + if (mcpTabActive) return handleMcpTabKey(input, key, ctx); + if (providersTabActive) return handleProvidersTabKey(input, key, ctx); + if (llmTabActive) return handleLlmPanelKey(input, key, ctx); + if (localModelsTabActive) return handleLocalModelsTabKey(input, key, ctx); + if (telegramTabActive) return handleTelegramTabKey(input, key, ctx); + if (importTabActive) return handleImportTabKey(input, key, ctx); + if (privacyTabActive) return handlePrivacyTabKey(input, key, ctx); + return null; + }; + + // While a modal or confirm owns the keyboard it owns the mouse too: + // raising the floor stops a click from reaching the list rendered + // behind it. Same predicate the key layer gates on. + const modalOwnsInput = + Boolean(state.pendingApproval) || + Boolean(state.updatePrompt) || + state.updateStatus === "done" || + state.sessionPickerOpen || + state.themePickerOpen || + state.slashPaletteOpen || + isPanelModalOpen(state); + useEffect(() => { + registry.setMinLayer(modalOwnsInput ? MOUSE_LAYER_MODAL : MOUSE_LAYER_BASE); + }, [registry, modalOwnsInput]); + + /** + * Whole-viewport wheel target. Scrolling over the chat moves the + * transcript; over a panel it walks that panel's cursor. Registered + * at the base layer and covering everything, so it only ever fires + * for events no smaller target claimed. + */ + const contentMouseRef = useRef(null); + const wheelHandler = (hit: MouseHit): boolean => { + if (hit.event.kind !== "wheel" || !hit.event.wheel) return false; + const direction = hit.event.wheel; + if (state.uiMode === "chat") { + dispatch({ + type: "chat_scrolled", + delta: direction === "up" ? WHEEL_SCROLL_LINES : -WHEEL_SCROLL_LINES, + }); + return true; + } + return routePanelKey("", arrowKey(direction)) === true; + }; + // TuiApp renders the provider, so it cannot consume the context hook + // itself — it registers on the registry it owns. The handler is read + // through a ref so the subscription survives every re-render. + const wheelHandlerRef = useRef(wheelHandler); + wheelHandlerRef.current = wheelHandler; + useEffect( + () => + registry.register({ + ref: contentMouseRef, + layer: MOUSE_LAYER_BASE, + handler: (hit) => wheelHandlerRef.current(hit), + }), + [registry], + ); + useInput((input, key) => { const appHandled = handleAppKey(input, key, { state, @@ -530,6 +736,9 @@ export function TuiApp({ ctrlCArmed, setCtrlCArmed, sidebarVisible, + menuLeaderArmed, + setMenuLeaderArmed, + activateMenuNode, }); if (appHandled) return; // While the slash-command palette is open, let the (now-focused) @@ -537,32 +746,7 @@ export function TuiApp({ // navigation, tab completion, enter to run, esc to close. Routing to // a debug-tab panel here would re-interpret letters as hotkeys. if (state.slashPaletteOpen) return; - let panelHandled: boolean | null = null; - if (tasksTabActive) { - panelHandled = handleTasksTabKey(input, key, { state, dispatch, callbacks }); - } else if (skillsTabActive) { - panelHandled = handleSkillsTabKey(input, key, { state, dispatch, callbacks }); - } else if (memoryTabActive) { - panelHandled = handleMemoryTabKey(input, key, { state, dispatch, callbacks }); - } else if (mcpTabActive) { - panelHandled = handleMcpTabKey(input, key, { state, dispatch, callbacks }); - } else if (providersTabActive) { - panelHandled = handleProvidersTabKey(input, key, { state, dispatch, callbacks }); - } else if (llmTabActive) { - panelHandled = handleLlmPanelKey(input, key, { state, dispatch, callbacks }); - } else if (localModelsTabActive) { - panelHandled = handleLocalModelsTabKey(input, key, { - state, - dispatch, - callbacks, - }); - } else if (telegramTabActive) { - panelHandled = handleTelegramTabKey(input, key, { state, dispatch, callbacks }); - } else if (importTabActive) { - panelHandled = handleImportTabKey(input, key, { state, dispatch, callbacks }); - } else if (privacyTabActive) { - panelHandled = handlePrivacyTabKey(input, key, { state, dispatch, callbacks }); - } + const panelHandled = routePanelKey(input, key); if (panelHandled !== null) { handlePanelEscape(key, { panelHandled, editorFocus, dispatch }); return; @@ -613,12 +797,38 @@ export function TuiApp({ dispatch({ type: "chat_scroll_reset" }); return; } - if (canAcceptMessage(state)) { - callbacks.onQuit(); - dispatch({ type: "quit_requested" }); - } else { - callbacks.onAbort(); - dispatch({ type: "abort_requested" }); + // A debug panel is open: Esc is the way back to Run, exactly as the + // hint strip advertises. The Observe tabs (Feed / World / Reasoning / + // Logs / LLM logs) have no key layer of their own, so `handlePanelEscape` + // never sees the keypress and the editor — which stays focused there so + // the operator can keep typing while watching the feed — used to fall + // through to the quit branch below and kill the agent instead. + // Only while idle: with a turn in flight the running hint says + // `[esc] abort`, and `handleAppKey` claims the key for exactly that — + // navigating away at the same time would make one keypress do two + // unrelated things. + if (state.uiMode === "debug" && canAcceptMessage(state)) { + dispatch({ type: "ui_mode_set", mode: "chat" }); + return; + } + // PRECEDENCE, decided rather than inherited from branch order: while + // a turn is in flight abort wins and the draft is left alone — and + // the abort itself is claimed by `handleAppKey`, on a subscription + // that fires whether or not the editor is live, so keeping a copy of + // the branch here would fire `onAbort` twice per keypress. Abort is + // the destructive, time-critical action; a draft is cheap to keep — + // one more Esc, this time idle, clears it. The running hint strip + // says `abort, draft kept` whenever there is a draft (see + // `hotkey-hint.tsx`). + if (!canAcceptMessage(state)) return; + // Idle: Esc never quits. Everywhere else in the TUI it means cancel / + // back one level, so a single unannounced press killing the agent — + // and the half-typed message with it — was a trap: no hint strip ever + // advertised it, while Ctrl+C deliberately asks twice. Quitting stays + // on Ctrl+C twice and `/quit`; Esc clears the draft and no-ops on an + // empty buffer. + if (state.inputValue.length > 0) { + dispatch({ type: "input_changed", value: "" }); } }, [state, callbacks]); @@ -686,8 +896,16 @@ export function TuiApp({ // the smoke tests assert against an overlapped frame. In production // the alt-screen + `height={rows}` combo gives us the opencode-style // pinned-input-at-bottom UX. + // Render-phase on purpose: `theme` is a read-at-render proxy, and children + // render after this body runs, so the flag is already correct for them. + setBackdropDimmed(state.menuOpen); + + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; + // Rows the content pane actually has, so the overlay can sit on its bottom + // edge and cap its own height. Same budget the debug pane already uses. + const menuPaneRows = Math.max(6, terminalSize.rows - APP_CHROME_ROWS); const promptLlm = selectPromptLlmMeta(state); // No local backend chosen yet ⇒ no local health to report. Without this the // splash screen of a fresh install announces that a server the user never @@ -707,17 +925,37 @@ export function TuiApp({ {promptLlm.cloudLabel ?? "cloud"} ); + // While a turn is running the meta-row's job changes: the operator + // needs to know what Enter will do to the message they are typing far + // more than they need the context-window size. + // Running only: during a pending approval every key routes to the + // approval modal first, so both Enter-routing and the ctrl+t flip are + // dead there — advertising them would promise bindings that do nothing. const promptRightSlot = - state.llmHealth.contextWindow !== null ? ( + state.status === "running" ? ( + + + {"\u23ce"} {state.whileBusyMode} + + (ctrl+t) + + ) : state.llmHealth.contextWindow !== null ? ( ctx {state.llmHealth.contextWindow} ) : null; return ( + @@ -725,7 +963,13 @@ export function TuiApp({ - + {state.uiMode === "chat" ? ( ) : ( @@ -743,6 +987,13 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( @@ -795,6 +1046,7 @@ export function TuiApp({ ) : null} + - + {sidebarVisible ? ( + ); } diff --git a/src/tui/tui-args.test.ts b/src/tui/tui-args.test.ts index bdde0131..3abbed21 100644 --- a/src/tui/tui-args.test.ts +++ b/src/tui/tui-args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { nonInteractiveStdinError, parseTuiArgs } from "./tui-args.js"; +import { nonInteractiveStdinError, parseTuiArgs, TUI_HELP } from "./tui-args.js"; describe("nonInteractiveStdinError", () => { it("refuses a piped stdin with an actionable sentence", () => { @@ -25,3 +25,23 @@ describe("parseTuiArgs", () => { expect(parseTuiArgs(["--definitely-not-a-flag"])).toHaveProperty("error"); }); }); + +describe("parseTuiArgs mouse flags", () => { + it("defers to the config by default", () => { + const parsed = parseTuiArgs([]); + expect(parsed).toMatchObject({ mouse: null }); + }); + + it("--no-mouse turns reporting off for the run", () => { + expect(parseTuiArgs(["--no-mouse"])).toMatchObject({ mouse: false }); + }); + + it("--mouse forces reporting on even when the config disabled it", () => { + expect(parseTuiArgs(["--mouse"])).toMatchObject({ mouse: true }); + }); + + it("advertises both flags in --help", () => { + expect(TUI_HELP).toContain("--no-mouse"); + expect(TUI_HELP).toContain("--mouse"); + }); +}); diff --git a/src/tui/tui-args.ts b/src/tui/tui-args.ts index d68a9599..fbd862ca 100644 --- a/src/tui/tui-args.ts +++ b/src/tui/tui-args.ts @@ -11,6 +11,12 @@ export interface TuiArgs { noApproval: boolean; /** Skip the first-run llama-server setup wizard when /health fails. */ skipLlamaSetup: boolean; + /** + * Terminal mouse reporting override. `null` defers to `tui.mouse` in + * the user config; `false` (`--no-mouse`) keeps the terminal's own + * text selection for this run. + */ + mouse: boolean | null; } export type TuiArgsResult = TuiArgs | { error: string } | { help: true }; @@ -28,6 +34,8 @@ export const TUI_HELP = " --max-steps Step budget per turn (default: agent.maxSteps from config)", " --no-approval Force approval level 5: auto-approve every dangerous tool call", " --skip-llama-setup Skip the first-run local-model setup gate", + " --mouse Force terminal mouse support on for this run", + " --no-mouse Disable mouse support; restores drag-to-select", "", "Needs an interactive terminal; in scripts use `atomic-agent run`.", ].join("\n") + "\n"; @@ -42,12 +50,14 @@ export const TUI_HELP = * --max-steps override the loop safety cap * --no-approval force approval level 5 (approve everything) for this run * --skip-llama-setup skip the startup llama URL wizard + * --mouse / --no-mouse force mouse reporting on / off */ export function parseTuiArgs(args: string[]): TuiArgsResult { let workingDir: string | null = null; let maxSteps: number | null = null; let noApproval = false; let skipLlamaSetup = false; + let mouse: boolean | null = null; for (let i = 0; i < args.length; i += 1) { const flag = args[i]; switch (flag) { @@ -74,6 +84,12 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { case "--skip-llama-setup": skipLlamaSetup = true; break; + case "--mouse": + mouse = true; + break; + case "--no-mouse": + mouse = false; + break; default: return { error: `unknown flag: ${flag}` }; } @@ -83,6 +99,7 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { maxSteps, noApproval, skipLlamaSetup, + mouse, }; } diff --git a/src/tui/tui-command.mouse.test.ts b/src/tui/tui-command.mouse.test.ts new file mode 100644 index 00000000..bd94894d --- /dev/null +++ b/src/tui/tui-command.mouse.test.ts @@ -0,0 +1,254 @@ +/** + * `/mouse on` has to reach the mounted tree. + * + * The toggle lives outside React — it reassigns a tracking controller and + * writes escape sequences — while the thing that consumes clicks is a + * prop fixed at mount. Gating that prop on the startup value made + * `/mouse on` a no-op for any session that started with `tui.mouse: + * false`: reporting turned on, the confirmation said so, and every click + * was dropped. These tests boot `tuiCommand` far enough to capture the + * props it hands `TuiApp` and then drive the toggle exactly as the slash + * command does. + * + * `mouse-app.test.tsx` covers the other half of the chain (a source + * event moving the real UI), so between the two the path from a byte on + * stdin to a section change is closed. + */ +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ensureUserConfigFileSync, + getConfig, + resetConfigCache, + writeUserConfigFileSync, +} from "../config/index.js"; +import type { TuiMouseEvent } from "./mouse/mouse-event.js"; +import type { MouseSource } from "./mouse/mouse-source.js"; + +const inkRender = vi.hoisted(() => vi.fn()); +const trackingCalls = vi.hoisted(() => ({ enabled: 0, disabled: 0 })); + +// `sea` is one of the few builtins Node only publishes under the +// `node:` prefix, and Vite's builtin check strips that prefix — so the +// bare import in `tui-command.ts` is unresolvable in the test runner. +// The restart handoff that uses it is not under test here. +vi.mock("node:sea", () => ({ isSea: () => false })); + +vi.mock("ink", async (importOriginal) => ({ + ...(await importOriginal()), + render: inkRender, +})); + +vi.mock("./alt-screen.js", () => ({ + enterAltScreen: () => ({ restore: () => {} }), +})); + +vi.mock("./mouse/mouse-tracking.js", () => ({ + enableMouseTracking: () => { + trackingCalls.enabled += 1; + return { + disable: () => { + trackingCalls.disabled += 1; + }, + }; + }, +})); + +vi.mock("../runtime/bootstrap.js", () => ({ + createAgentRuntime: async () => ({ + skillCatalog: [], + approvals: { resolve: () => {} }, + config: { telegram: { enabled: false } }, + }), +})); + +vi.mock("./chat-orchestrator.js", () => ({ + ChatOrchestrator: class { + exitCode = 0; + telegram = { forwardStatus: () => {} }; + localModels = { autoStartIfReady: async () => {} }; + start(): void {} + quit(): void {} + async checkForUpdate(): Promise {} + async shutdown(): Promise {} + }, +})); + +/** SGR 1006 left-button press at 1-based (col, row). */ +function sgrPress(col: number, row: number): Buffer { + return Buffer.from(`\u001B[<0;${col};${row}M`); +} + +/** + * Stands in for `process.stdin`: `tuiCommand` reads the real one, so the + * test swaps in a stream it can push bytes through. `isTTY` also gets + * the TUI past its non-interactive-stdin refusal. + */ +function makeFakeStdin(): NodeJS.ReadStream { + const stdin = new EventEmitter() as unknown as NodeJS.ReadStream; + Object.defineProperty(stdin, "isTTY", { value: true, configurable: true }); + stdin.setRawMode = () => stdin; + stdin.ref = () => stdin; + stdin.unref = () => stdin; + return stdin; +} + +interface Booted { + readonly mouse: MouseSource | undefined; + readonly setMouseEnabled: (next: boolean | null) => void; + readonly seen: TuiMouseEvent[]; + readonly stop: () => Promise; +} + +/** + * Runs `tuiCommand` up to the point where Ink would mount, and returns + * the mouse wiring it produced. `waitUntilExit` stays pending until + * `stop()` so the toggle can be exercised against a live session. + */ +async function bootTui(args: string[] = []): Promise { + let releaseExit = (): void => {}; + const exited = new Promise((resolve) => { + releaseExit = resolve; + }); + let props: Record | null = null; + inkRender.mockImplementation((element: { props: Record }) => { + props = element.props; + return { waitUntilExit: () => exited, clear: () => {} }; + }); + + const { tuiCommand } = await import("./tui-command.js"); + const finished = tuiCommand(["--skip-llama-setup", ...args]); + for (let attempt = 0; props === null && attempt < 200; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (props === null) throw new Error("TuiApp never rendered"); + const captured = props as { + mouse?: MouseSource; + callbacks: { onMouseSupportRequested?: (next: boolean | null) => void }; + }; + + const seen: TuiMouseEvent[] = []; + captured.mouse?.subscribe((event) => seen.push(event)); + const setMouseEnabled = captured.callbacks.onMouseSupportRequested; + if (!setMouseEnabled) throw new Error("onMouseSupportRequested not wired"); + + return { + mouse: captured.mouse, + setMouseEnabled, + seen, + stop: async () => { + releaseExit(); + return finished; + }, + }; +} + +describe("tuiCommand mouse wiring", () => { + let stateDir: string; + let realStdin: PropertyDescriptor | undefined; + let stdin: NodeJS.ReadStream; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "tui-mouse-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + resetConfigCache(); + trackingCalls.enabled = 0; + trackingCalls.disabled = 0; + stdin = makeFakeStdin(); + realStdin = Object.getOwnPropertyDescriptor(process, "stdin"); + Object.defineProperty(process, "stdin", { + value: stdin, + configurable: true, + }); + }); + + afterEach(() => { + if (realStdin) Object.defineProperty(process, "stdin", realStdin); + rmSync(stateDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + vi.resetModules(); + }); + + function writeMouseConfig(mouse: boolean): void { + const path = getConfig().paths.userConfigFile; + const file = ensureUserConfigFileSync(path); + writeUserConfigFileSync(path, { + ...file, + tui: { ...file.tui, theme: "dark", mouse }, + }); + resetConfigCache(); + } + + it("hands TuiApp a mouse source even when mouse support starts off", async () => { + writeMouseConfig(false); + const app = await bootTui(); + expect(app.mouse).toBeDefined(); + expect(trackingCalls.enabled).toBe(0); + await app.stop(); + }); + + it("delivers clicks to the mounted source after /mouse on", async () => { + writeMouseConfig(false); + const app = await bootTui(); + + // Nothing reaches the tree while reporting is off, even if bytes + // somehow arrive (a multiplexer that ate the disable, a paste). + stdin.emit("data", sgrPress(5, 3)); + expect(app.seen).toHaveLength(0); + + app.setMouseEnabled(true); + expect(trackingCalls.enabled).toBe(1); + + stdin.emit("data", sgrPress(5, 3)); + expect(app.seen).toHaveLength(1); + expect(app.seen[0]).toMatchObject({ kind: "press", button: "left", x: 4, y: 2 }); + // The source the tree subscribed to at mount is the one receiving + // them — that is the whole point. + expect(app.mouse).toBeDefined(); + await app.stop(); + }); + + it("stops delivering clicks after /mouse off", async () => { + writeMouseConfig(true); + const app = await bootTui(); + expect(trackingCalls.enabled).toBe(1); + + stdin.emit("data", sgrPress(9, 4)); + expect(app.seen).toHaveLength(1); + + app.setMouseEnabled(false); + expect(trackingCalls.disabled).toBe(1); + + stdin.emit("data", sgrPress(9, 4)); + expect(app.seen).toHaveLength(1); + await app.stop(); + }); + + it("survives a full off → on cycle", async () => { + writeMouseConfig(true); + const app = await bootTui(); + app.setMouseEnabled(false); + app.setMouseEnabled(true); + stdin.emit("data", sgrPress(2, 2)); + expect(app.seen).toHaveLength(1); + expect(getConfig().tui.mouse).toBe(true); + await app.stop(); + }); + + it("--no-mouse still wires the source so /mouse on can turn it on", async () => { + writeMouseConfig(true); + const app = await bootTui(["--no-mouse"]); + expect(app.mouse).toBeDefined(); + expect(trackingCalls.enabled).toBe(0); + + app.setMouseEnabled(true); + stdin.emit("data", sgrPress(7, 1)); + expect(app.seen).toHaveLength(1); + await app.stop(); + }); +}); diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 132a37c2..3fc51a7e 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -3,7 +3,11 @@ import { isSea } from "node:sea"; import { render } from "ink"; import React from "react"; import { resolveBootApprovalLevel } from "../approval/approval-level.js"; -import { formatDotenvReadWarning, getConfig } from "../config/index.js"; +import { + formatDotenvReadWarning, + getConfig, + type WhileBusySubmitMode, +} from "../config/index.js"; import { checkLlamaServer } from "../llm/llama-server-health.js"; import { createAgentRuntime } from "../runtime/bootstrap.js"; import type { LogRecord, LogSink } from "../tracing/structured-logger.js"; @@ -19,12 +23,26 @@ import { persistUserLocalLlmUrl, pointsAtManagedDaemon, } from "./persist-user-local-models-config.js"; -import { persistUserTuiTheme } from "./persist-user-tui-config.js"; +import { + persistUserTuiMouse, + persistUserTuiTheme, + persistUserWhileBusySubmit, +} from "./persist-user-tui-config.js"; +import { createMouseStdin } from "./mouse/mouse-stdin.js"; +import { makeMouseSource } from "./mouse/mouse-source.js"; +import { + enableMouseTracking, + type MouseTrackingController, +} from "./mouse/mouse-tracking.js"; import { isLocalBackendConfigured, isManagedModeReadyOnDisk, runLocalModelsStartupGateIfNeeded, } from "./run-local-models-config-wizard.js"; +import { + currentTerminalLaunchInput, + openAgentTerminalWindow, +} from "./open-terminal-window.js"; import { makeTuiEventBus, TuiApp } from "./tui-app.js"; import { detectTerminalBackground, @@ -89,11 +107,17 @@ export async function tuiCommand(args: string[]): Promise { // mode is selected but nothing is ready on disk yet — they still // need to pick + pull a model before chat is useful. Fully-ready // managed setups and external-URL setups land in chat as usual. - const initialLayout: InitialTuiLayoutOptions | undefined = + const layoutBase: InitialTuiLayoutOptions | undefined = startupGate === "saved_managed" && !isManagedModeReadyOnDisk() ? { uiMode: "debug", activeTab: "llm" } : undefined; const config = getConfig(); + // Seed what Enter does while a turn is running from the persisted + // preference, so a Ctrl+T flip survives a restart. + const initialLayout: InitialTuiLayoutOptions = { + ...(layoutBase ?? {}), + whileBusyMode: config.tui.whileBusySubmit, + }; const approvalLevel = resolveBootApprovalLevel( parsed.noApproval, config.agent.approvalLevel, @@ -180,18 +204,71 @@ export async function tuiCommand(args: string[]): Promise { const altScreen = enterAltScreen({ stdout: process.stdout, hideCursor: false }); - // Mouse-wheel scroll relies on the terminal's alternate-scroll mode - // (`\x1b[?1007h`, enabled by `enterAltScreen`): while the alt screen - // is active the wheel is translated by the terminal into cursor - // up/down keys, which `handleAppKey.shouldTreatArrowAsChatScroll` - // routes into `chat_scrolled`. Crucially we do NOT enable SGR mouse - // tracking (1000 + 1006) — doing so would hand every click/drag to - // the app and disable the terminal's native text selection (broken - // entirely in Apple Terminal, which has no Shift-bypass). Keeping - // capture off means drag-to-copy works natively everywhere, matching - // opencode's default. The wheel only drives chat scroll while the - // editor is focused and empty — an accepted trade-off for native - // selection. + // Mouse support. Enabling SGR tracking (1000 + 1006) is what makes + // clicking panels, rows, tabs and the prompt work at all — the app + // cannot see a click the terminal never reports. The cost is real and + // was the reason this was previously left off: while reporting is on, + // the terminal stops doing its own drag-to-select (Apple Terminal has + // no Shift-bypass at all). So it is a toggle, not a fact of life — + // `tui.mouse` in the config, `--mouse` / `--no-mouse` per run, and + // `/mouse on|off` live. With reporting off, behaviour is exactly what + // it was before: alternate-scroll (`\x1b[?1007h` from + // `enterAltScreen`) turns the wheel into cursor keys, which + // `handleAppKey.shouldTreatArrowAsChatScroll` routes into + // `chat_scrolled`. + // + // The decoded events reach React through `mouseSource`; the bytes + // themselves are stripped from the stream Ink reads, because Ink's key + // parser would otherwise type them into the chat buffer. + const mouseEnabled = parsed.mouse ?? config.tui.mouse; + const mouseSource = makeMouseSource(); + let mouseTracking: MouseTrackingController | null = mouseEnabled + ? enableMouseTracking({ stdout: process.stdout }) + : null; + // `mouseTracking` is the single source of truth for "is the mouse on", + // and it is read here on every report rather than captured, so + // `setMouseEnabled` reassigning it takes effect immediately. Normally + // a terminal that was told to stop reporting sends nothing anyway, but + // this keeps `/mouse off` honest for the cases where it still does: + // a multiplexer that swallowed the disable, or a bracketed paste whose + // payload happens to contain an SGR report. + const mouseStdin = createMouseStdin(process.stdin, (event) => { + if (mouseTracking) mouseSource.emit(event); + }); + const setMouseEnabled = (next: boolean | null): void => { + if (next === null) { + bus.emit({ + type: "system_message", + text: `mouse support is ${mouseTracking ? "on" : "off"} — /mouse on|off to change`, + }); + return; + } + if (next === Boolean(mouseTracking)) { + bus.emit({ + type: "system_message", + text: `mouse support already ${next ? "on" : "off"}`, + }); + return; + } + if (next) { + mouseTracking = enableMouseTracking({ stdout: process.stdout }); + } else { + mouseTracking?.disable(); + mouseTracking = null; + } + try { + persistUserTuiMouse(next); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + bus.emit({ type: "runtime_info", line: `mouse setting not saved: ${msg}` }); + } + bus.emit({ + type: "system_message", + text: next + ? "mouse support on — click panels, rows and the prompt; wheel scrolls" + : "mouse support off — the terminal's own text selection is back", + }); + }; const ink = render( React.createElement(TuiApp, { @@ -210,9 +287,14 @@ export async function tuiCommand(args: string[]): Promise { }); }, onMessageSubmitted: (text) => orchestrator.sendMessage(text), + onQueueClearRequested: () => orchestrator.clearQueue(), + onMessageSteered: (text) => orchestrator.steerMessage(text), + onWhileBusyModePersistRequested: (mode) => + persistWhileBusyMode(mode, bus), onSessionPickerRequested: () => orchestrator.openSessionPicker(), onSessionSwitchRequested: (id) => orchestrator.switchSession(id), onSessionNewRequested: () => orchestrator.newSession(), + onNewWindowRequested: () => openNewAgentWindow(parsed.workingDir, bus), onMemoryDumpRequested: () => orchestrator.dumpProfile(), onSkillCatalogRequested: () => orchestrator.dumpSkillCatalog(), onPersistLlamaUrl: (nextUrl) => persistLlamaUrl(nextUrl, bus, orchestrator), @@ -301,6 +383,8 @@ export async function tuiCommand(args: string[]): Promise { void orchestrator.providers.selectEmbeddingModel(providerId, modelId), onProvidersWizardSubmit: (wizard) => void orchestrator.providers.completeWizard(wizard), + onProvidersWizardSubmitCancel: () => + orchestrator.providers.cancelWizardVerification(), onProvidersRemove: (id) => void orchestrator.providers.removeProviderById(id), onImportPreview: (form) => orchestrator.import.preview(form), @@ -386,10 +470,21 @@ export async function tuiCommand(args: string[]): Promise { onUpdateRestart: () => { restartRequested = true; }, + onMouseSupportRequested: setMouseEnabled, }, + // Unconditional on purpose. `mouseEnabled` is a startup-time + // value, but `/mouse on` flips reporting *later* and cannot + // re-parent an already-mounted tree — gating the prop on it meant + // a session started with `tui.mouse: false` kept `mouse === + // undefined` forever, so `TuiApp`'s subscribe effect returned + // early and the clicks the terminal had just started reporting + // went nowhere while the UI claimed mouse support was on. + // Subscribing costs nothing while the mouse is off: the forwarder + // above is what decides whether anything is ever emitted. + mouse: mouseSource, }), { - stdin: process.stdin, + stdin: mouseStdin.stdin, stdout: process.stdout, stderr: process.stderr, exitOnCtrlC: false, @@ -434,6 +529,8 @@ export async function tuiCommand(args: string[]): Promise { process.off("SIGTERM", onSignal); process.off("SIGHUP", onSignal); try { + mouseTracking?.disable(); + mouseStdin.dispose(); altScreen.restore(); ink.clear(); } catch { @@ -469,6 +566,36 @@ export async function tuiCommand(args: string[]): Promise { return orchestrator.exitCode; } +/** + * Ctrl+N / `/window`: launch a second agent in a new OS terminal window. + * Fire-and-forget — the result is reported into the chat log either way, + * because a silently ignored keystroke is the worst possible outcome + * here (the operator cannot tell "not implemented" from "nothing + * happened"). + */ +function openNewAgentWindow( + workingDir: string, + bus: ReturnType, +): void { + void (async () => { + const result = await openAgentTerminalWindow( + currentTerminalLaunchInput(workingDir, isSea()), + ); + if (result.ok) { + bus.emit({ + type: "system_message", + text: `opened a new atomic-agent window (${result.label})`, + }); + return; + } + bus.emit({ + type: "system_message", + variant: "warn", + text: `could not open a new terminal window: ${result.reason}`, + }); + })(); +} + function persistThemeChoice( themeName: string, bus: ReturnType, @@ -482,6 +609,25 @@ function persistThemeChoice( } } +function persistWhileBusyMode( + mode: WhileBusySubmitMode, + bus: ReturnType, +): void { + try { + persistUserWhileBusySubmit(mode); + bus.emit({ + type: "runtime_info", + line: + mode === "steer" + ? "Enter now steers the running turn" + : "Enter now queues behind the running turn", + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + bus.emit({ type: "runtime_info", line: `mode not saved: ${msg}` }); + } +} + function persistLlamaUrl( nextUrl: string, bus: ReturnType, diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index aee4377f..1c7859bd 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -1,4 +1,5 @@ import type { ApprovalRequest } from "../approval/approval-gate.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { LatestResult, LoadedSkillBody, @@ -303,12 +304,30 @@ export interface TuiState { * the newest entry at the end. */ inputHistoryCursor: number | null; + /** + * The half-written draft that was in the editor when history recall + * started, parked so Down can hand it back. `null` whenever the editor + * is showing the live buffer — recall always stashes before it + * overwrites, and any real edit to a recalled entry drops the stash. + */ + inputHistoryDraft: string | null; /** Is the slash-command overlay currently visible below the editor? */ slashPaletteOpen: boolean; /** Current slash prefix (characters typed after the leading `/`). */ slashQuery: string; /** Highlighted row in the slash palette. */ slashPaletteCursor: number; + /** + * Operator menu (`ctrl+p`) — the browsable half of the navigation surface. + * `menuPath` is the id of the submenu currently open, or `null` at the + * root; the tree is one level deep by construction so a single id is + * enough. A non-empty `menuQuery` flattens the tree: search ranks across + * every node regardless of where it lives. + */ + menuOpen: boolean; + menuPath: string | null; + menuQuery: string; + menuCursor: number; /** Which tool cards are shown expanded by the user. */ toolsExpandedById: Readonly>; /** Is the session picker overlay visible? */ @@ -416,22 +435,54 @@ export interface TuiState { * loses sight of the freshest reply. */ chatScrollOffset: number; + /** + * Messages the operator submitted while a turn was still running, in + * submission order. Mirrors `ChatOrchestrator`'s internal queue — the + * orchestrator is the source of truth and re-publishes the list via + * `queue_changed` on every mutation; this slice exists so the prompt + * can show what is parked without reaching into the orchestrator. + */ + queuedMessages: readonly string[]; + /** + * What Enter does while a turn is running: `steer` folds the message + * into the turn in flight, `queue` parks it for the next one. Seeded + * from `config.tui.whileBusySubmit` at mount and flipped in-app with + * Ctrl+T (persisted). Irrelevant when idle — Enter always starts a + * turn then. + */ + whileBusyMode: WhileBusySubmitMode; } /** - * Derived selector: can the user submit a new chat message right now? - * Used by both the input component (disable when busy) and the - * orchestrator (reject submissions sent while a turn is still in flight). + * Derived selector: can a new turn start *right now*? Used by the + * submit pipeline to decide between running the message immediately and + * parking it behind the turn in flight. */ export function canAcceptMessage(state: TuiState): boolean { return state.status === "idle"; } +/** + * Derived selector: may the operator put characters into the editor? + * + * Deliberately weaker than {@link canAcceptMessage}. The editor used to + * be disabled for the whole duration of a turn, which meant a running + * agent swallowed every keystroke — you could not even draft the next + * message, let alone send it. Typing is now allowed whenever the app is + * not tearing down; a submission made while busy is queued rather than + * dropped (see `handleEditorSubmit`). + */ +export function canTypeMessage(state: TuiState): boolean { + return state.status !== "quitting"; +} + export const DEFAULT_RING_BUFFER_SIZE = 500; export interface InitialTuiLayoutOptions { uiMode?: TuiUiMode; activeTab?: TuiTab; + /** Seeds {@link TuiState.whileBusyMode} from the persisted user config. */ + whileBusyMode?: WhileBusySubmitMode; } export function createInitialTuiState( @@ -486,9 +537,14 @@ export function createInitialTuiState( inputValue: "", inputHistory: [], inputHistoryCursor: null, + inputHistoryDraft: null, slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, toolsExpandedById: {}, sessionPickerOpen: false, sessionPickerList: [], @@ -523,5 +579,7 @@ export function createInitialTuiState( sidebarCursor: 0, sidebarTasksCursor: 0, chatScrollOffset: 0, + queuedMessages: [], + whileBusyMode: layout?.whileBusyMode ?? "steer", }; }