diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..4b938829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,9 @@ Speculative batching (the runtime guessing that the model "should" have batched | `src/cli/` | `run`, `index`, `repl`, `tui`, `serve` commands | | `src/http/` | OpenAI-compatible HTTP API + atomic admin routes for `atomic-agent serve` | | `src/llm/` | HTTP client for external llama-server + GBNF grammar | +| `src/llm/run-mode/` | Resolves the operator run mode (`local` / `cloud` / `fusion`) against the configured providers | +| `src/agent/routing/` | Fusion step routing: complexity score, cutoff rule, per-session router | +| `src/tui/run-mode/` | Run-section mode strip + dial overlay (state, actions, reducer, keys, orchestrator) | | `src/prompt/` | Prompt builder, stable prefix, token budget. See [PROMPT.md](PROMPT.md) for full anatomy of the stable prefix and variable tail. | | `src/session/` | Session state + sqlite persistence | | `src/agent/` | Agent loop + step executor + parallel batch executor (`batch-executor.ts`) + resource-class taxonomy (`tool-resource-class.ts`) + no-progress loop detector | @@ -1703,6 +1706,7 @@ The remaining asymmetry: `tools` is populated only when the **primary's** transp 8. **A cross-transport fallover parses the response with the served link's transport, not the primary's**, and the turn reaches the fallback's answer instead of `loop_failed`. Pinned by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts) (real `AgentLoop` + `step-executor`, both unary and streaming). 9. **Breaker state is partitioned by session** — one partition's success does not clear another's armed cooldown, and a keyless call shares one default partition. Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts) ("partition isolation"). 10. **The cooldown ladder must be non-decreasing** — a decreasing `cooldownMs` is rejected at parse time so "escalating" stays true. Pinned by [src/config/llm-config.test.ts](src/config/llm-config.test.ts). +11. **A `preferredProviderId` changes only the STARTING link.** It is ignored while that specific provider is in cooldown, never sets or clears `overrideId`, and is never reported as a probe; a failure on a preferred start resumes the scan from the chain head (a preferred leg is commonly the tail, and advancing "after" it would strand a recoverable turn). Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts). ### TUI: the Fallback pane @@ -1725,6 +1729,86 @@ The LLM tab gains a fourth pane, `fallback`, reached with `←`/`→` after Loca 5. **`provider_switched` is mirrored into `fallbackPanel.lastSwitch`; the pane never invents a live countdown.** Pinned by [src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts](src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts), [src/tui/components/llm-fallback-rows.test.tsx](src/tui/components/llm-fallback-rows.test.tsx). 6. **Empty chain / nothing-addable shows a hint, not a broken list.** Pinned by [src/tui/components/llm-fallback-rows.test.tsx](src/tui/components/llm-fallback-rows.test.tsx), [src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts](src/tui/llm-panel/fallback/fallback-panel-reducer.test.ts). +## Run modes (Local / Cloud / Fusion) + +An operator-facing mode that names the *pair* of providers a turn may use, layered directly on top of the fallback chain above. `local` uses the configured llama-server provider, `cloud` the configured cloud provider, and `fusion` runs both: the cloud leg orchestrates, the local leg executes. + +### Config and the non-contradiction rule + +`llm.runMode` (sibling of `llm.fallback`; [src/config/llm-run-mode-config.ts](src/config/llm-run-mode-config.ts)): + +```jsonc +"runMode": { + "mode": "local" | "cloud" | "fusion", + "localProvider": "local-llama", // optional pin; default = first llama-server-kind provider + "cloudProvider": "openrouter", // optional pin; default = first non-llama-server provider + "fusion": { "cloudShare": 40, "subRunners": "local" } +} +``` + +**`llm.activeTextProvider` stays authoritative**; `runMode.mode` is additive. `resolveRunMode` ([src/llm/run-mode/resolve-run-mode.ts](src/llm/run-mode/resolve-run-mode.ts)) derives the effective mode from which provider is active, and honours a stored `fusion` only while the cloud leg is the active one. Consequences, all deliberate: a mode switch must write both keys in one go (`setRunModeInConfig`, [src/tui/persist-run-mode.ts](src/tui/persist-run-mode.ts)); an operator who changes provider by hand in Manage → LLM simply drops out of fusion on the next read, with no reconciliation step and no state that lies; and because fusion pins the cloud provider as primary, `resolveFallbackChain` hoists it to the chain head and appends local at the tail **with no changes of its own**. + +**`cloudShare` is a dial, not a quota.** It moves a cutoff on a bounded per-step score; it does not promise that N% of steps reach the cloud. `0` behaves exactly like `local`, `100` exactly like `cloud`. Do not "fix" it into a running-counter scheduler — a quota necessarily sends some trivial steps to the cloud and keeps some hard ones local, which is the opposite of the intent. + +### Degradation + +Reported, never silent ([src/llm/run-mode/run-mode-degradation.ts](src/llm/run-mode/run-mode-degradation.ts)): cloud/fusion with no cloud provider stays `local`; fusion with no local provider runs cloud-only; fusion with `llm.toolTransport` pinned still runs but warns, because a pinned transport sends one leg the wrong wire shape. + +### Fusion routing policy + +The loop is one inference per step, so the split is defined per step ([src/agent/routing/](src/agent/routing/)): + +| Step | Route | Why | +|---|---|---| +| Step 0 | cloud (whenever `cloudShare > 0`) | Forms the plan and the first tool batch; exactly one call per turn, so cost is bounded | +| Continuation | scored, with hysteresis | The bulk; mechanical read → edit chains score low and stay local | +| Parse-repair retry | same leg as the attempt it repairs | Inherited for free by spreading the original `LlmStreamParams`. A repair must be judged by the model that made the mistake, against the same transport | +| Memory sub-runners | local by default (`fusion.subRunners`) | Cold-path structured-JSON jobs on the reserved reflection slot, already KV-warm locally | +| MCP sampling | **not covered** — still hard-wired local ([src/mcp/mcp-sampling-handler.ts](src/mcp/mcp-sampling-handler.ts)) | Bypasses the provider registry entirely | + +**The final synthesis step is deliberately not special-cased.** The loop cannot know a step is final until the model returns `reply`, so a flag for it would be a lie. Instead the score's dominant term is context pressure, so a step carrying the whole turn escalates on its own. Making "always synthesise on cloud" explicit would need a loop-level change (a post-`reply` re-synthesis pass), not a routing flag. + +### The complexity score + +Integer 0-100, weights summing to 100 so it is directly comparable to `cloudShare` ([src/agent/routing/compute-step-complexity.ts](src/agent/routing/compute-step-complexity.ts)): context pressure (40) + turn depth (25) + transient notice (20) + tail growth (15). A step routes to the cloud when `score >= 100 - cloudShare`. + +**`cacheReused` is deliberately excluded.** It is produced by `slotManager.acquire`, which now runs *after* routing, so feeding it back in would be circular. Do not add it. + +`ROUTING_HYSTERESIS` (±10) is load-bearing, not 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. + +### Slot affinity and provider lifetime + +Two things fusion had to fix in the layers below it: + +* **Slot affinity follows the routed provider**, via `StepDependencies.resolveSlotAffinity`. Reading it off the *active* provider (cloud, no affinity) would have run every locally-routed step at `slotId: -1` with `cachePrompt` off — a full prompt reprocess per step. +* **Pinned providers survive an active swap** (`ProviderRegistry.setPinnedProviderIds`). `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. + +Cost attribution follows the **served** link (`CompletionResult.servedProviderId`), for the same reason `servedTransport` exists: pricing a local completion against the cloud provider's catalog reports a cost that was never incurred. + +### TUI surface + +A one-row pill strip under the status bar in chat mode ([src/tui/components/run-mode-bar.tsx](src/tui/components/run-mode-bar.tsx)) reading `> Local . Cloud . Fusion 40%`, plus a dial overlay ([src/tui/components/run-mode-picker.tsx](src/tui/components/run-mode-picker.tsx)). + +Note it is **not** `DebugPane`'s `SubTabBar` — that component only renders in debug mode, so its `section === "run"` branch is unreachable — and **not** `cycleSubTab`, which returns `TuiTab`s; run modes are not tabs and forcing them in would drag in `getCurrentSection`, `tab_changed`, `NAV_SLOT_ORDER` and the persisted `initialLayout` contract. + +**Keys.** `Ctrl+R` cycles Local -> Cloud -> Fusion from any section (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` — it needs `<-`/`->` and digits, which the focused chat editor would otherwise consume — and swallows **every** key while open. `/run` (own command now, no longer a `/chat` alias) still returns to the Run section and additionally opens the picker; `/run fusion 60` switches directly. + +**Persistence.** `RunModeOrchestrator` ([src/tui/run-mode/run-mode-orchestrator.ts](src/tui/run-mode/run-mode-orchestrator.ts)) is the **only** TUI writer of `llm.runMode`. It persists both keys first, then hot-applies the provider swap, so a failed 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. + +#### Locked invariants (Pinned by tests) + +1. **`activeTextProvider` wins**: a stored `fusion` with the local leg active resolves to `local`, and that is not a degradation. Pinned by [src/llm/run-mode/resolve-run-mode.test.ts](src/llm/run-mode/resolve-run-mode.test.ts). +2. **Every unavailable mode degrades to a reachable one and says why.** Pinned by [src/llm/run-mode/resolve-run-mode.test.ts](src/llm/run-mode/resolve-run-mode.test.ts), [src/llm/run-mode/run-mode-degradation.test.ts](src/llm/run-mode/run-mode-degradation.test.ts). +3. **`cloudShare` 0 / 100 are exact**, and step 0 always orchestrates when the cloud leg is in play. Pinned by [src/agent/routing/decide-routing-role.test.ts](src/agent/routing/decide-routing-role.test.ts). +4. **The score is a bounded integer, monotonic in each term, and NaN-free on zero budgets.** Pinned by [src/agent/routing/compute-step-complexity.test.ts](src/agent/routing/compute-step-complexity.test.ts). +5. **Hysteresis is per session** and drops when fusion is switched off. Pinned by [src/agent/routing/step-router.test.ts](src/agent/routing/step-router.test.ts). +6. **No router (or a declining one) leaves `LlmStreamParams` byte-identical to today**, and the slot follows the ROUTED provider. Pinned by [src/agent/step-executor-routing.test.ts](src/agent/step-executor-routing.test.ts). +7. **A preferred leg is a starting link, not an override** — health still wins, and the served id/transport are stamped from the link that answered. Pinned by [src/runtime/llm-fallback-seam.test.ts](src/runtime/llm-fallback-seam.test.ts). +8. **A pinned provider survives an active swap.** Pinned by [src/llm/provider/registry/provider-registry.test.ts](src/llm/provider/registry/provider-registry.test.ts). +9. **Mode and active provider move in ONE config write.** Pinned by [src/tui/persist-run-mode.test.ts](src/tui/persist-run-mode.test.ts). +10. **The overlay owns the keyboard while open and Esc reverts the draft.** Pinned by [src/tui/run-mode/run-mode-key-bindings.test.ts](src/tui/run-mode/run-mode-key-bindings.test.ts), [src/tui/run-mode/run-mode-reducer.test.ts](src/tui/run-mode/run-mode-reducer.test.ts). +11. **Bare `/run` keeps its historical "return to Run" behaviour.** Pinned by [src/tui/commands/slash-command-handler.test.ts](src/tui/commands/slash-command-handler.test.ts). + ## Traceability and replay Every run produces an append-only NDJSON trace at `/traces/.ndjson` — one event per line. Tracing is on by default for `atomic-agent run` / TUI / `atomic-agent serve`, and off by default in sidecar mode so the Tauri host decides whether to opt in. diff --git a/README.md b/README.md index 1b8ff1ee..f3a7224c 100644 --- a/README.md +++ b/README.md @@ -226,12 +226,37 @@ atomic-agent task list atomic-agent trace list --limit 10 ``` -Handy slash commands: `/help` lists every command, `/tools` lists the built-in tool families, `/model` jumps to the LLM panel and reopens the model picker for the active cloud provider, `/privacy` shows what leaves the machine (`/privacy analytics off` turns analytics off). The chat log scrolls with PgUp / PgDn (fn+arrows on macOS). +Handy slash commands: `/help` lists every command, `/tools` lists the built-in tool families, `/run` switches run mode, `/model` jumps to the LLM panel and reopens the model picker for the active cloud provider, `/privacy` shows what leaves the machine (`/privacy analytics off` turns analytics off). The chat log scrolls with PgUp / PgDn (fn+arrows on macOS). Cloud provider setup pulls each provider's full live model catalog, hundreds of models, instead of a short hardcoded list; OpenAI-compatible servers are asked for their own `/v1/models`. The picker filters as you type, and `/model` switches models mid-session. +
+Run modes: Local, Cloud, Fusion + +A strip under the status bar shows which pair of models the next turn will use, and `Ctrl+R` cycles it: + +- **Local** — your llama-server model only. +- **Cloud** — your cloud provider only. +- **Fusion** — the cloud model orchestrates, the local model executes. + +Fusion exists because the two halves of a turn have different needs. Planning the work and reconciling a pile of tool output is where a big model earns its price; the mechanical middle of a turn — read a file, edit it, read the next one — mostly does not. Fusion sends the first step of every turn to the cloud, scores each following step, and keeps the cheap ones local. + +``` +/run # open the picker +/run local # switch directly +/run fusion 60 # switch and set the cloud share +``` + +The **cloud share** is a dial, not a quota. It does not promise that 60% of steps go to the cloud; it lowers the bar a step has to clear to get there, using a score built from how full the context is, how deep into the turn you are, how much tool output the step is carrying, and whether the model just tripped the loop detector. `0` behaves exactly like Local and `100` exactly like Cloud. + +Health still wins over the split: if the cloud provider starts failing mid-turn, the usual fallback chain takes over and the turn finishes locally. Background memory work (reflection, distillation, query rewriting) stays local by default, since it is cold-path JSON that would multiply cost for no visible gain. + +Selecting a mode you cannot run — Cloud or Fusion with no cloud provider configured — leaves you where you are and says so, rather than failing on the next message. + +
+
Managed local models @@ -467,6 +492,24 @@ Useful environment variables: - `ATOMIC_AGENT_BROWSER_EXECUTABLE_PATH`: explicit Chromium-family executable path. - `ATOMIC_AGENT_BROWSER_CDP_URL`: attach to an already-running browser via CDP. +The run mode (Local / Cloud / Fusion) is stored under `llm.runMode`, alongside the providers it names: + +```json +{ + "llm": { + "activeTextProvider": "openrouter", + "runMode": { + "mode": "fusion", + "localProvider": "local-llama", + "cloudProvider": "openrouter", + "fusion": { "cloudShare": 40, "subRunners": "local" } + } + } +} +``` + +`localProvider` / `cloudProvider` are optional — the legs default to the first `llama-server`-kind provider and the first non-`llama-server` provider. `cloudShare` is the 0-100 dial described above. `subRunners` (`local` | `cloud` | `follow`) decides where background memory work runs. `activeTextProvider` remains authoritative: change it by hand and the mode follows it, so the two can never disagree. + Secrets for skills and channels belong in `/.env`, not in `config.json`: ```text diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index 7aabe437..4af9cb5d 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -27,6 +27,7 @@ import { reduceLlmPanelAction } from "./llm-panel/llm-panel-reducer.js"; import { reduceFallbackPanelAction } from "./llm-panel/fallback/fallback-panel-reducer.js"; import { reduceTelegramAction } from "./telegram/telegram-panel-reducer.js"; import { reducePrivacyAction } from "./privacy/privacy-panel-reducer.js"; +import { reduceRunModeAction } from "./run-mode/run-mode-reducer.js"; import type { TuiAction } from "./tui-action.js"; import type { RunOutcome, StreamingToolCall, TuiState } from "./tui-state.js"; @@ -55,6 +56,8 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { if (telegramHandled !== null) return telegramHandled; const privacyHandled = reducePrivacyAction(state, action); if (privacyHandled !== null) return privacyHandled; + const runModeHandled = reduceRunModeAction(state, action); + if (runModeHandled !== null) return runModeHandled; const uiHandled = reduceUiAction(state, action); if (uiHandled !== null) return uiHandled; switch (action.type) { diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..2ad1381e 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -9,6 +9,9 @@ import { formatApprovalCategory } from "../approval/approval-level.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; +import { handleRunModePickerKey } from "./run-mode/run-mode-key-bindings.js"; +import { cycleRunMode } from "./run-mode/run-mode-nav.js"; +import type { RunModeName } from "../config/index.js"; import type { TuiState } from "./tui-state.js"; /** @@ -35,6 +38,8 @@ export interface AppKeyCallbacks { ): void; onAbort(): void; onQuit(): void; + /** Optional — Ctrl+R cycles Local → Cloud → Fusion. */ + onRunModeChangeRequested?(mode: RunModeName, cloudShare?: number): void; /** Optional — called when Enter is pressed on the focused sidebar row. */ onSessionSwitchRequested?(sessionId: string): void; /** @@ -86,6 +91,13 @@ export function handleAppKey( ctx: AppKeyContext, ): boolean { const { state, dispatch, callbacks, ctrlCArmed, setCtrlCArmed } = ctx; + // The dial overlay opens over the chat surface, where the editor holds + // focus and would eat ←/→ and digits. Claim keys here — same place the + // approval and update prompts claim theirs — and swallow everything + // until it closes. + if (handleRunModePickerKey(input, key, { state, dispatch, callbacks })) { + return true; + } if (state.pendingApproval) { return handleApprovalKey(input, key, state.pendingApproval, ctx); } @@ -232,6 +244,24 @@ export function handleAppKey( applyNavSlot(dispatch, next); return true; } + // Ctrl+R cycles the run mode from anywhere — a run mode is global, not + // a property of the chat surface. Ctrl+R is free: this file binds only + // Ctrl+C and Ctrl+B, and `MultiLineEditor` ignores every ctrl chord + // outside a/e/u/k/w/c/o. + if ( + !debugTabBusy && + !state.slashPaletteOpen && + !state.pendingApproval && + key.ctrl && + !key.shift && + !key.meta && + input === "r" + ) { + callbacks.onRunModeChangeRequested?.( + cycleRunMode(state.runModePanel.effective, 1), + ); + return true; + } // Tab / Shift+Tab routing: // - In chat mode with the sidebar visible, plain Tab cycles // editor → sidebar(sessions) → sidebar(tasks) → editor so the diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 959af6fb..40077f80 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -22,6 +22,7 @@ import { ProvidersOrchestrator } from "./providers/providers-orchestrator.js"; import { FallbackOrchestrator } from "./llm-panel/fallback/fallback-orchestrator.js"; import { TuiTelegramOrchestrator } from "./telegram/tui-telegram-orchestrator.js"; import { PrivacyOrchestrator } from "./privacy/privacy-orchestrator.js"; +import { RunModeOrchestrator } from "./run-mode/run-mode-orchestrator.js"; import type { TuiEventBus } from "./tui-app.js"; import { formatAgentErrorForChat } from "./format-agent-error-for-chat.js"; import { turnsToMessages } from "./turns-to-messages.js"; @@ -93,6 +94,7 @@ export class ChatOrchestrator { public readonly llmHealth: LlmHealthPoller; public readonly telegram: TuiTelegramOrchestrator; public readonly privacy: PrivacyOrchestrator; + public readonly runMode: RunModeOrchestrator; constructor( private readonly runtime: AgentRuntime, @@ -122,6 +124,7 @@ export class ChatOrchestrator { }); this.telegram = new TuiTelegramOrchestrator(runtime, bus); this.privacy = new PrivacyOrchestrator(runtime, bus); + this.runMode = new RunModeOrchestrator(runtime, bus); } /** @@ -139,6 +142,7 @@ export class ChatOrchestrator { this.llmHealth.start(); this.telegram.start(); this.privacy.refresh(); + this.runMode.refresh(); // Boot the tasks orchestrator on TUI mount so the always-on // sidebar's Tasks pane has fresh data without waiting for the // operator to open the Tasks debug tab. Idempotent — opening the diff --git a/src/tui/commands/dispatch-run-mode.ts b/src/tui/commands/dispatch-run-mode.ts new file mode 100644 index 00000000..203d20d5 --- /dev/null +++ b/src/tui/commands/dispatch-run-mode.ts @@ -0,0 +1,64 @@ +import type { RunModeName } from "../../config/index.js"; +import { RUN_MODES } from "../run-mode/run-mode-nav.js"; + +export interface RunModeCommand { + /** Return to the Run section (what `/run` always did as a `/chat` alias). */ + returnToRun: boolean; + /** Open the dial overlay (bare `/run`). */ + openPicker: boolean; + mode?: RunModeName; + cloudShare?: number; + /** Usage line to echo instead of acting. */ + error?: string; +} + +const USAGE = "usage: /run [local|cloud|fusion] [0-100]"; + +/** + * Parse `/run [mode] [share]`. + * + * Bare `/run` keeps its historical behaviour — returning to the Run + * section, which it had as an alias of `/chat` — and additionally opens + * the mode picker. Keeping both means the alias's muscle memory still + * works while the name now also owns the thing it is named after. + * + * Lives in its own module because `slash-command-handler.ts` is already + * far past the 300-line budget. + */ +export function parseRunModeCommand(rawArgs: string): RunModeCommand { + const [rawMode, rawShare, ...rest] = rawArgs + .trim() + .split(/\s+/) + .filter((token) => token.length > 0); + if (rawMode === undefined) { + return { returnToRun: true, openPicker: true }; + } + if (rest.length > 0) { + return { returnToRun: false, openPicker: false, error: USAGE }; + } + const mode = rawMode.toLowerCase(); + if (!RUN_MODES.includes(mode as RunModeName)) { + return { + returnToRun: false, + openPicker: false, + error: `unknown run mode ${JSON.stringify(rawMode)} — ${USAGE}`, + }; + } + if (rawShare === undefined) { + return { returnToRun: true, openPicker: false, mode: mode as RunModeName }; + } + const share = Number.parseInt(rawShare.replace(/%$/, ""), 10); + if (!Number.isInteger(share) || share < 0 || share > 100) { + return { + returnToRun: false, + openPicker: false, + error: `cloud share must be an integer 0-100 — ${USAGE}`, + }; + } + return { + returnToRun: true, + openPicker: false, + mode: mode as RunModeName, + cloudShare: share, + }; +} diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts index 98563a41..c09b0123 100644 --- a/src/tui/commands/slash-command-handler.test.ts +++ b/src/tui/commands/slash-command-handler.test.ts @@ -54,11 +54,50 @@ describe("dispatchSlashCommand", () => { ]); }); - it("returns to the Run section for /run (alias of /chat)", () => { + it("still returns to the Run section for a bare /run, and opens the mode picker", () => { + // `/run` used to be a plain alias of `/chat`. It now owns the run + // MODE too, so the historical behaviour is preserved and the picker + // is opened on top of it — muscle memory keeps working. const result = dispatchSlashCommand("/run"); + expect(result.actions).toEqual([ + { type: "ui_mode_set", mode: "chat" }, + { type: "run_mode_picker_opened" }, + ]); + expect(result.runModeSet).toBeUndefined(); + }); + + it("switches run mode directly for /run ", () => { + const result = dispatchSlashCommand("/run fusion"); + expect(result.runModeSet).toBe("fusion"); + expect(result.runModeCloudShare).toBeUndefined(); expect(result.actions).toEqual([{ type: "ui_mode_set", mode: "chat" }]); }); + it("accepts a dial value, with or without a percent sign", () => { + expect(dispatchSlashCommand("/run fusion 75").runModeCloudShare).toBe(75); + expect(dispatchSlashCommand("/run fusion 75%").runModeCloudShare).toBe(75); + }); + + it("accepts the inclusive dial bounds", () => { + expect(dispatchSlashCommand("/run fusion 0").runModeCloudShare).toBe(0); + expect(dispatchSlashCommand("/run fusion 100").runModeCloudShare).toBe(100); + }); + + it("rejects an unknown mode without touching state", () => { + const result = dispatchSlashCommand("/run hybrid"); + expect(result.runModeSet).toBeUndefined(); + expect(result.actions).toEqual([]); + expect(result.systemMessage).toMatch(/unknown run mode/); + // Never forwarded to the model as a chat message. + expect(result.forwardAsMessage).toBe(false); + }); + + it("rejects an out-of-range dial value", () => { + const result = dispatchSlashCommand("/run fusion 101"); + expect(result.runModeSet).toBeUndefined(); + expect(result.systemMessage).toMatch(/0-100/); + }); + it("switches to debug mode and tab for /logs", () => { const result = dispatchSlashCommand("/logs"); expect(result.actions).toEqual([ diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..e394b859 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -1,4 +1,6 @@ import type { TuiAction } from "../tui-action.js"; +import type { RunModeName } from "../../config/index.js"; +import { parseRunModeCommand } from "./dispatch-run-mode.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; import { isThemeName, THEME_NAMES } from "../theme/theme.js"; import { parseSlashCommand } from "./slash-command-parser.js"; @@ -36,6 +38,10 @@ export interface SlashDispatchResult { readonly triggerDebugBundleDump: boolean; /** When true the caller should forward the raw buffer as a normal message. */ readonly forwardAsMessage: boolean; + /** When set, caller should persist this run mode and swap the provider. */ + readonly runModeSet?: RunModeName; + /** Optional dial value accompanying `runModeSet`. */ + readonly runModeCloudShare?: number; /** When set, caller should probe this URL, persist on success, then refresh UI. */ readonly persistLlamaUrl?: string; /** Task id to cancel via the orchestrator (`/task cancel `). */ @@ -162,6 +168,22 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([{ type: "ui_mode_toggled" }]); case "chat": return pureActions([{ type: "ui_mode_set", mode: "chat" }]); + case "run": { + const runCmd = parseRunModeCommand(parsed.args); + if (runCmd.error) { + return pureActions([], { systemMessage: runCmd.error }); + } + const actions: TuiAction[] = []; + // `/run` was an alias of `/chat`; keep that behaviour exactly. + if (runCmd.returnToRun) actions.push({ type: "ui_mode_set", mode: "chat" }); + if (runCmd.openPicker) actions.push({ type: "run_mode_picker_opened" }); + return pureActions(actions, { + ...(runCmd.mode ? { runModeSet: runCmd.mode } : {}), + ...(runCmd.cloudShare === undefined + ? {} + : { runModeCloudShare: runCmd.cloudShare }), + }); + } case "observe": return pureActions([ { type: "ui_mode_set", mode: "debug" }, @@ -272,6 +294,8 @@ function pureActions( triggerSkillCatalogDump: false, triggerDebugBundleDump: false, forwardAsMessage: false, + runModeSet: undefined, + runModeCloudShare: undefined, persistLlamaUrl: undefined, taskCancelId: undefined, taskRunId: undefined, diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..6079f338 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -36,7 +36,12 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ { name: "abort", description: "abort the running turn" }, { name: "quit", description: "exit atomic-agent", aliases: ["exit"] }, { name: "debug", description: "toggle debug pane (feed / logs / world …)" }, - { name: "chat", description: "return to single-view chat mode", aliases: ["run"] }, + { name: "chat", description: "return to single-view chat mode" }, + { + name: "run", + description: + "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` — fusion orchestrates on cloud, executes locally", + }, { name: "observe", description: diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..76ce555f 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -58,6 +58,15 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { { key: "esc", label: "abort run" }, ]; } + if (state.runModePanel.picker) { + return [ + { key: "↑↓", label: "mode" }, + { key: "←→", label: "share" }, + { key: "0-9", label: "set" }, + { key: "enter", label: "apply" }, + { key: "esc", label: "cancel" }, + ]; + } if (state.slashPaletteOpen) { return [ { key: "↑↓", label: "select" }, @@ -106,7 +115,10 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { } // Six chips is the cap for one row on narrow terminals. The scroll // hint replaces ctrl+b: Observe stays reachable via /observe, while - // scrolling had no visible entry point at all. + // scrolling had no visible entry point at all. ctrl+r (run mode) is + // unadvertised here for the same reason ctrl+b is — the mode strip + // above the chat is already the visible entry point, and `/run` + // reaches the picker from the palette. return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, diff --git a/src/tui/components/run-mode-bar.test.tsx b/src/tui/components/run-mode-bar.test.tsx new file mode 100644 index 00000000..0f9e7487 --- /dev/null +++ b/src/tui/components/run-mode-bar.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; + +import { RunModeBar } from "./run-mode-bar.js"; +import { createInitialRunModePanelState } from "../run-mode/run-mode-panel-state.js"; +import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js"; + +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function panelOf(over: Partial = {}): RunModePanelState { + return { ...createInitialRunModePanelState(), ...over }; +} + +function frameOf(panel: RunModePanelState): string { + const { lastFrame } = render(); + return stripAnsi(lastFrame() ?? ""); +} + +describe("RunModeBar", () => { + it("lists all three modes", () => { + const frame = frameOf(panelOf()); + expect(frame).toContain("Local"); + expect(frame).toContain("Cloud"); + expect(frame).toContain("Fusion"); + }); + + it("marks the mode in force with the chevron", () => { + expect(frameOf(panelOf({ effective: "local" }))).toContain("\u25b8 Local"); + expect(frameOf(panelOf({ effective: "cloud" }))).toContain("\u25b8 Cloud"); + }); + + it("shows the dial only while fusion is actually in force", () => { + expect( + frameOf(panelOf({ effective: "fusion", cloudShare: 40 })), + ).toContain("Fusion 40%"); + expect( + frameOf(panelOf({ effective: "local", cloudShare: 40 })), + ).not.toContain("40%"); + }); + + it("says why an unreachable mode is unreachable", () => { + // Silently hiding the pill would leave the operator wondering where + // Cloud went. + expect(frameOf(panelOf({ cloudProviderMissing: true }))).toContain( + "no cloud provider", + ); + }); + + it("renders as a single row", () => { + expect(frameOf(panelOf({ effective: "fusion" })).split("\n")).toHaveLength(1); + }); + + it("surfaces a failed switch inline", () => { + expect( + frameOf(panelOf({ lastError: "provider not configured" })), + ).toContain("provider not configured"); + }); +}); diff --git a/src/tui/components/run-mode-bar.tsx b/src/tui/components/run-mode-bar.tsx new file mode 100644 index 00000000..ade3b43e --- /dev/null +++ b/src/tui/components/run-mode-bar.tsx @@ -0,0 +1,56 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; + +import { theme } from "../theme/theme.js"; +import { RUN_MODES } from "../run-mode/run-mode-nav.js"; +import { runModePillLabel } from "../run-mode/run-mode-selectors.js"; +import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js"; + +export interface RunModeBarProps { + panel: RunModePanelState; +} + +/** + * The Run section's submenu: a one-row pill strip reading + * `▸ Local · Cloud · Fusion 40%`, rendered directly under the status bar + * while the chat surface is showing. + * + * It is a new row rather than `DebugPane`'s `SubTabBar` because that + * component only renders in debug mode — its `section === "run"` branch + * is unreachable. And it is a persistent strip rather than an overlay + * because a run mode is a state you are IN: an operator has to be able + * to see at a glance whether the next turn spends cloud tokens. + */ +export function RunModeBar({ panel }: RunModeBarProps): ReactElement { + return ( + + {RUN_MODES.map((mode, idx) => { + const active = mode === panel.effective; + return ( + + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {runModePillLabel(mode, panel)} + + {idx < RUN_MODES.length - 1 ? ( + + {" "} + {theme.glyphs.dotSeparator} + {" "} + + ) : null} + + ); + })} + {panel.lastError ? ( + + {" "} + {theme.glyphs.pipeSeparator} {panel.lastError} + + ) : null} + + ); +} diff --git a/src/tui/components/run-mode-picker.test.tsx b/src/tui/components/run-mode-picker.test.tsx new file mode 100644 index 00000000..cb432be6 --- /dev/null +++ b/src/tui/components/run-mode-picker.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; + +import { RunModePicker } from "./run-mode-picker.js"; +import { createInitialRunModePanelState } from "../run-mode/run-mode-panel-state.js"; +import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js"; + +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function open(over: Partial = {}): RunModePanelState { + return { + ...createInitialRunModePanelState(), + picker: { + cursor: 2, + draftMode: "fusion", + draftCloudShare: 40, + digitBuffer: "", + }, + ...over, + }; +} + +function frameOf(panel: RunModePanelState): string { + const { lastFrame } = render(); + return stripAnsi(lastFrame() ?? ""); +} + +describe("RunModePicker", () => { + it("renders nothing while closed", () => { + const { lastFrame } = render( + , + ); + expect(stripAnsi(lastFrame() ?? "").trim()).toBe(""); + }); + + it("lists the modes with the draft highlighted", () => { + const frame = frameOf(open()); + expect(frame).toContain("Local"); + expect(frame).toContain("\u25b8 Fusion"); + }); + + it("shows the dial value and a bar", () => { + const frame = frameOf(open()); + expect(frame).toContain("40%"); + expect(frame).toMatch(/[\u2588\u2591]/); + }); + + it("explains what the dial means in prose", () => { + expect(frameOf(open())).toContain("steps scoring \u2265 60"); + }); + + it("names the extremes plainly", () => { + const allLocal = open(); + allLocal.picker!.draftCloudShare = 0; + expect(frameOf(allLocal)).toContain("everything local"); + const allCloud = open(); + allCloud.picker!.draftCloudShare = 100; + expect(frameOf(allCloud)).toContain("everything cloud"); + }); + + it("says the dial is inert unless Fusion is selected", () => { + const local = open(); + local.picker!.draftMode = "local"; + local.picker!.cursor = 0; + expect(frameOf(local)).toContain("only applies to Fusion"); + }); + + it("surfaces a degradation warning", () => { + expect( + frameOf(open({ degradedMessage: "Fusion needs a cloud orchestrator" })), + ).toContain("Fusion needs a cloud orchestrator"); + }); + + it("advertises its own key bindings", () => { + const frame = frameOf(open()); + expect(frame).toContain("enter apply"); + expect(frame).toContain("esc cancel"); + }); + + /** + * Reported as "I don't see a way to configure fusion anywhere". Every + * mode here names a PAIR of providers and the overlay named neither, + * so with two cloud providers configured there was nothing on screen + * to say which one Fusion would orchestrate through — i.e. which + * account gets billed. + */ +}); diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx new file mode 100644 index 00000000..3cae37e6 --- /dev/null +++ b/src/tui/components/run-mode-picker.tsx @@ -0,0 +1,79 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { theme } from "../theme/theme.js"; +import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js"; +import { + describeCloudShare, + formatCloudShareBar, +} from "../run-mode/run-mode-selectors.js"; +import type { RunModePanelState } from "../run-mode/run-mode-panel-state.js"; + +export interface RunModePickerProps { + panel: RunModePanelState; +} + +const MODE_BLURBS: Record = { + local: "llama-server only", + cloud: "cloud provider only", + fusion: "cloud plans, local executes", +}; + +/** + * Overlay for choosing a run mode and, for Fusion, the cloud share. + * + * The dial is why this exists at all: a 0-100 control cannot live in the + * one-row strip. Everything here is a draft — Esc discards it and the + * committed mode is untouched, the same contract `ThemePicker` offers. + */ +export function RunModePicker({ panel }: RunModePickerProps): ReactElement | null { + const picker = panel.picker; + if (!picker) return null; + const fusionSelected = picker.draftMode === "fusion"; + return ( + + + Run mode + + {RUN_MODES.map((mode, idx) => { + const selected = idx === picker.cursor; + return ( + + {selected ? `${theme.glyphs.chevronRight} ` : " "} + {RUN_MODE_LABELS[mode]} + — {MODE_BLURBS[mode]} + {mode === panel.effective ? ( + (current) + ) : null} + + ); + })} + + {" "} + cloud share {String(picker.draftCloudShare).padStart(3, " ")}%{" "} + {formatCloudShareBar(picker.draftCloudShare)} + + + {" "} + {fusionSelected + ? describeCloudShare(picker.draftCloudShare) + : "the dial only applies to Fusion"} + + {panel.degradedMessage ? ( + {panel.degradedMessage} + ) : null} + + ↑↓ mode · ←→ share (shift ±25) · digits set · enter apply · esc cancel + + + ); +} diff --git a/src/tui/index.ts b/src/tui/index.ts index 229903db..12904090 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -47,3 +47,14 @@ export { resolveStartupTheme, } from "./theme/detect-terminal-background.js"; export type { TerminalBackgroundMode } from "./theme/detect-terminal-background.js"; +export { + createInitialRunModePanelState, + cycleRunMode, + reduceRunModeAction, + RUN_MODES, + RunModeOrchestrator, + runModeModelSummary, + runModePillLabel, + type RunModePanelState, +} from "./run-mode/index.js"; +export { setRunModeInConfig, RunModePersistError } from "./persist-run-mode.js"; diff --git a/src/tui/persist-run-mode.test.ts b/src/tui/persist-run-mode.test.ts new file mode 100644 index 00000000..dc06dad7 --- /dev/null +++ b/src/tui/persist-run-mode.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resetConfigCache } from "../config/config-cache.js"; +import { + getUserConfigPath, + writeUserConfigFileSync, +} from "../config/config-file.js"; +import { USER_CONFIG_DEFAULTS } from "../config/config-schema.js"; +import type { UserConfigFile } from "../config/config-schema.js"; +import { RunModePersistError, setRunModeInConfig } from "./persist-run-mode.js"; + +const TWO_LEG_LLM = { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto" as const, + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:8080" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" }, + ], +}; + +describe("setRunModeInConfig", () => { + let stateDir: string; + + const written = (): UserConfigFile => + JSON.parse( + readFileSync(getUserConfigPath(stateDir), "utf8"), + ) as UserConfigFile; + + const seed = (llm: unknown): void => { + writeUserConfigFileSync(getUserConfigPath(stateDir), { + ...USER_CONFIG_DEFAULTS, + ...(llm ? { llm } : {}), + } as UserConfigFile); + resetConfigCache(); + }; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-run-mode-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + seed(TWO_LEG_LLM); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + vi.restoreAllMocks(); + }); + + it("moves the mode AND the active provider in one write", () => { + // The load-bearing invariant: `resolveRunMode` only honours a stored + // fusion while the cloud leg is active, so persisting one key + // without the other leaves the file disagreeing with the runtime. + setRunModeInConfig({ mode: "fusion", primaryProviderId: "openrouter" }); + const file = written(); + expect(file.llm?.runMode?.mode).toBe("fusion"); + expect(file.llm?.activeTextProvider).toBe("openrouter"); + }); + + it("stores the dial when one is supplied", () => { + setRunModeInConfig({ + mode: "fusion", + primaryProviderId: "openrouter", + cloudShare: 75, + }); + expect(written().llm?.runMode?.fusion?.cloudShare).toBe(75); + }); + + it("leaves an existing dial alone when none is supplied", () => { + setRunModeInConfig({ + mode: "fusion", + primaryProviderId: "openrouter", + cloudShare: 75, + }); + setRunModeInConfig({ mode: "local", primaryProviderId: "local-llama" }); + const file = written(); + expect(file.llm?.runMode?.mode).toBe("local"); + expect(file.llm?.runMode?.fusion?.cloudShare).toBe(75); + }); + + it("preserves unrelated runMode keys", () => { + seed({ + ...TWO_LEG_LLM, + runMode: { mode: "local", cloudProvider: "openrouter" }, + }); + setRunModeInConfig({ mode: "cloud", primaryProviderId: "openrouter" }); + expect(written().llm?.runMode?.cloudProvider).toBe("openrouter"); + }); + + it("preserves the rest of the llm block", () => { + setRunModeInConfig({ mode: "cloud", primaryProviderId: "openrouter" }); + expect(written().llm?.providers).toHaveLength(2); + expect(written().llm?.toolTransport).toBe("auto"); + }); + + it("writes a file that parses back cleanly", () => { + setRunModeInConfig({ + mode: "fusion", + primaryProviderId: "openrouter", + cloudShare: 40, + }); + resetConfigCache(); + // A round-trip through the real parser: a write that produced an + // invalid block would throw here rather than at the next boot. + expect(() => written()).not.toThrow(); + expect(written().llm?.runMode).toEqual({ + mode: "fusion", + fusion: { cloudShare: 40 }, + }); + }); + + it("refuses a provider that is not configured", () => { + expect(() => + setRunModeInConfig({ mode: "cloud", primaryProviderId: "anthropic" }), + ).toThrow(RunModePersistError); + }); + + it("refuses to write when there is no llm block at all", () => { + seed(undefined); + expect(() => + setRunModeInConfig({ mode: "cloud", primaryProviderId: "openrouter" }), + ).toThrow(/add a provider first/); + }); +}); diff --git a/src/tui/persist-run-mode.ts b/src/tui/persist-run-mode.ts new file mode 100644 index 00000000..04461be4 --- /dev/null +++ b/src/tui/persist-run-mode.ts @@ -0,0 +1,67 @@ +import { + ensureUserConfigFileSync, + getConfig, + resetConfigCache, + writeUserConfigFileSync, + type RunModeName, + type UserLlmRunModeConfig, +} from "../config/index.js"; + +/** + * Writing the run mode is a SINGLE config write that moves two keys: + * `llm.runMode` and `llm.activeTextProvider`. + * + * They have to move together. `resolveRunMode` treats + * `activeTextProvider` as authoritative and only honours a stored + * `fusion` while the cloud leg is active, so persisting one without the + * other leaves a window — or a permanent state — where the file says + * fusion and the runtime says local. One `writeUserConfigFileSync` plus + * one `resetConfigCache()` closes it. + * + * Lives apart from `persist-llm-provider.ts` because that file is + * already at the 300-line budget. + */ +export class RunModePersistError extends Error {} + +export interface SetRunModeArgs { + mode: RunModeName; + /** Provider that must become active for `mode` to hold. */ + primaryProviderId: string; + /** Optional new dial value; omitted leaves the stored one alone. */ + cloudShare?: number; +} + +export function setRunModeInConfig(args: SetRunModeArgs): void { + const path = getConfig().paths.userConfigFile; + const file = ensureUserConfigFileSync(path); + const llm = file.llm; + if (!llm) { + throw new RunModePersistError( + "no llm block configured — add a provider first (Manage → LLM)", + ); + } + if (!llm.providers.some((p) => p.id === args.primaryProviderId)) { + throw new RunModePersistError( + `provider "${args.primaryProviderId}" is not configured`, + ); + } + const previous: UserLlmRunModeConfig = llm.runMode ?? {}; + const fusion = + args.cloudShare === undefined + ? previous.fusion + : { ...previous.fusion, cloudShare: args.cloudShare }; + const runMode: UserLlmRunModeConfig = { + ...previous, + mode: args.mode, + ...(fusion ? { fusion } : {}), + }; + writeUserConfigFileSync(path, { + ...file, + llm: { + ...llm, + activeTextProvider: args.primaryProviderId, + runMode, + }, + }); + resetConfigCache(); +} diff --git a/src/tui/run-mode/index.ts b/src/tui/run-mode/index.ts new file mode 100644 index 00000000..6565dabe --- /dev/null +++ b/src/tui/run-mode/index.ts @@ -0,0 +1,26 @@ +export { + isRunModeAction, + type RunModeAction, +} from "./run-mode-actions.js"; +export { + clampCloudShare, + cycleRunMode, + CLOUD_SHARE_COARSE_STEP, + CLOUD_SHARE_STEP, + RUN_MODES, + RUN_MODE_LABELS, +} from "./run-mode-nav.js"; +export { + createInitialRunModePanelState, + type RunModePanelState, + type RunModePickerState, +} from "./run-mode-panel-state.js"; +export { reduceRunModeAction } from "./run-mode-reducer.js"; +export { + describeCloudShare, + formatCloudShareBar, + runModeModelSummary, + runModePillLabel, +} from "./run-mode-selectors.js"; +export { handleRunModePickerKey } from "./run-mode-key-bindings.js"; +export { RunModeOrchestrator } from "./run-mode-orchestrator.js"; diff --git a/src/tui/run-mode/run-mode-actions.ts b/src/tui/run-mode/run-mode-actions.ts new file mode 100644 index 00000000..6f64c9a8 --- /dev/null +++ b/src/tui/run-mode/run-mode-actions.ts @@ -0,0 +1,42 @@ +import type { RunModeName } from "../../config/llm-run-mode-config.js"; + +/** + * Reducer actions for the Run-mode strip and its dial overlay. The + * `run_mode_` prefix lets the root reducer narrow without a tag table. + * + * There is deliberately no "apply this mode" action here. Only the + * orchestrator may write config or move the active provider, and it is + * unreachable from a dispatch: the bus it listens on is bridged into + * the reducer one way. Applying a mode goes through + * `TuiAppCallbacks.onRunModeChangeRequested`; the mirror updates when + * the orchestrator reports back via `run_mode_synced`. + */ +export type RunModeAction = + | { + type: "run_mode_synced"; + effective: RunModeName; + stored: RunModeName | null; + cloudShare: number; + localLabel: string | null; + cloudLabel: string | null; + localProviderId: string | null; + cloudProviderId: string | null; + cloudProviderMissing: boolean; + localProviderMissing: boolean; + degradedMessage: string | null; + } + | { type: "run_mode_change_started" } + | { type: "run_mode_change_settled"; error?: string } + | { type: "run_mode_picker_opened" } + | { type: "run_mode_picker_closed" } + | { type: "run_mode_picker_cursor_set"; cursor: number } + | { type: "run_mode_picker_share_set"; cloudShare: number } + | { type: "run_mode_picker_digit_typed"; digit: string } + | { type: "run_mode_picker_digits_cleared" }; + +/** Narrow runtime guard used by the root reducer to dispatch. */ +export function isRunModeAction(action: { + type: string; +}): action is RunModeAction { + return action.type.startsWith("run_mode_"); +} diff --git a/src/tui/run-mode/run-mode-key-bindings.test.ts b/src/tui/run-mode/run-mode-key-bindings.test.ts new file mode 100644 index 00000000..52c8518d --- /dev/null +++ b/src/tui/run-mode/run-mode-key-bindings.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Key } from "ink"; + +import { apply, fakeSession } from "../test-fixtures.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiState } from "../tui-state.js"; +import { handleRunModePickerKey } from "./run-mode-key-bindings.js"; + +const KEY: Key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, +}; + +function withPicker(): TuiState { + return apply(createInitialTuiState(fakeSession()), [ + { type: "run_mode_picker_opened" }, + ]); +} + +function press(state: TuiState, input: string, key: Partial = {}) { + const dispatch = vi.fn(); + const onRunModeChangeRequested = vi.fn(); + const handled = handleRunModePickerKey(input, { ...KEY, ...key }, { + state, + dispatch, + callbacks: { onRunModeChangeRequested }, + }); + return { handled, dispatch, onRunModeChangeRequested }; +} + +describe("handleRunModePickerKey", () => { + it("declines every key while the picker is closed", () => { + const state = createInitialTuiState(fakeSession()); + const { handled, dispatch } = press(state, "j"); + expect(handled).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("closes on Esc without requesting a change", () => { + const { handled, dispatch, onRunModeChangeRequested } = press( + withPicker(), + "", + { escape: true }, + ); + expect(handled).toBe(true); + expect(dispatch).toHaveBeenCalledWith({ type: "run_mode_picker_closed" }); + expect(onRunModeChangeRequested).not.toHaveBeenCalled(); + }); + + /** + * Enter used to dispatch a `run_mode_change_requested` action instead + * of calling the callback. Nothing consumed it — the reducer returned + * the panel unchanged on purpose, and `dispatch` never reaches the + * orchestrator's bus — so applying a mode from this overlay wrote + * nothing and swapped nothing. Asserting the dispatch, as the old test + * did, passed happily the whole time. + */ + it("applies the draft through the callback on Enter, then closes", () => { + const { dispatch, onRunModeChangeRequested } = press(withPicker(), "", { + return: true, + }); + expect(onRunModeChangeRequested).toHaveBeenCalledWith("local", 40); + expect(dispatch).toHaveBeenCalledWith({ type: "run_mode_picker_closed" }); + }); + + it("applies whatever the cursor moved to, not the mode in force", () => { + const onFusion = apply(withPicker(), [ + { type: "run_mode_picker_cursor_set", cursor: 2 }, + { type: "run_mode_picker_share_set", cloudShare: 70 }, + ]); + const { onRunModeChangeRequested } = press(onFusion, "", { return: true }); + expect(onRunModeChangeRequested).toHaveBeenCalledWith("fusion", 70); + }); + + it("moves with arrows and with j/k", () => { + for (const [input, key] of [ + ["j", {}], + ["", { downArrow: true }], + ] as const) { + const { dispatch } = press(withPicker(), input, key); + expect(dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_cursor_set", + cursor: 1, + }); + } + }); + + it("wraps the cursor in both directions", () => { + const { dispatch } = press(withPicker(), "k"); + expect(dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_cursor_set", + cursor: 2, + }); + }); + + it("steps the dial by 5, or 25 with shift", () => { + const fine = press(withPicker(), "", { rightArrow: true }); + expect(fine.dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_share_set", + cloudShare: 45, + }); + const coarse = press(withPicker(), "", { rightArrow: true, shift: true }); + expect(coarse.dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_share_set", + cloudShare: 65, + }); + const down = press(withPicker(), "", { leftArrow: true }); + expect(down.dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_share_set", + cloudShare: 35, + }); + }); + + it("forwards typed digits", () => { + const { dispatch } = press(withPicker(), "7"); + expect(dispatch).toHaveBeenCalledWith({ + type: "run_mode_picker_digit_typed", + digit: "7", + }); + }); + + it("swallows every unclaimed key so nothing leaks to the editor", () => { + // The picker floats over the chat surface, where the editor holds + // focus — an unswallowed letter would be typed into the prompt. + for (const input of ["x", "Z", " ", "/"]) { + const { handled } = press(withPicker(), input); + expect(handled).toBe(true); + } + expect(press(withPicker(), "", { tab: true }).handled).toBe(true); + expect(press(withPicker(), "b", { ctrl: true }).handled).toBe(true); + }); +}); diff --git a/src/tui/run-mode/run-mode-key-bindings.ts b/src/tui/run-mode/run-mode-key-bindings.ts new file mode 100644 index 00000000..98a4c6b1 --- /dev/null +++ b/src/tui/run-mode/run-mode-key-bindings.ts @@ -0,0 +1,104 @@ +import type { Key } from "ink"; +import type { RunModeName } from "../../config/llm-run-mode-config.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiState } from "../tui-state.js"; +import { + clampCloudShare, + CLOUD_SHARE_COARSE_STEP, + CLOUD_SHARE_STEP, + RUN_MODES, +} from "./run-mode-nav.js"; + +export interface RunModePickerKeyContext { + state: TuiState; + dispatch: (action: TuiAction) => void; + /** + * Applying a mode is a config write, so it has to go out through the + * callback layer. Dispatching cannot do it: `dispatch` feeds the React + * reducer only, and the bus the orchestrators listen on is bridged + * into the reducer ONE WAY (`bus.subscribe(dispatch)` in `tui-app`). + * This screen used to dispatch a `run_mode_change_requested` action + * that nothing on either side consumed, which is why Enter here did + * nothing at all — see the commit that added this parameter. + */ + callbacks?: { + onRunModeChangeRequested?(mode: RunModeName, cloudShare?: number): void; + }; +} + +/** + * Keyboard layer for the run-mode dial overlay. + * + * Unlike the other panel key files this is called from `handleAppKey` + * BEFORE the editor sees anything, next to the approval and update + * prompts. That is deliberate: the overlay opens over the chat surface, + * where the editor holds focus and would otherwise swallow `←`/`→` as + * cursor motion and digits as literal text. + * + * While the picker is open EVERY key is consumed — 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. + * + * - `↑`/`↓`, `j`/`k` — move between Local / Cloud / Fusion. + * - `←`/`→` — dial ±5 (shift: ±25). + * - digits — type a dial value directly. + * - `Enter` — apply the highlighted mode + dial. + * - `Esc` — close, reverting to what was in force. + */ +export function handleRunModePickerKey( + input: string, + key: Key, + ctx: RunModePickerKeyContext, +): boolean { + const picker = ctx.state.runModePanel.picker; + if (!picker) return false; + const { dispatch } = ctx; + + if (key.escape) { + dispatch({ type: "run_mode_picker_closed" }); + return true; + } + if (key.return) { + // Same call the mouse layer makes when a selected row is clicked, so + // the two gestures cannot drift into meaning different things. + ctx.callbacks?.onRunModeChangeRequested?.( + picker.draftMode, + picker.draftCloudShare, + ); + dispatch({ type: "run_mode_picker_closed" }); + return true; + } + if (key.upArrow || input === "k") { + dispatch({ + type: "run_mode_picker_cursor_set", + cursor: picker.cursor - 1 < 0 ? RUN_MODES.length - 1 : picker.cursor - 1, + }); + return true; + } + if (key.downArrow || input === "j") { + dispatch({ + type: "run_mode_picker_cursor_set", + cursor: picker.cursor + 1 >= RUN_MODES.length ? 0 : picker.cursor + 1, + }); + return true; + } + if (key.leftArrow || key.rightArrow) { + const step = key.shift ? CLOUD_SHARE_COARSE_STEP : CLOUD_SHARE_STEP; + const delta = key.rightArrow ? step : -step; + dispatch({ + type: "run_mode_picker_share_set", + cloudShare: clampCloudShare(picker.draftCloudShare + delta), + }); + return true; + } + if (input.length === 1 && input >= "0" && input <= "9") { + dispatch({ type: "run_mode_picker_digit_typed", digit: input }); + return true; + } + if (key.backspace || key.delete) { + dispatch({ type: "run_mode_picker_digits_cleared" }); + return true; + } + // Anything else is swallowed while the picker owns the keyboard. + return true; +} diff --git a/src/tui/run-mode/run-mode-nav.test.ts b/src/tui/run-mode/run-mode-nav.test.ts new file mode 100644 index 00000000..df446ffa --- /dev/null +++ b/src/tui/run-mode/run-mode-nav.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { clampCloudShare, cycleRunMode, RUN_MODES } from "./run-mode-nav.js"; + +describe("cycleRunMode", () => { + it("cycles forward and wraps", () => { + expect(cycleRunMode("local", 1)).toBe("cloud"); + expect(cycleRunMode("cloud", 1)).toBe("fusion"); + expect(cycleRunMode("fusion", 1)).toBe("local"); + }); + + it("cycles backward and wraps", () => { + expect(cycleRunMode("local", -1)).toBe("fusion"); + expect(cycleRunMode("fusion", -1)).toBe("cloud"); + }); + + it("returns to the start after a full lap", () => { + let mode = RUN_MODES[0]!; + for (let i = 0; i < RUN_MODES.length; i += 1) mode = cycleRunMode(mode, 1); + expect(mode).toBe(RUN_MODES[0]); + }); +}); + +describe("clampCloudShare", () => { + it("clamps to the inclusive 0-100 range", () => { + expect(clampCloudShare(-10)).toBe(0); + expect(clampCloudShare(140)).toBe(100); + expect(clampCloudShare(40)).toBe(40); + }); + + it("rounds fractional input", () => { + expect(clampCloudShare(42.6)).toBe(43); + }); + + it("treats non-finite input as zero rather than NaN", () => { + expect(clampCloudShare(Number.NaN)).toBe(0); + }); +}); diff --git a/src/tui/run-mode/run-mode-nav.ts b/src/tui/run-mode/run-mode-nav.ts new file mode 100644 index 00000000..4930dfc2 --- /dev/null +++ b/src/tui/run-mode/run-mode-nav.ts @@ -0,0 +1,39 @@ +import type { RunModeName } from "../../config/llm-run-mode-config.js"; + +/** + * Run modes in display + cycle order. + * + * Deliberately NOT folded into `TuiTab` / `cycleSubTab`: a run mode is + * not a view, and forcing it into the tab union would drag in + * `getCurrentSection`, `tab_changed`, `NAV_SLOT_ORDER` and the persisted + * `initialLayout` contract for something none of them describe. + */ +export const RUN_MODES: readonly RunModeName[] = ["local", "cloud", "fusion"]; + +export const RUN_MODE_LABELS: Record = { + local: "Local", + cloud: "Cloud", + fusion: "Fusion", +}; + +/** Step through the modes, wrapping in both directions. */ +export function cycleRunMode( + current: RunModeName, + direction: 1 | -1, +): RunModeName { + const idx = RUN_MODES.indexOf(current); + const safe = idx === -1 ? 0 : idx; + const next = (safe + direction + RUN_MODES.length) % RUN_MODES.length; + return RUN_MODES[next] ?? RUN_MODES[0]!; +} + +/** Smallest dial step, used by ←/→ in the picker. */ +export const CLOUD_SHARE_STEP = 5; +/** Coarse dial step, used by shift+←/→. */ +export const CLOUD_SHARE_COARSE_STEP = 25; + +/** Clamp + quantise a dial value into the inclusive 0-100 range. */ +export function clampCloudShare(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} diff --git a/src/tui/run-mode/run-mode-orchestrator.ts b/src/tui/run-mode/run-mode-orchestrator.ts new file mode 100644 index 00000000..696319af --- /dev/null +++ b/src/tui/run-mode/run-mode-orchestrator.ts @@ -0,0 +1,138 @@ +import { getConfig, type RunModeName } from "../../config/index.js"; +import { + describeRunModeDegradation, + resolveRunMode, +} from "../../llm/run-mode/index.js"; +import { resolveLlmConfig } from "../../llm/provider/registry/provider-types.js"; +import type { AgentRuntime } from "../../runtime/bootstrap.js"; +import type { TuiEventBus } from "../tui-app.js"; +import { setRunModeInConfig } from "../persist-run-mode.js"; + +/** + * Only TUI module that writes `llm.runMode` or moves the active text + * provider for a mode change. The reducer and components stay pure. + * + * A mode switch is deliberately two steps in a fixed order: persist + * both keys in one write, THEN hot-apply the provider swap. If the swap + * fails the file is already correct, so the next boot comes up in the + * requested mode — and the error says so rather than pretending the + * switch did not happen. + */ +export class RunModeOrchestrator { + constructor( + private readonly runtime: AgentRuntime, + private readonly bus: TuiEventBus & { emit(action: unknown): void }, + ) {} + + /** Push the resolved run-mode snapshot into the UI. */ + refresh(): void { + const resolved = resolveRunMode(resolveLlmConfig(getConfig())); + this.bus.emit({ + type: "run_mode_synced", + effective: resolved.effective, + stored: resolved.stored, + cloudShare: resolved.fusion.cloudShare, + localLabel: this.modelLabel(resolved.localProviderId), + cloudLabel: this.modelLabel(resolved.cloudProviderId), + localProviderId: resolved.localProviderId, + cloudProviderId: resolved.cloudProviderId, + cloudProviderMissing: resolved.cloudProviderId === null, + localProviderMissing: resolved.localProviderId === null, + degradedMessage: resolved.degraded + ? describeRunModeDegradation(resolved.degraded) + : null, + }); + } + + /** + * Switch mode (and optionally the dial), persisting both config keys + * in one write before touching the runtime. + * + * A mode the config cannot support is NOT written: `resolveRunMode` + * is consulted first, and an unsupported request surfaces its + * degradation sentence instead. Writing a mode that immediately + * resolves to something else would leave the file disagreeing with + * the strip on the very next refresh. + */ + async setMode(mode: RunModeName, cloudShare?: number): Promise { + this.bus.emit({ type: "run_mode_change_started" }); + try { + const target = this.resolveTarget(mode, cloudShare); + if (target.blocked) { + this.bus.emit({ + type: "run_mode_change_settled", + error: target.blocked, + }); + this.refresh(); + return; + } + setRunModeInConfig({ + mode, + primaryProviderId: target.primaryProviderId, + ...(cloudShare === undefined ? {} : { cloudShare }), + }); + await this.runtime.providerRegistry.setActive(target.primaryProviderId); + this.bus.emit({ type: "run_mode_change_settled" }); + } catch (err) { + this.bus.emit({ + type: "run_mode_change_settled", + error: err instanceof Error ? err.message : String(err), + }); + } + this.refresh(); + } + + /** + * Which provider must become active for `mode`, or why it cannot. + * + * Resolution runs against a hypothetical config in which the mode is + * already stored, so the answer describes the world AFTER the switch + * rather than the one before it. + */ + private resolveTarget( + mode: RunModeName, + cloudShare?: number, + ): { primaryProviderId: string; blocked?: string } { + const live = resolveLlmConfig(getConfig()); + const hypothetical = resolveRunMode({ + ...live, + runMode: { + ...live.runMode, + mode, + ...(cloudShare === undefined + ? {} + : { fusion: { ...live.runMode?.fusion, cloudShare } }), + }, + // Pretend the leg this mode needs is already active, so the + // fusion rule ("cloud leg must be primary") can be satisfied. + activeTextProvider: this.legFor(mode, live.activeTextProvider), + }); + if (hypothetical.degraded && hypothetical.effective !== mode) { + return { + primaryProviderId: hypothetical.primaryProviderId, + blocked: describeRunModeDegradation(hypothetical.degraded), + }; + } + return { primaryProviderId: hypothetical.primaryProviderId }; + } + + private legFor(mode: RunModeName, fallback: string): string { + const resolved = resolveRunMode(resolveLlmConfig(getConfig())); + if (mode === "local") return resolved.localProviderId ?? fallback; + return resolved.cloudProviderId ?? fallback; + } + + private modelLabel(providerId: string | null): string | null { + if (!providerId) return null; + const entry = resolveLlmConfig(getConfig()).providers.find( + (p) => p.id === providerId, + ); + if (!entry) return null; + return ( + entry.defaultChatModel ?? + entry.model ?? + getConfig().localModels.managed.modelId ?? + entry.id + ); + } +} diff --git a/src/tui/run-mode/run-mode-panel-state.ts b/src/tui/run-mode/run-mode-panel-state.ts new file mode 100644 index 00000000..811ceb9e --- /dev/null +++ b/src/tui/run-mode/run-mode-panel-state.ts @@ -0,0 +1,76 @@ +import type { RunModeName } from "../../config/llm-run-mode-config.js"; + +/** + * UI state for the Run-section mode strip and its dial overlay. + * + * Everything here is a MIRROR pushed in by `run-mode-orchestrator` via + * `run_mode_synced`; the reducer never reads config or the runtime. The + * mirror is of the RESOLVED mode, so `effective` already accounts for a + * missing cloud leg or an operator who switched provider by hand. + */ +export interface RunModePanelState { + /** Resolved mode actually in force. */ + effective: RunModeName; + /** Mode stored in config, when it differs from `effective`. */ + stored: RunModeName | null; + /** Fusion dial, 0-100. */ + cloudShare: number; + /** Display labels for the two legs, or null when unknown. */ + localLabel: string | null; + cloudLabel: string | null; + /** + * Provider id filling each leg, as `resolveRunMode` resolved it. + * + * Separate from the labels, which carry the MODEL name: with two cloud + * providers configured, "the cloud leg" is a specific one of them + * (`llm.runMode.cloudProvider`, else the first non-llama-server entry) + * and an operator cannot tell which without being told its id. + */ + localProviderId: string | null; + cloudProviderId: string | null; + /** + * Whether each leg is configured at all. Tracked separately from the + * labels because a provider can exist with no model name resolved + * yet, and "unavailable" must not be inferred from "unnamed". + */ + cloudProviderMissing: boolean; + localProviderMissing: boolean; + /** One-line explanation when the requested mode was degraded. */ + degradedMessage: string | null; + /** True while a mode switch is being persisted. */ + busy: boolean; + lastError: string | null; + /** Non-null while the dial overlay owns the keyboard. */ + picker: RunModePickerState | null; +} + +/** + * The overlay's own draft. Kept separate from the committed mirror so + * Esc can revert to what was in force when it opened — the same + * contract `ThemePicker` offers. + */ +export interface RunModePickerState { + cursor: number; + draftMode: RunModeName; + draftCloudShare: number; + /** Digits typed so far for a direct dial entry; committed on Enter. */ + digitBuffer: string; +} + +export function createInitialRunModePanelState(): RunModePanelState { + return { + effective: "local", + stored: null, + cloudShare: 40, + localLabel: null, + cloudLabel: null, + localProviderId: null, + cloudProviderId: null, + cloudProviderMissing: false, + localProviderMissing: false, + degradedMessage: null, + busy: false, + lastError: null, + picker: null, + }; +} diff --git a/src/tui/run-mode/run-mode-reducer.test.ts b/src/tui/run-mode/run-mode-reducer.test.ts new file mode 100644 index 00000000..a1b81038 --- /dev/null +++ b/src/tui/run-mode/run-mode-reducer.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { apply, fakeSession } from "../test-fixtures.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiState } from "../tui-state.js"; + +const base = (): TuiState => createInitialTuiState(fakeSession()); + +const synced = { + type: "run_mode_synced" as const, + effective: "fusion" as const, + stored: "fusion" as const, + cloudShare: 40, + localLabel: "qwen3.6-9b", + cloudLabel: "openai/gpt-4o-mini", + cloudProviderMissing: false, + localProviderMissing: false, + degradedMessage: null, +}; + +describe("reduceRunModeAction", () => { + it("starts on local with no picker open", () => { + const state = base(); + expect(state.runModePanel.effective).toBe("local"); + expect(state.runModePanel.picker).toBeNull(); + }); + + it("mirrors a synced snapshot", () => { + const state = apply(base(), [synced]); + expect(state.runModePanel).toMatchObject({ + effective: "fusion", + stored: "fusion", + cloudShare: 40, + localLabel: "qwen3.6-9b", + cloudLabel: "openai/gpt-4o-mini", + }); + }); + + it("opens the picker on the mode currently in force", () => { + const state = apply(base(), [synced, { type: "run_mode_picker_opened" }]); + expect(state.runModePanel.picker).toMatchObject({ + cursor: 2, + draftMode: "fusion", + draftCloudShare: 40, + }); + }); + + it("moves the cursor and the draft mode together", () => { + const state = apply(base(), [ + synced, + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_cursor_set", cursor: 0 }, + ]); + expect(state.runModePanel.picker?.draftMode).toBe("local"); + }); + + it("clamps the cursor to the available modes", () => { + const state = apply(base(), [ + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_cursor_set", cursor: 99 }, + ]); + expect(state.runModePanel.picker?.cursor).toBe(2); + }); + + it("clamps the dial to 0-100", () => { + const high = apply(base(), [ + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_share_set", cloudShare: 250 }, + ]); + expect(high.runModePanel.picker?.draftCloudShare).toBe(100); + const low = apply(base(), [ + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_share_set", cloudShare: -40 }, + ]); + expect(low.runModePanel.picker?.draftCloudShare).toBe(0); + }); + + it("builds a dial value from typed digits", () => { + const state = apply(base(), [ + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_digit_typed", digit: "7" }, + { type: "run_mode_picker_digit_typed", digit: "5" }, + ]); + expect(state.runModePanel.picker?.draftCloudShare).toBe(75); + }); + + it("keeps 100 reachable but nothing longer", () => { + const state = apply(base(), [ + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_digit_typed", digit: "1" }, + { type: "run_mode_picker_digit_typed", digit: "0" }, + { type: "run_mode_picker_digit_typed", digit: "0" }, + ]); + expect(state.runModePanel.picker?.draftCloudShare).toBe(100); + }); + + it("reverts on close — the committed mirror was never touched", () => { + const state = apply(base(), [ + synced, + { type: "run_mode_picker_opened" }, + { type: "run_mode_picker_share_set", cloudShare: 90 }, + { type: "run_mode_picker_closed" }, + ]); + expect(state.runModePanel.picker).toBeNull(); + expect(state.runModePanel.cloudShare).toBe(40); + }); + + it("tracks busy and surfaces a settle error", () => { + const busy = apply(base(), [{ type: "run_mode_change_started" }]); + expect(busy.runModePanel.busy).toBe(true); + const settled = apply(busy, [ + { type: "run_mode_change_settled", error: "no cloud provider" }, + ]); + expect(settled.runModePanel).toMatchObject({ + busy: false, + lastError: "no cloud provider", + }); + }); + + it("clears a previous error when a new change starts", () => { + const state = apply(base(), [ + { type: "run_mode_change_settled", error: "boom" }, + { type: "run_mode_change_started" }, + ]); + expect(state.runModePanel.lastError).toBeNull(); + }); + + it("ignores picker actions while the picker is closed", () => { + const state = apply(base(), [ + { type: "run_mode_picker_share_set", cloudShare: 90 }, + ]); + expect(state.runModePanel.picker).toBeNull(); + }); +}); diff --git a/src/tui/run-mode/run-mode-reducer.ts b/src/tui/run-mode/run-mode-reducer.ts new file mode 100644 index 00000000..b6cfc6e2 --- /dev/null +++ b/src/tui/run-mode/run-mode-reducer.ts @@ -0,0 +1,113 @@ +import type { TuiState } from "../tui-state.js"; +import { isRunModeAction, type RunModeAction } from "./run-mode-actions.js"; +import { clampCloudShare, RUN_MODES } from "./run-mode-nav.js"; +import type { RunModePanelState } from "./run-mode-panel-state.js"; + +/** + * Reducer slice for `state.runModePanel`. Returns an updated `TuiState` + * when the action belongs to this slice, `null` otherwise so the root + * reducer falls through. Pure: config writes and provider swaps live in + * `run-mode-orchestrator.ts`. + */ +export function reduceRunModeAction( + state: TuiState, + action: { type: string }, +): TuiState | null { + if (!isRunModeAction(action)) return null; + const panel = state.runModePanel; + const next = reducePanel(panel, action); + if (next === panel) return state; + return { ...state, runModePanel: next }; +} + +function reducePanel( + panel: RunModePanelState, + action: RunModeAction, +): RunModePanelState { + switch (action.type) { + case "run_mode_synced": + return { + ...panel, + effective: action.effective, + stored: action.stored, + cloudShare: action.cloudShare, + localLabel: action.localLabel, + cloudLabel: action.cloudLabel, + localProviderId: action.localProviderId, + cloudProviderId: action.cloudProviderId, + cloudProviderMissing: action.cloudProviderMissing, + localProviderMissing: action.localProviderMissing, + degradedMessage: action.degradedMessage, + }; + case "run_mode_change_started": + return { ...panel, busy: true, lastError: null }; + case "run_mode_change_settled": + return { + ...panel, + busy: false, + lastError: action.error ?? null, + }; + case "run_mode_picker_opened": + return { + ...panel, + picker: { + // Land on the mode currently in force so Enter alone is a no-op. + cursor: Math.max(0, RUN_MODES.indexOf(panel.effective)), + draftMode: panel.effective, + draftCloudShare: panel.cloudShare, + digitBuffer: "", + }, + }; + case "run_mode_picker_closed": + // Esc reverts by construction: the draft is discarded and the + // committed mirror was never touched. + return { ...panel, picker: null }; + case "run_mode_picker_cursor_set": { + if (!panel.picker) return panel; + const cursor = Math.max( + 0, + Math.min(RUN_MODES.length - 1, action.cursor), + ); + return { + ...panel, + picker: { + ...panel.picker, + cursor, + draftMode: RUN_MODES[cursor] ?? panel.picker.draftMode, + digitBuffer: "", + }, + }; + } + case "run_mode_picker_share_set": { + if (!panel.picker) return panel; + return { + ...panel, + picker: { + ...panel.picker, + draftCloudShare: clampCloudShare(action.cloudShare), + digitBuffer: "", + }, + }; + } + case "run_mode_picker_digit_typed": { + if (!panel.picker) return panel; + // Cap at three digits so "100" is reachable but nothing longer is. + const buffer = (panel.picker.digitBuffer + action.digit).slice(-3); + const parsed = Number.parseInt(buffer, 10); + return { + ...panel, + picker: { + ...panel.picker, + digitBuffer: buffer, + draftCloudShare: Number.isNaN(parsed) + ? panel.picker.draftCloudShare + : clampCloudShare(parsed), + }, + }; + } + case "run_mode_picker_digits_cleared": { + if (!panel.picker) return panel; + return { ...panel, picker: { ...panel.picker, digitBuffer: "" } }; + } + } +} diff --git a/src/tui/run-mode/run-mode-selectors.ts b/src/tui/run-mode/run-mode-selectors.ts new file mode 100644 index 00000000..1851ea13 --- /dev/null +++ b/src/tui/run-mode/run-mode-selectors.ts @@ -0,0 +1,48 @@ +import type { RunModeName } from "../../config/llm-run-mode-config.js"; +import { RUN_MODE_LABELS } from "./run-mode-nav.js"; +import type { RunModePanelState } from "./run-mode-panel-state.js"; + +/** + * Label for one pill in the Run-mode strip. Fusion carries its dial so + * the split is visible without opening anything, and an unavailable + * mode is annotated rather than hidden — a mode you cannot reach should + * say why, not silently disappear. + */ +export function runModePillLabel( + mode: RunModeName, + panel: RunModePanelState, +): string { + const base = RUN_MODE_LABELS[mode]; + if (mode === "fusion") { + if (panel.cloudProviderMissing) return `${base} (no cloud provider)`; + return panel.effective === "fusion" + ? `${base} ${panel.cloudShare}%` + : base; + } + if (mode === "cloud" && panel.cloudProviderMissing) { + return `${base} (no cloud provider)`; + } + return base; +} + +/** Model pair shown in the prompt meta row. */ +export function runModeModelSummary(panel: RunModePanelState): string | null { + if (panel.effective === "fusion") { + if (!panel.cloudLabel || !panel.localLabel) return null; + return `${panel.cloudLabel} ⇄ ${panel.localLabel}`; + } + return panel.effective === "cloud" ? panel.cloudLabel : panel.localLabel; +} + +/** Dial rendered as a fixed-width bar so the row never reflows. */ +export function formatCloudShareBar(cloudShare: number, width = 20): string { + const filled = Math.round((cloudShare / 100) * width); + return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`; +} + +/** How the dial reads in prose: what goes where. */ +export function describeCloudShare(cloudShare: number): string { + if (cloudShare <= 0) return "everything local"; + if (cloudShare >= 100) return "everything cloud"; + return `cloud handles steps scoring ≥ ${100 - cloudShare}`; +} diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index e7542555..894ac020 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -171,6 +171,12 @@ export function runSlashCommand( if (result.triggerSessionNew) callbacks.onSessionNewRequested?.(); if (result.triggerMemoryDump) callbacks.onMemoryDumpRequested?.(); if (result.triggerSkillCatalogDump) callbacks.onSkillCatalogRequested?.(); + if (result.runModeSet) { + callbacks.onRunModeChangeRequested?.( + result.runModeSet, + result.runModeCloudShare, + ); + } if (result.persistLlamaUrl) { callbacks.onPersistLlamaUrl?.(result.persistLlamaUrl); } diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..f86ff983 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -10,6 +10,7 @@ import type { McpAction } from "./mcp/mcp-actions.js"; import type { ImportAction } from "./import/import-actions.js"; import type { TelegramAction } from "./telegram/telegram-actions.js"; import type { PrivacyAction } from "./privacy/privacy-actions.js"; +import type { RunModeAction } from "./run-mode/run-mode-actions.js"; import type { ProvidersAction } from "./providers/providers-actions.js"; import type { LlmPanelAction } from "./llm-panel/llm-panel-actions.js"; import type { FallbackPanelAction } from "./llm-panel/fallback/fallback-panel-actions.js"; @@ -193,6 +194,7 @@ export type TuiAction = | McpAction | TelegramAction | PrivacyAction + | RunModeAction | ProvidersAction | LlmPanelAction | FallbackPanelAction diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..a72b8afb 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -30,6 +30,9 @@ import { Sidebar } from "./components/sidebar.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { SlashPalette } from "./components/slash-palette.js"; import { StatusBar } from "./components/status-bar.js"; +import { RunModeBar } from "./components/run-mode-bar.js"; +import { RunModePicker } from "./components/run-mode-picker.js"; +import type { RunModeName } from "../config/index.js"; import { TasksCancelModal } from "./components/tasks-cancel-modal.js"; import { UpdateModal } from "./components/update-modal.js"; import { UpdateIndicator } from "./components/update-indicator.js"; @@ -79,6 +82,8 @@ export interface TuiAppCallbacks { onAbort(): void; onQuit(): void; onMessageSubmitted(message: string): void; + /** Persist a run-mode switch and hot-apply the provider swap. */ + onRunModeChangeRequested?(mode: RunModeName, cloudShare?: number): void; /** Ask the orchestrator to emit the recent-sessions list to the bus. */ onSessionPickerRequested?(): void; /** Ask the orchestrator to swap to an existing persisted session. */ @@ -723,6 +728,11 @@ export function TuiApp({ + {state.uiMode === "chat" ? ( + + + + ) : null} @@ -749,6 +759,11 @@ export function TuiApp({ ) : null} + {state.runModePanel.picker ? ( + + + + ) : null} {state.sessionPickerOpen ? ( { onApprovalLevelSetRequested: (level) => orchestrator.privacy.setApprovalLevel(level), onPrivacyRefreshRequested: () => orchestrator.privacy.refresh(), + onRunModeChangeRequested: (mode, cloudShare) => + void orchestrator.runMode.setMode(mode, cloudShare), onUpdateConfirmed: () => orchestrator.runUpdate(), onUpdateRestart: () => { restartRequested = true; diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index aee4377f..39caf4ca 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -46,6 +46,10 @@ import { createInitialPrivacyPanelState, type PrivacyPanelState, } from "./privacy/privacy-panel-state.js"; +import { + createInitialRunModePanelState, + type RunModePanelState, +} from "./run-mode/run-mode-panel-state.js"; import { createInitialProvidersPanelState, type ProvidersPanelState, @@ -354,6 +358,8 @@ export interface TuiState { importPanel: ImportPanelState; /** State slice driving the Privacy tab (data-egress preferences). */ privacyPanel: PrivacyPanelState; + /** Run-section mode strip (Local / Cloud / Fusion) + its dial overlay. */ + runModePanel: RunModePanelState; /** Cloud / local LLM provider registry (hot-swap active text provider). */ providersPanel: ProvidersPanelState; /** Unified operator LLM panel combining provider routing and local daemon state. */ @@ -506,6 +512,7 @@ export function createInitialTuiState( mcpPanel: createInitialMcpPanelState(), importPanel: createInitialImportPanelState(), privacyPanel: createInitialPrivacyPanelState(), + runModePanel: createInitialRunModePanelState(), providersPanel: createInitialProvidersPanelState(), llmPanel, fallbackPanel: createInitialFallbackPanelState(),