feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial - #163
Open
plombeer31 wants to merge 2 commits into
Open
feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial#163plombeer31 wants to merge 2 commits into
plombeer31 wants to merge 2 commits into
Conversation
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.
This was referenced Aug 19, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack: 3 of 3. Based on
feat/fusion-routing(#162), which is based onfeat/run-mode-config(#161). This PR's diff is the operator surface and the docs.This is where the feature becomes usable.
What it looks like
A one-row pill strip under the status bar in chat mode:
Ctrl+Rcycles it./runopens a dial overlay;/run fusion 60switches directly.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.
ThemePicker/SessionPickerare the wrong precedent for that part. The overlay exists only because a 0-100 dial cannot fit in a one-row strip.Two structures that look like the obvious homes, and why they are not
DebugPane'sSubTabBarhas anif (section === "run") return nullbranch that reads exactly like an extension point. It is unreachable:DebugPaneonly renders whenuiMode !== "chat", so a submenu hung off it would never display. The strip is a new row rendered byTuiApp;debug-pane.tsxis untouched.cycleSubTabreturnsTuiTabs. Run modes are not tabs, and forcing them into that union would drag ingetCurrentSection,tab_changed,NAV_SLOT_ORDERand the persistedinitialLayoutcontract for something none of them describe.section.tsis untouched and its tests stay green.Keyboard
Ctrl+Rcycles from any section — run mode is global, not chat-local. Verified free:app-key-bindings.tsbinds onlyCtrl+CandCtrl+B, andMultiLineEditorbails on every ctrl chord outsidea/e/u/k/w/c/o.The overlay claims keys inside
handleAppKey, beside the approval and update prompts, rather than throughsubmit-handleras the other Run-section overlays do. That divergence is deliberate: the dial needs←/→and digits, which the focused chat editor would otherwise consume as cursor motion and literal text. While open it returnstruefor every key — the same total-swallow discipline as the Fallback pane's add-picker — so nothing leaks into the editor, the nav cycle or the slash palette behind it.One pinned test knowingly rewritten
slash-commands.tsregistered{ name: "chat", aliases: ["run"] }, pinned byslash-command-handler.test.ts./runbecomes its own command, so that test is replaced — but the behaviour it protected is preserved and re-pinned: bare/runstill returns to the Run section, and now additionally opens the picker. Muscle memory survives; the name now also owns the thing it is named after. (/localis left alone — it is already an alias of/model.)Persistence
RunModeOrchestratoris the only TUI writer ofllm.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 the error says so rather than pretending the switch did not happen. It also 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
## Run modes (Local / Cloud / Fusion)section after## Provider fallback chain— config rule, degradation, the step table, the score (including whycacheReusedis excluded), slot/KV behaviour, provider lifetime, what fusion does not cover, the TUI surface, and 11 pinned invariants. Plus three module-map rows and invariant 11 on the fallback chain./runin the slash-command line, and thellm.runModeshape under Requirements & Configuration.Verification
npm run lintandnpm run buildclean.npm test: 4202 passed. The 6 failing files are the same ones that already fail onmain @ 667dae1;llm-health-polleris the documented load-flake (passes 2 runs in 3, and this branch does not touch it).54 new tests: reducer, nav, picker keys, both components (
ink-testing-library), persistence, and the/runparser.End-to-end against the built
dist/, with a two-provider config and a simulated 5-step turn:Step 0 plans on the cloud, the mechanical middle stays local, and the 26k-token synthesis step escalates back to the cloud on context pressure alone — with no special-casing, which is the design claim in #162 working in practice. Line 5 is the non-contradiction rule: changing the provider by hand drops the mode out of fusion with no reconciliation step.