Skip to content

atomic-agent 0.3.0 — every open PR integrated, plus what four rounds of hand testing found - #175

Closed
plombeer31 wants to merge 84 commits into
mainfrom
release/v0.3.0
Closed

atomic-agent 0.3.0 — every open PR integrated, plus what four rounds of hand testing found#175
plombeer31 wants to merge 84 commits into
mainfrom
release/v0.3.0

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

The 0.3.0 integration build: every open PR of mine merged into one branch, plus the fixes that came out of testing the result by hand.

What is in it

All 24 open PRs#150#173 — merged in dependency order. Nothing from other contributors.

Then four rounds of hand testing, each of which turned up things no individual PR could have caught, because they only exist where two PRs meet.

The bugs that only appeared once everything was together

Config version skew made the release unusable. #161 and #165 both bump USER_CONFIG_VERSION to 38. Two builds share one ~/.atomic-agent/config.json, so the moment this one wrote v38 the installed v0.2.2 died on every command — models status, config get, the TUI — with unsupported config version 38; expected one of 5, 6, … 37. The supported-version list was an enumeration with no upper bound. Newer versions are now read rather than refused, and ensureUserConfigFileSync never rewrites a newer file down (with a shared file, two builds would otherwise take turns destroying each other's keys).

Asset directories resolved against the working directory. resolveAssetDir looked next to the Node binary, then fell back to cwd. Starting the agent by absolute path from an unrelated directory — exactly what #150's Ctrl+N spawn generates — died with ENOENT … /grammars/tool-call.gbnf. It now tries a module-relative path in between. Ctrl+N also forwards the asset-dir env vars, since a spawned login shell inherits nothing.

Enter in the /run overlay did nothing. #163 dispatched run_mode_change_requested; the reducer no-ops it by design and the orchestrator never saw it, because the bus is bridged into the reducer one way. The mouse path already used the callback — so clicking a selected row applied the mode and pressing Enter on it did not. The old test asserted the dispatch, so it passed for the whole life of the bug.

The add-provider wizard painted over its own rows. It drew at the top of LlmPanel with the route card, mode header and row list still rendered underneath, and the panel already spends its whole row budget on those. The frame ran ~16 rows past the terminal, and Ink 7 paints later lines over earlier ones instead of clipping — which is why OpenRouter was invisible at some window heights and the list looked like it held one provider.

A refused API key was invisible. #166's verification worked — the key was rejected and nothing was written to disk — but the list screens had no error line and no busy state, so Enter ran the check, swallowed every key but Esc, and repainted an identical screen. It read as being stuck on model selection.

The tasks table was laid out for a width it never had. Rows padded fixed columns totalling ~123 while the panel had 73–88, so every row wrapped and the cells collided; and the table drew ~5 rows more than the pane budgeted, overpainting the filter bar and column header. The key surface was fine the whole time — the hint strip explaining it was being overpainted too.

The UI round

The chrome was reorganised after looking at the running app:

  • The left rail replaces the top bar: brand mark, version, session id, a Menu button, Sessions and Tasks, on its own inverted ground (per-palette, never a literal white — #fff disappears on the four light themes). Below the rail's width threshold the one-row bar returns, so a narrow terminal is not left with no chrome at all.
  • The menu is a centred modal that dims the app behind it, takes clicks on its rows, scrolls with the wheel, and closes on a click anywhere outside. Esc on an idle Run screen opens it.
  • The composer is a framed field with a Send button on the same inverted ground.
  • Every message carries [copy] and, on user messages, [try again].
  • The run-mode strip moved below the composer, says how to change it, switches the composer's model immediately, and routes an unconfigured mode to the screen that configures it.
  • The brand mark is scaled from one drawing by logo-raster.ts rather than hand-drawn per size, so every size is the same silhouette.

Two honest notes

Mouse text selection is not implemented in-app, deliberately. It would need motion tracking (off on purpose), a way to read which character is painted in each cell (Ink exposes sizes only), and every component under the selection rectangle to become selection-aware — and the result would still be worse than the terminal's own, copying rendered text with borders and wrap points baked in. Shift+drag already works wherever the terminal supports it; Apple Terminal gets a 10-second reporting pause instead. That pause is unverified against Terminal.app itself from this environment.

"Arrows don't work the first time you open /model" could not be reproduced. An adversarial verifier drove the unmodified build at two sizes, six repeats, including with a deliberately slow catalog, and the first arrow moved the cursor every time. The reducer change that shipped is sound and covered, but it targets a window that the app's own wiring does not appear to reach. If it reproduces for you, the exact provider configuration matters and I would like it.

Verification

npm run lint clean. 4816 passed, 3 failedfs-glob-real (looks for a CV in $HOME), send-message-concurrency and persist-embedding-hybrid-recall, all three failing identically on pristine main in this environment.

Every UI change was driven through a PTY against the built dist/ at 120×40, 100×30, 80×24 and 60×20 and read back through a terminal emulator, not asserted from the source. The four fixes in the last round each went through an independent verifier whose brief was to refute them, including reverting the source and re-running the tests to prove they fail without the fix, and mutation-testing the guards.

plombeer31 and others added 30 commits August 19, 2026 04:05
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.
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>
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.
…n cloud-share dial

Adds the config foundation for an operator run mode without changing any
behaviour: nothing calls `resolveRunMode` outside its tests yet.

`llm.runMode` sits beside `llm.fallback` rather than under `agent` (loop
budgets) or at the top level: `llm` has a single writer, and the mode and
`activeTextProvider` must move in one write + cache-reset cycle or they can
disagree.

`activeTextProvider` stays authoritative — `runMode.mode` is additive. The
effective mode is derived from which provider is active, and a stored
`fusion` is honoured only when the cloud leg is the active one. So an
operator who switches provider by hand in Manage → LLM simply drops out of
fusion on the next read: no reconciliation step, and no state that lies
about what is running. It also means fusion pins the cloud provider as the
fallback chain's primary, so `resolveFallbackChain` hoists it to the head
and appends local at the tail with no changes of its own.

`fusion.cloudShare` is documented as a dial, not a quota: it moves the
cutoff on a bounded per-step complexity score rather than promising that
N% of steps reach the cloud.

Degradation is explicit and reported rather than silent — cloud/fusion
without a cloud provider stays local, fusion without a local provider runs
cloud-only, and a pinned `toolTransport` warns without downgrading.

USER_CONFIG_VERSION 37 → 38. No migration code: `runMode` is an optional
sub-key of an already-optional block, so absence is exactly the v37
behaviour; the bump only records the schema change.

Verified: npm run lint and npm run build clean; npm test 4094 passed / 8
failed, the same 6 files / 8 tests that already fail on main @ 667dae1
(stale banner + tui-app fixtures, the `localModels.embeddings.url` fixture,
a dev-machine-specific fs-glob path, and send-message-concurrency).
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".
…ning

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>
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>
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.
…plexity-gated

Implements the Fusion run mode: the cloud leg plans, the local leg
executes, and a per-step complexity score decides which one serves each
inference. Behaviour only — no UI yet, so this is reachable by setting
`llm.runMode.mode` in config.json.

The loop is one inference per step, so "orchestrate on cloud, execute on
local" is defined per step:

  * step 0 always orchestrates (it forms the plan and picks the first
    tool batch — exactly one call per turn, so the cost is bounded),
  * continuation steps are scored, with hysteresis,
  * the parse-repair retry stays on the leg that produced the malformed
    call — it already inherits `preferredProviderId` by spreading the
    original params, which is exactly right: a repair must be judged by
    the model that made the mistake, against the same transport,
  * memory sub-runners default to the local leg,
  * MCP sampling is untouched (still hard-wired local).

The final synthesis step is deliberately NOT special-cased: the loop
cannot know a step is final until the model returns `reply`. Instead the
score's dominant term is context pressure, so a step carrying the whole
turn escalates on its own.

`cloudShare` sets a cutoff at `100 - cloudShare`. The ±10 hysteresis is
load-bearing rather than cosmetic: llama-server reuses its KV cache by
longest common prefix, so alternating legs every step forces it to
reprocess the tail that grew in between. Hysteresis produces runs of
consecutive local steps, which is what makes the local cache pay off.

Three things this had to get right:

  * Slot affinity now follows the ROUTED provider, not the active one.
    Under fusion the active provider is the cloud leg, which reports no
    affinity, so every locally-routed step would otherwise have run at
    slotId -1 with cachePrompt off — a full prompt reprocess per step.
  * A preferred leg is a starting link, not a policy override: health
    still wins. It is ignored while that specific provider is in
    cooldown, never sets or clears the sticky override, and is never a
    probe. A failure on a preferred start resumes the scan from the
    chain HEAD, because a preferred leg is commonly the chain's tail and
    advancing "after" it would strand a recoverable turn with the rest
    of the chain untried.
  * Providers pinned by fusion survive an active-provider swap. close()
    is a no-op on both shipped kinds today, so this is not a live crash,
    but the interface promises teardown and switching into fusion would
    otherwise close the leg it is about to route to.

Also fixes a real cost-attribution defect this made reachable:
`resolveModelPricing` looked pricing up against `activeTextProvider`, so
a local completion was priced against the cloud provider's catalog. It
now prices against the served link, which `servedProviderId` carries for
the same reason `servedTransport` already existed.

Verified: npm run lint and npm run build clean; npm test 4147 passed.
Failures are the same 6 files / 8 tests that already fail on main
@ 667dae1; three further files (parallel-tool-calls wall-clock timing,
two git-tool suites) flaked under parallel CPU load and pass in
isolation.
…ind 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>
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>
Adds the operator surface for the run mode: a one-row pill strip under the
status bar in chat mode reading `▸ Local · Cloud · Fusion 40%`, plus an
overlay for the fusion dial.

A persistent strip rather than a one-shot overlay because a run mode is a
state you are IN — an operator has to see at a glance whether the next turn
spends cloud tokens. The overlay exists only because a 0-100 dial cannot fit
in a one-row strip.

Two structures that look like the obvious homes for this are deliberately
not used:

  * `DebugPane`'s `SubTabBar` has an `if (section === "run") return null`
    branch that reads like an extension point, but `DebugPane` only renders
    when `uiMode !== "chat"` — the branch is unreachable, so a submenu
    hung off it would never display. The strip is a new row rendered by
    `TuiApp` instead, and `debug-pane.tsx` is untouched.
  * `cycleSubTab` returns `TuiTab`s. Run modes are not tabs, and forcing
    them into that union would drag in `getCurrentSection`, `tab_changed`,
    `NAV_SLOT_ORDER` and the persisted `initialLayout` contract for
    something none of them describe. `section.ts` is untouched and its
    tests stay green.

Ctrl+R cycles the mode from any section — verified free: this file binds
only Ctrl+C and Ctrl+B, and `MultiLineEditor` ignores every ctrl chord
outside a/e/u/k/w/c/o. The overlay claims keys inside `handleAppKey`,
beside the approval and update prompts rather than through
`submit-handler`, because it needs ←/→ and digits and the chat editor holds
focus; it swallows every key while open, the same discipline the Fallback
pane's add-picker uses.

`/run` becomes its own command instead of a `/chat` alias. Its bare form
still returns to the Run section — the behaviour the old alias test pinned,
now re-pinned alongside the picker — and it additionally takes
`/run local|cloud|fusion [0-100]`. That is the one existing test this
knowingly rewrites.

`RunModeOrchestrator` is the only TUI writer of `llm.runMode`. It persists
both config keys in one write before touching the runtime, so a failed
provider swap still leaves a file that boots into the requested mode, and
it refuses to write a mode that would immediately resolve to something
else — surfacing the degradation sentence instead of leaving the strip
disagreeing with the file.

Docs: AGENTS.md gains a "Run modes" section (config rule, degradation, the
step table, the score, slot/KV behaviour, provider lifetime, what fusion
does NOT cover, the TUI surface, and 11 pinned invariants), three module-map
rows, and invariant 11 on the fallback chain. README gains a run-modes
block and the `llm.runMode` config shape.

Verified: npm run lint and npm run build clean; npm test 4202 passed. The
6 failing files are the same ones that already fail on main @ 667dae1;
llm-health-poller is the documented load-flake and passes 2 runs in 3.
…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.
…providers

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: 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).
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.
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.
…PI 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.
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.
…tion of it

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).
plombeer31 and others added 22 commits August 19, 2026 19:11
The columns were sized from constants adding up to ~123 characters no
matter how wide the panel was. That was survivable while the debug pane
owned the whole terminal; under the permanent left rail the panel gets
88 columns at 120x40 and 73 at 100x30, so every row wrapped onto a
second line — the columns collided into each other ("pendincron: 0 1
* * *"), the status column was clipped mid-word, and the session id fell
onto the wrap line under the schedule.

Column widths, the header labels and the footer hint strip now all come
from one width-aware fit module, so a row is always exactly one line and
the header always sits over its own data. Narrow panels shed the session
id first (the detail view prints it in full) and defend the message
column, which is what identifies a task to a human.

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

The prompt was an opencode-style left "tail": one border column down
the side of the editor, capped by a `╹`. That reads as a quote block
rather than as a place you type into, and it left the two things a
message box has to advertise — send, and reference a file — with
nowhere to live.

It is now a closed frame around the field with an action bar under it,
drawn on the same inverted ground the rail established: per-palette,
never a literal white, because `#fff` disappears on the four light
themes. The bar carries the model on the left and two button chips on
the right. Every chip colour is a token *pair* the palette already
guarantees to be opposite (`border` against `railBackground`, `accent`
against `railForeground`), so the buttons stay legible on all eleven
palettes without a per-theme table.

Send goes through the same `onSubmit` callback Enter fires, with the
same buffer — one submit path, so slash commands and the busy-mode
queue cannot drift. It greys to a ghost button when the buffer is
blank or the editor is disabled, and registers no click target then.

The file button is honest about what it is: there is no attachment
pipeline in this codebase, so it appends the prose marker `file: ` to
the draft and leaves the caret after it. The agent reads files by path
with `os.fs.read`; the button's job is to say that affordance exists
and put the caret where the path goes. It does not read, upload or
embed anything.

The frame is one row shorter than the tail it replaced (border, field,
bar, border — where the tail spent a top pad, a blank row and the cap),
and its height is bounded by the buffer alone. That matters: Ink 7 does
not clip a frame taller than the terminal, it overlaps the lines above.
`maxRows` is what the debug pane reserved for everything the tab draws,
but the list spent all of it on task rows and then added a column
header, two scroll markers and a hint strip on top — five rows the pane
had not reserved. Ink 7 does not clip an over-tall frame, it paints
later lines over earlier ones, so on a 100x30 or 80x24 terminal the
overflow landed on the filter bar and the column header: task text and
fragments of the diagnostics line ("| approval L1 | s") appeared inside
the table, and a "↓ 4 below" marker sat on top of a task row.

The table now splits the budget before it windows: chrome first, then
whatever rows are left. Both scroll markers are reserved as soon as the
list is longer than the window, because reserving only the one that
happens to be on screen overflows again the moment the cursor scrolls
the other into view. A budget too small for header + hints + three rows
sheds the blank spacer, then the hints, then the header, rather than
overflowing. Windowing itself now uses the shared `computeRowWindow`
instead of a private copy of the same maths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
`/tasks` on a fresh install answered "no tasks match the current filter"
with `filter: all` in the bar above it, which sends a first-time
operator hunting for a filter that was never set instead of telling them
what a task is. The empty table now distinguishes the three reasons it
can be empty — nothing created yet, a status filter, a live search —
and the first case says what tasks are and which key makes one.

The footer hint strip is drawn on the empty screen too. The keys are
the only thing to learn on this tab, and withholding them until a task
exists is a chicken-and-egg for the operator who has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user wants two things: to select text with the mouse and copy it,
and a copy control at the end of each message.

Selection. Mouse reporting (1000+1006) takes the terminal's own
drag-to-select away; that is the trade-off PR #165 accepted. Rather than
rebuild selection inside the app — which needs motion reports, a
per-cell readback Ink does not expose, and every component under the
rectangle to become selection-aware, and would still copy borders and
wrap points instead of the message source — this leans on the
terminal's own bypass and makes it reachable:

  - Shift+drag already works on iTerm2, kitty, WezTerm, Alacritty, foot,
    Windows Terminal and VS Code. It was simply never advertised; the
    `/mouse on` confirmation now says so.
  - A shift-modified press that *reaches* the app is proof the terminal
    has no bypass (Apple Terminal). `selection-passthrough.ts` reads it
    as "I was trying to select", suspends reporting for 10s and says so.
    Inert on every terminal where the bypass works.
  - `MouseTrackingController` grows `suspend()`/`resume()` for that,
    keeping the `process.on("exit")` restore installed across the gap
    and refusing to re-write the disable pair on a suspended controller.

Copy button. `[copy]` under every finalised bubble in the palette's
`muted` grey, dimmed, flipping to `[copied!]` for 2s. It copies the raw
message text, not the rendered frame. The badge timer lives in a ref so
a re-render cannot strand it, restarts rather than stacks on a re-click,
and is cleared on unmount.

Clipboard. New `src/tui/clipboard/`: OSC 52 *and* the platform command,
because they fail in opposite situations — OSC 52 is the only route that
survives SSH but is advisory and never answers, the platform command is
authoritative but targets the wrong machine remotely. Nothing is
attempted on a non-TTY stdout, which is also what keeps the test suite
off the developer's clipboard. Fully injectable.

Verified on a real pty (pty.fork + pyte, 120x40): click flips
`[copy]` → `[copied!]` → back, OSC 52 goes out, pbcopy receives the
message text, Shift+click emits `1000l`, and quitting restores
1006l/1000l/1049l.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "I don't see a way to configure fusion anywhere too".

There was not one. Opening `/run`, moving to Fusion and pressing Enter
closed the overlay and did nothing: no config write, no provider swap,
the strip still reading `▸ Local`. `handleRunModePickerKey` dispatched a
`run_mode_change_requested` action, and nothing on either side of the
bridge consumed it. The reducer returned the panel unchanged on purpose
("a request is handled by the orchestrator"), and the orchestrator never
saw it, because the bus it listens on is bridged into the reducer ONE
WAY — `bus.subscribe(dispatch)` — a rule `TuiAppCallbacks` already
documents twice for the provider picker. Applying a mode had to be a
callback, and it is the callback the MOUSE path was already using: click
a selected row and the mode applied, press Enter on the same row and it
did not.

Enter now calls `onRunModeChangeRequested(draftMode, draftCloudShare)`,
the same call as the click. The unreachable action type is gone rather
than left as a trap for the next person, and the key layer takes the
callbacks it needs — `handleAppKey` already had them in scope for the
Ctrl+R cycle two branches below.

The old test asserted the dispatch, so it passed for the entire life of
the bug. It now asserts the callback, and a second case checks the
overlay applies the row the cursor moved to rather than the mode in
force.

Also: the overlay now names the two legs a mode runs on. Every row here
is a claim about a PAIR of providers — Fusion runs both at once — and it
named neither, so with two cloud providers configured nothing on screen
said which one Fusion would orchestrate through, i.e. which account gets
billed. `run_mode_synced` already carried the model labels; it now
carries the resolved provider ids beside them.

The leg rows report, they do not edit. Pinning a leg writes
`llm.runMode.cloudProvider` / `localProvider`, and the single wire this
screen has to the orchestrator that owns config writes takes a mode and
a dial value, with no room for a provider id — widening it means editing
`TuiAppCallbacks` in tui-app.tsx. Showing an inert control would repeat
the bug this commit fixes, so the rows stay read-only and the missing
seam is named in the component's own docblock.

`cloudShare` is untouched: still a cutoff on the complexity score, still
not a quota. Verified end to end in a pty at 120x40 — Fusion at 50% now
writes `runMode.mode: "fusion"`, `fusion.cloudShare: 50` and
`activeTextProvider: "openrouter"` in one config write, which is what
keeps the cloud leg primary.

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

Eleven changes asked for after looking at the running app.

Rail:
- The breadcrumb row is gone. The run-mode strip and the sub-tab strip
  already say which surface you are on.
- '+ New session' sits at the head of the session list, because that is
  the list it adds to and /new was the only way to reach it.
- The menu button moves to the foot, where an application parks the
  control you reach for occasionally rather than look at.
- The mark drops to 6x4 with the wordmark and version BESIDE it. Stacked,
  branding spent six rows before the first useful line.

Composer:
- The file button is gone. It appended a prose marker nothing resolved,
  which is a button that only looks like a feature.
- The newline gesture is advertised as ctrl+j, and this is the real fix
  for 'newline does not work': shift+enter cannot be told apart from
  enter unless the app turns on the kitty keyboard protocol or
  modifyOtherKeys, so a bare terminal sent plain CR and submitted. Ctrl+J
  is the literal LF byte — every terminal can send it with no
  negotiation. Shift/alt+enter still work where a terminal can express
  them.
- The Local/Cloud/Fusion strip moves below the input: it describes the
  message you are about to send.

Menu:
- Clicking away from it closes it, and the wheel walks the list. Both
  ride on the root element's own rect at the modal layer — a backdrop
  rendered inside the popup could not cover the rail, and one sized by
  percentage inside the pane never took a press.
- 'Toggle debug pane' moves to Help. The go group is where you are
  going; the
  debug pane is a diagnostic you switch on.

Theme:
- Assistant prose on github-dark is the default foreground, not GitHub's
  green. It is the bulk of what is on screen, and a saturated colour on
  every reply reads as status — which the markers and tool cards already
  carry.
Every finalised bubble had `[copy]` and nothing that acts on the message.
Re-running a command meant retyping it or walking the input history.

`[try again]` sits beside `[copy]` on **user** messages and re-submits
that message's source through `handleEditorSubmit`, the function Enter
calls, so a re-run inherits the operator's routing instead of growing a
second submit path: idle starts a turn, and while a turn is running
`tui.whileBusySubmit` (Ctrl+T) steers or queues it. Assistant and system
bubbles keep `[copy]` only — their text is not a command anyone gave the
agent.

Two things the obvious implementation gets wrong:

- Every landing on that path blanks `inputValue` (`startNewRun`,
  `message_queued`, `message_steered`), so a re-run over a half-written
  draft ate it. The draft is snapshotted before the submit and written
  back after.
- A terminal reports a double-click as two presses, and a turn is not
  free. The `[sent]` badge doubles as the guard: clicks are ignored
  while it is up, and it also answers the case where the click has no
  visible effect at all — a steered message is not rendered until the
  loop applies it.

Both buttons share one footer row, so `estimateMessageHeight` still
charges one row per message whatever the role; the row-sharing is pinned
by a test because Ink 7 paints an over-tall frame's later lines over its
earlier ones instead of clipping.

The badge timer both buttons need is now `useTransientStatus` — the
`[copy]` tests pass untouched, which is what proves the extraction is
behaviour-preserving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… setup it needs

Two things were wrong when switching Local / Cloud / Fusion from the strip.

The composer's model name never moved. The switch itself worked — config
written, registry swapped, strip repainted — but the meta row reads the
PROVIDERS mirror (`selectPromptLlmMeta` → `providersPanel.rows`), and
nothing republishes that mirror after a mode switch. It went on naming
the leg you had just switched away from until some unrelated event
refreshed it. A successful `setMode` now emits `providers_refresh_requested`
on the bus after the swap; emitting rather than dispatching is the point,
since the bus is bridged into the reducer one way.

Cloud and Fusion also refused with a sentence and no way forward, and
Local was worse than that: `resolveRunMode` does not degrade a `local`
request with no llama-server, so the orchestrator wrote
`runMode.mode: "local"` while leaving a cloud provider active — a stored
mode that resolves to something else on the next read — and said nothing
at all. A missing leg is now checked directly and routes to the screen
that can fill it, per leg: the cloud wizard for a missing provider, the
Local pane for a missing llama-server. The `n` key and the overlay's
clickable row follow the highlighted mode through the same code.

Fusion is the one mode whose route is a pair, and the fusion rule puts
the cloud leg in `activeTextProvider` — so following the active row alone
printed the same single model for Cloud and Fusion. The prompt meta now
reports the resolved pair when `effective` is fusion, with each half
given its own share of the label budget so neither is truncated away.
`activeTextProvider` stays authoritative throughout: `effective` is only
ever fusion when that provider IS the cloud leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the operator hit on the first /model of a session.

The pane flip was deferred, the focus that rode with it was not.
`/model` dispatches `llm_mode_set_to_active_route` and then
`llm_cloud_filter_focus_set` back to back. The first can only resolve
the pane once `providersPanel.rows` exist; before that it parks the
request in `syncModeToActiveRoute` and leaves the mode at its `local`
default. The second saw a non-cloud pane and dropped the request on
the floor. The refresh that finally resolved the route restored the
pane but not the focus, so `/model` arrived with the filter row
unfocused (typing fired panel hotkeys instead of filtering) and the
cursor still on a provider row above the model section — where the
first arrow press is spent climbing into the list while the counter,
which clamps, does not move. One keystroke silently eaten, and after
that everything works: exactly the report. The request now waits with
the mode flip and is applied by the same refresh.

The rows were registered below the floor they had to clear. A
focused filter is a text-entry surface, so `isPanelModalOpen` counts
the pane as a modal and TuiApp raises the mouse registry's floor to
`MOUSE_LAYER_MODAL` — which silently disqualified the panel-layer
targets on the model rows at exactly the moment the list was open.
Same gap in the reopenable picker modal and in the wizard's pick
lists, which had no click targets at all. All three now register at
the modal layer and keep the app's rule: first click selects, a
second click on the selected row runs the row's own Enter path.

Swept the rest: session picker, theme picker, slash palette and the
approval modal were already wired correctly and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Esc reached the bottom of its ladder and did nothing: an idle Run screen
with an empty buffer swallowed the press. It now opens the operator menu
— the same surface ctrl+p opens — which is the natural end of a key that
already means "cancel / back one level" everywhere else: once there is
nothing to back out of, "get me out of here" becomes "show me where I
can go".

Additive only. Every prior claimant keeps the key, and the order is
written down once in `escapeHasNothingToCancel`: an open overlay
(approval, update offer, run-mode dial, slash palette, session/theme
picker), a focused sidebar, an open debug panel, a scrolled-back
transcript, a running turn, a half-typed draft. `escapeOpensMenu` adds
the one rung only the binding cares about — an already-open menu, which
Esc closes — so the press cannot toggle the popup it just opened.

The hint strip reads the shared predicate rather than a hardcoded word,
so the chat row says `esc menu` or `esc cancel` depending on what the
key will actually do. It deliberately ignores the menu being open: the
row describes the surface behind the popup, and rewriting it would move
the frame the popup is built to leave alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a live session on AI/ML API serving anthropic/claude-sonnet-5:

  Turn failed [tool]: tool not registered in this agent:
  replyreplyreplyreplyreplyreplyreplyreplyreplyreplyreply...

The model had done nothing wrong. The OpenAI streaming shape sends a tool
call's function name ONCE, in the first delta for a given index, and
streams only arguments after that — so the consumer appended every name
fragment it saw. Gateways that re-send the whole name in every chunk, as
AI/ML API fronting Anthropic does, therefore built `reply` up into one
long repetition, which then missed the registry and failed the turn with
the tool the model actually asked for appearing nowhere in the message.

A repeat of what we already hold is dropped; a genuine continuation is
still appended, so a gateway that really does split a name across chunks
keeps assembling. The two are told apart by whether the accumulated name
already ends with the incoming fragment, which is the only signal the
stream gives.

This is why it fired "all the time" rather than occasionally: it needs
only a provider that repeats the name and a turn long enough to stream
in more than one chunk. Local llama-server drives the grammar transport
and never took this path, which is why it went unseen until a cloud
provider was configured.
@sosidudku1

Copy link
Copy Markdown
Collaborator

Reviewed only the work unique to this branch (after 3e6ba4a) — the individual PRs were reviewed separately, so nothing here re-litigates those.

Two things worth fixing before this ships.

1. openai-stream-consumer.ts:155 — the de-duplication truncates real tool names.

if (fn.name === fragment || fn.name.endsWith(fragment)) return;

The second clause drops a genuine fragment whenever it happens to be a suffix of what's already accumulated. For a gateway that splits names across chunks — the case the third new test explicitly commits to supporting — "os.proc.kil" + "l" stays os.proc.kil, misses the registry, and fails the turn with exactly the "tool not registered" error this commit set out to fix.

Enumerating two-way splits of the repo's real tool names, eight are lossy: os.proc.kill, os.fs.diff, os.git.diff, browser.scroll, the three memory.*.recall tools, and app — every name ending in a doubled letter, split before the final character.

The signal can't separate a repeat from a continuation even in principle (["search","search"] for searchsearch yields search). The first clause already covers the reported shape, where the whole name repeats each chunk; the endsWith clause only adds loss. The four new tests pass because none exercises a lossy split — the relevant one uses os.fs + .read, which happens to be safe.

2. config-schema.ts:2704 — the comment claims a property the code doesn't have.

The justification for permissive newer-version reading says writeUserConfigFileSync "preserves unknown top-level keys rather than dropping them." It doesn't — it's JSON.stringify over a UserConfigFile that parseUserConfigFile rebuilds field-by-field from a fixed key list, with no passthrough.

So a newer build's config survives being read (the fix works, and ensureUserConfigFileSync correctly declines to rewrite), but the first config set — or any of the other persist callers — drops the unknown key and stamps version back down. The loop this is meant to prevent is delayed until the operator's first setting change, not prevented.

The permissiveness itself is defensible: the version floor is retained and the integer check is a real tightening. It's the stated premise that needs correcting, either in the comment or by making the write actually preserve unknown keys.

Checked and clean: the asset-dir fix is correct for both SEA and global npm installs (the execPath probe runs before the new module-relative step, and cwd correctly stays last). I looked specifically for merge-resolution damage in the key-binding dispatch, where several PRs collided, and found none — the Esc ladder preserves every rung in order, and the new menu rung re-checks all prior claimants rather than trusting fallthrough. Test delta against main is zero; the one failure at HEAD reproduces identically on main.

Valerii and others added 2 commits August 20, 2026 01:03
Review of the previous commit: the second clause of

  if (fn.name === fragment || fn.name.endsWith(fragment)) return;

drops a genuine fragment whenever it happens to be a suffix of what has
already accumulated. For the gateway that splits a name across chunks —
the case that commit explicitly set out to keep supporting — `os.proc.kil`
+ `l` stays `os.proc.kil`, misses the registry and fails the turn with
exactly the "tool not registered in this agent" error the fix was for.

Enumerating two-way splits of this repo's real tool names, eight are
lossy: os.proc.kill, os.fs.diff, os.git.diff, browser.scroll, the three
memory.*.recall tools and app — every name ending in a doubled letter,
split before its final character.

The signal cannot separate a repeat from a continuation even in principle
(["search", "search"] is both a repeated `search` and a split
`searchsearch`), and the equality clause already covers the reported
shape, where the whole name repeats in every chunk. The endsWith clause
only ever loses characters, so it goes; the doc comment above the
function said the two cases are told apart by it, and now says plainly
that only a whole-name repeat is detectable.

The four tests that shipped with the previous commit all passed because
none exercised a lossy split (the relevant one uses os.fs + .read, which
happens to be safe). Adds the doubled-final-letter case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment justifying the permissive newer-version read claimed
`writeUserConfigFileSync` "preserves unknown top-level keys rather than
dropping them". It did not: the write was JSON.stringify over a
UserConfigFile that parseUserConfigFile rebuilds field by field from a
fixed key list, with no passthrough. So a newer build's config survived
being read — and ensureUserConfigFileSync correctly declined to rewrite
it — but the operator's first `config set`, or any other persist caller,
deleted the unknown key and stamped `version` back down. The version-skew
loop that made an installed older build die on every command was
postponed to the first setting change, not prevented.

Makes the code match the comment.

- parseUserConfigFile collects the top-level keys it has no parser for
  and hangs them on the object it returns under a new symbol,
  UNKNOWN_USER_CONFIG_KEYS. A symbol, not a field: no JSON key can
  collide with it, JSON.stringify skips it (so a preserved key is written
  once, by the merge below, never twice), and the `{ ...prev, tui: … }`
  drafts in every persist-* helper carry it for free — object spread
  copies own enumerable symbol properties. The collector unions the
  literal keys with any already on the carrier, so the read → merge →
  validate → write round trip those helpers perform does not lose them on
  the second validation.
- The known-key set is derived from USER_CONFIG_DEFAULTS plus the two
  input-only keys (`llm`, and the legacy `telemetry` alias folded into
  `tracing`), so a newly added block registers itself.
- writeUserConfigFileSync merges the carrier AND the unknown keys still
  in the file on disk back into the payload, and leaves a newer on-disk
  `version` standing instead of stamping it down. Both sources are
  needed: the carrier covers the persist-* helpers, the disk read covers
  the whole-payload replacements (`config set`, PATCH /api/config) whose
  payload never saw those keys. A key this build owns always wins, so a
  preserved key can never shadow a parsed value.

Tests: the carrier survives a parse → spread → parse round trip and is
absent when the file is fully understood; on disk, a v43 file with a
foreign top-level key keeps both the key and its version through a
persist-* rewrite and through a whole-file replacement that never saw
them.

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

Copy link
Copy Markdown
Collaborator Author

Both findings fixed on release/v0.3.0.

1. Tool-name truncation (f23e8a6) — the endsWith clause is gone; only an exact whole-name repeat is dropped now. You're right that the signal can't tell a partial repeat from a continuation, and it only ever loses characters: os.proc.kil + l was landing as os.proc.kil and failing the same registry lookup the commit set out to fix. The doc comment above appendToolName no longer claims the two cases are separable — it states that only a whole-name repeat is detectable, with the doubled-letter names as the reason not to guess. New test pins os.proc.kil + los.proc.kill.

2. config-schema.ts comment vs. code (faabd9a) — made the code match the comment, per the owner's call.

  • parseUserConfigFile collects the top-level keys it has no parser for and carries them on the returned object under a new UNKNOWN_USER_CONFIG_KEYS symbol. Symbol, not a field: no JSON key can collide with it, JSON.stringify skips it (so a preserved key is written once, by the merge, never twice), and the { ...prev, tui: … } drafts in every persist-* helper carry it for free. The collector unions literal keys with the carrier, so the read → merge → validate → write round trip doesn't drop them on the second validation. The known-key set derives from USER_CONFIG_DEFAULTS plus the two input-only keys (llm, legacy telemetry), so a new block registers itself.
  • writeUserConfigFileSync merges the carrier and the unknown keys still in the file on disk back into the payload, and leaves a newer on-disk version standing rather than stamping it down. Both sources are needed: the carrier covers the persist-* helpers; the disk read covers the whole-payload replacements (config set, PATCH /api/config) whose payload never saw those keys. Keys this build owns always win, so a preserved key can't shadow a parsed value.

Tests: carrier survives parse → spread → parse and is absent when the file is fully understood; on disk, a v43 file with a foreign top-level key keeps both the key and its version through a persist-* rewrite and through a whole-file replacement.

Verification: npm run lint clean. Full suite before my changes on this branch: 6 files / 7 tests failing (llm-health-poller ×2, send-message-concurrency, persist-embedding-hybrid-recall, fs-glob-real, and two os.git.* timeouts). After: same set modulo the os.git.* family, which times out non-deterministically on this machine — re-running src/tools/os/git/ on the untouched HEAD gives 12 failures vs 11 with the changes applied, so it's environmental. src/config/**, src/llm/provider/openai/**, config-command, route-config, persist-user-tui-config and telegram-settings: 20 files / 289 tests, all green. Net new tests: 5.

Known limitation, not addressed: only top-level keys are preserved. A new sub-key inside a block this build knows (e.g. memory.somethingNew) is still dropped by the field-by-field parse.

@sosidudku1

Copy link
Copy Markdown
Collaborator

Closing this one, and I want to explain the reasoning properly, because the work in here was not wasted.

What happened since this branch was cut

release/v0.3.0 branches from 667dae1 (v0.2.2). Since then main has taken 25 PRs, including your whole non-fusion stack, reviewed and landed individually:

Why this branch can no longer be merged as-is

It is not that the diff is stale — it is that it now moves backwards. Measured against current main rather than the old merge base:

git diff origin/main..pr175 --shortstat
277 files changed, 14298 insertions(+), 9267 deletions(-)

Those 9267 deletions are real. None of today's commits are ancestors of this branch, so merging it would remove 15 files that landed today, among them:

The config-version line tells the same story: main is on USER_CONFIG_VERSION = 39, this branch is on 38.

Reconciling that would mean re-merging 25 PRs into a 286-file branch and re-testing the result — strictly more work than carrying the two things that are genuinely still missing, on top of a main that is already green.

What is actually still outstanding, and where it goes

Only two areas from this integration are not in main:

  1. The fusion / run-mode stackfeat(config): llm.runMode block — local | cloud | fusion with a fusion cloud-share dial #161feat(agent): fusion routing — cloud orchestrator, local executor, complexity-gated #162feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163feat(tui): Local · Cloud · Fusion in the ctrl+p menu, with ctrl+g 1/2/3 to pick one #172, with feat(tui): every visible control takes a click — menu rows, run-mode pills, the dial #173 sitting on top of them. Untouched by today's merges and still the real review unit. That review is a separate decision, not a casualty of this close.
  2. The mouse layer (feat(tui): mouse support — click the nav bar, panels, selectors and the prompt #165) — being ported onto current main directly, as its own PR.

The findings from your hand-testing rounds

These were the most valuable part of this PR and they should not disappear with it, since several are cross-cutting bugs no single branch could surface:

  • the supported-config-version list being a closed enumeration, so a newer build bricks an older one sharing ~/.atomic-agent/config.json
  • resolveAssetDir falling back to cwd, which breaks precisely the ctrl+n spawn from feat(tui): ctrl+n opens a new terminal window running atomic-agent #150
  • Enter in the /run overlay dispatching an action the orchestrator never sees, while the mouse path used the callback and worked
  • the add-provider wizard and the tasks table both overrunning their row budget, with Ink 7 overpainting instead of clipping

The first two apply to main today and are not fixed there. They are worth one small PR each against current main, where they can be reviewed on their own merits and land in a day instead of waiting on a 286-file branch. Happy to pick them up if you would rather not re-cut them.

Thanks for doing the integration and the four rounds of manual testing — the per-PR reviews were substantially faster because this branch had already found where the seams were.

@sosidudku1 sosidudku1 closed this Aug 20, 2026
sosidudku1 added a commit that referenced this pull request Aug 20, 2026
…d composer (#194)

* feat(tui): per-message copy and try-again buttons

First slice of the #175 UI round, ported onto current main.

Every message carries a [copy] button; user messages also carry
[try again], which re-runs the text through the same submit path Enter
uses rather than a second code path that could drift from it.

The clipboard layer is its own module: copy-to-clipboard owns the
platform command (pbcopy / clip.exe / wl-copy / xclip), and
clipboard-context exposes it through a provider so a test can observe a
copy without touching the real clipboard. useClipboard falls back to the
shared default writer, so the buttons work whether or not a provider is
mounted.

Also lands selection-passthrough (the honest answer to mouse text
selection) and tasks-list-fit, both self-contained.

Deliberately NOT in this slice: the run-mode / fusion files that ship in
the same #175 round, since the fusion stack is still under review; and
anything touching the contested files (tui-app, menu-popup, sidebar,
theme) where main has moved since #175 was cut.

Tests: 4828 total, no new failures. The two LlmHealthPoller cases that
appear under full-suite load pass in isolation on this branch and on
pristine main.

* feat(tui): the rail moves left and becomes the app frame

Second slice of the #175 UI round.

The rail leads the row container, so it renders on the LEFT, and it now
carries the brand lockup, the version and a Menu button alongside
Sessions and Tasks. It is drawn in every mode, not just chat: switching
to a panel used to take all the chrome off screen.

Three things came out of porting it rather than from the branch:

- The one-row status bar STAYS. #175 removed it on the grounds that the
  rail carries the same four things, but its rail never grew the
  breadcrumb, so removing the bar left no indicator of where you are.
  The bar keeps the breadcrumb and drops its own brand lockup while the
  rail is up, since two copies read as a rendering bug.
- SIDEBAR_CHROME_ROWS goes 5 -> 13. The rail spends those rows on the
  mark, the version line and the Menu button. Measured off the rendered
  component, not estimated: at 24 rows the old constant had the rail
  drawing 28 rows into a 24-row terminal, and Ink 7 overlaps rather than
  clips, which garbles the whole frame.
- main's layout.ts is kept over #175's. It has SIDEBAR_OUTER_ROWS and
  SIDEBAR_MIN_ROWS, which #175 predates; only the constant is retuned.

Theme gains railBackground / railForeground / railMuted (additive; no
palette key is removed) and logo.tsx gains RAIL_MARK, the same drawing
as the splash rasterised to a 6x4 cell.

Tests: 4830 total, no new failures. The layout and smoke expectations
that encoded the old chrome are updated, not deleted: the row-budget test
still pins the 2:1 ratio, and the Tab-focus test still branches on
whether the rail is up.

* feat(tui): the menu becomes a centred overlay

Third slice of the #175 UI round.

The menu was anchored to the bottom of the pane, hanging off the prompt
like a dropdown. It is now a true overlay: position=absolute, centred on
both axes, with the app behind it dimmed through setBackdropDimmed.

availableColumns subtracts the rail width, so the frame is centred on the
content area rather than on the terminal — without that the right border
falls off screen whenever the rail is up.

The menu keeps this tree's onActivate contract: a node that carries a
slash name is run as that command, so the menu still has exactly one
dispatch path shared with the slash handler.

Tests: 4830 total, no new failures.

* feat(tui): the composer becomes a framed field with a Send button

Fourth slice of the #175 UI round.

The prompt was an opencode-style left tail: one border column capped by a
`╹` glyph. It is now a closed rounded frame whose bottom row is an action
bar on the rail ground, carrying the model label and a clickable
`send →`. Net one row shorter than the tail, so CHROME_ROWS is unchanged.

The action bar is unconditional now. It used to appear only when a model
or a slot was set; since it carries Send, it cannot come and go without
the composer changing height under the operator.

Nothing here is coupled to the fusion stack. `formatModel` splits on a
local ` ⇄ ` constant so a two-legged label stays readable, but with no
fusion present the split yields one element and it behaves exactly as
before — kept so the fusion stack can land later without re-touching this
file.

Also corrects the debug-pane comment that still described the `╹` cap.

Tests: 4840 total, no new failures. prompt-shell.test.tsx is replaced
rather than merged: it asserted the tail cap the frame removes.

---------

Co-authored-by: sosidudku1 <273119990+sosidudku1@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants