feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there - #171
Conversation
|
Pushed It was rendered in the content column, so opening it pushed the chat log and everything below it around. It now sits in the content pane with Terminals have no compositing and Ink has no z-index, so occlusion is earned: every interior line is padded to the popup's exact inner width, painting spaces over whatever was beneath. Rows are fixed-width columns rather than A New test pins the property — opening the menu must not change the frame's row count or its last line: it("floats over the UI without moving it — the frame below is unchanged")Verified in a real PTY at 100×30: the splash art behind stays put, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. |
|
Pushed The What replaces it is a breadcrumb — Nine smoke tests were using the pill row as their way to detect the active section; they now assert the breadcrumb instead, and one asserts the pills are gone, which is the actual behaviour change. Run modes (Local · Cloud · Fusion) in the menu are #172, stacked on this and on #163. |
|
The menu layer is solid — no new key handler is registered ( One blocker: export function resolveLeaderChord(input: string, key: Key): MenuNode | null {
if (key.escape || input.length === 0) return null;
return menuNodeByChord(input); // key.ctrl / key.meta never consulted
}Press ctrl+g, then change your mind and press Ctrl+C. Ink delivers The same class hits The fix matches a guard style already in the same file — if (key.ctrl || key.meta) return null;Smaller one: One process note: the head commit subject reads "status bar shows where you are, not a menu of where to go", which doesn't match the PR title. The branch may have been amended — worth confirming the intended head before merging. |
|
Fixed in 279eec2. Blocker — Disarm timeout. Pending indicator. In the hint strip, which is where transient key state already lives ( One thing I did not test: typing into the prompt right after the auto-disarm. Ink re-subscribes an editor's Head commit.
|
…here 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.
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.
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.
…er 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>
279eec2 to
22c647c
Compare
* 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…
…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…
…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…
…he prompt (#165) * feat(tui): mouse support — click the nav bar, panels, selectors and the 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. * 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 /mouse on reach the running app instead of just the terminal 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> * 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 therefo…
* test(tui): follow the breadcrumb and the block splash mark #171 replaced the three-section pill row with a breadcrumb, and #151 made the splash mark a scaled block raster. Three test files still asserted the old surface, so seven tests failed on main against code that behaves correctly — the one behavioural assertion in the same file (Esc twice does not quit) passed throughout. - escape-observe-tabs / escape-import-tab: the section is now the left half of a crumb (`Observe > Feed`), so assert `Observe >` rather than `> Observe`, and `Run` rather than `> Run`. - tui-app smoke: the splash mark is drawn in block glyphs whose exact shape varies with the surface, so assert the rail the Run screen always carries. Tests only; no production code touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(tui): fix the last two stale smoke expectations Shift+Tab from Run wraps to the LAST Manage sub-tab. That is privacy, not telegram — MANAGE_TABS gained import and privacy after the test was written. The marker matters: a bare "Privacy" also matches the inactive chip in the sub-tab strip, so the assertion carries the active marker. The two-mode LLM panel test asserted RouteCard chrome the panel deliberately drops. ink-testing-library reports no rows, so LlmPanel falls back to the 80x24 surface and picks its compact header — correct code never renders "Active chat route" at that budget. The full/compact split is owned by components/llm-panel.test.tsx, which drives maxRows directly; the smoke test now asserts the two-mode body every budget keeps. Both assertions were checked by ablation: they fail when the behaviour they describe is removed. --------- Co-authored-by: sosidudku1 <rodiionovanp@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Why
Manage → Privacy was fourteen
Tabpresses from chat, and the fourteen destinations plus every verb were discoverable only by reading the source. This is the browsable half, rendered entirely from the registry #170 landed.What it does
ctrl+popens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover — it should read as belonging to the input you were already typing in.(captured from the built TUI in a real PTY, not a mockup)
Typing flattens the tree. Hierarchy is for browsing; a query ranks across the whole registry, drops whatever submenu you had walked into, and puts a breadcrumb on each hit. That is why the list moves on arrows only and never
j/k— the letters belong to the search box.ctrl+gthen a key jumps directly. The leader is the point: the chord namespace stays disjoint from the panels' own letter hotkeys (rrefresh,aadd,dremove …), so 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.Activation runs the node's slash command where it has one — the menu is a second door onto
slash-command-handler.ts, never a second dispatch path./keeps working unchanged.The backdrop dims. One flag on the
themeproxy — the same read-at-render machinery that makes/themelive-preview repaint everything — rather than threading adimmedprop through every component. Every colour collapses to the active theme'smuted, because a terminal has no alpha channel and "faded" has to mean one low-contrast tone. The menu readschromeTheme, which ignores the flag. Measured against a real terminal: distinct foreground colours drop from 4 to 2 when the menu opens.The Ink hazard this had to be built around
Ink delivers every keypress to every live
useInput, child first. The prompt editor's handler therefore runs before the app's, and areturn trueupstream cannot stop it — the "returns true so the editor ignores it" contract inapp-key-bindings.tsdescribes a mechanism that does not exist.Left alone,
ctrl+g twould navigate and typetinto the prompt. The editor is unfocused while the menu is open and while the leader is armed, and there is a test asserting the letter never reaches the buffer:Verification
npm run lintclean.npx vitest run src/tui— same five pre-existing failures asmain, plus two that pass in isolation and fail only under parallel load. No new failures.ctrl+g tlands on Manage → Tasks, backdrop dims.A bug this caught during development: the search box first 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.
Not in this PR
?keys overlay, generateddocs/KEYBINDINGS.md, and narrowingTabto sub-tabs within a section. TheTabchange is the only breaking one and stays separable on purpose.