Skip to content

feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial - #163

Open
plombeer31 wants to merge 2 commits into
feat/fusion-routingfrom
feat/run-mode-tui
Open

feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial#163
plombeer31 wants to merge 2 commits into
feat/fusion-routingfrom
feat/run-mode-tui

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

Stack: 3 of 3. Based on feat/fusion-routing (#162), which is based on feat/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:

▸ Local  ·  Cloud  ·  Fusion 40%

Ctrl+R cycles it. /run opens a dial overlay; /run fusion 60 switches 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/SessionPicker are 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's SubTabBar has an if (section === "run") return null branch that reads exactly like an extension point. It is unreachable: DebugPane only renders when uiMode !== "chat", so a submenu hung off it would never display. The strip is a new row rendered by TuiApp; debug-pane.tsx is untouched.

cycleSubTab returns TuiTabs. 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.

Keyboard

Ctrl+R cycles from any section — run mode is global, not chat-local. Verified free: app-key-bindings.ts binds only Ctrl+C and Ctrl+B, and MultiLineEditor bails on 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 as 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 returns true for 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.ts registered { name: "chat", aliases: ["run"] }, pinned by slash-command-handler.test.ts. /run becomes its own command, so that test is replaced — but the behaviour it protected is preserved and re-pinned: bare /run still 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. (/local is left alone — it is already an alias of /model.)

Persistence

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 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

  • AGENTS.md: a new ## Run modes (Local / Cloud / Fusion) section after ## Provider fallback chain — config rule, degradation, the step table, the score (including why cacheReused is 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.
  • README.md: a run-modes block under Ways to Use It, /run in the slash-command line, and the llm.runMode shape under Requirements & Configuration.

Verification

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 (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 /run parser.

End-to-end against the built dist/, with a two-provider config and a simulated 5-step turn:

1. fresh install      -> local
2. after /run fusion  -> fusion | share 60 | cloud openrouter | local local-llama
3. simulated turn:
     step 0 (6000 tok)  -> orchestrator openrouter  complexity 8
     step 1 (7000 tok)  -> executor     local-llama complexity 11
     step 2 (9000 tok)  -> executor     local-llama complexity 16
     step 3 (15000 tok) -> executor     local-llama complexity 30
     step 4 (26000 tok) -> orchestrator openrouter  complexity 52
4. sub-runners        -> local-llama
5. hand-swap to local -> local (stored still fusion)
6. router now         -> null
7. cloud w/o provider -> local | Cloud mode needs a cloud provider — none is configured. …

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.

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.
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>
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.

1 participant