atomic-agent 0.3.0 — every open PR integrated, plus what four rounds of hand testing found - #175
atomic-agent 0.3.0 — every open PR integrated, plus what four rounds of hand testing found#175plombeer31 wants to merge 84 commits into
Conversation
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).
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.
|
Reviewed only the work unique to this branch (after Two things worth fixing before this ships. 1. 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 — Enumerating two-way splits of the repo's real tool names, eight are lossy: The signal can't separate a repeat from a continuation even in principle ( 2. The justification for permissive newer-version reading says So a newer build's config survives being read (the fix works, and 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 |
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>
|
Both findings fixed on 1. Tool-name truncation ( 2.
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 Verification: Known limitation, not addressed: only top-level keys are preserved. A new sub-key inside a block this build knows (e.g. |
|
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
Why this branch can no longer be merged as-isIt is not that the diff is stale — it is that it now moves backwards. Measured against current main rather than the old merge base: 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 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 goesOnly two areas from this integration are not in main:
The findings from your hand-testing roundsThese 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 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. |
…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>
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_VERSIONto 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 — withunsupported 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, andensureUserConfigFileSyncnever 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.
resolveAssetDirlooked next to the Node binary, then fell back tocwd. Starting the agent by absolute path from an unrelated directory — exactly what #150's Ctrl+N spawn generates — died withENOENT … /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
/runoverlay did nothing. #163 dispatchedrun_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
LlmPanelwith 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:
#fffdisappears 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.Escon an idle Run screen opens it.[copy]and, on user messages,[try again].logo-raster.tsrather 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 lintclean. 4816 passed, 3 failed —fs-glob-real(looks for a CV in$HOME),send-message-concurrencyandpersist-embedding-hybrid-recall, all three failing identically on pristinemainin 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.