feat(tui): mouse support — click the nav bar, panels, selectors and the prompt - #165
Conversation
…he prompt
The TUI had no mouse layer at all: Ink parses stdin as keystrokes only,
and `tui-command.ts` deliberately left SGR mouse tracking off because
capture takes the terminal's own drag-to-select away (Apple Terminal has
no Shift-bypass). That trade-off is real, but it is a *setting*, not a
reason to have no mouse at all — so mouse reporting is now on by default
with three ways to turn it off, and everything the keyboard can reach is
clickable.
New `src/tui/mouse/`:
- `mouse-tracking.ts` — enables 1000 + 1006 (button events + SGR
coordinates). Motion tracking (1002/1003) is deliberately not
requested: nothing hovers or drags. Paired with a `process.on("exit")`
restore so a crash never leaves the terminal reporting clicks into the
user's shell.
- `parse-mouse-events.ts` — pure decoder for SGR and legacy X10 reports.
Buffers a report split across two reads; passes a lone trailing ESC
straight through, since holding it would delay the Escape key by one
keystroke.
- `mouse-stdin.ts` — hands Ink a stream with the mouse bytes removed
(they would otherwise be typed into the chat buffer as mojibake) while
proxying isTTY / setRawMode / ref / unref to the real stdin.
- `mouse-registry.ts` — hit testing. Ink exposes no absolute positions,
but every node keeps its Yoga node, so `absoluteRect` sums
getComputedLeft/Top up the parent chain — the same walk the renderer
does when painting. Ancestors with `overflow: hidden` clip the result.
- `mouse-context.tsx` / `mouse-list-row.tsx` — React glue plus the shared
row: first click selects, a second click on the selected row activates.
Both activation and the wheel are routed through each panel's existing
`*-key-bindings.ts` handler with a synthetic Enter / arrow, so the
mouse cannot drift from the keyboard.
Wired up: Run / Observe / Manage pills and the sub-tab strip, sidebar
sessions and tasks, skills, skills hub, tasks, memory, MCP, LLM,
providers and local-model rows, the session / theme / slash pickers,
approval decision buttons, tool cards (the per-card toggle had no key
binding at all until now), clickable hotkey chips, click-to-place-caret
in the prompt, and wheel scrolling.
Delta-only cursors gained absolute `*_cursor_set` actions (sidebar,
session picker, theme picker, providers, local models, skills hub).
`isPanelModalOpen` and `decideApproval` were extracted from
`app-key-bindings.ts` so the mouse gates and resolves on exactly the same
predicates as the keyboard.
Off switch: `tui.mouse` (config v38, default true), `--mouse` /
`--no-mouse`, and `/mouse on|off` at runtime. With it off, behaviour is
byte-for-byte what it was before — alternate-scroll turns the wheel into
cursor keys.
|
The terminal-mode hygiene here is genuinely good and worth crediting: cleanup runs on normal quit, SIGINT/SIGTERM/SIGHUP and via a One blocker:
...(mouseEnabled ? { mouse: mouseSource } : {}),
Net result: clicks do nothing, drag-to-select is now broken, and the UI claimed it worked. Only a restart fixes it. Since |
…providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent.
Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level).
…tion of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).
…rminal The mouse source was passed to TuiApp only when mouse support was on at startup, so a session launched with tui.mouse: false (or --no-mouse) kept mouse === undefined for its whole life. /mouse on still wrote the 1000/1006 enable sequences and still printed "mouse support on — click panels, rows and the prompt", but the subscribe effect had returned early at mount: every click the terminal now reported went nowhere, drag-to-select was gone, and only a restart fixed it. Pass the source unconditionally — it is created unconditionally already — and move the live gate to the tracking controller, which the stdin forwarder now reads per report rather than capturing. That keeps /mouse off honest at both ends: the terminal stops reporting, and anything that arrives anyway (a multiplexer that ate the disable, a paste carrying an SGR report) is dropped before it reaches the tree. Verified on a real pty with tui.mouse: false — /mouse on negotiates 1000h + 1006h and a click on the Observe pill switches the section; /mouse off writes 1006l + 1000l and clicks stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in a34db18. The fix. Live pty check,
Mouse bytes never leaked into the chat buffer in any state. Tests. New One incidental:
Also added an AGENTS.md line under §Mouse support recording why the prop is unconditional, since the failure mode is invisible until you toggle at runtime. |
* feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(tui): start page adapts to terminal size
The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".
Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.
Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.
The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.
Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".
* fix(tui): scale one brand mark instead of hand-drawing each size
Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.
There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.
- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
Ink paints an over-tall frame over the rows above rather than clipping
it — which is the bug this module exists to prevent. The tips are the
half worth keeping at that size.
The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.
Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.
* fix(tui): drop the right rail in a short terminal instead of overlapping it
`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.
Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev>
…here (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev>
* fix(os.shell.run): never drop argv when a glob matches nothing
`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so
{"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}
reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.
Three changes:
- Zero matches now pass the original argument through verbatim instead
of discarding it. This is POSIX shell default behaviour (bash without
`nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
to file commands, but the `/`-containing branch bypassed that list for
every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
python/python3/node/perl/ruby) is exempt from expansion — it is code,
not a file path, and routinely carries `?`/`*` in regexes and URLs.
Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.
The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(os.shell.run): tighten the -c payload exemption per review
- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
--command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
path keeps expanding
- differential tests pin the exemption with a payload that really
matches files — the ablation that previously left every new branch
untested now fails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
… in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
* fix(tui): Esc aborts a running turn again
The hint strip advertises "[esc] abort" for the whole time a turn is in
flight, but the keypress did nothing — Ctrl+C was the only way to stop a
run.
The abort lived in the chat editor's `onEscape`, and the editor is handed
`disabled={!canAcceptMessage(state)}` — true for every status except
idle. `disabled` switches its `useInput` off, so while a turn runs the
editor is deaf and the abort branch is unreachable.
Esc-to-abort now lives in `handleAppKey`, which is subscribed
independently of editor focus and disabled state. Overlays that own Esc
themselves (slash palette, theme picker, session picker) keep it, and a
pending approval still returns earlier with its own abort semantics.
Precedence matches the hint strip, which resolves `running` before
`uiMode === "debug"`: a turn in flight aborts rather than navigating.
* config: parse userModels, promptCache and providerPreferences on llm providers (#164)
parseLlmProviderEntry rebuilds its result field by field, and three
fields documented in the config schema and typed on
LlmProviderConfigEntry were never read: a
`llm.providers[].userModels` array written into config.json was
silently dropped at parse time, as were `promptCache` and
`providerPreferences`.
userModels is the one with teeth today: resolveModel reads it as its
highest-priority source (userModels > bundled catalog > defaults), so
a hand-configured model for a provider the catalog does not know
silently fell back to the 128k/no-pricing defaults instead of the
configured context window, capabilities and prices. promptCache and
providerPreferences have no consumer yet — this only makes them
survive the round-trip so one can read them.
Validation mirrors the surrounding parsers: ConfigValidationError with
a `llm.providers[i].userModels[j].x` field path. Duplicate model ids
inside one provider are rejected because resolveModel looks a model up
with .find, so a duplicate would silently shadow.
Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153)
Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM
logs) terminated the agent. The bottom hint strip promises "[esc] back
to Run" on every debug tab, so this was a one-keypress kill on a surface
that advertised the opposite.
Cause: the TUI runs two independent Ink `useInput` subscriptions — the
global one in `TuiApp` and the chat editor's own. The Observe tabs have
no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape`
is never reached, and the keypress falls through to the editor. The
editor keeps focus on those tabs by design (you can keep typing while
watching the feed), and its `onEscape` ends in `onQuit()` whenever the
agent is idle.
Esc in the editor now resolves debug mode to "back to Run" before it can
reach the quit branch, reusing the same `ui_mode_set` action that
`handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are
unaffected: they unfocus the editor, so their panel layer still owns Esc.
Reachable in one keypress from the model picker: `/model` then `L` opens
Local LLM logs, where Esc killed the agent.
* fix(tui): Esc leaves the Import tab instead of being swallowed (#154)
Esc did nothing at all on the Import tab — pressing it repeatedly left
the operator parked on the form with no way back to Run, while the hint
strip advertised "[esc] back to Run".
`handleConfigureKey` ends in a catch-all `return true` so stray letters
cannot leak out of the form into the global hotkey layer. That catch-all
also claimed Esc, which made `TuiApp` see `panelHandled === true` and
skip `handlePanelEscape` — the layer that turns a declined Esc into
"back to Run".
Configure mode now declines Esc explicitly, the same way the local models
panel already does, so the panel-escape layer can act on it. The letter
swallowing is untouched, and preview / done modes keep their own Esc
meaning (reset the form one level).
* refactor(tui): one menu registry — the slash palette becomes a projection of it (#170)
The TUI has no keymap, no help overlay and no keybinding doc; what it has
instead is several hand-kept parallel lists of the same surface, which have
measurably drifted apart. Two of the five TUI test failures on main right now
are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy`
tab order, and a splash banner asserting `/observe /manage /run` which the
splash stopped printing.
This lands the single list those surfaces should be derived from, and converts
the first consumer.
`src/tui/menu/menu-registry.ts` declares every destination and every verb once:
id, label, group, optional `ctrl+g` chord, optional slash command. Three node
kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one
level deep), `action` (a verb). Nodes are pure data: a node that does something
carries a slash name and is activated by running that command, so the menu will
never grow a second dispatch path alongside `slash-command-handler.ts`.
`SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order
is user-visible — an empty query lists the registry as-is and fuzzy-search ties
break by index — so it is carried explicitly on `MenuSlash.rank` and preserved
exactly.
No behaviour changes. `menu-registry.test.ts` pins the derived palette against a
snapshot of the v0.2.2 list, so "no visible change" is checked by the suite
rather than promised in a description. The remaining tests turn the properties
the old lists could not enforce into build failures: unique ids, unique chords,
unique slash names and aliases, unique ranks, every parent a real submenu, no
tree deeper than one level, no empty submenu.
The `chord` fields are declared here and consumed in a follow-up that adds the
`ctrl+g` leader; the uniqueness test is live from this commit.
Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five
pre-existing failures as main, plus three that pass in isolation and fail only
under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).
* fix(tui): Esc while running keeps the scroll-reset rung first
Review catch on #155: the new running-abort branch matched on
`status === "running"` alone and runs ahead of the rung it supersedes,
so submit → PageUp → Esc destroyed the in-flight turn instead of
snapping the chat back to the latest reply. The scroll-reset check now
lives inside the abort branch and claims the key when the offset is
non-zero — in chat mode only, since on a debug tab the chat is
off-screen and resetting an invisible offset would just make Esc look
dead.
Also drops the abort branch from the editor's `onEscape`. It is
unreachable today (the editor is `disabled` for the whole run) and
would be a second live subscription firing `onAbort` twice per keypress
once #156 keeps the editor awake during a turn. The editor keeps its
other Esc duties: overlay close, scroll reset, idle quit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(llm): more cloud providers and a refreshed model catalog (#167)
* feat(llm): more cloud providers and a refreshed model catalog
Three things kept models out of reach.
1. No preset for most vendors. PROVIDER_PRESETS gains eight entries —
Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen
(DashScope), SambaNova — all resolving to the existing
`openai-compatible` kind, so there is no new provider kind and no
config-schema change. Every base URL was probed live: 200 with a
`data` array, or 401 while a bogus sibling path on the same host
answers 404. z.ai, Cohere and DeepInfra were dropped for failing
that bar rather than shipped half-working.
2. `scoreChat` in the OpenRouter fetcher returned -1 for every
`anthropic/*` id and everything matching /gemini/i, which deleted
~40 currently served models — the whole Claude 5 and Gemini 3.x
lines — from the picker with no operator override. Vendor is a
ranking input now, not a gate; the models this agent is tuned for
still sort to the top.
3. The bundled catalogs were stale snapshots. Both are regenerated
from the vendors' public model endpoints (2026-08-19): OpenRouter
grows 18 -> 55 chat rows with real context windows, capabilities
and USD/1M pricing; aimlapi grows 13 -> 32, including the
vendor-prefixed Claude and Gemini ids it serves on
`openai/chat-completions`.
The OpenRouter chat rows move into two sibling modules (frontier /
open-weight) and the duplicated row builders collapse into
`model-catalog-entry.ts`, keeping every file inside the 300-line rule.
Tests that pinned the old exclusions now pin the opposite, with a
comment saying why; new ones cover row integrity and picker-order
sync. The `qwen/qwen3.7-max` price expectation in
llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what
OpenRouter charges today.
* feat(llm): let a provider entry name the header that carries its API key
Both openai-compatible request paths hard-coded `Authorization: Bearer` —
`fetch-openai-compat-models.ts` for model discovery and `openai-http.ts`
for every chat/embedding turn — so a vendor that authenticates any other
way could not be reached at all, and nothing in config could correct it:
`deps.extraHeaders` is static, and an API key is not.
`openai-auth-headers.ts` becomes the single place that decides how a key
is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on
the entry names the header the key rides in (absent keeps the Bearer
default); the existing `headers` field carries any static headers the
service mandates. Keyless servers still send no auth header at all.
The field rides on the saved `UserLlmProviderEntry`, not on a build-time
table, so it round-trips through `config.json` and a hand-written entry
can express the same thing. Header names normalize to lower case, which
is why the two Bearer assertions in the touched tests move to
`authorization`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): make the Anthropic preset actually authenticate
The preset shipped unusable. It resolves to the `openai-compatible` kind,
which sent `Authorization: Bearer`; `api.anthropic.com` reads that header
as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key
on every path. Only `x-api-key` reaches the real key check, and
`anthropic-version` is mandatory. An operator picked the first row in the
list, pasted a valid key, and model discovery 401'd — as did every chat
turn after it — with no escape, because `presetId` skips the base_url
step.
The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned
`anthropic-version`. `buildProviderEntryFromWizard` copies both onto the
saved entry, so the fix survives a restart rather than living only in the
build-time table, and both discovery call sites pass the contract through.
Also rewrites the admission bar the preset was let in under. The old rule
read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header
is required` — it was asking for a header we never sent, which is the
exact condition that should have disqualified it. A preset now qualifies
only on a 200 with a `data` array, or a 401/403 that rejects the
*credential* when probed with the headers the preset actually sends. A 401
naming a header the preset does not send is a failing probe.
`provider-presets.test.ts` only asserted literal baseUrl and envVar
strings, which is why this shipped; it now pins the bytes that leave the
process on both paths, after the entry has been through `config.json`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: plombeer31 <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): reject an empty API key on the provider wizard key screen (#152)
* fix(tui): reject an empty API key on the provider wizard key screen
* fix(tui): point the wizard screen at the shared provider-target helpers
* fix(tui): let the key screen see a key already saved in config.json
`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.
`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.
Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.
The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tui): verify a cloud API key is active and funded before saving it (#166)
* feat(tui): verify a cloud API key is active and funded before saving it
* feat(llm): add the cloud credential check module
* feat(tui): run the key check from the wizard and first-run onboarding
* feat(tui): route the wizard cancel through the LLM panel modal too
* feat(tui): wire the cancel callback and print an unverified-key notice
* docs: document the pre-save cloud credential check
* fix(tui): the add-provider wizard stops painting over its own rows
Reported twice: "there is only aimlapi in the config menu, there should
be other providers on the list", and "some issue with rendering
openrouter, I don't see it on some screen sizes at all".
No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and
the counter on screen said `(1/24)` the whole time. The wizard was drawn
by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the
mode header, the windowed row list and the footer still rendered
underneath it — and the panel already spends the entire `maxRows` tab
budget on those. The frame therefore ran ~16 rows past the terminal, and
Ink 7 does not clip an over-tall frame, it paints later lines over
earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter
wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box
title gone entirely. Which rows survived depended on the terminal height,
which is why the same list looked different at different sizes.
Two changes, both about height:
* A modal now owns the frame. `hasLlmModal` covers exactly the states
`handleLlmModalKey` claims every key for, so the panel behind one is
already unreachable — drawing it only spent the budget twice.
* `renderPickList` sizes its viewport from the budget it is handed
(`pickWindowRows`) instead of always asking for 12 rows plus chrome.
PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row
catalog three rows at a time is not paging. The hint line gained
`truncate-end` for the same reason the option rows have it — a wrapped
hint is a row nobody budgeted for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): a refused key says so on the screen that refused it
Reported as "key validation is still not working — I added a random key
and got stuck on embedding selection".
The validation was working. A bogus OpenRouter key reached
`verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard`
returned before `saveProviderWizardToConfig` — the PTY run confirms
nothing was written to config.json or .env. What failed was saying so.
`renderPickList` had no error line and no busy state, so on the three
list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the
`wizard.error` the reducer stores from `providers_wizard_failed` had
nowhere to go, and neither did the seconds the check spends waiting on
the provider. Enter set `submitting`, swallowed every key but Esc, then
cleared it and painted an identical screen. Pressing Enter again did the
same. From the operator's chair that is a wizard that has stopped
responding on the embedding screen, which is exactly what was reported.
The refusal now renders where it happened, over at most two lines split
at the sentence boundary — the verdicts are "what happened" plus "what
to do about it", and truncating the pair to one line drops the half that
tells you to check which service the key belongs to. Those rows come out
of the option viewport, so the box height is still bounded by the budget
the previous commit gave it. While the check runs the actions hint is
REPLACED by "checking the key with the provider… (Esc cancels)": every
other key is swallowed until it settles, so listing them would be a lie.
`providerLabelForWizard` also stopped handing the raw config token to
prose. `"openrouter" rejected this key` on a screen whose own row says
"OpenRouter" reads as some other, lower-case product.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): drop the subscription-CLI labels this branch does not have
The service-label map came across with the wizard fix and named the two
CLI-backed kinds from #169, which are not on this branch.
* fix(tui): a cancelled key check cannot save the provider behind the operator
Reported on review: in `CloudProviderOnboarding` the only guard after
the verify await was `if (!alive.current) return;`, which asks whether
the component is mounted. Esc does not unmount it. `verifyProviderKey`
samples the abort signal at the top of each probe and in the fetch
catch, so an abort landing between the response arriving and
`classifyVerifyResponse` returning comes back as an ordinary verdict —
and execution fell straight through to `saveProviderWizardToConfig` and
`onFinished("saved_cloud")`. The operator read "Key check cancelled —
press Enter to try again" while the provider was written to config and
onboarding exited as saved. `completeWizard` has carried the matching
`if (abort.signal.aborted) return;` since it was written.
That check alone is not enough here. `submit` guarded re-entry on the
`submitting` state captured in its closure, and the cancel handler
resets that state, so Enter after Esc started a second check while the
first was still resolving. Two key events drained from stdin in one
turn did it too — and in that interleaving neither run is cancelled, so
no post-await abort check can tell them apart. Both saved, and both
called `onFinished`. Re-entry is guarded on the in-flight
`AbortController` ref instead: written before the first await, cleared
only by the run that owns it or by a cancel, and therefore immune to
the state reset. With one non-cancelled run at a time, the abort check
settles the rest.
The failure exit gets the same guard. An abandoned run's error would
otherwise paint over the screen the operator was handed back and free
`submitting` under a check that is still running.
Covers the gap the review named: there was no cancel-then-resolve test.
The new cases drive real key bindings and the real verify path against
a fetch that answers before it hands over its body, which is where the
race lives. Reverting each of the three hunks separately fails only its
own cases.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
---------
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(tui): start page adapts to terminal size (#151)
* feat(tui): start page adapts to terminal size
The splash needed ~90 columns and ~29 rows before it could render
honestly, and Ink 7 overlaps rather than clips an over-tall frame, so
anything smaller garbled the start page. The right rail made it worse:
it appeared at 100 columns and took a flat 30, leaving 70 for artwork
that wanted 87, and without flexShrink={0} Yoga let the chat column
claw columns back out of it — a 100-column terminal rendered an 18-wide
rail with a truncated "(no sessions ye…".
Add src/tui/layout.ts as the shared geometry both sides read: rail
visibility and a proportional rail width, the chat width left over, the
chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a
correction for narrow terminals where the status bar, hint strip and
prompt placeholder wrap), and a per-pane row budget for the rail.
Add src/tui/components/splash-fit.ts for the breakpoints: which mark to
draw, whether the wordmark and tagline fit, how many tips survive, the
label column width, and whether descriptions are full, terse or dropped.
It is React-free so the table can be unit-tested directly.
The mark now comes in three sizes — the original 34x20 art, a 17x10
reduction and a single-line fallback — and the tip list drops entries
from its tail after collapsing descriptions to terse copy. The rail
keeps the width it is given, derives preview truncation from that width,
and both its panes use the shared computeRowWindow, so the Tasks pane
finally scrolls to its cursor instead of slicing the first five rows.
Also fixes two tests that were already failing on main: splash-banner
asserted the pre-#97 tip list, and chat-log asserted the banner text
"Local-First AI Agent" for a component that renders "Local AI-First
Agent".
* fix(tui): scale one brand mark instead of hand-drawing each size
Hand testing turned up the mark looking wrong as the window shrank. It
shipped as three hand-drawn copies, and the half-size one had lost the
taper of its lower-right tail — it read as a blob rather than the mark,
and every new breakpoint meant drawing the shape again by eye.
There is one drawing now. logo-raster.ts scales it with half-block
glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage
threshold so thin arms survive, and 'small' and 'mini' are measured off
'full' at load time. Proportions hold because nothing is drawn twice.
- small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row
drawing is 12 half-block rows)
- mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark
- new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and
Ink paints an over-tall frame over the rows above rather than clipping
it — which is the bug this module exists to prevent. The tips are the
half worth keeping at that size.
The mark also gets its own colour, theme.colors.brandMark: a lighter,
whiter blue than 'accent' across all eleven palettes. It is not a
control, and sharing the accent blue made the start page read as one
large highlighted widget.
Knowingly rewrites the expectations that pinned the old 17x10 art, the
one-line text fallback, and 'mini on a two-row surface'.
* fix(tui): drop the right rail in a short terminal instead of overlapping it
`isSidebarVisible` gated on width alone, so a 100x8 split pane drew the
rail anyway, and the row budget floored the split at two sessions and one
task no matter how little height there was. The rail came to nine rows
against eight and Ink overlapped the frame.
Visibility now needs height as well as width, and the budget hands back
only rows the window actually has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(models): ranked multi-term model search, in the TUI and the CLI (#168)
* feat(models): ranked multi-term model search, in the TUI and the CLI
Search was one case-insensitive `includes` over the model id
(`filterModelIds`), shared by the /model picker and the Cloud pane.
That is fine for the 18 rows the bundled catalog used to hold and
useless against the 300-400 the live OpenRouter list returns: "claude
vision" is not a substring of anything, and there was no way to search
cloud models from outside the TUI at all.
`src/llm/provider/model-search.ts` is now the one scorer:
- terms are split on whitespace and ANDed, so each word narrows;
- a term matches the id, the vendor prefix, or a tag derived from the
catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand
(`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the
rendered price, so `openrouter/auto` is `routed`, never `free`;
- a term that matches nothing as a substring still matches as an
ordered subsequence, ranked last, so typos land somewhere;
- results are ranked (exact id > id prefix > vendor > word start >
substring > subsequence) and equal ranks keep input order — the
picker re-runs this per keystroke and rows must not jitter.
`filterModelIds` keeps its name and signature and delegates, gaining an
optional catalog lookup so the Cloud pane can search on capabilities
for curated kinds. No new keybindings or state: `f`, `cloudModelFilter`
and the existing reducer actions carry it.
`atomic-agent models search <query> [--provider id] [--limit n] [--json]
[--refresh]` puts the same search on the CLI, over every configured
provider's catalog plus, with --refresh, the live lists. It prints the
same context/price/capability strings as the picker — those formatters
moved to `src/llm/provider/format-model-details.ts` so the CLI does not
import from `src/tui/`. No match exits 1 with one line, per #145.
Two notes for review: `parseLlmProviderEntry` silently drops
`userModels`, so that source can only be reached programmatically today
(covered by a direct `collectHits` test rather than a config fixture);
and the OpenRouter fetcher still filters Anthropic and Gemini out of
the live catalog, so those stay unsearchable until that is lifted.
* fix(models): a `1m` search term finds every million-token window
The context-window tag was the display string, and tag matching is exact
equality, so only a window of exactly 1_000_000 ever answered to `1m`.
`formatCompactNumber` renders non-integer millions with `toFixed(1)`, so
1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`;
terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped
those rows outright. The README advertises the query — `models search
"1m cache" --provider openrouter` — and against #167's catalog it hid 19
of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both
Llama 4. Worse than an empty result, the exactly-1M Claude rows did
match, so the answer looked complete.
`formatContextWindow` is untouched — it feeds the rendered rows. The
normalised forms ride alongside it instead. A window now carries up to
three tags, deduped:
- the display string, so searching for what a row shows still works;
- the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 ->
`202k`). Floor rather than round, because a size term reads as a lower
bound: `1m` must find every window from 1M up to 2M, and must not find
a 950k row that would round up to it;
- the binary reading when the window is an exact multiple of 1024
(131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 ->
`1m`). Those windows are power-of-two sized and are sold by the binary
number, which decimal rounding hides; the exact-multiple guard keeps
the reading off windows that were never binary.
Raw token counts (`131072`) are deliberately not tagged: no surface
renders one, so nobody reads it off a row to type it back.
Against the bundled OpenRouter catalog on this branch, `1m` goes from 3
rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to
3, and `2m` still matches only `openrouter/auto`. With #167's catalog
merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3.
Both test files gained the `1m` cases they were missing, which is how
this shipped in the first place.
---------
Co-authored-by: plombeer31 <valeryb@bearle.dev>
* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171)
* feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there
Reaching Manage → Privacy from chat took fourteen Tab presses, and the
fourteen destinations plus every verb were discoverable only by reading the
source. This adds the browsable half of the navigation surface, rendered from
the registry landed in the previous commit.
**ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather
than as a full-screen takeover, so it reads as belonging to the input you were
already typing in. Groups come from the registry; `Go` mirrors the product's
own Run / Observe / Manage split, and Observe / Manage are submenus exactly one
level deep. Destinations carry live counts read from the same state slices the
sub-tab strip already counts, so opening the menu costs a few array lengths and
never a refresh.
**Typing flattens the tree.** Hierarchy is for browsing; a query ranks across
the whole registry and drops whatever submenu you had walked into, with a
breadcrumb on each hit. This is why the list is navigated with the arrows only
and never with j/k — the letters belong to the search box.
**ctrl+g then a key** jumps directly. The leader exists so the chord namespace
stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add,
`d` remove …) — nothing had to be renamed to make room. An unclaimed chord is
swallowed rather than passed on, so a mistyped leader cannot leak a letter into
the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode
uses: `ctrl+x` is emacs' prefix and some terminals eat it.
Activation runs the node's slash command where it has one, so the menu is a
second door onto `slash-command-handler.ts` and never a second dispatch path.
`/` keeps working unchanged.
**The backdrop dims** while the menu is open. Implemented as one flag on the
`theme` proxy — the same read-at-render machinery that makes `/theme`
live-preview repaint everything — rather than threading a `dimmed` prop through
every component. Every colour collapses to the active theme's `muted`: a
terminal has no alpha channel, so "faded" has to mean one low-contrast tone.
The menu reads `chromeTheme`, which ignores the flag, and stays at full
contrast. Verified against a real terminal: distinct foreground colours drop
from four to two when the menu opens.
The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a
superset, and the strip is capped at six chips.
### The Ink hazard this had to be built around
Ink delivers every keypress to *every* live `useInput`, **child first**, so the
prompt editor's handler runs before the app's and a `return true` upstream
cannot stop it. A chord letter would therefore be typed into the prompt as well
as consumed. The editor is unfocused while the menu is open *and* while the
leader is armed; `tui-app.test.tsx` asserts the letter never lands in the
buffer, so a regression here fails the build rather than being noticed later.
### Verification
- `npm run lint` clean.
- 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in
`tui-app.test.tsx`, covering open, search, submenu in/out, activation,
key swallowing, paste bursts, escape-fragment rejection, and the chord path.
- `npx vitest run src/tui`: the same five pre-existing failures as main, plus
two that pass in isolation and fail only under parallel load. No new failures.
- Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t`
lands on Manage → Tasks, backdrop dims.
One bug this caught during development: the search box originally accepted only
single characters, so a paste — which arrives as one input event — was silently
swallowed. It now takes a whole burst and rejects escape-sequence fragments per
code point.
* fix(tui): make the menu a real overlay — it floats, nothing reflows
The menu was rendered inline in the content column, so opening it pushed the
chat log and everything below it around. A popup should composite on top, the
way a modal does in a browser.
It now sits in the content pane with `position="absolute"`, anchored to the
pane's bottom edge so it still hangs off the prompt, and it caps its own
height to the rows the pane actually has.
Terminals have no compositing and Ink has no z-index, so occlusion has to be
earned: every interior line is padded to the popup's exact inner width, which
paints spaces over whatever was underneath. That is also why the rows are laid
out as fixed-width columns instead of with `flexGrow` — a flexed row stops at
its content and lets the background bleed through.
Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at
both edges that the backdrop showed through as a ragged column of debris down
each side. The one-column gutter is now baked into the padded strings instead.
A background colour would do the same job in a line, but only by choosing a
colour, and the TUI ships eleven themes across light and dark grounds. Spaces
are theme-agnostic.
`tui-app.test.tsx` pins the property: opening the menu must not change the
frame's row count or its last line, so an inline regression fails the build.
Verified in a real PTY at 100×30: with the menu open the splash art behind it
stays exactly where it was, the prompt and hint strip do not move, and the
popup shrinks around a search result instead of resizing the screen.
* feat(tui): status bar shows where you are, not a menu of where to go
The three-section pill row (`Run · Observe · Manage`) was a menu drawn into
the header — and a bad one, because it could only ever list three of the
fifteen destinations and had no keys attached to it. The menu now lives behind
`ctrl+p`, where it holds all of them.
What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the
popup cannot tell you, because you have to open it to read it. The tab half
comes from the registry (`menuPlaceByTab`), so a renamed destination renames
in the header too.
The smoke tests were using the pill row as their way to detect the active
section, so they now assert the breadcrumb instead. One of them asserts the
pills are *gone*, which is the actual behaviour change.
* fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g
`resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as
input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their
mind and reached for Ctrl+C did not abort the turn — they landed on the MCP
tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where
ctrl+L is the conventional clear-screen).
The resolver now refuses a modified key, which is the guard its two siblings
in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on
`key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the
armed branch swallowed everything it could not resolve, so the key still never
reached its real handler. Swallowing is right for a *bare* key — a mistyped
leader must not leak a letter into the prompt — but a modified one was never
aimed at the leader, so it now disarms and falls through to the bindings below.
The leader also had no way to end other than a keystroke, unlike the Ctrl+C
flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a
stray ctrl+g left the editor unfocused with nothing on screen saying so, and
ate the next key — or, if that key happened to be `h`, opened the theme picker.
It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same
timer-in-a-ref cleanup, and while it is pending the hint strip says so:
`[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip
is where transient key state already lives (`ctrl+c → press again to quit`),
and it needs no room in the breadcrumb the header just became.
The integration test presses ctrl+g and then nothing at all, so only the timer
can clear the indicator. It stops there rather than typing afterwards: Ink
re-subscribes an editor's `useInput` in a passive effect one commit after the
render that refocused it, so a keystroke fired at a loaded runner in that gap
is genuinely lost — the same race already documented in `multi-line-editor`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(models): a size term names its unit bucket, it is not a `>=` filter (#189)
The README claimed "a size term reads as a lower bound whatever the row
displays". `contextWindowTags` floors to the whole unit and tag matching
is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and
`1m` does not reach it: in the bundled catalogs `openrouter/auto` and
`x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone,
and both score 0 for `1m`. A reader taking the README literally would
expect the widest window in each catalog in that result and conclude the
search was still broken.
Flooring is the behaviour worth keeping, not a lower bound, so the
wording is what moves. A true `>=` reading cannot be uniform: exact tag
equality is what makes the scorer one lookup per term, and a `>=` rule
applied down the units would make `128k` — or `1k` — match nearly the
whole catalog, which is the opposite of what an ANDed narrowing term is
for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a
term stays a filter rather than a prefix of a bigger result.
The source comment already said "every row from 1M up to 2M", but led
with "a window of a million or better", which is the same overstatement
in miniature and is where the README wording came from; both now say
bucket. AGENTS.md carried "a lower bound" too. The unit test excluded
the 2M row only by leaving it out of a `toEqual` list, so the boundary
the README got wrong was pinned by omission — it is now asserted by
name.
No behaviour change: `contextWindowTags` is untouched.
Co-authored-by: Valerii <valeryb@bearle.dev>
* fix(os.shell.run): never drop argv when a glob matches nothing (#176)
* fix(os.shell.run): never drop argv when a glob matches nothing
`expandShellGlobArgs` treated any argument containing `*` or `?` plus a
`/` as a filesystem glob, and dropped it entirely when nothing matched.
A URL with a query string satisfies both conditions, so
{"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]}
reached the shell as a bare `bash -c` and failed with
`bash: -c: option requires an argument`. The model's command was correct;
the payload was lost inside the agent, so the error read as a model
mistake. Observed 238 times in a GAIA benchmark run, 237 of which
carried `?` or `*` — almost all URLs with query strings.
Three changes:
- Zero matches now pass the original argument through verbatim instead
of discarding it. This is POSIX shell default behaviour (bash without
`nullglob`, zsh with `nomatch` off). An argument is never dropped.
- URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated
as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion
to file commands, but the `/`-containing branch bypassed that list for
every command.
- The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/
python/python3/node/perl/ruby) is exempt from expansion — it is code,
not a file path, and routinely carries `?`/`*` in regexes and URLs.
Not benchmark-specific: any user command embedding a URL with query
parameters, or a `-c` payload containing a regex with `?`/`*`, hit the
same silent truncation.
The previous test `omits argv when glob matches nothing (nullglob-style)`
pinned the buggy behaviour and is replaced by its inverse. Added
coverage for zero-match passthrough, the `bash -c` URL regression, bare
URL arguments, and real file globs continuing to expand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(os.shell.run): tighten the -c payload exemption per review
- node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax
check, so globbing that argument stays correct
- the interpreter check basenames and case-folds the command, matching
the guard's own normalization, so /bin/bash and python3.12 qualify
- -c is recognized inside a short-option cluster (-lc, -ec) and as
--command, the same spellings the dangerous-command rules accept
- the URL rule requires a two-letter scheme so a sloppy Windows C://
path keeps expanding
- differential tests pin the exemption with a payload that really
matches files — the ablation that previously left every new branch
untested now fails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): stop arrow keys from discarding the typed draft (#188)
* fix(tui): stop arrow keys from discarding the typed draft
Two defects made the editor lose in-progress text on arrow keys.
Up recalled a history entry straight over `inputValue` without saving
what was already typed, and Down walked back past the newest entry to a
hardcoded `""`. The draft was unrecoverable: press Up by reflex and the
half-written message was gone.
Left/Right were worse because they look inert. The editor owns the caret
and reports every move through `onChange`, so a caret-only keystroke
re-emitted the buffer unchanged; `input_changed` reset
`inputHistoryCursor` on every such emit. One Left after Up dropped the
recall position, so the next Up jumped back to the newest entry instead
of stepping further back.
Park the draft in `inputHistoryDraft` when recall starts and hand it back
when Down leaves history, and treat an `input_changed` whose value equals
the current buffer as the no-op it is. Editing a recalled entry still
drops the stash, and submit/reset clear it so a stale draft cannot
resurface.
Covered by reducer tests plus an end-to-end test that drives the real
editor with real arrow-key escape sequences; the latter fails on both
counts without this change.
* chore(tui): drop the unused input_history_reset action
The action was declared in `TuiAction` and handled in `reduceUiAction`,
but nothing ever dispatched it — history recall exits through
`input_history_navigated` and the submit/reset paths clear the cursor
directly. Dead weight that reads as a live escape hatch.
Removing the variant means TypeScript would reject any dispatch site, so
the exhaustive switch confirms there were none.
---------
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
* test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177)
Three TUI tests asserted behaviour the components had already moved away
from. All three failed on main and none of them indicated a real defect —
the code is correct, the expectations had drifted.
- `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner
stopped printing them in #170; `/observe` and `/manage` are still real
commands, and `/run` does not exist in the registry at all. The banner
is a short tip-list, not the command catalogue, so pinning its exact
copy is what made this drift in the first place. It now asserts the
commands the banner actually advertises, plus a new invariant: every
slash command printed on the welcome screen must exist in
`toSlashCommands()`. That catches the failure worth catching — a
renamed or deleted command leaving a dead verb on the splash — without
breaking again the next time the copy changes.
- `chat-log` asserted the tagline `Local-First AI Agent`. The words are
transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three
other tests that assert it.
- `persist-embedding-hybrid-recall` expected three fields where
`persistEmbeddingHybridRecall` writes four. The `url` key is written
deliberately, derived from the configured port, and is present in the
config schema — the test simply predated it.
No production code is touched. Verified the new registry assertion fails
when the banner is pointed at a non-existent command, so it is not
vacuous.
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187)
* fix(tui): reject an empty API key on the provider wizard key screen
* fix(tui): point the wizard screen at the shared provider-target helpers
* fix(tui): let the key screen see a key already saved in config.json
`apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`,
which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key
the operator typed into the wizard once lives in `config.json` — that is
where `upsertLlmProvider` puts it, with nothing written to `.env` — so
the gate could not see it. Selecting that provider and pressing `c`
opened the wizard on `api_key` with an empty buffer, and Enter refused,
leaving no way to change the entry's model without retyping a key that
was already stored.
`apiKeyForWizard` now consults the stored entry the same way
`saveProviderWizardToConfig` does, so the gate is never stricter than
the save it fronts. Only a `configure` run with a `providerId` looks one
up; `add` still demands a key of its own, and the kind-scoped lookup
that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is
untouched.
Esc was the other half of the dead end. A configure run opens on
`api_key`, so stepping "back" from there built a `pick_kind` screen that
run never showed and dropped the entry's kind and base URL on the way.
`isWizardFirstScreen` names the screen a run opened at; Esc there closes
the wizard, as it already did on the provider list when adding.
The key screen also stops telling a reconfiguring operator the key has
to be in `.env` when it is sitting in `config.json`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tui): accept keyless loopback providers and reject non-ASCII keys
Three fixes in the "add a custom OpenAI-compatible provider" flow, all
around API key handling for local llama.cpp servers.
1. A hand-added compat endpoint pointing at a loopback address (no
preset) is a local server and needs no key. `wizardKeyIsOptional`
now returns true for any loopback base URL, so the key screen and the
save-time backstop both let an empty key through. A new exported
helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts)
recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at
any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom
URL with an empty key is still rejected, unchanged.
2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used
to crash the model-list fetch with an opaque ByteString error from
inside `fetch`, because header values must be ASCII. A new
`ascii-header-guard` module exports `isAsciiOnly` and
`assertAsciiApiKey`; the two Authorization-header sites now assert the
key first and throw a clear, named error, and the wizard rejects the
key on the key screen and at save time with the same message.
3. When a submit is rejected while the chat-model step shows a discovered
model list, the pick list had no error slot, so the wizard's error was
invisible and pressing Enter looked dead. The error line now renders
under the pick list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: plombeer31 <bvv3131@gmail.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
---------
Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Valerii <valeryb@bearle.dev>
Co-authored-by: Dudka <n@atomicbot.ai>
Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Co-authored-by: Dudka <sosidudku1@users.noreply.github.com>
…156) * fix(tui): keep the editor live during a turn and queue what you type Pressing Enter used to make the input dead until the turn finished: `canAcceptMessage` (status === "idle") gated both the submit pipeline and the `disabled` prop on `PromptShell`, so `MultiLineEditor` dropped every keystroke. You could not draft the next message, let alone send one. The queue this needs has existed all along and was unreachable: `ChatOrchestrator.sendMessage` buffers into `this.queue` whenever a turn is in flight and `runOneTurn` drains it FIFO. Nothing ever got that far because the UI rejected the submit first. - Split the selector: `canAcceptMessage` still means "a new turn may start now"; the new `canTypeMessage` (status !== "quitting") gates the editor. Typing is allowed for the whole run. - A submit made mid-run dispatches the new `message_queued` action, not `message_submitted`. That distinction is load-bearing — `message_submitted` calls `startNewRun`, which wipes `feed`, `reasoning` and `streamingToolCards` and would blank the screen of the turn the operator is reading. - The orchestrator re-publishes its queue on every push, drain, clear, session switch and quit via `queue_changed`, so the UI mirrors what will actually run instead of tracking an optimistic copy. - Parked messages render as a dim strip above the prompt (`QueuedMessages`), the hint strip advertises what Enter does mid-run plus how many messages are parked, and `/queue` lists them while `/queue clear` drops them. Runtime, agent loop and prompt are untouched — this is a TUI-layer fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc stops the agent, not just the current turn Review blocker on #156. `abortCurrentTurn()` was only `this.currentController?.abort()`. `runOneTurn` swallows the abort rejection in its catch and falls straight through to `this.queue.shift()`, so aborting a turn *drained* the queue instead of discarding it: the next parked message started immediately, and stopping a wrong run cost one Esc per parked message. `quit()` four lines below already had this right (set `quitting`, clear the queue, then abort) — the two paths now agree. Newly reachable because of this PR: with the editor disabled mid-turn nobody could put anything in the queue to begin with. - `abortCurrentTurn()` clears the queue and re-publishes it before aborting, and reports `aborted: dropped N parked message(s)` in the feed and the transcript. The operator typed those messages; binning them silently is worse than one extra line. - The queue is bounded at `MAX_QUEUED_MESSAGES` (20). Past the cap a submission is refused with a running `queue: full at 20 — dropped N message(s); Esc stops the run, /queue clear empties it`. The refusal still emits `queue_changed`, so the reducer's optimistic `message_queued` insert cannot stick on the strip. - `emitQueue`'s whole-array copy stays, now bounded at 20 elements per push. Trading it for a push/shift/clear delta would put queue arithmetic back in the reducer — the drift this design removed by making the orchestrator authoritative. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distanc…
… the agent (#158) * fix(tui): Esc returns to Run from Observe tabs instead of quitting Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc in the chat editor clears the draft instead of quitting A single Esc on the Run screen terminated the agent the moment the session was idle. Nothing advertised it — the chat hint strip only ever offered "[ctrl+c] quit", and Ctrl+C deliberately asks twice before it kills anything. Esc means cancel / back one level on every other surface of this TUI, so the one place it meant "exit" was a trap, and it took any half-typed message down with it. It is reachable straight out of normal navigation: Esc walks back from a Manage panel to Run, and the next press — the natural "and out of here too" — used to end the session. Esc on an idle Run screen now clears the draft (and no-ops on an empty buffer), reusing the same `input_changed` reset the submit handler dispatches, which also clears the input-history cursor. Quitting stays on Ctrl+C twice and `/quit`. The abort path for a non-idle session is unchanged. Stacks on the Observe-tab Esc fix — both touch `onEscape`. Merge that one first. * fix(tui): decide Esc's abort-over-draft precedence and put it on screen Review feedback on the Esc change: state the precedence between "abort" and "clear draft" instead of letting it fall out of `if` ordering, and stop the new behaviour from being as undiscoverable as the trap the branch removed. Precedence, decided rather than inherited: while a turn is in flight Esc aborts and leaves the draft alone; the next Esc, now idle, clears it. Abort is the destructive, time-critical action — the operator pressing Esc mid-run wants the agent stopped — and a draft is cheap to keep. The handler now tests `!canAcceptMessage(state)` first, under the name `turnInFlight`, with the reasoning written down next to it. That precedence turned out to be unreachable. The chat editor is `disabled` for the duration of a turn, so its Ink `useInput` unsubscribed — and on the chat surface it owns the *only* Esc subscription (`handlePanelEscape` bows out on `editorFocus`, `handleAppKey` has no chat-mode Esc branch). Esc during a turn did nothing whatsoever, which made the strip's `esc / abort` chip a promise the TUI could not keep. A disabled editor now forwards Esc and still swallows every key that could touch the buffer. Hint strip. Idle with a non-empty buffer swaps `[/] commands` — inert there, since `/` only opens the palette from a leading slash — for `[esc] clear draft`, so the row stays six chips wide instead of growing a seventh. The running chip reads `abort, draft kept` when there is a draft, which is the one thing the screen could not previously tell you. The strip also never fitted. Ink wraps an over-wide row instead of clipping it: at 108 columns of chips the idle footer was already two rows at 100 columns and three garbled ones at 60, while `debug-pane` budgets it as exactly one (`APP_CHROME_ROWS`). Chips now carry a `shed` rank and are dropped in that order until the row fits the chat column (terminal minus gutter minus sidebar, passed in as `width`), with `truncate-end` clipping the essential remainder on a terminal too narrow even for those. Idle at 80 columns: send / newline / commands or clear draft / quit, one row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
…ning (#157) * feat(runtime): mid-turn steering — reach the turn that is already running Per-session FIFO is the right answer for *starting* turns and the wrong one for *correcting* one. Today a message sent while the agent is working cannot reach the model at all until the turn closes, so an operator watching it head the wrong way has only one lever: abort. `SteeringInbox` is the out-of-band channel for that, and deliberately not a second queue — there is still exactly one path into `AgentLoop.runTurn`: - `runtime.steer(sessionId, text)` returns false and queues nothing when the session has no turn in flight. "Not steered" is a signal the caller acts on (fall back to runTurn, or to its own queue), never a silent drop. - `AgentLoop` drains the inbox at the top of every step, so the effect lands at the next step boundary — never mid-inference, never mid-tool-call. A turn parked in a long shell call will not react until that call returns; that is inherent, not a bug. - Each drained message is recorded as a real `user` ConversationTurn. The transcript must not lie about what the operator said. `packConversation` already pins the last user turn visible and `findCurrentMacroTurnStart` folds it into the macro-turn in progress. - The text is ALSO repeated in `### notice`, composed with (not over) whatever the loop detector left there. The duplication is deliberate: `### notice` is the last block before `### respond`, which is the one place a 30B local model reliably acts on. Long pastes are clipped inline and point back at the transcript copy. Tail-only, so no KV cache invalidation. - Nothing is lost. A message pushed after the loop's final drain — the last inference, or a turn cancelled before it stepped — comes back on `RunTurnResult.undelivered` for the caller to re-route. Shutdown clears the inbox so a stale steer cannot resurface in a later process. - Bounded at 16 pending per session; push refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. No producer is wired up yet — the TUI gesture and the sidecar/HTTP endpoints land separately. Without the `steeringInbox` dep the loop is byte-identical to before, pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(runtime): close the lost-update window between the final drain and busy.delete `steer()` was a check-then-act across two facts that stop being true at two different moments. `turnController.isBusy` is cleared in the controller's own `finally`, after `run()` settles; the loop does its final drain earlier, inside `runTurn`. A `steer()` landing in between was accepted — the operator's UI said the message got through — and then sat in the inbox until the NEXT turn on that session, where it arrived at step 0 under a notice telling the model the user sent it "while you were working", about a turn that had already ended. It was not in `undelivered` either: it arrived after the flush. Acceptance is now one fact, owned by the inbox. `AgentLoop.runTurn` calls `open(sessionId)` on entry and `closeAndDrain(sessionId)` on the way out; `push` refuses whenever the window is shut. Because the same indivisible call closes the window and takes what is pending, there is no window left: a message is delivered at a step boundary, returned on `RunTurnResult.undelivered`, or refused outright so the caller re-routes it. `steer()` no longer consults `isBusy` at all — that was the stale half of the pair. Considered and rejected: a per-session turn generation captured before the push. To be correct the generation still has to be validated inside `push` (comparing it in the caller is the same check-then-act again), so it buys nothing over a boolean window and adds a counter to reason about. The loop's `finally` also closes the window on the throw path, where no result exists to carry `undelivered`; anything stranded there is warn-logged rather than left for a later turn to pick up. The new bootstrap test stands in the window deterministically — no sleeps: `runTurn` is `enqueue({ run: () => executeTurn(...) })`, so spelling that composition out by hand puts an assertion between the loop's final drain and `busy.delete`, with the real controller, inbox, loop and `runtime.steer`. With the gate removed it fails exactly as reported: `steer()` returns true, and the text resurfaces as `steer_applied` at step 0 of the following turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): re-route steering messages the turn could not deliver AGENTS.md said callers MUST re-route `RunTurnResult.undelivered` and that "the TUI pushes it onto its pending-message queue". Nothing outside tests read the field, so every undelivered steer was dropped — a message `steer()` had already answered "yes" to, and that the operator watched being accepted. `ChatOrchestrator` now holds up both ends of that contract: - `sendMessage` offers a message typed during a turn to that turn first (`runtime.steer`) and only falls back to its own pending queue when the turn refuses it — the shape AGENTS.md prescribes for callers. - `rerouteUndelivered` puts anything handed back at the HEAD of the queue. Those messages were accepted as steers for the turn that just ran; anything already queued was typed after `steer` had started refusing, i.e. later. The keyboard gesture is unchanged: `canAcceptMessage` still accepts submissions only while idle, because `message_submitted` resets the run view and opening the input mid-turn needs its own action. This is the re-route contract, not the gesture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): render steer_applied, and make the reducer's default exhaustive `steer_applied` shipped with a doc comment promising inline rendering and no case in `reduceAgentEvent`, so it fell through to `default: return state`. A message the operator sent mid-turn reached the model and never appeared on their screen. It now renders as a user bubble plus a feed line naming the step it was folded into — and none of the per-turn resets `user_message` implies, because it is a correction to the turn in flight, not the start of one. The bare `default` is what let that ship: a non-exhaustive switch gives TypeScript nothing to say about a new event. Both switches in this file now end in `const unhandled: never = event`, which turns the next missing case into a compile error while still returning `state` at runtime — a UI reducer must never throw on an event it does not recognise. Events that are deliberately not rendered are now listed as explicit no-ops (`loop_detected`, `prompt_built`, `llm_completed`, `llm_raw_completion`) so the check stays meaningful. The check immediately found a second one: `batch_trimmed` was also swallowed. It gets a feed line — otherwise the dropped calls reappear one-by-one on later steps with no explanation for why the batch shrank. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a steer refused before the window opens is not backlog `ChatOrchestrator` treats `currentController` as "a turn is in flight", and that fact is strictly WIDER than the steering window. `runOneTurn` sets it and then awaits `runtime.runTurn`; the loop's `open()` runs only after `turnController.enqueue` stops parking in `waitOrAbort` — which is the whole remainder of any out-of-band turn already holding the session lock, and, on the way out, the span between the previous turn's final drain and its promise settling. A message submitted in that span took the steer branch, `steer()` refused it, and it landed at the BACK of the pending queue with no acknowledgement at all. Nothing was lost, but the operator's ordering was: they aimed it at the turn they were watching and it queued behind messages meant for an earlier one. They were also told nothing, having just been told "steering the running turn" a moment earlier. The fallback now splices the message in at the FRONT of the queue — the same rule, and the same reason, as `rerouteUndelivered`: it is a correction to the turn in flight, so it does not wait behind backlog. `steeredAhead` marks how many leading entries were re-routed for the current turn, so two messages sent into the same gap keep their typing order instead of reversing; it resets when a turn starts, which is what turns "aimed at the turn in flight" into "ordinary backlog". And the refusal now emits its own `steering the running turn` line, worded to hold whether the window was shut, not yet open, or full. Considered and rejected: opening the window at the caller's commit point. The window is a per-session single slot, so opening it before the submission owns the session lock aliases two turns onto one window — the turn still running would drain a message meant for the parked one, and its `closeAndDrain` would carry off the parked turn's pending steers as its own `undelivered`. Making that correct needs a per-turn window, i.e. exactly the generation counter 725307c rejected. It is also unfixable on the abort-while-parked path: `run()` never executes, so nothing closes the window or hands anything back, and a message `steer` answered "yes" to would be dropped — the defect bcc46dd fixed. `sendMessage` still reads exactly one fact. It does not ask `steeringInbox.isOpen` to tell "too late" from "not yet" from "full" apart; that would be a second fact read at a different moment than the one `steer` acted on, and all three land the message in the same place. The new tests run the real `TurnController` and the real `SteeringInbox`, with only the loop body stubbed in the shape `AgentLoop.runTurn` has (`open` on entry, `closeAndDrain` on the way out, then a settle phase for the session save and the controller's own `finally`). Gates, not sleeps: an out-of-band turn takes the lock, the TUI turn parks in `waitOrAbort` behind it, the occupant drains and closes, and the steer lands with the loop that will serve it still parked. Before the change both fail — no acknowledgement, and the correction runs after backlog left by an earlier turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. …
…#160) * feat(runtime): mid-turn steering — reach the turn that is already running Per-session FIFO is the right answer for *starting* turns and the wrong one for *correcting* one. Today a message sent while the agent is working cannot reach the model at all until the turn closes, so an operator watching it head the wrong way has only one lever: abort. `SteeringInbox` is the out-of-band channel for that, and deliberately not a second queue — there is still exactly one path into `AgentLoop.runTurn`: - `runtime.steer(sessionId, text)` returns false and queues nothing when the session has no turn in flight. "Not steered" is a signal the caller acts on (fall back to runTurn, or to its own queue), never a silent drop. - `AgentLoop` drains the inbox at the top of every step, so the effect lands at the next step boundary — never mid-inference, never mid-tool-call. A turn parked in a long shell call will not react until that call returns; that is inherent, not a bug. - Each drained message is recorded as a real `user` ConversationTurn. The transcript must not lie about what the operator said. `packConversation` already pins the last user turn visible and `findCurrentMacroTurnStart` folds it into the macro-turn in progress. - The text is ALSO repeated in `### notice`, composed with (not over) whatever the loop detector left there. The duplication is deliberate: `### notice` is the last block before `### respond`, which is the one place a 30B local model reliably acts on. Long pastes are clipped inline and point back at the transcript copy. Tail-only, so no KV cache invalidation. - Nothing is lost. A message pushed after the loop's final drain — the last inference, or a turn cancelled before it stepped — comes back on `RunTurnResult.undelivered` for the caller to re-route. Shutdown clears the inbox so a stale steer cannot resurface in a later process. - Bounded at 16 pending per session; push refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. No producer is wired up yet — the TUI gesture and the sidecar/HTTP endpoints land separately. Without the `steeringInbox` dep the loop is byte-identical to before, pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(sidecar,http): steering API so hosts can redirect a running turn Exposes the runtime's mid-turn steering on the two surfaces the desktop shell talks to. Both handlers deliberately bypass `turnController.enqueue` — enqueueing would park the message behind the very turn it is meant to redirect, which is the whole bug. Sidecar (NDJSON): - `steer_message` request `{sessionId, text}` -> `{steered}`. `false` when the session is not the active one or has no turn in flight; the host's cue to fall back to `send_message`. - `steer_applied` event when a message reaches the model, carrying the step index, so hosts can render it inline in the running turn instead of as the start of a new one. - `steer_undelivered` event, emitted from `send_message` for anything `RunTurnResult.undelivered` hands back. The sidecar has no queue of its own, so a late steer goes to the host rather than nowhere. HTTP (`atomic-agent serve`): - `POST /api/sessions/{id}/steer` with `{text}`. - `200 {steered:true}` while a turn is in flight. - `409` when the session is idle, naming `/v1/chat/completions` as the right endpoint. Refusing beats accepting a message nothing will read. - `429` when the per-session inbox is full. - `400` on a missing or blank `text`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(serve): list the steer route in --help The endpoint list in `serve --help` is the discovery surface for this API; a route that is not in it does not exist as far as an operator is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(http): a steer the API accepted can no longer vanish `POST /api/sessions/{id}/steer` answered `200 {steered:true}` and the HTTP path then dropped `RunTurnResult.undelivered` on the floor. A steer that landed during the final inference — or in a turn cancelled before its next step — was swept out of the inbox by `flushSteering` and, with nobody reading the hand-back, existed nowhere. The host could neither detect the loss nor resend. The sidecar half of the same feature already emits `steer_undelivered` for exactly this; the promise held on one surface and not the other. The steer and the turn are different HTTP exchanges: the steer was answered long before the turn closed, and the completion response goes to whoever owns the turn, who is generally not whoever steered. So the route that ran the turn always parks the hand-back, and mirrors it onto the response only as a fast path: - `UndeliveredSteerStore` — per-session, in-memory, one per server. `openai-chat-completions.ts` parks on the success, `failed` and threw paths alike (on the threw path nothing flushed the inbox, so it drains it rather than let a steer resurface in an unrelated later turn). - `GET /api/sessions/{id}/steer` lists what was stranded. `DELETE /api/sessions/{id}/steer?through={seq}` acks it. Reads do not consume — a retried or prefetched GET must not be able to lose the text — and the ack is by cursor, so a steer parked between the two calls is not swallowed unseen. - `undelivered_steers` on the non-stream `chat.completion` body, absent when the turn delivered everything so vanilla completions are unchanged; `event: steer_undelivered` on extensions-opt-in streams, named after the sidecar event. Both carry the parked `seq`, so they are the same message, not a second copy. - `DELETE /api/sessions/{id}` takes that session's parked steers with the row; the per-session cap reports what it discarded rather than quietly returning a short list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(http): acking parked steers no longer erases the loss count `UndeliveredSteerStore.ack` dropped the whole box once the cursor covered the last entry, and the box carried `discarded` too. So a host that acks the highest seq it was given — before, or in the same pass as, inspecting `discarded` — permanently lost the "N messages were dropped" signal, and the next `GET` reported `discarded: 0` for a session that genuinely lost text. The comment on that line claimed the opposite ("the discard notice included"), but nothing proved the host had ever read it. The two facts now have two acks, for the same reason the entry ack is a cursor and not a bare clear: the discarded messages have no `seq` the host was ever shown, so acking the entries cannot stand in for having read the count. - `ack` touches entries only, and the comment says so. - `ackDiscarded` counts the loss down. A count rather than a flag, so discards that land between the host's `GET` and its `DELETE` stay outstanding instead of being cleared unseen — the same discipline as the cursor, applied to a counter. - `DELETE /api/sessions/{id}/steer` takes `?through={seq}` and/or `?discarded={n}`; at least one is required (a bare DELETE is still a 400), and the response reports both what is left and what loss is still outstanding, so a host that only ever calls DELETE sees it too. The box now outlives its entries while a loss is unacknowledged, which is what makes the notice survivable. It is still reclaimed the moment both are empty, by `DELETE /api/sessions/{id}` (session purge), and by `MAX_PARKED_SESSIONS` eviction — a host that never acks anything cannot pin the store open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(http): hand a stranded-steer batch back whole `park` pushed the batch, spliced the oldest entries out at the cap, and returned only the survivors. That return value is the hand-back: it becomes `undelivered_steers` on the completion body and the `steer_undelivered` SSE frame. So a `RunTurnResult.undelivered` batch larger than the remaining cap silently omitted its oldest messages from the very response that exists to give them back, leaving nothing behind but `box.discarded`. The cap was always about accumulation — its own comment says one turn can never hand back more than `MAX_PENDING_STEERS`, and that it "only bites when several turns strand messages and nobody ever acks". It now says that in code: capacity is `max(MAX_PARKED_STEERS, batch.length)`, so eviction can only reach entries parked by *earlier* calls, never the batch just handed over. `park` returns that batch unfiltered, and everything it returns is retrievable on `GET` until acked. Not reachable from today's callers — the inbox refuses past `MAX_PENDING_STEERS` and `MAX_PARKED_STEERS` is defined as that same number — but the store is a standalone component with its own constant, and the failure mode if either drifts is a message that exists nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(http): let runtime.steer decide a steer, not a stale isBusy read `POST /api/sessions/{id}/steer` gated on `turnController.isBusy(id)` before calling `runtime.steer`. #157 removes exactly that check from inside `steer()` as the stale half of a check-then-act: `isBusy` and "a step boundary is still coming" stop being true at different moments — the loop's final drain happens inside `runTurn`, `busy.delete` later in the controller's own `finally`. Left in the route, the pre-check would be reintroduced one layer up, rejecting a steer the runtime would have taken. `steer()` already returns a truthful accept/refuse, so the route asks it first and only translates the answer. The inbox is read after a refusal and only to choose the status code: a refusal with a full inbox is the 429, anything else is the 409. That read cannot strand a message — the decision is already made — where the pre-check could reject one. Both codes the API promises are unchanged: 409 for a session with no turn to steer (naming `/v1/chat/completions` as the follow-up), 429 for a full inbox. The 409 text now says "no turn accepting steers" rather than "no turn in flight", which is the fact `steer()` actually reports. Independent of #157 landing: with today's `steer()` the pre-check is redundant, and after it the route stops being wrong. It reads `steeringInbox.peek`, which both versions expose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter …
…ind it (#159) * fix(tui): keep the editor live during a turn and queue what you type Pressing Enter used to make the input dead until the turn finished: `canAcceptMessage` (status === "idle") gated both the submit pipeline and the `disabled` prop on `PromptShell`, so `MultiLineEditor` dropped every keystroke. You could not draft the next message, let alone send one. The queue this needs has existed all along and was unreachable: `ChatOrchestrator.sendMessage` buffers into `this.queue` whenever a turn is in flight and `runOneTurn` drains it FIFO. Nothing ever got that far because the UI rejected the submit first. - Split the selector: `canAcceptMessage` still means "a new turn may start now"; the new `canTypeMessage` (status !== "quitting") gates the editor. Typing is allowed for the whole run. - A submit made mid-run dispatches the new `message_queued` action, not `message_submitted`. That distinction is load-bearing — `message_submitted` calls `startNewRun`, which wipes `feed`, `reasoning` and `streamingToolCards` and would blank the screen of the turn the operator is reading. - The orchestrator re-publishes its queue on every push, drain, clear, session switch and quit via `queue_changed`, so the UI mirrors what will actually run instead of tracking an optimistic copy. - Parked messages render as a dim strip above the prompt (`QueuedMessages`), the hint strip advertises what Enter does mid-run plus how many messages are parked, and `/queue` lists them while `/queue clear` drops them. Runtime, agent loop and prompt are untouched — this is a TUI-layer fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(runtime): mid-turn steering — reach the turn that is already running Per-session FIFO is the right answer for *starting* turns and the wrong one for *correcting* one. Today a message sent while the agent is working cannot reach the model at all until the turn closes, so an operator watching it head the wrong way has only one lever: abort. `SteeringInbox` is the out-of-band channel for that, and deliberately not a second queue — there is still exactly one path into `AgentLoop.runTurn`: - `runtime.steer(sessionId, text)` returns false and queues nothing when the session has no turn in flight. "Not steered" is a signal the caller acts on (fall back to runTurn, or to its own queue), never a silent drop. - `AgentLoop` drains the inbox at the top of every step, so the effect lands at the next step boundary — never mid-inference, never mid-tool-call. A turn parked in a long shell call will not react until that call returns; that is inherent, not a bug. - Each drained message is recorded as a real `user` ConversationTurn. The transcript must not lie about what the operator said. `packConversation` already pins the last user turn visible and `findCurrentMacroTurnStart` folds it into the macro-turn in progress. - The text is ALSO repeated in `### notice`, composed with (not over) whatever the loop detector left there. The duplication is deliberate: `### notice` is the last block before `### respond`, which is the one place a 30B local model reliably acts on. Long pastes are clipped inline and point back at the transcript copy. Tail-only, so no KV cache invalidation. - Nothing is lost. A message pushed after the loop's final drain — the last inference, or a turn cancelled before it stepped — comes back on `RunTurnResult.undelivered` for the caller to re-route. Shutdown clears the inbox so a stale steer cannot resurface in a later process. - Bounded at 16 pending per session; push refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. No producer is wired up yet — the TUI gesture and the sidecar/HTTP endpoints land separately. Without the `steeringInbox` dep the loop is byte-identical to before, pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): choose whether Enter steers the running turn or queues behind it With the editor live for the whole turn, Enter needs a meaning while the agent is working. `tui.whileBusySubmit` decides it — default `steer`, because someone who types *while* the agent is working is usually reacting to what they see it doing. - Ctrl+T flips the mode in-app and persists it; the prompt meta-row shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is running, and the hint strip offers the flip. - `/steer <msg>` and `/queue <msg>` land one message in the other mode without changing the default; bare `/steer` / `/queue` switch it. - `/queue` also still lists what is parked, and `/queue clear` drops it. Alt+Enter was the obvious gesture and turns out to be unavailable: `multi-line-editor.tsx` treats Return with ANY modifier (`key.meta || key.shift || key.ctrl`) as "insert newline", so binding it would cost multi-line input. An explicit, visible toggle is the honest alternative — Ctrl+T also had to join `isGlobalHotkey` so the editor does not swallow it as text. Two things that keep the message from ever being shown twice or lost: - `ChatOrchestrator.steerMessage` falls back to the queue when `runtime.steer` returns false (the turn can end between the keypress and the dispatch) and re-queues anything handed back on `RunTurnResult.undelivered`. - The user bubble is rendered on the `steer_applied` event, not optimistically at submit time, so a steer that misses the turn and falls back to the queue renders exactly once — when it actually reaches the model. The hint strip was re-tightened while doing this: with the new chips it no longer fit one terminal row at 80 columns, and a wrapped strip pushes the prompt down. Labels are terse now and an armed Ctrl+C takes the row for itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): make bare /steer and /queue persist the mode, like Ctrl+T The docstring already said bare `/steer` / `/queue` are the persisting form — the code only flipped `TuiState`. The operator typed `/queue`, read "Enter now parks messages", and found `steer` back on the next launch because `createInitialTuiState` seeds from `config.tui.whileBusySubmit`, which nothing had written. `dispatchQueueSub` / `dispatchSteerSub` now return `setWhileBusyMode` alongside the `while_busy_mode_changed` action, and `runSlashCommand` hands it to `onWhileBusyModePersistRequested` — the same callback the Ctrl+T binding uses, so all three routes end up in one `persistUserWhileBusySubmit` call with one error path, shaped like the `setThemeName` branch directly above it. The message-carrying form is untouched: `/steer <msg>` / `/queue <msg>` return `submitWhileBusy` and leave `setWhileBusyMode` unset, so a one-off still cannot move the default. `/queue clear` likewise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opu…
) * feat(tui): ctrl+n opens a new terminal window running atomic-agent Running a second agent meant leaving the TUI, opening a terminal by hand, cd'ing back and typing the command. Ctrl+N (and `/window`, alias `/newwindow`) now does it in one keystroke: a new OS terminal window with a fresh `atomic-agent tui` in the same working directory. `/new` keeps its meaning — a fresh session inside this process. The platform logic is split so it is unit-reachable without opening windows: `build-terminal-launch.ts` is a pure resolver (osascript → Terminal/iTerm on macOS; $ATOMIC_AGENT_TERMINAL → $TERMINAL → gnome-terminal/konsole/xfce4-terminal/kitty/alacritty/ wezterm/x-terminal-emulator/xterm on Linux; wt.exe or a `start`ed cmd.exe on Windows), and `open-terminal-window.ts` owns the detached spawn plus the PATH probe. A missing emulator comes back as a value and lands in the chat log as one warn line — never a throw in the render loop. Two details worth keeping: argv[1] is dropped for SEA builds and kept under plain node (same reasoning as the self-update relaunch), and ATOMIC_AGENT_STATE_DIR travels inside the command line, because the spawned terminal starts a login shell that inherits nothing — without it the second window would silently use a different state dir. * fix(config): resolve asset dirs relative to the module, not the cwd Ctrl+N opens a new terminal that starts the agent by absolute path from whatever directory the operator happens to be in. resolveAssetDir's last resort was `<cwd>/grammars`, so the child died before the first token: Error: ENOENT: no such file or directory, open '/Users/valerii/grammars/tool-call.gbnf' at async buildGrammar (dist/llm/grammar/build-grammar.js:16:25) at async createAgentRuntime (dist/runtime/bootstrap.js:559:19) buildGrammar has its own module-relative fallback, but bootstrap passes config.paths.grammarsDir explicitly, so it never got a chance to run. Precedence is now: explicit env var, then binary-adjacent (the SEA layout), then two levels up from this module — `grammars/` sits beside `dist/` in the published package and at the repo root under tsx — then cwd. cwd is kept last rather than dropped so any layout that only ever worked by being run from the project root keeps working. Ctrl+N also has to hand the child every env var that steers this resolution: a spawned login shell inherits nothing, so a parent pointed at a non-default grammars/ or starter-skills/ would otherwise be silently disagreed with by its own new window. They now travel inline next to ATOMIC_AGENT_STATE_DIR on both the POSIX and cmd.exe paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSa…
…TDIR
The user-supplied path was handed to spawn as the child process cwd while the
search target was hardcoded to ".", so pointing the tool at a file -- which
its own description invites -- failed before ripgrep ever ran:
os.fs.grep {"pattern":"endopsychic","path":"darktrace.txt"}
spawn ENOTDIR
The target now goes to ripgrep as a positional argument and cwd is a real
directory: the path itself when it is a directory, its parent when it is a
file. A path that does not exist reports that plainly rather than surfacing an
OS errno the model cannot act on.
The existing suite stubbed runCommand against a fixture root that never existed
on disk, which is why it could not see this; the new tests assert on the argv
and cwd handed to the stub.
Closes #183
(cherry picked from commit 777af37)
… by failing vision.describe rejects more than four images per call, but the cap appeared nowhere the model could read it -- not in the tool description, not in the stable-prefix descriptor, not in the args schema. The model learned it by sending twelve crops and getting an error after the work was already done. The cap is now named in all three places, and the error carries the remedy rather than only the complaint: at most 4 images per call (got 12) - split into 3 calls of at most 4 The tool description interpolates the configured value, so it stays accurate when vision.maxImagesPerCall is raised. The descriptor and JSON schema are static and document the default of 4, worded so a reconfigured cap does not make them read as false. paths gets maxItems via a copy of stringArraySchema rather than a mutation -- that const is shared by fourteen tools, and editing it in place would have silently capped os.shell.run args at four elements. A test pins the isolation. No auto-chunking: splitting a batch would change behaviour and multiply vision-provider calls, which is the caller's decision to make. Closes #185 (cherry picked from commit 25f0a04)
Models sometimes emit a tool argument as a string that contains the value:
a number as "200000", an array as "[\"a.png\"]", an object as
"{\"User-Agent\": ...}". The payload is valid JSON of the declared shape,
but a strict typeof check rejects it, the step is spent, and the error reads
as a range complaint rather than a type one.
Coercion now happens once at the dispatch point, reusing the existing
coerceJsonSchemaValue rather than patching each tool. This reaches tools the
issue did not name -- browser.scroll was rejecting amount: "3000" the same way.
The decision to coerce asks whether the schema accepts the value as written,
not whether the schema mentions string. That distinction matters for a union
like browser.scroll's amount ("page" | "half" | number): "page" validates
and is left alone, "3000" does not and is converted. os.http.request's
string-or-object body is handled by the same rule -- a JSON-looking string
there is a legitimate body, not an over-encoding.
Do no harm: only strings are candidates, a failed coercion keeps the original
so the tool's own validation still speaks, and a tool with no registered schema
passes through untouched.
Closes #182
(cherry picked from commit e301342)
The veto told the model its last N calls were identical. On the wandering path that was the inverse of the truth: thirteen fetches to thirteen different URLs were reported as thirteen identical outcomes. A model that reads that after genuinely varying its attempts learns nothing it can act on, which is a plausible reason the same veto kept re-firing. The message now names the invariant the model could not see -- the host it kept returning to, or the command it kept re-running -- and distinguishes the two cases honestly: BLOCKED: `os.web.fetch` - 5 consecutive calls to `web.archive.org` returned the same no-progress outcome. BLOCKED: `os.web.fetch` - 13 different attempts against `web.archive.org` and still no answer. The alternative offered is now an action rather than a restatement of the failure, following the phrasing the wandering redirect in this file already used. Loop signals report the corrected detector, so a wandering escalation is no longer mislabelled in traces. Only the host reaches the message, never the full URL: a query string or an Authorization header would otherwise be copied into the model's context, and backticks and newlines are stripped so a crafted URL cannot inject markup into the prompt. Two tests cover that. Thresholds are untouched -- the issue asked for an earlier trigger, but the default is already 5. Closes #186 (cherry picked from commit 9c182ba)
The supported-input list ends with USER_CONFIG_VERSION, so raising that constant silently drops the version it replaces instead of grandfathering it. Whoever bumps next locks out every config still on the outgoing number: unsupported config version 38; expected one of 5, ..., 37, 39 The v36 -> v37 bump appended 36 by hand, so the convention exists; it is just easy to miss, and the existing migration tests only exercise the current version and much older ones, never the adjacent one. Appends the versions the recent bumps left behind and adds a test that walks every version from the oldest supported to the current one, so the next bump that forgets fails loudly rather than locking users out of their own config.
curl reads [ ] { } in a URL as its own range/set syntax unless --globoff is
passed, so arXiv date-range queries and Wayback CDX regex filters never left
the machine:
curl: (3) bad range in URL position 124
The error message is the mild half. A URL whose brackets happen to form a
valid range is worse: curl expands it and issues one request per value, so a
single tool call silently multiplies its rate-limit consumption while the
meta parser sees only the last response. Brace syntax in a hostname would
likewise have curl contact a name the SSRF guard never resolved -- not
reachable today, since the guard's own lookup of the literal brace-bearing
host fails first, but the flag closes it as defence in depth.
All three curl argv builders were affected -- os.web.fetch, os.http.request,
and the shared web-search transport, which the issue did not name but which
fails identically for any query carrying a character class.
Closes #184
…igurable
A 503 ended the fetch outright, though 128 of the 130 seen came from a single
host that serves the same URL seconds later. Retries now cover 429/502/503/504
and curl's timeout exit, two attempts on top of the first with exponential
backoff capped at 5s -- about 1.5s against a 25-minute task budget. Retry-After
is honoured when the server sends one, still clamped to the cap so a hostile
value cannot park the agent for an hour.
The 30s timeout was a module constant with no per-call or configured override,
so each timeout burned a full 30s and returned nothing. web.fetch now carries
timeoutMs and connectTimeoutMs, and the tool accepts a per-call timeoutMs the
way os.http.request already did. Separating the two matters: a host that has
not completed a handshake in 10s is unreachable rather than slow, while a large
healthy document still gets the whole transfer budget.
Reading Retry-After needs response headers, which the -w format did not
capture. %{header_json} is appended last because it contains the pipe and the
newlines the field split relies on; curl older than 7.83 emits the token
verbatim and degrades to plain backoff, which a test pins.
os.web.fetch is GET-only, so every retry is safe. The SSRF guard re-resolves
the host on each attempt -- a backoff wait must not become a window for a
stale pinned address.
Config v37 -> v38, additive: timeoutMs keeps its 30_000 default, so behaviour
is unchanged for anyone who does not opt in.
Closes #180
Closes #181
…tles The wandering spread is a property of the history window, so it stays above the threshold after the model stops varying its argument and starts repeating one. A verbatim repeat was therefore still announced as BLOCKED: `os.web.fetch` — 13 different attempts against `web.archive.org` and, on the escalation path where the verdict carries no streak of its own, as `0 different attempts` -- a count nothing had established. That is the same false statement about what the model did that this wording was written to remove, only mirrored. Two changes make the message follow the call being blocked: - The detector stops classifying a call as wandering once it repeats an argument already in the window; such a call falls through to the repeat detector, which describes it accurately. - The veto path trusts `verdict.detector` and no longer promotes the window-scoped escalation flag over it. A third case had no truthful wording at all: a breaker firing on a verdict with no streak of its own now says `repeated calls to X are not making progress` rather than quoting a zero.
fix(tools): seven tool-reliability defects from the GAIA validation campaign
…n CLIs (no API key) (#169) * feat(llm): drive a Claude Code subscription through its own CLI (no API key) Every cloud provider kind so far needs a paid per-token API key. Anyone already paying for a Claude Code subscription had no way to point the agent at it. Adds the `subscription-cli` provider kind, which runs the vendor CLI you are already signed into as an inference backend. The CLI authenticates from its own session — we never read, copy, or replay OAuth tokens or keychain entries, and never pass `--bare` (whose docs say OAuth and keychain are never read, which would defeat the feature). One kind, parameterised by `subscriptionCli.cli`, with every CLI-specific byte behind a `CliAdapterDescriptor` — so a second vendor CLI is a new descriptor, not a new provider kind. Three decisions worth knowing: - The prompt travels on stdin, never argv. A full two-zone prompt exceeds the 128 KiB single-argument limit, so argv delivery would E2BIG on exactly the long sessions that matter most. - `--tools ""` and `--strict-mcp-config` are safety-critical, not cosmetic: without them Claude Code's own Bash/Edit/Write would act on the machine outside the approval ladder, and the operator's MCP servers would leak into what should be a stateless completion. - The transport is `native_tools` even though this provider never returns `tool_calls`. On `grammar`, any format drift throws out of `parseToolCalls` and buys a second full CLI invocation on the repair path; on `native_tools` an empty `toolCalls` sends step-executor down its guarded recovery ladder instead. Same result when the model complies, no extra process when it does not. Verified end to end against claude 2.1.220: a real turn drives `os.fs.read` -> `reply`, and a multi-step turn drives `os.shell.run` -> `os.fs.write` -> `reply`, with zero parse retries. Server-side prompt caching survives across separate invocations, so the KV-stable prompt is not wasted; the cost is ~0.8s of process spawn per completion. Not supported here, and dropped rather than silently approximated: vision, embeddings (they stay on the local daemon), and the sampling knobs temperature/top_p/top_k/seed/stop/maxTokens — the CLI exposes no flag for any of them. * feat(llm): add the OpenAI Codex subscription to subscription-cli Second vendor CLI behind the same provider kind. Written against the real `codex exec --json` interface (codex-cli 0.148.0) rather than from its docs, because four things did not match what the Claude adapter assumes. - Structured output takes a *file path* (`--output-schema <FILE>`), not inline JSON, so the provider now stages a temp file and cleans it up. The argv builders stay pure; the side effect lives with the process. - Codex exits 0 even when the turn fails. A bad model id, an expired login and a rate limit all give a clean exit plus a `turn.failed` event, so the parser treats a missing `turn.completed` as a failure instead of trusting the exit code and returning empty content. - Under a ChatGPT login Codex rejects every explicit model id ("not supported when using Codex with a ChatGPT account") and resolves one server-side. So `-m` is omitted unless the operator sets one, and the descriptor ships no model list rather than a guessed one. - `exec --json` emits the answer in a single `item.completed` with no incremental text events, so `streamMode: "none"` and the provider buffers rather than pretending to stream. The one real gap versus Claude: Codex has no `--tools ""`. `-s read-only` confines its tools but cannot remove them, and left alone Codex tries to *perform* the request with its own tools instead of emitting Atomic's protocol — the first live run answered "I can't find probe.txt" after looking in its own working directory. Since Codex also has no system-prompt flag, the steering is prepended to the prompt instead. That works, but it is a prompt-level guarantee rather than a structural one, and the README says so plainly. Verified end to end: `os.fs.read` -> `reply`, and `os.fs.read` -> `os.fs.write` -> `reply`, both with zero parse retries. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(sandbox): an undrained stdin pipe no longer kills the whole agent `runCommand` wrote `options.input` to `child.stdin` with no `error` listener anywhere in the file. A CLI that rejects the request — signed out, unknown model, rate-limited — exits without draining stdin, so a prompt past the pipe buffer cannot flush and raises EPIPE on a stream nobody listens to. `installGlobalErrorHandlers` deliberately preserves Node's fatal semantics for `uncaughtException`, so that reached `process.exit(1)` and the operator lost the entire session instead of seeing the `SubscriptionCliAuthError` the CLI provider builds for exactly this case. Measured against a child that exits without reading: 16 KiB and 64 KiB land in the pipe buffer and complete cleanly; 128 KiB, 256 KiB and 1 MiB all produce `UNCAUGHT EXCEPTION: EPIPE`. Not an exotic size — the provider's own comment notes a two-zone prompt routinely exceeds the 128 KiB single-argument limit, which is why the prompt is on stdin at all. Latent here before subscription-cli, but unreachable: the git and test-runner callers stay well under the buffer. The fix absorbs broken-pipe codes only. The child's exit code and stderr still arrive on `close` and still map to the typed error. Everything else on that stream rejects, so a genuine local fault is not swallowed. `CommandResult.inputTruncated` covers the remaining silent case: a CLI that exits 0 regardless (`codex` does) would otherwise pass a completion computed from a half-delivered prompt off as a good answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(llm): Ctrl+C during a streaming CLI turn no longer takes the session with it Two process-lifecycle defects in `streamCliCommand`, both on the abort path, which is the most routine action in the TUI. The stdin write had the same missing `error` listener as `runCommand`, but here we are the trigger: `onAbort` -> `stop("abort")` -> SIGTERM lands on a child that has not read its stdin, the pending write faults with EPIPE, and cancelling a turn tore down the runtime. The `finally` then cancelled the SIGKILL escalation it had just scheduled: if (!settled) stop("done"); // may set killTimer if (killTimer) clearTimeout(killTimer); // always clears it The clear ran microseconds later, long before the 2s delay, so a child that traps SIGTERM was never force-killed — one orphan per aborted turn. Reproduced with a child that ignores SIGTERM: it outlives an 8s watch before the fix and dies inside the escalation window after it. The timer is now cleared only once the child has actually exited, and `stop` no longer re-arms an escalation that is already pending. `inputTruncated` joins the failure conditions here too, for the same reason it does in the buffered runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): c on a subscription-cli row opens its model instead of doing nothing `isCloudProviderKind` was never extended for the new kind, so pressing `c` on a `claude-cli` row matched no branch — and the handler returns `true` regardless, so the key was consumed and nothing happened. Same dead end from the LLM tab's configure action. Extending that predicate is not the fix: two wizard rows (`claude-cli`, `codex-cli`) collapse onto one config kind (`subscription-cli`), so the stored `kind` cannot say which CLI an entry drives — only `entry.subscriptionCli.cli` can. `configureWizardKindForRow` does that resolution and becomes the single "can this row be configured, and as what" answer; `isCloudProviderKind` stays what its name claims, the key-based cloud kinds. What `c` should do on a CLI row: not the cloud wizard as it stands. A subscription CLI has no key to paste and no endpoint to type, so the `api_key` screen configure opens on is the same dead end `advanceWizardPhase` already skips on the add path. Configure now lands straight on `chat_model_line` — the one thing that is editable — prefilled with the pinned model, so Enter keeps it instead of silently resetting it to the adapter default. The step renders the CLI's own placeholder (`sonnet` for claude, "the CLI resolves the model" for codex, which rejects explicit ids under a ChatGPT login) rather than `gpt-5.4-mini`, and lists nothing, because there is no endpoint behind it to list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It resolves to the `openai-compatible` kind, which sent `Authorization: Bearer`; `api.anthropic.com` reads that header as an OAuth token and answers "Invalid bearer token" to an `sk-ant-…` key on every path. Only `x-api-key` reaches the real key check, and `anthropic-version` is mandatory. An operator picked the first row in the list, pasted a valid key, and model discovery 401'd — as did every chat turn after it — with no escape, because `presetId` skips the base_url step. The preset now declares `apiKeyHeader: "x-api-key"` plus a pinned `anthropic-version`. `buildProviderEntryFromWizard` copies both onto the saved entry, so the fix survives a restart rather than living only in the build-time table, and both discovery call sites pass the contract through. Also rewrites the admission bar the preset was let in under. The old rule read any 401 as "asking for a key"; Anthropic's 401 said `x-api-key header is required` — it was asking for a header we never sent, which is the exact condition that should have disqualified it. A preset now qualifies only on a 200 with a `data` array, or a 401/403 that rejects the *credential* when probed with the headers the preset actually sends. A 401 naming a header the preset does not send is a failing probe. `provider-presets.test.ts` only asserted literal baseUrl and envVar strings, which is why this shipped; it now pins the bytes that leave the process on both paths, after the entry has been through `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): reject an empty API key on the provider wizard key screen (#152) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(tui): verify a cloud API key is active and funded before saving it (#166) * feat(tui): verify a cloud API key is active and funded before saving it * feat(llm): add the cloud credential check module * feat(tui): run the key check from the wizard and first-run onboarding * feat(tui): route the wizard cancel through the LLM panel modal too * feat(tui): wire the cancel callback and print an unverified-key notice * docs: document the pre-save cloud credential check * fix(tui): the add-provider wizard stops painting over its own rows Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): a refused key says so on the screen that refused it Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): drop the subscription-CLI labels this branch does not have The service-label map came across with the wizard fix and named the two CLI-backed kinds from #169, which are not on this branch. * fix(tui): a cancelled key check cannot save the provider behind the operator Reported on review: in `CloudProviderOnboarding` the only guard after the verify await was `if (!alive.current) return;`, which asks whether the component is mounted. Esc does not unmount it. `verifyProviderKey` samples the abort signal at the top of each probe and in the fetch catch, so an abort landing between the response arriving and `classifyVerifyResponse` returning comes back as an ordinary verdict — and execution fell straight through to `saveProviderWizardToConfig` and `onFinished("saved_cloud")`. The operator read "Key check cancelled — press Enter to try again" while the provider was written to config and onboarding exited as saved. `completeWizard` has carried the matching `if (abort.signal.aborted) return;` since it was written. That check alone is not enough here. `submit` guarded re-entry on the `submitting` state captured in its closure, and the cancel handler resets that state, so Enter after Esc started a second check while the first was still resolving. Two key events drained from stdin in one turn did it too — and in that interleaving neither run is cancelled, so no post-await abort check can tell them apart. Both saved, and both called `onFinished`. Re-entry is guarded on the in-flight `AbortController` ref instead: written before the first await, cleared only by the run that owns it or by a cancel, and therefore immune to the state reset. With one non-cancelled run at a time, the abort check settles the rest. The failure exit gets the same guard. An abandoned run's error would otherwise paint over the screen the operator was handed back and free `submitting` under a check that is still running. Covers the gap the review named: there was no cancel-then-resolve test. The new cases drive real key bindings and the real verify path against a fetch that answers before it hands over its body, which is where the race lives. Reverting each of the three hunks separately fails only its own cases. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Valerii <valeryb@bearle.dev> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(tui): start page adapts to terminal size (#151) * feat(tui): start page adapts to terminal size The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". * fix(tui): scale one brand mark instead of hand-drawing each size Hand testing turned up the mark looking wrong as the window shrank. It shipped as three hand-drawn copies, and the half-size one had lost the taper of its lower-right tail — it read as a blob rather than the mark, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold so thin arms survive, and 'small' and 'mini' are measured off 'full' at load time. Proportions hold because nothing is drawn twice. - small: 17x10 hand copy -> 20x12 scaled (the honest half of a 20-row drawing is 12 half-block rows) - mini: a one-line '+ ATOMIC AGENT' text stand-in -> a real 7x4 mark - new: 'none'. Below ~6 rows the mark and the tips cannot both fit, and Ink paints an over-tall frame over the rows above rather than clipping it — which is the bug this module exists to prevent. The tips are the half worth keeping at that size. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' across all eleven palettes. It is not a control, and sharing the accent blue made the start page read as one large highlighted widget. Knowingly rewrites the expectations that pinned the old 17x10 art, the one-line text fallback, and 'mini on a two-row surface'. * fix(tui): drop the right rail in a short terminal instead of overlapping it `isSidebarVisible` gated on width alone, so a 100x8 split pane drew the rail anyway, and the row budget floored the split at two sessions and one task no matter how little height there was. The rail came to nine rows against eight and Ink overlapped the frame. Visibility now needs height as well as width, and the budget hands back only rows the window actually has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(models): ranked multi-term model search, in the TUI and the CLI (#168) * feat(models): ranked multi-term model search, in the TUI and the CLI Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search <query> [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. * fix(models): a `1m` search term finds every million-token window The context-window tag was the display string, and tag matching is exact equality, so only a window of exactly 1_000_000 ever answered to `1m`. `formatCompactNumber` renders non-integer millions with `toFixed(1)`, so 1_048_576 tagged as `1.0m`, 1_050_000 as `1.1m`, 1_310_720 as `1.3m`; terms are ANDed and short-circuit on `RANK.none`, so a `1m` term dropped those rows outright. The README advertises the query — `models search "1m cache" --provider openrouter` — and against #167's catalog it hid 19 of 55 chat rows: every Gemini, every GPT-5.x, both DeepSeek v4, both Llama 4. Worse than an empty result, the exactly-1M Claude rows did match, so the answer looked complete. `formatContextWindow` is untouched — it feeds the rendered rows. The normalised forms ride alongside it instead. A window now carries up to three tags, deduped: - the display string, so searching for what a row shows still works; - the whole-unit floor in that same unit (1_310_720 -> `1m`, 202_752 -> `202k`). Floor rather than round, because a size term reads as a lower bound: `1m` must find every window from 1M up to 2M, and must not find a 950k row that would round up to it; - the binary reading when the window is an exact multiple of 1024 (131_072 -> `128k`, 204_800 -> `200k`, 262_144 -> `256k`, 1_048_576 -> `1m`). Those windows are power-of-two sized and are sold by the binary number, which decimal rounding hides; the exact-multiple guard keeps the reading off windows that were never binary. Raw token counts (`131072`) are deliberately not tagged: no surface renders one, so nobody reads it off a row to type it back. Against the bundled OpenRouter catalog on this branch, `1m` goes from 3 rows to 9, `1m cache` from nothing (exit 1) to 2, `256k` from nothing to 3, and `2m` still matches only `openrouter/auto`. With #167's catalog merged in locally: `1m` 15 -> 34, `1m cache` 13 -> 30, `128k` 0 -> 3. Both test files gained the `1m` cases they were missing, which is how this shipped in the first place. --------- Co-authored-by: plombeer31 <valeryb@bearle.dev> * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there (#171) * feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. * fix(tui): make the menu a real overlay — it floats, nothing reflows The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. * feat(tui): status bar shows where you are, not a menu of where to go The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. * fix(tui): a held modifier is not a chord — ctrl+c stays reachable after ctrl+g `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * docs(models): a size term names its unit bucket, it is not a `>=` filter (#189) The README claimed "a size term reads as a lower bound whatever the row displays". `contextWindowTags` floors to the whole unit and tag matching is exact equality, so `contextWindowTags(2_000_000)` emits only `2m` and `1m` does not reach it: in the bundled catalogs `openrouter/auto` and `x-ai/grok-4-fast-reasoning` are both 2_000_000, both tagged `2m` alone, and both score 0 for `1m`. A reader taking the README literally would expect the widest window in each catalog in that result and conclude the search was still broken. Flooring is the behaviour worth keeping, not a lower bound, so the wording is what moves. A true `>=` reading cannot be uniform: exact tag equality is what makes the scorer one lookup per term, and a `>=` rule applied down the units would make `128k` — or `1k` — match nearly the whole catalog, which is the opposite of what an ANDed narrowing term is for. Buckets also keep `1m` and `2m` disjoint instead of nesting, so a term stays a filter rather than a prefix of a bigger result. The source comment already said "every row from 1M up to 2M", but led with "a window of a million or better", which is the same overstatement in miniature and is where the README wording came from; both now say bucket. AGENTS.md carried "a lower bound" too. The unit test excluded the 2M row only by leaving it out of a `toEqual` list, so the boundary the README got wrong was pinned by omission — it is now asserted by name. No behaviour change: `contextWindowTags` is untouched. Co-authored-by: Valerii <valeryb@bearle.dev> * fix(os.shell.run): never drop argv when a glob matches nothing (#176) * fix(os.shell.run): never drop argv when a glob matches nothing `expandShellGlobArgs` treated any argument containing `*` or `?` plus a `/` as a filesystem glob, and dropped it entirely when nothing matched. A URL with a query string satisfies both conditions, so {"cmd":"bash","args":["-c","curl -s 'https://…/api.php?action=query' | …"]} reached the shell as a bare `bash -c` and failed with `bash: -c: option requires an argument`. The model's command was correct; the payload was lost inside the agent, so the error read as a model mistake. Observed 238 times in a GAIA benchmark run, 237 of which carried `?` or `*` — almost all URLs with query strings. Three changes: - Zero matches now pass the original argument through verbatim instead of discarding it. This is POSIX shell default behaviour (bash without `nullglob`, zsh with `nomatch` off). An argument is never dropped. - URL-shaped arguments (`^[a-z][a-z0-9+.-]*://`) are no longer treated as globs. `RELATIVE_GLOB_CMDS` already limited bare-pattern expansion to file commands, but the `/`-containing branch bypassed that list for every command. - The token after `-c` for a known interpreter (bash/sh/zsh/dash/ksh/ python/python3/node/perl/ruby) is exempt from expansion — it is code, not a file path, and routinely carries `?`/`*` in regexes and URLs. Not benchmark-specific: any user command embedding a URL with query parameters, or a `-c` payload containing a regex with `?`/`*`, hit the same silent truncation. The previous test `omits argv when glob matches nothing (nullglob-style)` pinned the buggy behaviour and is replaced by its inverse. Added coverage for zero-match passthrough, the `bash -c` URL regression, bare URL arguments, and real file globs continuing to expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(os.shell.run): tighten the -c payload exemption per review - node, perl and ruby leave CODE_PAYLOAD_CMDS: their -c is a file syntax check, so globbing that argument stays correct - the interpreter check basenames and case-folds the command, matching the guard's own normalization, so /bin/bash and python3.12 qualify - -c is recognized inside a short-option cluster (-lc, -ec) and as --command, the same spellings the dangerous-command rules accept - the URL rule requires a two-letter scheme so a sloppy Windows C:// path keeps expanding - differential tests pin the exemption with a payload that really matches files — the ablation that previously left every new branch untested now fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): stop arrow keys from discarding the typed draft (#188) * fix(tui): stop arrow keys from discarding the typed draft Two defects made the editor lose in-progress text on arrow keys. Up recalled a history entry straight over `inputValue` without saving what was already typed, and Down walked back past the newest entry to a hardcoded `""`. The draft was unrecoverable: press Up by reflex and the half-written message was gone. Left/Right were worse because they look inert. The editor owns the caret and reports every move through `onChange`, so a caret-only keystroke re-emitted the buffer unchanged; `input_changed` reset `inputHistoryCursor` on every such emit. One Left after Up dropped the recall position, so the next Up jumped back to the newest entry instead of stepping further back. Park the draft in `inputHistoryDraft` when recall starts and hand it back when Down leaves history, and treat an `input_changed` whose value equals the current buffer as the no-op it is. Editing a recalled entry still drops the stash, and submit/reset clear it so a stale draft cannot resurface. Covered by reducer tests plus an end-to-end test that drives the real editor with real arrow-key escape sequences; the latter fails on both counts without this change. * chore(tui): drop the unused input_history_reset action The action was declared in `TuiAction` and handled in `reduceUiAction`, but nothing ever dispatched it — history recall exits through `input_history_navigated` and the submit/reset paths clear the cursor directly. Dead weight that reads as a live escape hatch. Removing the variant means TypeScript would reject any dispatch site, so the exhaustive switch confirms there were none. --------- Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * test(tui): pin banner tips to the menu registry, expect the url field in hybrid recall (#177) Three TUI tests asserted behaviour the components had already moved away from. All three failed on main and none of them indicated a real defect — the code is correct, the expectations had drifted. - `splash-banner` asserted `/observe`, `/manage` and `/run`. The banner stopped printing them in #170; `/observe` and `/manage` are still real commands, and `/run` does not exist in the registry at all. The banner is a short tip-list, not the command catalogue, so pinning its exact copy is what made this drift in the first place. It now asserts the commands the banner actually advertises, plus a new invariant: every slash command printed on the welcome screen must exist in `toSlashCommands()`. That catches the failure worth catching — a renamed or deleted command leaving a dead verb on the splash — without breaking again the next time the copy changes. - `chat-log` asserted the tagline `Local-First AI Agent`. The words are transposed; `logo.tsx` renders `Local AI-First Agent`, as do the three other tests that assert it. - `persist-embedding-hybrid-recall` expected three fields where `persistEmbeddingHybridRecall` writes four. The `url` key is written deliberately, derived from the configured port, and is present in the config schema — the test simply predated it. No production code is touched. Verified the new registry assertion fails when the banner is pointed at a non-existent command, so it is not vacuous. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): local custom URL saves without a key, and a non-ASCII key fails clearly (#187) * fix(tui): reject an empty API key on the provider wizard key screen * fix(tui): point the wizard screen at the shared provider-target helpers * fix(tui): let the key screen see a key already saved in config.json `apiKeyPhaseError` resolved the key through `keyLookupEntryForWizard`, which builds `{ id, kind, apiKeyEnvVar }` and never sets `apiKey`. A key the operator typed into the wizard once lives in `config.json` — that is where `upsertLlmProvider` puts it, with nothing written to `.env` — so the gate could not see it. Selecting that provider and pressing `c` opened the wizard on `api_key` with an empty buffer, and Enter refused, leaving no way to change the entry's model without retyping a key that was already stored. `apiKeyForWizard` now consults the stored entry the same way `saveProviderWizardToConfig` does, so the gate is never stricter than the save it fronts. Only a `configure` run with a `providerId` looks one up; `add` still demands a key of its own, and the kind-scoped lookup that keeps a stray `OPENAI_API_KEY` from answering for OpenRouter is untouched. Esc was the other half of the dead end. A configure run opens on `api_key`, so stepping "back" from there built a `pick_kind` screen that run never showed and dropped the entry's kind and base URL on the way. `isWizardFirstScreen` names the screen a run opened at; Esc there closes the wizard, as it already did on the provider list when adding. The key screen also stops telling a reconfiguring operator the key has to be in `.env` when it is sitting in `config.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): accept keyless loopback providers and reject non-ASCII keys Three fixes in the "add a custom OpenAI-compatible provider" flow, all around API key handling for local llama.cpp servers. 1. A hand-added compat endpoint pointing at a loopback address (no preset) is a local server and needs no key. `wizardKeyIsOptional` now returns true for any loopback base URL, so the key screen and the save-time backstop both let an empty key through. A new exported helper `isLoopbackBaseUrl` (in persist-user-local-models-config.ts) recognizes 127.0.0.1, 0.0.0.0, localhost and *.localhost, and ::1 at any port; `pointsAtManagedDaemon` now reuses it. A non-loopback custom URL with an empty key is still rejected, unchanged. 2. A non-ASCII API key (a stray Cyrillic character, a smart quote) used to crash the model-list fetch with an opaque ByteString error from inside `fetch`, because header values must be ASCII. A new `ascii-header-guard` module exports `isAsciiOnly` and `assertAsciiApiKey`; the two Authorization-header sites now assert the key first and throw a clear, named error, and the wizard rejects the key on the key screen and at save time with the same message. 3. When a submit is rejected while the chat-model step shows a discovered model list, the pick list had no error slot, so the wizard's error was invisible and pressing Enter looked dead. The error line now renders under the pick list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: plombeer31 <bvv3131@gmail.com> Co-authored-by: Valerii <valeryb@bearle.dev> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Dudka <sosidudku1@users.noreply.github.com> Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> * fix(tui): Esc aborts a running turn again (#155) * fix(tui): Esc aborts a running turn again The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. * config: parse userModels, promptCache and providerPreferences on llm providers (#164) parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-authored-by: Валерий Брижатюк <valerii@Valerijs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(tui): Esc returns to Run from Observe tabs instead of quitting (#153) Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. * fix(tui): Esc leaves the Import tab instead of being swallowed (#154) Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). * refactor(tui): one menu registry — the slash palette becomes a projection of it (#170) The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). * fix(tui): Esc while running keeps the scroll-reset rung first Review catch on #155: the new running-abort branch matched on `status === "running"` alone and runs ahead of the rung it supersedes, so submit → PageUp → Esc destroyed the in-flight turn instead of snapping the chat back to the latest reply. The scroll-reset check now lives inside the abort branch and claims the key when the offset is non-zero — in chat mode only, since on a debug tab the chat is off-screen and resetting an invisible offset would just make Esc look dead. Also drops the abort branch from the editor's `onEscape`. It is unreachable today (the editor is `disabled` for the whole run) and would be a second live subscription firing `onAbort` twice per keypress once #156 keeps the editor awake during a turn. The editor keeps its other Esc duties: overlay close, scroll reset, idle quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): more cloud providers and a refreshed model catalog (#167) * feat(llm): more cloud providers and a refreshed model catalog Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. * feat(llm): let a provider entry name the header that carries its API key Both openai-compatible request paths hard-coded `Authorization: Bearer` — `fetch-openai-compat-models.ts` for model discovery and `openai-http.ts` for every chat/embedding turn — so a vendor that authenticates any other way could not be reached at all, and nothing in config could correct it: `deps.extraHeaders` is static, and an API key is not. `openai-auth-headers.ts` becomes the single place that decides how a key is attached. Both paths call it, so they cannot drift. `apiKeyHeader` on the entry names the header the key rides in (absent keeps the Bearer default); the existing `headers` field carries any static headers the service mandates. Keyless servers still send no auth header at all. The field rides on the saved `UserLlmProviderEntry`, not on a build-time table, so it round-trips through `config.json` and a hand-written entry can express the same thing. Header names normalize to lower case, which is why the two Bearer assertions in the touched tests move to `authorization`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tui): make the Anthropic preset actually authenticate The preset shipped unusable. It reso…
…squash (#192) The #169 branch update was assembled while #191 landed on main in parallel; the file-level resolution read "differs from the integration tree" as "belongs to #169" and carried twenty pre-#191 files into the squash, silently reverting the whole tool-reliability batch (--globoff, web-fetch retries, loop-veto wording, grandfathered config versions, vision cap notes, grep-on-file fix). This restores every clobbered file to its #191 state byte-for-byte; #169's own delta touched none of them. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The #192 restore covered clean reverts; these two carried edits from BOTH sides (the #191 tool-reliability behavior plus #169's sandbox stdin-pipe adjustments), so they are rebuilt as the #191 version with the #169 branch's own delta applied on top. Both suites pass against the restored sources. Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d the prompt Ports plombeer31's mouse layer (#165) onto current main. The engine lands unchanged: SGR/X10 decoding with split-read reassembly, Yoga-based hit testing, and the stdin split that keeps Ink from typing raw reports into the chat buffer. Only 1000+1006 are requested — motion tracking stays off, since nothing in the UI hovers or drags. The chrome had to be rewritten. #165 was built against the Run / Observe / Manage pill strip and the sub-tab strip; #170 replaced both with a breadcrumb and a single menu registry. Rather than reinstate the pills and give navigation two competing controls, the breadcrumb itself takes the click and opens the menu — exactly what ctrl+p does. /mouse is registered as a menu node, so the command and the menu entry come from one definition. Also carried over from main rather than the branch: - The hint strip keeps its one-row shedding contract. #165 switched it to flexWrap, which is the two-line smear the shed ranks exist to prevent; chips became measurable boxes instead, keeping the ranks intact. - ctrl+p keeps the slot #165 gave to a chip — main removed that chip deliberately when stopped opening the palette over a draft. - USER_CONFIG_VERSION goes to 40 (main reached 39 after the branch was cut), and 38 stays in the supported list. Fixed while porting: the sidebar sent a window-relative row index to sidebar_tasks_cursor_set, which takes an absolute one — clicking a task in a scrolled rail selected the wrong task. Tests: 4766 passing, zero new failures against main's 5 pre-existing (3 stale TUI smoke expectations, 1 flaky concurrency, 1 $HOME-dependent). The two nav tests that drove the deleted pills are replaced by one that drives the breadcrumb; it fails if the click handler is removed.
|
Rebased this branch onto current Nothing about the mouse engine changed. What did need rewriting is the chrome, because The pill strip is gone. #170 replaced the Run / Observe / Manage pills and the sub-tab strip with a breadcrumb plus one menu registry. Rather than reinstate the pills — which would give navigation two competing controls — the breadcrumb itself now takes the click and opens the menu, exactly as Three places where
One bug found while porting. Tests. 4766 passing, zero new failures against Two notes on the PR description, since it no longer matches the branch: the config version is 40, and the "Overlap with the open run-mode stack" section is now moot for #163 (the fusion stack is still open and unmerged, so the config-version note there still applies to whichever of us lands second). Happy to adjust any of the three chrome calls above if you disagree — particularly the breadcrumb-opens-menu choice, since that is the one that changes what your PR does rather than just where it sits. |
What
Makes the TUI clickable: the navigation bar and sub-tabs, the sidebar, every list panel, the pickers and modals, tool cards, and the prompt input (click to place the caret). The wheel scrolls the chat and walks the focused panel.
Why this reverses a deliberate decision
src/tui/tui-command.tscarried an explicit comment choosing not to enable SGR mouse tracking, because capture takes away the terminal's own drag-to-select — and Apple Terminal has no Shift-bypass. That reasoning is sound, but it is an argument for a setting, not for having no mouse layer at all. So:/mouse offat runtime,atomic-agent tui --no-mousefor one run, and"tui": { "mouse": false }inconfig.json(config v38, transparently upgraded).With mouse off, behaviour is byte-for-byte what it was before: alternate-scroll (
\x1b[?1007h) turns the wheel into cursor keys and the old arrow-key chat scroll handles it.Only 1000 + 1006 are requested — button events plus SGR coordinates. Motion tracking (1002/1003) is deliberately not enabled: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream for a UI that would ignore them.
How it works —
src/tui/mouse/mouse-tracking.tsalt-screen.ts, including theprocess.on("exit")restore so a crash never leaves the host terminal printing click escapes into the user's shell.parse-mouse-events.tsESCis passed straight through — buffering it would delay the Escape key until the next keystroke.mouse-stdin.tsPassThroughcarrying only keyboard bytes, withisTTY/setRawMode/ref/unrefproxied to the real stdin.mouse-registry.tsmeasureElementreturns a size only), but every node keeps its Yoga node —absoluteRectsumsgetComputedLeft/Topup the parent chain, the same walkrender-node-to-output.tsperforms when painting, so the rectangle is exactly where the node was drawn. Ancestors withoverflow: hiddenclip it.mouse-context.tsxdispatch/callbacks/ a livegetState, plususeMouseTargetand a layout-neutral<MouseTarget>. Outside the provider (component tests,--no-mouse) every clickable component renders exactly as before.mouse-list-row.tsxsynthetic-key.ts*-key-bindings.tshandler as a synthetic Enter / arrow key.Two behavioural rules worth calling out:
TuiAppraises the registry's layer floor usingisPanelModalOpen— the same predicate extracted out ofhandleAppKey— so a click can never reach the list rendered behind an open confirm.What became clickable
[y]/[s]/[a]/[n], plus they/nhotkey chipstab,shift+tab,esc,/) — chips that name a gesture rather than a command stay plain text/expandand/collapse, which act on every card. The mouse is the first way to open one card.rowColToCursordoes not clamp, so a click past the end of a short line would have run the offset into the next line; it is clamped at the call site, making a click in the empty space right of a line mean "end of this line".Supporting changes
*_cursor_setactions for the cursors that were delta-only: sidebar sessions/tasks, session picker, theme picker, providers, local models, skills hub.isPanelModalOpenanddecideApprovalextracted fromapp-key-bindings.tsso the mouse and the keyboard share one predicate and one approval path instead of two copies that can drift.<Text>runs) so each item is measurable; they useflexWrap="wrap"so a narrow terminal wraps whole chips instead of clipping characters.tui.mouseconfig key (v38),--mouse/--no-mouseflags,/mouse [on|off]command.Testing
npm run lintclean.mouse-tracking.test.ts), the decoder including split chunks / wheel / modifiers / X10 / lone-ESC (parse-mouse-events.test.ts), the stdin split (mouse-stdin.test.ts), and hit testing including nesting, clipping and the modal layer gate (mouse-registry.test.ts).mouse-app.test.tsxdrives the real Ink tree: it finds a label in the rendered frame and emits a click at those coordinates, then asserts the section switched, the sub-tab switched, the caret landed mid-word, a click past EOL clamped, the wheel walked a panel cursor, and a row click moved the cursor. Because Ink commits frames on a ~30fps throttle, these poll the frame instead of sleeping a fixed interval.mainat667dae1. Every failure on the branch is also a failure onmain(stale splash/hint expectations, the$HOME-dependentfs-glob-realcase, and the known flakysend-message-concurrency/llm-health-pollerpair) — no new ones, +50 tests.node dist/cli/index.js tui) with synthetic SGR reports, rendered through a terminal emulator: mouse enable sequences on startup ✅, click on the Observe pill switches section ✅, click on the Logs sub-tab switches tab ✅, click mid-word in the prompt puts the caret there (typing producedhelXlo world) ✅,/mouse offwrites\x1b[?1006l\x1b[?1000land confirms on screen ✅,/mouse onre-enables ✅, quitting restores reporting and leaves the alt screen ✅.Overlap with the open run-mode stack (#161–#163)
Branched off
main @ 667dae1, so it is conflict-free today, but two touch points are worth knowing:USER_CONFIG_VERSION37 → 38 fortui.mouse; feat(config): llm.runMode block — local | cloud | fusion with a fusion cloud-share dial #161 takes the same step forllm.runMode. Whichever lands second should rebase onto 39 — the parser change is a one-lineSUPPORTED_INPUT_VERSIONSentry plus the constant.TuiApp; this PR splits the status-bar pills and theSubTabBarstrip into per-item boxes. Both edit the same region ofstatus-bar.tsx/debug-pane.tsx. Rebasing this one after feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163 is the easier direction — the new submenu row just needs the same<MouseTarget>wrapper the other strips got, and it becomes clickable for free.