Skip to content

feat(tui): mouse support — click the nav bar, panels, selectors and the prompt - #165

Merged
sosidudku1 merged 36 commits into
mainfrom
feat/tui-mouse
Aug 20, 2026
Merged

feat(tui): mouse support — click the nav bar, panels, selectors and the prompt#165
sosidudku1 merged 36 commits into
mainfrom
feat/tui-mouse

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

What

Makes the TUI clickable: the navigation bar and sub-tabs, the sidebar, every list panel, the pickers and modals, tool cards, and the prompt input (click to place the caret). The wheel scrolls the chat and walks the focused panel.

Why this reverses a deliberate decision

src/tui/tui-command.ts carried an explicit comment choosing not to enable SGR mouse tracking, because capture takes away the terminal's own drag-to-select — and Apple Terminal has no Shift-bypass. That reasoning is sound, but it is an argument for a setting, not for having no mouse layer at all. So:

  • mouse reporting is on by default, and
  • there are three ways to turn it off: /mouse off at runtime, atomic-agent tui --no-mouse for one run, and "tui": { "mouse": false } in config.json (config v38, transparently upgraded).

With mouse off, behaviour is byte-for-byte what it was before: alternate-scroll (\x1b[?1007h) turns the wheel into cursor keys and the old arrow-key chat scroll handles it.

Only 1000 + 1006 are requested — button events plus SGR coordinates. Motion tracking (1002/1003) is deliberately not enabled: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream for a UI that would ignore them.

How it works — src/tui/mouse/

File Role
mouse-tracking.ts Enable/disable the reporting modes. Same shape as alt-screen.ts, including the process.on("exit") restore so a crash never leaves the host terminal printing click escapes into the user's shell.
parse-mouse-events.ts Pure decoder for SGR and legacy X10 reports. Reassembles a report split across two reads. A lone trailing ESC is passed straight through — buffering it would delay the Escape key until the next keystroke.
mouse-stdin.ts Ink has no mouse layer and would type the raw reports into the chat buffer, so Ink is handed a PassThrough carrying only keyboard bytes, with isTTY / setRawMode / ref / unref proxied to the real stdin.
mouse-registry.ts Hit testing. Ink exposes no absolute positions (measureElement returns a size only), but every node keeps its Yoga node — absoluteRect sums getComputedLeft/Top up the parent chain, the same walk render-node-to-output.ts performs when painting, so the rectangle is exactly where the node was drawn. Ancestors with overflow: hidden clip it.
mouse-context.tsx React glue: a provider carrying dispatch / callbacks / a live getState, plus useMouseTarget and a layout-neutral <MouseTarget>. Outside the provider (component tests, --no-mouse) every clickable component renders exactly as before.
mouse-list-row.tsx The shared list row: click selects, a second click on the selected row activates.
synthetic-key.ts Activation and the wheel are fed to each panel's existing *-key-bindings.ts handler as a synthetic Enter / arrow key.

Two behavioural rules worth calling out:

  • First click selects, second click activates. No double-click timing window (unreliable over SSH, invisible to the user) and no open-on-first-click, which would make a mis-click destructive in lists where Enter starts a download or switches sessions. It mirrors what the keyboard already does: arrow to the row, then Enter.
  • A modal owns the mouse exactly when it owns the keyboard. TuiApp raises the registry's layer floor using isPanelModalOpen — the same predicate extracted out of handleAppKey — so a click can never reach the list rendered behind an open confirm.

What became clickable

  • Run / Observe / Manage pills and the Observe / Manage sub-tab strip
  • Sidebar: session rows and task rows (click focuses the rail and moves the cursor; click again opens)
  • Lists: skills, skills hub, tasks, memory, MCP, LLM panel (all four panes), providers, local models
  • Pickers: session, theme (with live preview on select), slash palette
  • Approval modal [y] / [s] / [a] / [n], plus the y / n hotkey chips
  • Hotkey chips with an unambiguous meaning (tab, shift+tab, esc, /) — chips that name a gesture rather than a command stay plain text
  • Tool cards: the per-card expand toggle existed in the reducer but had no key binding at all — only /expand and /collapse, which act on every card. The mouse is the first way to open one card.
  • The prompt: click places the caret. rowColToCursor does not clamp, so a click past the end of a short line would have run the offset into the next line; it is clamped at the call site, making a click in the empty space right of a line mean "end of this line".

Supporting changes

  • Absolute *_cursor_set actions for the cursors that were delta-only: sidebar sessions/tasks, session picker, theme picker, providers, local models, skills hub.
  • isPanelModalOpen and decideApproval extracted from app-key-bindings.ts so the mouse and the keyboard share one predicate and one approval path instead of two copies that can drift.
  • The nav pill, sub-tab and hotkey strips became per-item boxes (they were single <Text> runs) so each item is measurable; they use flexWrap="wrap" so a narrow terminal wraps whole chips instead of clipping characters.
  • tui.mouse config key (v38), --mouse / --no-mouse flags, /mouse [on|off] command.
  • README + AGENTS.md document the layer and the selection trade-off.

Testing

  • npm run lint clean.
  • New unit tests: escape sequences (mouse-tracking.test.ts), the decoder including split chunks / wheel / modifiers / X10 / lone-ESC (parse-mouse-events.test.ts), the stdin split (mouse-stdin.test.ts), and hit testing including nesting, clipping and the modal layer gate (mouse-registry.test.ts).
  • mouse-app.test.tsx drives the real Ink tree: it finds a label in the rendered frame and emits a click at those coordinates, then asserts the section switched, the sub-tab switched, the caret landed mid-word, a click past EOL clamped, the wheel walked a panel cursor, and a row click moved the cursor. Because Ink commits frames on a ~30fps throttle, these poll the frame instead of sleeping a fixed interval.
  • Full suite on this machine: 8 failed / 4113 passed, against 9 failed / 4063 passed on main at 667dae1. Every failure on the branch is also a failure on main (stale splash/hint expectations, the $HOME-dependent fs-glob-real case, and the known flaky send-message-concurrency / llm-health-poller pair) — no new ones, +50 tests.
  • Driven manually in a real PTY against the built binary (node dist/cli/index.js tui) with synthetic SGR reports, rendered through a terminal emulator: mouse enable sequences on startup ✅, click on the Observe pill switches section ✅, click on the Logs sub-tab switches tab ✅, click mid-word in the prompt puts the caret there (typing produced helXlo world) ✅, /mouse off writes \x1b[?1006l\x1b[?1000l and confirms on screen ✅, /mouse on re-enables ✅, quitting restores reporting and leaves the alt screen ✅.

Overlap with the open run-mode stack (#161#163)

Branched off main @ 667dae1, so it is conflict-free today, but two touch points are worth knowing:

…he prompt

The TUI had no mouse layer at all: Ink parses stdin as keystrokes only,
and `tui-command.ts` deliberately left SGR mouse tracking off because
capture takes the terminal's own drag-to-select away (Apple Terminal has
no Shift-bypass). That trade-off is real, but it is a *setting*, not a
reason to have no mouse at all — so mouse reporting is now on by default
with three ways to turn it off, and everything the keyboard can reach is
clickable.

New `src/tui/mouse/`:

- `mouse-tracking.ts` — enables 1000 + 1006 (button events + SGR
  coordinates). Motion tracking (1002/1003) is deliberately not
  requested: nothing hovers or drags. Paired with a `process.on("exit")`
  restore so a crash never leaves the terminal reporting clicks into the
  user's shell.
- `parse-mouse-events.ts` — pure decoder for SGR and legacy X10 reports.
  Buffers a report split across two reads; passes a lone trailing ESC
  straight through, since holding it would delay the Escape key by one
  keystroke.
- `mouse-stdin.ts` — hands Ink a stream with the mouse bytes removed
  (they would otherwise be typed into the chat buffer as mojibake) while
  proxying isTTY / setRawMode / ref / unref to the real stdin.
- `mouse-registry.ts` — hit testing. Ink exposes no absolute positions,
  but every node keeps its Yoga node, so `absoluteRect` sums
  getComputedLeft/Top up the parent chain — the same walk the renderer
  does when painting. Ancestors with `overflow: hidden` clip the result.
- `mouse-context.tsx` / `mouse-list-row.tsx` — React glue plus the shared
  row: first click selects, a second click on the selected row activates.
  Both activation and the wheel are routed through each panel's existing
  `*-key-bindings.ts` handler with a synthetic Enter / arrow, so the
  mouse cannot drift from the keyboard.

Wired up: Run / Observe / Manage pills and the sub-tab strip, sidebar
sessions and tasks, skills, skills hub, tasks, memory, MCP, LLM,
providers and local-model rows, the session / theme / slash pickers,
approval decision buttons, tool cards (the per-card toggle had no key
binding at all until now), clickable hotkey chips, click-to-place-caret
in the prompt, and wheel scrolling.

Delta-only cursors gained absolute `*_cursor_set` actions (sidebar,
session picker, theme picker, providers, local models, skills hub).
`isPanelModalOpen` and `decideApproval` were extracted from
`app-key-bindings.ts` so the mouse gates and resolves on exactly the same
predicates as the keyboard.

Off switch: `tui.mouse` (config v38, default true), `--mouse` /
`--no-mouse`, and `/mouse on|off` at runtime. With it off, behaviour is
byte-for-byte what it was before — alternate-scroll turns the wheel into
cursor keys.
@sosidudku1

Copy link
Copy Markdown
Collaborator

The terminal-mode hygiene here is genuinely good and worth crediting: cleanup runs on normal quit, SIGINT/SIGTERM/SIGHUP and via a process.once("exit") net, disable() is idempotent, SGR 1006 is negotiated so wide terminals work, hit regions unregister on unmount, and the text-selection tradeoff is acknowledged and mitigated by making it a toggle rather than ignored.

One blocker: /mouse on is half-wired.

tui-command.ts:208 computes mouseEnabled once at startup, and line 444 gates the prop on that value:

...(mouseEnabled ? { mouse: mouseSource } : {}),

setMouseEnabled reassigns the local mouseTracking controller and persists the setting, but never re-renders or otherwise informs the tree. So starting with tui.mouse: false and then typing /mouse on writes the escape sequences and reports "mouse support on — click panels, rows and the prompt" while the mounted TuiApp still has mouse === undefined and its effect returns early at if (!mouse) return;.

Net result: clicks do nothing, drag-to-select is now broken, and the UI claimed it worked. Only a restart fixes it.

Since mouseSource is already created unconditionally and is inert when tracking is off, passing it unconditionally should be enough.

plombeer31 and others added 5 commits August 19, 2026 23:28
…providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.
Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).
…tion of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).
…rminal

The mouse source was passed to TuiApp only when mouse support was on at
startup, so a session launched with tui.mouse: false (or --no-mouse) kept
mouse === undefined for its whole life. /mouse on still wrote the 1000/1006
enable sequences and still printed "mouse support on — click panels, rows
and the prompt", but the subscribe effect had returned early at mount:
every click the terminal now reported went nowhere, drag-to-select was gone,
and only a restart fixed it.

Pass the source unconditionally — it is created unconditionally already —
and move the live gate to the tracking controller, which the stdin
forwarder now reads per report rather than capturing. That keeps /mouse off
honest at both ends: the terminal stops reporting, and anything that
arrives anyway (a multiplexer that ate the disable, a paste carrying an SGR
report) is dropped before it reaches the tree.

Verified on a real pty with tui.mouse: false — /mouse on negotiates 1000h +
1006h and a click on the Observe pill switches the section; /mouse off
writes 1006l + 1000l and clicks stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@plombeer31

Copy link
Copy Markdown
Collaborator Author

Fixed in a34db18.

The fix. mouse: mouseSource is now passed unconditionally. Your one-liner was the right diagnosis, but I added a second half: the stdin forwarder is now (event) => { if (mouseTracking) mouseSource.emit(event); } instead of a bare mouseSource.emit. It reads the let binding per report rather than capturing it, so the tracking controller — not a mount-time boolean — is the single live gate. Without it /mouse off only stopped clicks because the terminal went quiet; anything that still arrived (a multiplexer that swallowed the disable, a paste carrying an SGR report) would have been acted on.

Live pty check, tui.mouse: false in the config, real TUI, pyte screen, SGR reports injected only while the app has actually asked for reporting:

step before after
boot no 1000h written no 1000h written
click Observe pill (mouse off) terminal sends nothing, no switch same
/mouse on writes 1000h 1006h, banner shown same
click Observe pill nothing happens ▸ Observe
click Run pill ▸ Run
/mouse off writes 1006l 1000l, banner shown same
click Observe pill no switch no switch
forced SGR bytes with mouse off ignored (no subscriber) ignored (forwarder gate)
/mouse on again → click nothing happens ▸ Observe

Mouse bytes never leaked into the chat buffer in any state.

Tests. New src/tui/tui-command.mouse.test.ts boots tuiCommand with a fake tty and a mocked render, captures the props handed to TuiApp, and drives onMouseSupportRequested exactly as the slash command does — asserting that bytes pushed through stdin land on the source the tree subscribed to at mount. Not vacuous: reverting the unconditional prop fails 3 of the 5, reverting the forwarder gate fails 2. Together with mouse-app.test.tsx (source event → real Ink tree → UI moves) the path from a byte on stdin to a section change is covered end to end.

One incidental: tui-command.ts imports node:sea, which Node only publishes under the node: prefix while Vite's builtin check strips it — so importing this module in a test is unresolvable without vi.mock("node:sea", …). Mocked locally with a note rather than touching vitest.config.ts.

npm run lint clean. src/tui/mouse/ + the new file: 46 passed, 0 failed. Full suite (minus the src/tools/os/git/* suites, which time out in my sandbox): 4099 passed, 8 failed across 6 files — the same 8 that fail at 78a8bc0 unmodified (splash-banner/chat-log and tui-app fixtures, localModels.embeddings.url, a dev-machine fs-glob path, send-message-concurrency).

Also added an AGENTS.md line under §Mouse support recording why the prop is unconditional, since the failure mode is invisible until you toggle at runtime.

plombeer31 and others added 20 commits August 20, 2026 02:22
* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
…here (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>
* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
… in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

---------

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Dudka <n@atomicbot.ai>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
…156)

* fix(tui): keep the editor live during a turn and queue what you type

Pressing Enter used to make the input dead until the turn finished:
`canAcceptMessage` (status === "idle") gated both the submit pipeline
and the `disabled` prop on `PromptShell`, so `MultiLineEditor` dropped
every keystroke. You could not draft the next message, let alone send
one.

The queue this needs has existed all along and was unreachable:
`ChatOrchestrator.sendMessage` buffers into `this.queue` whenever a turn
is in flight and `runOneTurn` drains it FIFO. Nothing ever got that far
because the UI rejected the submit first.

- Split the selector: `canAcceptMessage` still means "a new turn may
  start now"; the new `canTypeMessage` (status !== "quitting") gates the
  editor. Typing is allowed for the whole run.
- A submit made mid-run dispatches the new `message_queued` action, not
  `message_submitted`. That distinction is load-bearing —
  `message_submitted` calls `startNewRun`, which wipes `feed`,
  `reasoning` and `streamingToolCards` and would blank the screen of the
  turn the operator is reading.
- The orchestrator re-publishes its queue on every push, drain, clear,
  session switch and quit via `queue_changed`, so the UI mirrors what
  will actually run instead of tracking an optimistic copy.
- Parked messages render as a dim strip above the prompt
  (`QueuedMessages`), the hint strip advertises what Enter does mid-run
  plus how many messages are parked, and `/queue` lists them while
  `/queue clear` drops them.

Runtime, agent loop and prompt are untouched — this is a TUI-layer fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc stops the agent, not just the current turn

Review blocker on #156. `abortCurrentTurn()` was only
`this.currentController?.abort()`. `runOneTurn` swallows the abort
rejection in its catch and falls straight through to
`this.queue.shift()`, so aborting a turn *drained* the queue instead of
discarding it: the next parked message started immediately, and
stopping a wrong run cost one Esc per parked message. `quit()` four
lines below already had this right (set `quitting`, clear the queue,
then abort) — the two paths now agree.

Newly reachable because of this PR: with the editor disabled mid-turn
nobody could put anything in the queue to begin with.

- `abortCurrentTurn()` clears the queue and re-publishes it before
  aborting, and reports `aborted: dropped N parked message(s)` in the
  feed and the transcript. The operator typed those messages; binning
  them silently is worse than one extra line.
- The queue is bounded at `MAX_QUEUED_MESSAGES` (20). Past the cap a
  submission is refused with a running `queue: full at 20 — dropped N
  message(s); Esc stops the run, /queue clear empties it`. The refusal
  still emits `queue_changed`, so the reducer's optimistic
  `message_queued` insert cannot stick on the strip.
- `emitQueue`'s whole-array copy stays, now bounded at 20 elements per
  push. Trading it for a push/shift/clear delta would put queue
  arithmetic back in the reducer — the drift this design removed by
  making the orchestrator authoritative.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distanc…
… the agent (#158)

* fix(tui): Esc returns to Run from Observe tabs instead of quitting

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc in the chat editor clears the draft instead of quitting

A single Esc on the Run screen terminated the agent the moment the
session was idle. Nothing advertised it — the chat hint strip only ever
offered "[ctrl+c] quit", and Ctrl+C deliberately asks twice before it
kills anything. Esc means cancel / back one level on every other surface
of this TUI, so the one place it meant "exit" was a trap, and it took any
half-typed message down with it.

It is reachable straight out of normal navigation: Esc walks back from a
Manage panel to Run, and the next press — the natural "and out of here
too" — used to end the session.

Esc on an idle Run screen now clears the draft (and no-ops on an empty
buffer), reusing the same `input_changed` reset the submit handler
dispatches, which also clears the input-history cursor. Quitting stays on
Ctrl+C twice and `/quit`. The abort path for a non-idle session is
unchanged.

Stacks on the Observe-tab Esc fix — both touch `onEscape`. Merge that one
first.

* fix(tui): decide Esc's abort-over-draft precedence and put it on screen

Review feedback on the Esc change: state the precedence between "abort"
and "clear draft" instead of letting it fall out of `if` ordering, and
stop the new behaviour from being as undiscoverable as the trap the
branch removed.

Precedence, decided rather than inherited: while a turn is in flight Esc
aborts and leaves the draft alone; the next Esc, now idle, clears it.
Abort is the destructive, time-critical action — the operator pressing
Esc mid-run wants the agent stopped — and a draft is cheap to keep. The
handler now tests `!canAcceptMessage(state)` first, under the name
`turnInFlight`, with the reasoning written down next to it.

That precedence turned out to be unreachable. The chat editor is
`disabled` for the duration of a turn, so its Ink `useInput`
unsubscribed — and on the chat surface it owns the *only* Esc
subscription (`handlePanelEscape` bows out on `editorFocus`,
`handleAppKey` has no chat-mode Esc branch). Esc during a turn did
nothing whatsoever, which made the strip's `esc / abort` chip a promise
the TUI could not keep. A disabled editor now forwards Esc and still
swallows every key that could touch the buffer.

Hint strip. Idle with a non-empty buffer swaps `[/] commands` — inert
there, since `/` only opens the palette from a leading slash — for
`[esc] clear draft`, so the row stays six chips wide instead of growing
a seventh. The running chip reads `abort, draft kept` when there is a
draft, which is the one thing the screen could not previously tell you.

The strip also never fitted. Ink wraps an over-wide row instead of
clipping it: at 108 columns of chips the idle footer was already two
rows at 100 columns and three garbled ones at 60, while `debug-pane`
budgets it as exactly one (`APP_CHROME_ROWS`). Chips now carry a `shed`
rank and are dropped in that order until the row fits the chat column
(terminal minus gutter minus sidebar, passed in as `width`), with
`truncate-end` clipping the essential remainder on a terminal too narrow
even for those. Idle at 80 columns: send / newline / commands or clear
draft / quit, one row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
…ning (#157)

* feat(runtime): mid-turn steering — reach the turn that is already running

Per-session FIFO is the right answer for *starting* turns and the wrong
one for *correcting* one. Today a message sent while the agent is
working cannot reach the model at all until the turn closes, so an
operator watching it head the wrong way has only one lever: abort.

`SteeringInbox` is the out-of-band channel for that, and deliberately
not a second queue — there is still exactly one path into
`AgentLoop.runTurn`:

- `runtime.steer(sessionId, text)` returns false and queues nothing when
  the session has no turn in flight. "Not steered" is a signal the
  caller acts on (fall back to runTurn, or to its own queue), never a
  silent drop.
- `AgentLoop` drains the inbox at the top of every step, so the effect
  lands at the next step boundary — never mid-inference, never
  mid-tool-call. A turn parked in a long shell call will not react until
  that call returns; that is inherent, not a bug.
- Each drained message is recorded as a real `user` ConversationTurn.
  The transcript must not lie about what the operator said.
  `packConversation` already pins the last user turn visible and
  `findCurrentMacroTurnStart` folds it into the macro-turn in progress.
- The text is ALSO repeated in `### notice`, composed with (not over)
  whatever the loop detector left there. The duplication is deliberate:
  `### notice` is the last block before `### respond`, which is the one
  place a 30B local model reliably acts on. Long pastes are clipped
  inline and point back at the transcript copy. Tail-only, so no KV
  cache invalidation.
- Nothing is lost. A message pushed after the loop's final drain — the
  last inference, or a turn cancelled before it stepped — comes back on
  `RunTurnResult.undelivered` for the caller to re-route. Shutdown
  clears the inbox so a stale steer cannot resurface in a later process.
- Bounded at 16 pending per session; push refuses past the cap rather
  than evicting the oldest, so the caller learns the message did not
  land.

No producer is wired up yet — the TUI gesture and the sidecar/HTTP
endpoints land separately. Without the `steeringInbox` dep the loop is
byte-identical to before, pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(runtime): close the lost-update window between the final drain and busy.delete

`steer()` was a check-then-act across two facts that stop being true at
two different moments. `turnController.isBusy` is cleared in the
controller's own `finally`, after `run()` settles; the loop does its
final drain earlier, inside `runTurn`. A `steer()` landing in between
was accepted — the operator's UI said the message got through — and
then sat in the inbox until the NEXT turn on that session, where it
arrived at step 0 under a notice telling the model the user sent it
"while you were working", about a turn that had already ended. It was
not in `undelivered` either: it arrived after the flush.

Acceptance is now one fact, owned by the inbox. `AgentLoop.runTurn`
calls `open(sessionId)` on entry and `closeAndDrain(sessionId)` on the
way out; `push` refuses whenever the window is shut. Because the same
indivisible call closes the window and takes what is pending, there is
no window left: a message is delivered at a step boundary, returned on
`RunTurnResult.undelivered`, or refused outright so the caller re-routes
it. `steer()` no longer consults `isBusy` at all — that was the stale
half of the pair.

Considered and rejected: a per-session turn generation captured before
the push. To be correct the generation still has to be validated inside
`push` (comparing it in the caller is the same check-then-act again), so
it buys nothing over a boolean window and adds a counter to reason
about.

The loop's `finally` also closes the window on the throw path, where no
result exists to carry `undelivered`; anything stranded there is
warn-logged rather than left for a later turn to pick up.

The new bootstrap test stands in the window deterministically — no
sleeps: `runTurn` is `enqueue({ run: () => executeTurn(...) })`, so
spelling that composition out by hand puts an assertion between the
loop's final drain and `busy.delete`, with the real controller, inbox,
loop and `runtime.steer`. With the gate removed it fails exactly as
reported: `steer()` returns true, and the text resurfaces as
`steer_applied` at step 0 of the following turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): re-route steering messages the turn could not deliver

AGENTS.md said callers MUST re-route `RunTurnResult.undelivered` and
that "the TUI pushes it onto its pending-message queue". Nothing outside
tests read the field, so every undelivered steer was dropped — a message
`steer()` had already answered "yes" to, and that the operator watched
being accepted.

`ChatOrchestrator` now holds up both ends of that contract:

- `sendMessage` offers a message typed during a turn to that turn first
  (`runtime.steer`) and only falls back to its own pending queue when
  the turn refuses it — the shape AGENTS.md prescribes for callers.
- `rerouteUndelivered` puts anything handed back at the HEAD of the
  queue. Those messages were accepted as steers for the turn that just
  ran; anything already queued was typed after `steer` had started
  refusing, i.e. later.

The keyboard gesture is unchanged: `canAcceptMessage` still accepts
submissions only while idle, because `message_submitted` resets the run
view and opening the input mid-turn needs its own action. This is the
re-route contract, not the gesture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): render steer_applied, and make the reducer's default exhaustive

`steer_applied` shipped with a doc comment promising inline rendering
and no case in `reduceAgentEvent`, so it fell through to
`default: return state`. A message the operator sent mid-turn reached
the model and never appeared on their screen. It now renders as a user
bubble plus a feed line naming the step it was folded into — and none of
the per-turn resets `user_message` implies, because it is a correction
to the turn in flight, not the start of one.

The bare `default` is what let that ship: a non-exhaustive switch gives
TypeScript nothing to say about a new event. Both switches in this file
now end in `const unhandled: never = event`, which turns the next
missing case into a compile error while still returning `state` at
runtime — a UI reducer must never throw on an event it does not
recognise. Events that are deliberately not rendered are now listed as
explicit no-ops (`loop_detected`, `prompt_built`, `llm_completed`,
`llm_raw_completion`) so the check stays meaningful.

The check immediately found a second one: `batch_trimmed` was also
swallowed. It gets a feed line — otherwise the dropped calls reappear
one-by-one on later steps with no explanation for why the batch shrank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a steer refused before the window opens is not backlog

`ChatOrchestrator` treats `currentController` as "a turn is in flight",
and that fact is strictly WIDER than the steering window. `runOneTurn`
sets it and then awaits `runtime.runTurn`; the loop's `open()` runs only
after `turnController.enqueue` stops parking in `waitOrAbort` — which is
the whole remainder of any out-of-band turn already holding the session
lock, and, on the way out, the span between the previous turn's final
drain and its promise settling.

A message submitted in that span took the steer branch, `steer()`
refused it, and it landed at the BACK of the pending queue with no
acknowledgement at all. Nothing was lost, but the operator's ordering
was: they aimed it at the turn they were watching and it queued behind
messages meant for an earlier one. They were also told nothing, having
just been told "steering the running turn" a moment earlier.

The fallback now splices the message in at the FRONT of the queue — the
same rule, and the same reason, as `rerouteUndelivered`: it is a
correction to the turn in flight, so it does not wait behind backlog.
`steeredAhead` marks how many leading entries were re-routed for the
current turn, so two messages sent into the same gap keep their typing
order instead of reversing; it resets when a turn starts, which is what
turns "aimed at the turn in flight" into "ordinary backlog". And the
refusal now emits its own `steering the running turn` line, worded to
hold whether the window was shut, not yet open, or full.

Considered and rejected: opening the window at the caller's commit
point. The window 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`. Making that correct needs a per-turn window,
i.e. exactly the generation counter 725307c rejected. It is also
unfixable on the abort-while-parked path: `run()` never executes, so
nothing closes the window or hands anything back, and a message `steer`
answered "yes" to would be dropped — the defect bcc46dd fixed.

`sendMessage` still reads exactly one fact. It does not ask
`steeringInbox.isOpen` to tell "too late" from "not yet" from "full"
apart; that would be a second fact read at a different moment than the
one `steer` acted on, and all three land the message in the same place.

The new tests run the real `TurnController` and the real
`SteeringInbox`, with only the loop body stubbed in the shape
`AgentLoop.runTurn` has (`open` on entry, `closeAndDrain` on the way
out, then a settle phase for the session save and the controller's own
`finally`). Gates, not sleeps: an out-of-band turn takes the lock, the
TUI turn parks in `waitOrAbort` behind it, the occupant drains and
closes, and the steer lands with the loop that will serve it still
parked. Before the change both fail — no acknowledgement, and the
correction runs after backlog left by an earlier turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

…
…#160)

* feat(runtime): mid-turn steering — reach the turn that is already running

Per-session FIFO is the right answer for *starting* turns and the wrong
one for *correcting* one. Today a message sent while the agent is
working cannot reach the model at all until the turn closes, so an
operator watching it head the wrong way has only one lever: abort.

`SteeringInbox` is the out-of-band channel for that, and deliberately
not a second queue — there is still exactly one path into
`AgentLoop.runTurn`:

- `runtime.steer(sessionId, text)` returns false and queues nothing when
  the session has no turn in flight. "Not steered" is a signal the
  caller acts on (fall back to runTurn, or to its own queue), never a
  silent drop.
- `AgentLoop` drains the inbox at the top of every step, so the effect
  lands at the next step boundary — never mid-inference, never
  mid-tool-call. A turn parked in a long shell call will not react until
  that call returns; that is inherent, not a bug.
- Each drained message is recorded as a real `user` ConversationTurn.
  The transcript must not lie about what the operator said.
  `packConversation` already pins the last user turn visible and
  `findCurrentMacroTurnStart` folds it into the macro-turn in progress.
- The text is ALSO repeated in `### notice`, composed with (not over)
  whatever the loop detector left there. The duplication is deliberate:
  `### notice` is the last block before `### respond`, which is the one
  place a 30B local model reliably acts on. Long pastes are clipped
  inline and point back at the transcript copy. Tail-only, so no KV
  cache invalidation.
- Nothing is lost. A message pushed after the loop's final drain — the
  last inference, or a turn cancelled before it stepped — comes back on
  `RunTurnResult.undelivered` for the caller to re-route. Shutdown
  clears the inbox so a stale steer cannot resurface in a later process.
- Bounded at 16 pending per session; push refuses past the cap rather
  than evicting the oldest, so the caller learns the message did not
  land.

No producer is wired up yet — the TUI gesture and the sidecar/HTTP
endpoints land separately. Without the `steeringInbox` dep the loop is
byte-identical to before, pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sidecar,http): steering API so hosts can redirect a running turn

Exposes the runtime's mid-turn steering on the two surfaces the desktop
shell talks to. Both handlers deliberately bypass
`turnController.enqueue` — enqueueing would park the message behind the
very turn it is meant to redirect, which is the whole bug.

Sidecar (NDJSON):
- `steer_message` request `{sessionId, text}` -> `{steered}`. `false`
  when the session is not the active one or has no turn in flight; the
  host's cue to fall back to `send_message`.
- `steer_applied` event when a message reaches the model, carrying the
  step index, so hosts can render it inline in the running turn instead
  of as the start of a new one.
- `steer_undelivered` event, emitted from `send_message` for anything
  `RunTurnResult.undelivered` hands back. The sidecar has no queue of
  its own, so a late steer goes to the host rather than nowhere.

HTTP (`atomic-agent serve`):
- `POST /api/sessions/{id}/steer` with `{text}`.
- `200 {steered:true}` while a turn is in flight.
- `409` when the session is idle, naming `/v1/chat/completions` as the
  right endpoint. Refusing beats accepting a message nothing will read.
- `429` when the per-session inbox is full.
- `400` on a missing or blank `text`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(serve): list the steer route in --help

The endpoint list in `serve --help` is the discovery surface for this
API; a route that is not in it does not exist as far as an operator is
concerned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(http): a steer the API accepted can no longer vanish

`POST /api/sessions/{id}/steer` answered `200 {steered:true}` and the
HTTP path then dropped `RunTurnResult.undelivered` on the floor. A steer
that landed during the final inference — or in a turn cancelled before
its next step — was swept out of the inbox by `flushSteering` and, with
nobody reading the hand-back, existed nowhere. The host could neither
detect the loss nor resend. The sidecar half of the same feature already
emits `steer_undelivered` for exactly this; the promise held on one
surface and not the other.

The steer and the turn are different HTTP exchanges: the steer was
answered long before the turn closed, and the completion response goes
to whoever owns the turn, who is generally not whoever steered. So the
route that ran the turn always parks the hand-back, and mirrors it onto
the response only as a fast path:

- `UndeliveredSteerStore` — per-session, in-memory, one per server.
  `openai-chat-completions.ts` parks on the success, `failed` and threw
  paths alike (on the threw path nothing flushed the inbox, so it drains
  it rather than let a steer resurface in an unrelated later turn).
- `GET /api/sessions/{id}/steer` lists what was stranded.
  `DELETE /api/sessions/{id}/steer?through={seq}` acks it. Reads do not
  consume — 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 is not swallowed unseen.
- `undelivered_steers` on the non-stream `chat.completion` body, absent
  when the turn delivered everything so vanilla completions are
  unchanged; `event: steer_undelivered` on extensions-opt-in streams,
  named after the sidecar event. Both carry the parked `seq`, so they
  are the same message, not a second copy.
- `DELETE /api/sessions/{id}` takes that session's parked steers with
  the row; the per-session cap reports what it discarded rather than
  quietly returning a short list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(http): acking parked steers no longer erases the loss count

`UndeliveredSteerStore.ack` dropped the whole box once the cursor
covered the last entry, and the box carried `discarded` too. So a host
that acks the highest seq it was given — before, or in the same pass as,
inspecting `discarded` — permanently lost the "N messages were dropped"
signal, and the next `GET` reported `discarded: 0` for a session that
genuinely lost text. The comment on that line claimed the opposite ("the
discard notice included"), but nothing proved the host had ever read it.

The two facts now have two acks, for the same reason the entry ack is a
cursor and not a bare clear: the discarded messages have no `seq` the
host was ever shown, so acking the entries cannot stand in for having
read the count.

- `ack` touches entries only, and the comment says so.
- `ackDiscarded` counts the loss down. A count rather than a flag, so
  discards that land between the host's `GET` and its `DELETE` stay
  outstanding instead of being cleared unseen — the same discipline as
  the cursor, applied to a counter.
- `DELETE /api/sessions/{id}/steer` takes `?through={seq}` and/or
  `?discarded={n}`; at least one is required (a bare DELETE is still a
  400), and the response reports both what is left and what loss is
  still outstanding, so a host that only ever calls DELETE sees it too.

The box now outlives its entries while a loss is unacknowledged, which
is what makes the notice survivable. It is still reclaimed the moment
both are empty, by `DELETE /api/sessions/{id}` (session purge), and by
`MAX_PARKED_SESSIONS` eviction — a host that never acks anything cannot
pin the store open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(http): hand a stranded-steer batch back whole

`park` pushed the batch, spliced the oldest entries out at the cap, and
returned only the survivors. That return value is the hand-back: it
becomes `undelivered_steers` on the completion body and the
`steer_undelivered` SSE frame. So a `RunTurnResult.undelivered` batch
larger than the remaining cap silently omitted its oldest messages from
the very response that exists to give them back, leaving nothing behind
but `box.discarded`.

The cap was always about accumulation — its own comment says one turn
can never hand back more than `MAX_PENDING_STEERS`, and that it "only
bites when several turns strand messages and nobody ever acks". It now
says that in code: capacity is `max(MAX_PARKED_STEERS, batch.length)`,
so eviction can only reach entries parked by *earlier* calls, never the
batch just handed over. `park` returns that batch unfiltered, and
everything it returns is retrievable on `GET` until acked.

Not reachable from today's callers — the inbox refuses past
`MAX_PENDING_STEERS` and `MAX_PARKED_STEERS` is defined as that same
number — but the store is a standalone component with its own constant,
and the failure mode if either drifts is a message that exists nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(http): let runtime.steer decide a steer, not a stale isBusy read

`POST /api/sessions/{id}/steer` gated on `turnController.isBusy(id)`
before calling `runtime.steer`. #157 removes exactly that check from
inside `steer()` as the stale half of a check-then-act: `isBusy` 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 own `finally`. Left in the route, the pre-check would
be reintroduced one layer up, rejecting a steer the runtime would have
taken.

`steer()` already returns a truthful accept/refuse, so the route asks it
first and only translates the answer. The inbox is read after a refusal
and only to choose the status code: a refusal with a full inbox is the
429, anything else is the 409. That read cannot strand a message — the
decision is already made — where the pre-check could reject one.

Both codes the API promises are unchanged: 409 for a session with no
turn to steer (naming `/v1/chat/completions` as the follow-up), 429 for
a full inbox. The 409 text now says "no turn accepting steers" rather
than "no turn in flight", which is the fact `steer()` actually reports.

Independent of #157 landing: with today's `steer()` the pre-check is
redundant, and after it the route stops being wrong. It reads
`steeringInbox.peek`, which both versions expose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
  …
…ind it (#159)

* fix(tui): keep the editor live during a turn and queue what you type

Pressing Enter used to make the input dead until the turn finished:
`canAcceptMessage` (status === "idle") gated both the submit pipeline
and the `disabled` prop on `PromptShell`, so `MultiLineEditor` dropped
every keystroke. You could not draft the next message, let alone send
one.

The queue this needs has existed all along and was unreachable:
`ChatOrchestrator.sendMessage` buffers into `this.queue` whenever a turn
is in flight and `runOneTurn` drains it FIFO. Nothing ever got that far
because the UI rejected the submit first.

- Split the selector: `canAcceptMessage` still means "a new turn may
  start now"; the new `canTypeMessage` (status !== "quitting") gates the
  editor. Typing is allowed for the whole run.
- A submit made mid-run dispatches the new `message_queued` action, not
  `message_submitted`. That distinction is load-bearing —
  `message_submitted` calls `startNewRun`, which wipes `feed`,
  `reasoning` and `streamingToolCards` and would blank the screen of the
  turn the operator is reading.
- The orchestrator re-publishes its queue on every push, drain, clear,
  session switch and quit via `queue_changed`, so the UI mirrors what
  will actually run instead of tracking an optimistic copy.
- Parked messages render as a dim strip above the prompt
  (`QueuedMessages`), the hint strip advertises what Enter does mid-run
  plus how many messages are parked, and `/queue` lists them while
  `/queue clear` drops them.

Runtime, agent loop and prompt are untouched — this is a TUI-layer fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(runtime): mid-turn steering — reach the turn that is already running

Per-session FIFO is the right answer for *starting* turns and the wrong
one for *correcting* one. Today a message sent while the agent is
working cannot reach the model at all until the turn closes, so an
operator watching it head the wrong way has only one lever: abort.

`SteeringInbox` is the out-of-band channel for that, and deliberately
not a second queue — there is still exactly one path into
`AgentLoop.runTurn`:

- `runtime.steer(sessionId, text)` returns false and queues nothing when
  the session has no turn in flight. "Not steered" is a signal the
  caller acts on (fall back to runTurn, or to its own queue), never a
  silent drop.
- `AgentLoop` drains the inbox at the top of every step, so the effect
  lands at the next step boundary — never mid-inference, never
  mid-tool-call. A turn parked in a long shell call will not react until
  that call returns; that is inherent, not a bug.
- Each drained message is recorded as a real `user` ConversationTurn.
  The transcript must not lie about what the operator said.
  `packConversation` already pins the last user turn visible and
  `findCurrentMacroTurnStart` folds it into the macro-turn in progress.
- The text is ALSO repeated in `### notice`, composed with (not over)
  whatever the loop detector left there. The duplication is deliberate:
  `### notice` is the last block before `### respond`, which is the one
  place a 30B local model reliably acts on. Long pastes are clipped
  inline and point back at the transcript copy. Tail-only, so no KV
  cache invalidation.
- Nothing is lost. A message pushed after the loop's final drain — the
  last inference, or a turn cancelled before it stepped — comes back on
  `RunTurnResult.undelivered` for the caller to re-route. Shutdown
  clears the inbox so a stale steer cannot resurface in a later process.
- Bounded at 16 pending per session; push refuses past the cap rather
  than evicting the oldest, so the caller learns the message did not
  land.

No producer is wired up yet — the TUI gesture and the sidecar/HTTP
endpoints land separately. Without the `steeringInbox` dep the loop is
byte-identical to before, pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): choose whether Enter steers the running turn or queues behind it

With the editor live for the whole turn, Enter needs a meaning while the
agent is working. `tui.whileBusySubmit` decides it — default `steer`,
because someone who types *while* the agent is working is usually
reacting to what they see it doing.

- Ctrl+T flips the mode in-app and persists it; the prompt meta-row
  shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is
  running, and the hint strip offers the flip.
- `/steer <msg>` and `/queue <msg>` land one message in the other mode
  without changing the default; bare `/steer` / `/queue` switch it.
- `/queue` also still lists what is parked, and `/queue clear` drops it.

Alt+Enter was the obvious gesture and turns out to be unavailable:
`multi-line-editor.tsx` treats Return with ANY modifier
(`key.meta || key.shift || key.ctrl`) as "insert newline", so binding it
would cost multi-line input. An explicit, visible toggle is the honest
alternative — Ctrl+T also had to join `isGlobalHotkey` so the editor
does not swallow it as text.

Two things that keep the message from ever being shown twice or lost:

- `ChatOrchestrator.steerMessage` falls back to the queue when
  `runtime.steer` returns false (the turn can end between the keypress
  and the dispatch) and re-queues anything handed back on
  `RunTurnResult.undelivered`.
- The user bubble is rendered on the `steer_applied` event, not
  optimistically at submit time, so a steer that misses the turn and
  falls back to the queue renders exactly once — when it actually
  reaches the model.

The hint strip was re-tightened while doing this: with the new chips it
no longer fit one terminal row at 80 columns, and a wrapped strip pushes
the prompt down. Labels are terse now and an armed Ctrl+C takes the row
for itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): make bare /steer and /queue persist the mode, like Ctrl+T

The docstring already said bare `/steer` / `/queue` are the persisting
form — the code only flipped `TuiState`. The operator typed `/queue`,
read "Enter now parks messages", and found `steer` back on the next
launch because `createInitialTuiState` seeds from
`config.tui.whileBusySubmit`, which nothing had written.

`dispatchQueueSub` / `dispatchSteerSub` now return `setWhileBusyMode`
alongside the `while_busy_mode_changed` action, and `runSlashCommand`
hands it to `onWhileBusyModePersistRequested` — the same callback the
Ctrl+T binding uses, so all three routes end up in one
`persistUserWhileBusySubmit` call with one error path, shaped like the
`setThemeName` branch directly above it.

The message-carrying form is untouched: `/steer <msg>` / `/queue <msg>`
return `submitWhileBusy` and leave `setWhileBusyMode` unset, so a
one-off still cannot move the default. `/queue clear` likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opu…
)

* feat(tui): ctrl+n opens a new terminal window running atomic-agent

Running a second agent meant leaving the TUI, opening a terminal by
hand, cd'ing back and typing the command. Ctrl+N (and `/window`, alias
`/newwindow`) now does it in one keystroke: a new OS terminal window
with a fresh `atomic-agent tui` in the same working directory. `/new`
keeps its meaning — a fresh session inside this process.

The platform logic is split so it is unit-reachable without opening
windows: `build-terminal-launch.ts` is a pure resolver
(osascript → Terminal/iTerm on macOS; $ATOMIC_AGENT_TERMINAL →
$TERMINAL → gnome-terminal/konsole/xfce4-terminal/kitty/alacritty/
wezterm/x-terminal-emulator/xterm on Linux; wt.exe or a `start`ed
cmd.exe on Windows), and `open-terminal-window.ts` owns the detached
spawn plus the PATH probe. A missing emulator comes back as a value and
lands in the chat log as one warn line — never a throw in the render
loop.

Two details worth keeping: argv[1] is dropped for SEA builds and kept
under plain node (same reasoning as the self-update relaunch), and
ATOMIC_AGENT_STATE_DIR travels inside the command line, because the
spawned terminal starts a login shell that inherits nothing — without
it the second window would silently use a different state dir.

* fix(config): resolve asset dirs relative to the module, not the cwd

Ctrl+N opens a new terminal that starts the agent by absolute path from
whatever directory the operator happens to be in. resolveAssetDir's last
resort was `<cwd>/grammars`, so the child died before the first token:

  Error: ENOENT: no such file or directory,
    open '/Users/valerii/grammars/tool-call.gbnf'
      at async buildGrammar (dist/llm/grammar/build-grammar.js:16:25)
      at async createAgentRuntime (dist/runtime/bootstrap.js:559:19)

buildGrammar has its own module-relative fallback, but bootstrap passes
config.paths.grammarsDir explicitly, so it never got a chance to run.

Precedence is now: explicit env var, then binary-adjacent (the SEA
layout), then two levels up from this module — `grammars/` sits beside
`dist/` in the published package and at the repo root under tsx — then
cwd. cwd is kept last rather than dropped so any layout that only ever
worked by being run from the project root keeps working.

Ctrl+N also has to hand the child every env var that steers this
resolution: a spawned login shell inherits nothing, so a parent pointed
at a non-default grammars/ or starter-skills/ would otherwise be
silently disagreed with by its own new window. They now travel inline
next to ATOMIC_AGENT_STATE_DIR on both the POSIX and cmd.exe paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSa…
…TDIR

The user-supplied path was handed to spawn as the child process cwd while the
search target was hardcoded to ".", so pointing the tool at a file -- which
its own description invites -- failed before ripgrep ever ran:

  os.fs.grep {"pattern":"endopsychic","path":"darktrace.txt"}
  spawn ENOTDIR

The target now goes to ripgrep as a positional argument and cwd is a real
directory: the path itself when it is a directory, its parent when it is a
file. A path that does not exist reports that plainly rather than surfacing an
OS errno the model cannot act on.

The existing suite stubbed runCommand against a fixture root that never existed
on disk, which is why it could not see this; the new tests assert on the argv
and cwd handed to the stub.

Closes #183

(cherry picked from commit 777af37)
… by failing

vision.describe rejects more than four images per call, but the cap appeared
nowhere the model could read it -- not in the tool description, not in the
stable-prefix descriptor, not in the args schema. The model learned it by
sending twelve crops and getting an error after the work was already done.

The cap is now named in all three places, and the error carries the remedy
rather than only the complaint:

  at most 4 images per call (got 12) - split into 3 calls of at most 4

The tool description interpolates the configured value, so it stays accurate
when vision.maxImagesPerCall is raised. The descriptor and JSON schema are
static and document the default of 4, worded so a reconfigured cap does not
make them read as false.

paths gets maxItems via a copy of stringArraySchema rather than a mutation --
that const is shared by fourteen tools, and editing it in place would have
silently capped os.shell.run args at four elements. A test pins the isolation.

No auto-chunking: splitting a batch would change behaviour and multiply
vision-provider calls, which is the caller's decision to make.

Closes #185

(cherry picked from commit 25f0a04)
Models sometimes emit a tool argument as a string that contains the value:
a number as "200000", an array as "[\"a.png\"]", an object as
"{\"User-Agent\": ...}". The payload is valid JSON of the declared shape,
but a strict typeof check rejects it, the step is spent, and the error reads
as a range complaint rather than a type one.

Coercion now happens once at the dispatch point, reusing the existing
coerceJsonSchemaValue rather than patching each tool. This reaches tools the
issue did not name -- browser.scroll was rejecting amount: "3000" the same way.

The decision to coerce asks whether the schema accepts the value as written,
not whether the schema mentions string. That distinction matters for a union
like browser.scroll's amount ("page" | "half" | number): "page" validates
and is left alone, "3000" does not and is converted. os.http.request's
string-or-object body is handled by the same rule -- a JSON-looking string
there is a legitimate body, not an over-encoding.

Do no harm: only strings are candidates, a failed coercion keeps the original
so the tool's own validation still speaks, and a tool with no registered schema
passes through untouched.

Closes #182

(cherry picked from commit e301342)
sosidudku and others added 10 commits August 20, 2026 06:35
The veto told the model its last N calls were identical. On the wandering path
that was the inverse of the truth: thirteen fetches to thirteen different URLs
were reported as thirteen identical outcomes. A model that reads that after
genuinely varying its attempts learns nothing it can act on, which is a
plausible reason the same veto kept re-firing.

The message now names the invariant the model could not see -- the host it kept
returning to, or the command it kept re-running -- and distinguishes the two
cases honestly:

  BLOCKED: `os.web.fetch` - 5 consecutive calls to `web.archive.org` returned
  the same no-progress outcome.
  BLOCKED: `os.web.fetch` - 13 different attempts against `web.archive.org` and
  still no answer.

The alternative offered is now an action rather than a restatement of the
failure, following the phrasing the wandering redirect in this file already
used. Loop signals report the corrected detector, so a wandering escalation is
no longer mislabelled in traces.

Only the host reaches the message, never the full URL: a query string or an
Authorization header would otherwise be copied into the model's context, and
backticks and newlines are stripped so a crafted URL cannot inject markup into
the prompt. Two tests cover that.

Thresholds are untouched -- the issue asked for an earlier trigger, but the
default is already 5.

Closes #186

(cherry picked from commit 9c182ba)
The supported-input list ends with USER_CONFIG_VERSION, so raising that
constant silently drops the version it replaces instead of grandfathering it.
Whoever bumps next locks out every config still on the outgoing number:

  unsupported config version 38; expected one of 5, ..., 37, 39

The v36 -> v37 bump appended 36 by hand, so the convention exists; it is just
easy to miss, and the existing migration tests only exercise the current
version and much older ones, never the adjacent one. Appends the versions the
recent bumps left behind and adds a test that walks every version from the
oldest supported to the current one, so the next bump that forgets fails
loudly rather than locking users out of their own config.
curl reads [ ] { } in a URL as its own range/set syntax unless --globoff is
passed, so arXiv date-range queries and Wayback CDX regex filters never left
the machine:

  curl: (3) bad range in URL position 124

The error message is the mild half. A URL whose brackets happen to form a
valid range is worse: curl expands it and issues one request per value, so a
single tool call silently multiplies its rate-limit consumption while the
meta parser sees only the last response. Brace syntax in a hostname would
likewise have curl contact a name the SSRF guard never resolved -- not
reachable today, since the guard's own lookup of the literal brace-bearing
host fails first, but the flag closes it as defence in depth.

All three curl argv builders were affected -- os.web.fetch, os.http.request,
and the shared web-search transport, which the issue did not name but which
fails identically for any query carrying a character class.

Closes #184
…igurable

A 503 ended the fetch outright, though 128 of the 130 seen came from a single
host that serves the same URL seconds later. Retries now cover 429/502/503/504
and curl's timeout exit, two attempts on top of the first with exponential
backoff capped at 5s -- about 1.5s against a 25-minute task budget. Retry-After
is honoured when the server sends one, still clamped to the cap so a hostile
value cannot park the agent for an hour.

The 30s timeout was a module constant with no per-call or configured override,
so each timeout burned a full 30s and returned nothing. web.fetch now carries
timeoutMs and connectTimeoutMs, and the tool accepts a per-call timeoutMs the
way os.http.request already did. Separating the two matters: a host that has
not completed a handshake in 10s is unreachable rather than slow, while a large
healthy document still gets the whole transfer budget.

Reading Retry-After needs response headers, which the -w format did not
capture. %{header_json} is appended last because it contains the pipe and the
newlines the field split relies on; curl older than 7.83 emits the token
verbatim and degrades to plain backoff, which a test pins.

os.web.fetch is GET-only, so every retry is safe. The SSRF guard re-resolves
the host on each attempt -- a backoff wait must not become a window for a
stale pinned address.

Config v37 -> v38, additive: timeoutMs keeps its 30_000 default, so behaviour
is unchanged for anyone who does not opt in.

Closes #180
Closes #181
…tles

The wandering spread is a property of the history window, so it stays above
the threshold after the model stops varying its argument and starts repeating
one. A verbatim repeat was therefore still announced as

  BLOCKED: `os.web.fetch` — 13 different attempts against `web.archive.org`

and, on the escalation path where the verdict carries no streak of its own,
as `0 different attempts` -- a count nothing had established. That is the same
false statement about what the model did that this wording was written to
remove, only mirrored.

Two changes make the message follow the call being blocked:

- The detector stops classifying a call as wandering once it repeats an
  argument already in the window; such a call falls through to the repeat
  detector, which describes it accurately.
- The veto path trusts `verdict.detector` and no longer promotes the
  window-scoped escalation flag over it.

A third case had no truthful wording at all: a breaker firing on a verdict
with no streak of its own now says `repeated calls to X are not making
progress` rather than quoting a zero.
fix(tools): seven tool-reliability defects from the GAIA validation campaign
…n CLIs (no API key) (#169)

* feat(llm): drive a Claude Code subscription through its own CLI (no API key)

Every cloud provider kind so far needs a paid per-token API key. Anyone
already paying for a Claude Code subscription had no way to point the
agent at it.

Adds the `subscription-cli` provider kind, which runs the vendor CLI you
are already signed into as an inference backend. The CLI authenticates
from its own session — we never read, copy, or replay OAuth tokens or
keychain entries, and never pass `--bare` (whose docs say OAuth and
keychain are never read, which would defeat the feature).

One kind, parameterised by `subscriptionCli.cli`, with every CLI-specific
byte behind a `CliAdapterDescriptor` — so a second vendor CLI is a new
descriptor, not a new provider kind.

Three decisions worth knowing:

- The prompt travels on stdin, never argv. A full two-zone prompt exceeds
  the 128 KiB single-argument limit, so argv delivery would E2BIG on
  exactly the long sessions that matter most.
- `--tools ""` and `--strict-mcp-config` are safety-critical, not
  cosmetic: without them Claude Code's own Bash/Edit/Write would act on
  the machine outside the approval ladder, and the operator's MCP servers
  would leak into what should be a stateless completion.
- The transport is `native_tools` even though this provider never returns
  `tool_calls`. On `grammar`, any format drift throws out of
  `parseToolCalls` and buys a second full CLI invocation on the repair
  path; on `native_tools` an empty `toolCalls` sends step-executor down
  its guarded recovery ladder instead. Same result when the model
  complies, no extra process when it does not.

Verified end to end against claude 2.1.220: a real turn drives
`os.fs.read` -> `reply`, and a multi-step turn drives `os.shell.run` ->
`os.fs.write` -> `reply`, with zero parse retries. Server-side prompt
caching survives across separate invocations, so the KV-stable prompt is
not wasted; the cost is ~0.8s of process spawn per completion.

Not supported here, and dropped rather than silently approximated:
vision, embeddings (they stay on the local daemon), and the sampling
knobs temperature/top_p/top_k/seed/stop/maxTokens — the CLI exposes no
flag for any of them.

* feat(llm): add the OpenAI Codex subscription to subscription-cli

Second vendor CLI behind the same provider kind. Written against the
real `codex exec --json` interface (codex-cli 0.148.0) rather than from
its docs, because four things did not match what the Claude adapter
assumes.

- Structured output takes a *file path* (`--output-schema <FILE>`), not
  inline JSON, so the provider now stages a temp file and cleans it up.
  The argv builders stay pure; the side effect lives with the process.
- Codex exits 0 even when the turn fails. A bad model id, an expired
  login and a rate limit all give a clean exit plus a `turn.failed`
  event, so the parser treats a missing `turn.completed` as a failure
  instead of trusting the exit code and returning empty content.
- 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 `-m` is omitted unless the operator sets one, and the
  descriptor ships no model list rather than a guessed one.
- `exec --json` emits the answer in a single `item.completed` with no
  incremental text events, so `streamMode: "none"` and the provider
  buffers rather than pretending to stream.

The one real gap versus Claude: Codex has no `--tools ""`. `-s read-only`
confines its tools but cannot remove them, and left alone Codex tries to
*perform* the request with its own tools instead of emitting Atomic's
protocol — the first live run answered "I can't find probe.txt" after
looking in its own working directory. Since Codex also has no
system-prompt flag, the steering is prepended to the prompt instead.
That works, but it is a prompt-level guarantee rather than a structural
one, and the README says so plainly.

Verified end to end: `os.fs.read` -> `reply`, and `os.fs.read` ->
`os.fs.write` -> `reply`, both with zero parse retries.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(sandbox): an undrained stdin pipe no longer kills the whole agent

`runCommand` wrote `options.input` to `child.stdin` with no `error`
listener anywhere in the file. A CLI that rejects the request — signed
out, unknown model, rate-limited — exits without draining stdin, so a
prompt past the pipe buffer cannot flush and raises EPIPE on a stream
nobody listens to. `installGlobalErrorHandlers` deliberately preserves
Node's fatal semantics for `uncaughtException`, so that reached
`process.exit(1)` and the operator lost the entire session instead of
seeing the `SubscriptionCliAuthError` the CLI provider builds for
exactly this case.

Measured against a child that exits without reading: 16 KiB and 64 KiB
land in the pipe buffer and complete cleanly; 128 KiB, 256 KiB and 1 MiB
all produce `UNCAUGHT EXCEPTION: EPIPE`. Not an exotic size — the
provider's own comment notes a two-zone prompt routinely exceeds the
128 KiB single-argument limit, which is why the prompt is on stdin at
all.

Latent here before subscription-cli, but unreachable: the git and
test-runner callers stay well under the buffer.

The fix absorbs broken-pipe codes only. The child's exit code and stderr
still arrive on `close` and still map to the typed error. Everything
else on that stream rejects, so a genuine local fault is not swallowed.
`CommandResult.inputTruncated` covers the remaining silent case: a CLI
that exits 0 regardless (`codex` does) would otherwise pass a completion
computed from a half-delivered prompt off as a good answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(llm): Ctrl+C during a streaming CLI turn no longer takes the session with it

Two process-lifecycle defects in `streamCliCommand`, both on the abort
path, which is the most routine action in the TUI.

The stdin write had the same missing `error` listener as `runCommand`,
but here we are the trigger: `onAbort` -> `stop("abort")` -> SIGTERM
lands on a child that has not read its stdin, the pending write faults
with EPIPE, and cancelling a turn tore down the runtime.

The `finally` then cancelled the SIGKILL escalation it had just
scheduled:

    if (!settled) stop("done");            // may set killTimer
    if (killTimer) clearTimeout(killTimer); // always clears it

The clear ran microseconds later, long before the 2s delay, so a child
that traps SIGTERM was never force-killed — one orphan per aborted turn.
Reproduced with a child that ignores SIGTERM: it outlives an 8s watch
before the fix and dies inside the escalation window after it. The timer
is now cleared only once the child has actually exited, and `stop` no
longer re-arms an escalation that is already pending.

`inputTruncated` joins the failure conditions here too, for the same
reason it does in the buffered runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): c on a subscription-cli row opens its model instead of doing nothing

`isCloudProviderKind` was never extended for the new kind, so pressing
`c` on a `claude-cli` row matched no branch — and the handler returns
`true` regardless, so the key was consumed and nothing happened. Same
dead end from the LLM tab's configure action.

Extending that predicate is not the fix: two wizard rows (`claude-cli`,
`codex-cli`) collapse onto one config kind (`subscription-cli`), so the
stored `kind` cannot say which CLI an entry drives — only
`entry.subscriptionCli.cli` can. `configureWizardKindForRow` does that
resolution and becomes the single "can this row be configured, and as
what" answer; `isCloudProviderKind` stays what its name claims, the
key-based cloud kinds.

What `c` should do on a CLI row: not the cloud wizard as it stands. A
subscription CLI has no key to paste and no endpoint to type, so the
`api_key` screen configure opens on is the same dead end
`advanceWizardPhase` already skips on the add path. Configure now lands
straight on `chat_model_line` — the one thing that is editable —
prefilled with the pinned model, so Enter keeps it instead of silently
resetting it to the adapter default. The step renders the CLI's own
placeholder (`sonnet` for claude, "the CLI resolves the model" for
codex, which rejects explicit ids under a ChatGPT login) rather than
`gpt-5.4-mini`, and lists nothing, because there is no endpoint behind
it to list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.

The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.

Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.

`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): reject an empty API key on the provider wizard key screen (#152)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): verify a cloud API key is active and funded before saving it (#166)

* feat(tui): verify a cloud API key is active and funded before saving it

* feat(llm): add the cloud credential check module

* feat(tui): run the key check from the wizard and first-run onboarding

* feat(tui): route the wizard cancel through the LLM panel modal too

* feat(tui): wire the cancel callback and print an unverified-key notice

* docs: document the pre-save cloud credential check

* fix(tui): the add-provider wizard stops painting over its own rows

Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".

No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.

Two changes, both about height:

* A modal now owns the frame. `hasLlmModal` covers exactly the states
  `handleLlmModalKey` claims every key for, so the panel behind one is
  already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
  (`pickWindowRows`) instead of always asking for 12 rows plus chrome.
  PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
  catalog three rows at a time is not paging. The hint line gained
  `truncate-end` for the same reason the option rows have it — a wrapped
  hint is a row nobody budgeted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): a refused key says so on the screen that refused it

Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".

The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.

`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.

The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.

`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): drop the subscription-CLI labels this branch does not have

The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.

* fix(tui): a cancelled key check cannot save the provider behind the operator

Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `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 comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.

That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.

The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.

Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): start page adapts to terminal size (#151)

* feat(tui): start page adapts to terminal size

The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".

Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.

Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.

The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.

Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".

* fix(tui): scale one brand mark instead of hand-drawing each size

Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.

There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.

- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
  drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
  Ink paints an over-tall frame over the rows above rather than clipping
  it — which is the bug this module exists to prevent. The tips are the
  half worth keeping at that size.

The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.

Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.

* fix(tui): drop the right rail in a short terminal instead of overlapping it

`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.

Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)

* feat(models): ranked multi-term model search, in the TUI and the CLI

Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.

`src/llm/provider/model-search.ts` is now the one scorer:

- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
  catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
  (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
  rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
  ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
  substring > subsequence) and equal ranks keep input order — the
  picker re-runs this per keystroke and rows must not jitter.

`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.

`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.

Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.

* fix(models): a `1m` search term finds every million-token window

The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.

`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:

- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
  `202k`). Floor rather than round, because a size term reads as a lower
  bound: `1m` must find every window from 1M up to 2M, and must not find
  a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
  (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, which decimal rounding hides; the exact-multiple guard keeps
  the reading off windows that were never binary.

Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.

Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.

---------

Co-authored-by: plombeer31 <valeryb@bearle.dev>

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)

* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there

Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.

**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.

**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.

**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.

Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.

**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.

The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.

### The Ink hazard this had to be built around

Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.

### Verification

- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
  `tui-app.test.tsx`, covering open, search, submenu in/out, activation,
  key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
  two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
  lands on Manage → Tasks, backdrop dims.

One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.

* fix(tui): make the menu a real overlay — it floats, nothing reflows

The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.

It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.

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 instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.

Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.

A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.

`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.

Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.

* feat(tui): status bar shows where you are, not a menu of where to go

The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.

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. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.

The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.

* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g

`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).

The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.

The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.

The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)

The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.

Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.

The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.

No behaviour change: `contextWindowTags` is untouched.

Co-authored-by: Valerii <valeryb@bearle.dev>

* fix(os.shell.run): never drop argv when a glob matches nothing (#176)

* fix(os.shell.run): never drop argv when a glob matches nothing

`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so

    {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}

reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.

Three changes:

- Zero matches now pass the original argument through verbatim instead
  of discarding it. This is POSIX shell default behaviour (bash without
  `nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
  as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
  to file commands, but the `/`-containing branch bypassed that list for
  every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
  python/python3/node/perl/ruby) is exempt from expansion — it is code,
  not a file path, and routinely carries `?`/`*` in regexes and URLs.

Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.

The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(os.shell.run): tighten the -c payload exemption per review

- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
  check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
  the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
  --command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
  path keeps expanding
- differential tests pin the exemption with a payload that really
  matches files — the ablation that previously left every new branch
  untested now fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): stop arrow keys from discarding the typed draft (#188)

* fix(tui): stop arrow keys from discarding the typed draft

Two defects made the editor lose in-progress text on arrow keys.

Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.

Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.

Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.

Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.

* chore(tui): drop the unused input_history_reset action

The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.

Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)

Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.

- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
  stopped printing them in #170; `/observe` and `/manage` are still real
  commands, and `/run` does not exist in the registry at all. The banner
  is a short tip-list, not the command catalogue, so pinning its exact
  copy is what made this drift in the first place. It now asserts the
  commands the banner actually advertises, plus a new invariant: every
  slash command printed on the welcome screen must exist in
  `toSlashCommands()`. That catches the failure worth catching — a
  renamed or deleted command leaving a dead verb on the splash — without
  breaking again the next time the copy changes.

- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
  transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
  other tests that assert it.

- `persist-embedding-hybrid-recall` expected three fields where
  `persistEmbeddingHybridRecall` writes four. The `url` key is written
  deliberately, derived from the configured port, and is present in the
  config schema — the test simply predated it.

No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)

* fix(tui): reject an empty API key on the provider wizard key screen

* fix(tui): point the wizard screen at the shared provider-target helpers

* fix(tui): let the key screen see a key already saved in config.json

`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.

`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.

Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.

The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): accept keyless loopback providers and reject non-ASCII keys

Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.

1. A hand-added compat endpoint pointing at a loopback address (no
   preset) is a local server and needs no key. `wizardKeyIsOptional`
   now returns true for any loopback base URL, so the key screen and the
   save-time backstop both let an empty key through. A new exported
   helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
   recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
   any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
   URL with an empty key is still rejected, unchanged.

2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
   to crash the model-list fetch with an opaque ByteString error from
   inside `fetch`, because header values must be ASCII. A new
   `ascii-header-guard` module exports `isAsciiOnly` and
   `assertAsciiApiKey`; the two Authorization-header sites now assert the
   key first and throw a clear, named error, and the wizard rejects the
   key on the key screen and at save time with the same message.

3. When a submit is rejected while the chat-model step shows a discovered
   model list, the pick list had no error slot, so the wizard's error was
   invisible and pressing Enter looked dead. The error line now renders
   under the pick list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>

* fix(tui): Esc aborts a running turn again (#155)

* fix(tui): Esc aborts a running turn again

The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.

The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.

Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.

* config: parse userModels, promptCache and providerPreferences on llm providers (#164)

parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.

userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.

Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.

Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)

Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.

Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.

Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.

Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.

* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)

Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".

`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".

Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).

* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)

The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.

This lands the single list those surfaces should be derived from, and converts
the first consumer.

`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.

`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.

No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.

The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.

Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).

* fix(tui): Esc while running keeps the scroll-reset rung first

Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.

Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): more cloud providers and a refreshed model catalog (#167)

* feat(llm): more cloud providers and a refreshed model catalog

Three things kept models out of reach.

1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
   Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
   (DashScope), SambaNova — all resolving to the existing
   `openai-compatible` kind, so there is no new provider kind and no
   config-schema change. Every base URL was probed live: 200 with a
   `data` array, or 401 while a bogus sibling path on the same host
   answers 404. z.ai, Cohere and DeepInfra were dropped for failing
   that bar rather than shipped half-working.

2. `scoreChat` in the OpenRouter fetcher returned -1 for every
   `anthropic/*` id and everything matching /gemini/i, which deleted
   ~40 currently served models — the whole Claude 5 and Gemini 3.x
   lines — from the picker with no operator override. Vendor is a
   ranking input now, not a gate; the models this agent is tuned for
   still sort to the top.

3. The bundled catalogs were stale snapshots. Both are regenerated
   from the vendors' public model endpoints (2026-08-19): OpenRouter
   grows 18 -> 55 chat rows with real context windows, capabilities
   and USD/1M pricing; aimlapi grows 13 -> 32, including the
   vendor-prefixed Claude and Gemini ids it serves on
   `openai/chat-completions`.

The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.

Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.

* feat(llm): let a provider entry name the header that carries its API key

Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.

`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.

The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tui): make the Anthropic preset actually authenticate

The preset shipped unusable. It reso…
…squash (#192)

The #169 branch update was assembled while #191 landed on main in
parallel; the file-level resolution read "differs from the integration
tree" as "belongs to #169" and carried twenty pre-#191 files into the
squash, silently reverting the whole tool-reliability batch (--globoff,
web-fetch retries, loop-veto wording, grandfathered config versions,
vision cap notes, grep-on-file fix). This restores every clobbered file
to its #191 state byte-for-byte; #169's own delta touched none of them.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The #192 restore covered clean reverts; these two carried edits from
BOTH sides (the #191 tool-reliability behavior plus #169's sandbox
stdin-pipe adjustments), so they are rebuilt as the #191 version with
the #169 branch's own delta applied on top. Both suites pass against
the restored sources.

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d the prompt

Ports plombeer31's mouse layer (#165) onto current main.

The engine lands unchanged: SGR/X10 decoding with split-read reassembly,
Yoga-based hit testing, and the stdin split that keeps Ink from typing raw
reports into the chat buffer. Only 1000+1006 are requested — motion
tracking stays off, since nothing in the UI hovers or drags.

The chrome had to be rewritten. #165 was built against the Run / Observe /
Manage pill strip and the sub-tab strip; #170 replaced both with a
breadcrumb and a single menu registry. Rather than reinstate the pills and
give navigation two competing controls, the breadcrumb itself takes the
click and opens the menu — exactly what ctrl+p does. /mouse is registered
as a menu node, so the command and the menu entry come from one definition.

Also carried over from main rather than the branch:

- The hint strip keeps its one-row shedding contract. #165 switched it to
  flexWrap, which is the two-line smear the shed ranks exist to prevent;
  chips became measurable boxes instead, keeping the ranks intact.
- ctrl+p keeps the slot #165 gave to a  chip — main removed
  that chip deliberately when  stopped opening the palette over a draft.
- USER_CONFIG_VERSION goes to 40 (main reached 39 after the branch was
  cut), and 38 stays in the supported list.

Fixed while porting: the sidebar sent a window-relative row index to
sidebar_tasks_cursor_set, which takes an absolute one — clicking a task in
a scrolled rail selected the wrong task.

Tests: 4766 passing, zero new failures against main's 5 pre-existing
(3 stale TUI smoke expectations, 1 flaky concurrency, 1 $HOME-dependent).
The two nav tests that drove the deleted pills are replaced by one that
drives the breadcrumb; it fails if the click handler is removed.
@sosidudku1

Copy link
Copy Markdown
Collaborator

Rebased this branch onto current main and force-pushed the result, so this PR is live again rather than being superseded by a new one. Your original commits are preserved on backup/pr165-original (a34db18) if you want to compare or recover anything.

Nothing about the mouse engine changed. src/tui/mouse/ — the SGR/X10 decoder with split-read reassembly, the Yoga-based hit testing, the stdin split that keeps Ink from typing raw reports into the chat buffer, the layer gate — merged onto current main without a single conflict, which is a good sign for how it was factored. All 15 files came across as-is.

What did need rewriting is the chrome, because main moved underneath it:

The pill strip is gone. #170 replaced the Run / Observe / Manage pills and the sub-tab strip with a breadcrumb plus one menu registry. Rather than reinstate the pills — which would give navigation two competing controls — the breadcrumb itself now takes the click and opens the menu, exactly as ctrl+p does. /mouse is registered as a menu node (setup.mouse), so the slash command and the menu entry come from one definition instead of two.

Three places where main's newer decision won over the branch:

  • The hint strip keeps its one-row shedding contract. This branch switched it to flexWrap, which produces exactly the two-line smear the shed ranks were added to prevent. Chips became individually measurable boxes instead, so hit testing works and the ranks stay intact.
  • ctrl+p keeps the slot the branch gave back to a / commands chip. main removed that chip deliberately when / stopped opening the palette over a non-empty draft.
  • USER_CONFIG_VERSION goes to 40, not 38 — main reached 39 (the web.fetch block) after this branch was cut. 38 stays in the supported list.

One bug found while porting. SidebarRow passed a window-relative row index to sidebar_tasks_cursor_set, which takes an absolute one. In a scrolled task rail, clicking a row selected a different task. Both lists now pass window.start + idx.

Tests. 4766 passing, zero new failures against main's 5 pre-existing ones (3 stale TUI smoke expectations, 1 flaky send_message concurrency case, 1 $HOME-dependent glob test). The two tests that drove the deleted pill and sub-tab strips are replaced by one that drives the breadcrumb; I checked it fails if the click handler is removed, so it is testing the path and not just the frame.

Two notes on the PR description, since it no longer matches the branch: the config version is 40, and the "Overlap with the open run-mode stack" section is now moot for #163 (the fusion stack is still open and unmerged, so the config-version note there still applies to whichever of us lands second).

Happy to adjust any of the three chrome calls above if you disagree — particularly the breadcrumb-opens-menu choice, since that is the one that changes what your PR does rather than just where it sits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants