From 8e544c863ab9b522d8caded1328458dbff2544fb Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:05:05 +0300 Subject: [PATCH 01/57] feat(tui): ctrl+n opens a new terminal window running atomic-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running a second agent meant leaving the TUI, opening a terminal by hand, cd'ing back and typing the command. Ctrl+N (and `/window`, alias `/newwindow`) now does it in one keystroke: a new OS terminal window with a fresh `atomic-agent tui` in the same working directory. `/new` keeps its meaning — a fresh session inside this process. The platform logic is split so it is unit-reachable without opening windows: `build-terminal-launch.ts` is a pure resolver (osascript → Terminal/iTerm on macOS; $ATOMIC_AGENT_TERMINAL → $TERMINAL → gnome-terminal/konsole/xfce4-terminal/kitty/alacritty/ wezterm/x-terminal-emulator/xterm on Linux; wt.exe or a `start`ed cmd.exe on Windows), and `open-terminal-window.ts` owns the detached spawn plus the PATH probe. A missing emulator comes back as a value and lands in the chat log as one warn line — never a throw in the render loop. Two details worth keeping: argv[1] is dropped for SEA builds and kept under plain node (same reasoning as the self-update relaunch), and ATOMIC_AGENT_STATE_DIR travels inside the command line, because the spawned terminal starts a login shell that inherits nothing — without it the second window would silently use a different state dir. --- AGENTS.md | 18 ++ README.md | 3 + src/tui/app-key-bindings.test.ts | 94 +++++++++ src/tui/app-key-bindings.ts | 22 ++ src/tui/build-terminal-launch.test.ts | 170 +++++++++++++++ src/tui/build-terminal-launch.ts | 198 ++++++++++++++++++ .../commands/slash-command-handler.test.ts | 12 ++ src/tui/commands/slash-command-handler.ts | 7 + src/tui/commands/slash-commands.ts | 6 + src/tui/open-terminal-window.test.ts | 115 ++++++++++ src/tui/open-terminal-window.ts | 137 ++++++++++++ src/tui/submit-handler.ts | 1 + src/tui/tui-app.tsx | 5 + src/tui/tui-command.ts | 35 ++++ 14 files changed, 823 insertions(+) create mode 100644 src/tui/build-terminal-launch.test.ts create mode 100644 src/tui/build-terminal-launch.ts create mode 100644 src/tui/open-terminal-window.test.ts create mode 100644 src/tui/open-terminal-window.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..2f3c30ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1328,6 +1328,24 @@ Slash commands: `/memory` opens the tab; `/memory dump` keeps the legacy profile 4. **Note detail exposes link neighbours when `memory.links.enabled`.** `g` runs `linkStore.expand`; Enter on a neighbour opens that note by id. 5. **Config gates surface hints, not crashes.** Disabled channels show an empty list + `channelHint` string. +## New terminal window (Ctrl+N) + +**Ctrl+N** in the TUI (and the `/window` slash command, alias `/newwindow`) opens a **new OS terminal window** running a fresh `atomic-agent tui` in the same working directory. It is a second agent in a second process — not a second view of the current session, which the per-session runtime lock would not allow. `/new` remains the in-process "fresh session, warm runtime" reset; the two are deliberately different commands. + +The resolver is split so the platform logic is unit-reachable without opening windows: + +- [src/tui/build-terminal-launch.ts](src/tui/build-terminal-launch.ts) — **pure**. `buildTerminalLaunch({platform, execPath, argv, isSea, cwd, env, hasBinary})` → `{cmd, args, label}` or `null`. macOS drives `osascript` → `Terminal` (or `iTerm` when `TERM_PROGRAM === "iTerm.app"`); Linux probes `$ATOMIC_AGENT_TERMINAL` → `$TERMINAL` → gnome-terminal / konsole / xfce4-terminal / kitty / alacritty / wezterm / x-terminal-emulator / xterm through the injected `hasBinary`; Windows uses `wt.exe -w -1 nt` when present, else `cmd.exe /c start … cmd /k`. +- [src/tui/open-terminal-window.ts](src/tui/open-terminal-window.ts) — the effectful half: `detached: true, stdio: "ignore"` + `unref()` so the new window outlives this process, `spawn` injectable, every failure returned as `{ok: false, reason}` and never thrown into the render loop. Also owns the `isOnPath` PATH probe (no `which` shell-out). + +Two details that are easy to regress: + +1. **`argv[1]` must be dropped for a SEA build** and kept under plain node — same reasoning as the self-update relaunch in [src/tui/tui-command.ts](src/tui/tui-command.ts); `tui` is always appended explicitly. +2. **`ATOMIC_AGENT_STATE_DIR` travels inside the command line.** A spawned terminal starts a login shell and inherits nothing from us, so without the inline assignment the second window would silently attach to a different state dir. + +The POSIX command line ends with `exec "${SHELL:-sh}"` on Linux because `-e` closes the window the instant the agent exits, which would eat a startup error. macOS `do script` already leaves the shell alive, so it does not need this. + +Pinned by [src/tui/build-terminal-launch.test.ts](src/tui/build-terminal-launch.test.ts) (per-platform argv shapes, SEA split, state-dir passthrough, shell + AppleScript escaping, `null` on a headless box), [src/tui/open-terminal-window.test.ts](src/tui/open-terminal-window.test.ts) (detach/unref, error-as-value, PATH probe), [src/tui/app-key-bindings.test.ts](src/tui/app-key-bindings.test.ts) (Ctrl+N fires only outside modals / the slash palette / a pending approval) and [src/tui/commands/slash-command-handler.test.ts](src/tui/commands/slash-command-handler.test.ts) (`/window` vs `/new`). + ## Vision (multimodal input) Image recognition is an opt-in feature wired through the active **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `LlamaServerProvider` for local `/v1/chat/completions`, `OpenAiProvider` / `OpenRouterProvider` for cloud. The text agent loop is unchanged — vision lives outside the conversation transcript, exposed only via the `vision.describe` tool. diff --git a/README.md b/README.md index 1b8ff1ee..285373a5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ The installer downloads the release archive, verifies the checksum, and installs atomic-agent ``` +> [!TIP] +> Need a second agent? Press **Ctrl+N** (or run `/window`) inside the TUI — it opens a new terminal window with a fresh atomic-agent in the same directory. + > [!TIP] > Coming from Hermes or OpenClaw? Run `/import` in the TUI for a one-shot migration: sessions, cron jobs, and optionally your provider keys. diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index 3152f3f3..489ae815 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -596,3 +596,97 @@ describe("handlePanelEscape", () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +describe("handleAppKey — ctrl+n opens a new terminal window", () => { + function ctx( + state: ReturnType, + onNewWindowRequested: () => void, + ) { + return { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + onNewWindowRequested, + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }; + } + + it("fires the callback and claims the key in chat mode", () => { + const onNewWindowRequested = vi.fn(); + const state = createInitialTuiState(stubSession()); + const handled = handleAppKey( + "n", + emptyKey({ ctrl: true }), + ctx(state, onNewWindowRequested), + ); + expect(handled).toBe(true); + expect(onNewWindowRequested).toHaveBeenCalledTimes(1); + }); + + it("stays silent while an approval is pending", () => { + // The approval layer owns every key — y/n/esc must not compete with + // a window spawn. + const onNewWindowRequested = vi.fn(); + const state = { + ...createInitialTuiState(stubSession()), + pendingApproval: pendingRequest(), + }; + handleAppKey("n", emptyKey({ ctrl: true }), ctx(state, onNewWindowRequested)); + expect(onNewWindowRequested).not.toHaveBeenCalled(); + }); + + it("stays silent while the slash palette is open", () => { + const onNewWindowRequested = vi.fn(); + const state = { + ...createInitialTuiState(stubSession()), + slashPaletteOpen: true, + }; + const handled = handleAppKey( + "n", + emptyKey({ ctrl: true }), + ctx(state, onNewWindowRequested), + ); + expect(handled).toBe(false); + expect(onNewWindowRequested).not.toHaveBeenCalled(); + }); + + it("ignores a plain `n` and shift/meta variants", () => { + const onNewWindowRequested = vi.fn(); + const state = createInitialTuiState(stubSession()); + handleAppKey("n", emptyKey(), ctx(state, onNewWindowRequested)); + handleAppKey( + "n", + emptyKey({ ctrl: true, shift: true }), + ctx(state, onNewWindowRequested), + ); + handleAppKey( + "n", + emptyKey({ ctrl: true, meta: true }), + ctx(state, onNewWindowRequested), + ); + expect(onNewWindowRequested).not.toHaveBeenCalled(); + }); + + it("does not throw when no handler is wired", () => { + const state = createInitialTuiState(stubSession()); + const handled = handleAppKey("n", emptyKey({ ctrl: true }), { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + }); + expect(handled).toBe(true); + }); +}); diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..b6ae6584 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -52,6 +52,12 @@ export interface AppKeyCallbacks { * key binding additionally dispatches `quit_requested` so Ink unmounts. */ onUpdateRestart?(): void; + /** + * Optional — Ctrl+N: open a new OS terminal window running a fresh + * `atomic-agent tui` in the same working directory. The handler owns + * the spawn and reports success / failure into the chat log. + */ + onNewWindowRequested?(): void; } export interface AppKeyContext { @@ -214,6 +220,22 @@ export function handleAppKey( mcpTabBusy || providersTabBusy || llmTabBusy; + // Ctrl+N opens a second agent in a new OS terminal window. Guarded + // like Ctrl+B so it cannot fire from inside a modal, the slash + // palette, or a pending approval. The editor never claims Ctrl+N + // (it handles only ctrl+a/e/u/k/w/c), so no keystroke is stolen. + if ( + !debugTabBusy && + !state.slashPaletteOpen && + !state.pendingApproval && + key.ctrl && + !key.shift && + !key.meta && + input === "n" + ) { + callbacks.onNewWindowRequested?.(); + return true; + } // Ctrl+B is the dedicated nav-cycle escape valve: it always advances // one nav slot forward regardless of where focus currently is. This // is the key power users press when they want to reach Observe / diff --git a/src/tui/build-terminal-launch.test.ts b/src/tui/build-terminal-launch.test.ts new file mode 100644 index 00000000..4aca94fa --- /dev/null +++ b/src/tui/build-terminal-launch.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; + +import { + agentArgv, + buildTerminalLaunch, + type TerminalLaunchInput, +} from "./build-terminal-launch.js"; + +function input(overrides: Partial = {}): TerminalLaunchInput { + return { + platform: "darwin", + execPath: "/usr/local/bin/node", + argv: ["/usr/local/bin/node", "/opt/atomic/dist/cli/index.js", "tui"], + isSea: false, + cwd: "/home/val/work", + env: {}, + hasBinary: () => false, + ...overrides, + }; +} + +describe("agentArgv", () => { + it("keeps the script path under plain node", () => { + expect(agentArgv(input())).toEqual([ + "/usr/local/bin/node", + "/opt/atomic/dist/cli/index.js", + "tui", + ]); + }); + + it("drops the script slot for a SEA binary", () => { + // A SEA binary is its own entry point; re-injecting argv[1] makes the + // child read the invoke path as a command name ("unknown command"). + expect( + agentArgv( + input({ + isSea: true, + execPath: "/usr/local/bin/atomic-agent", + argv: ["/usr/local/bin/atomic-agent", "/usr/local/bin/atomic-agent"], + }), + ), + ).toEqual(["/usr/local/bin/atomic-agent", "tui"]); + }); + + it("always asks for the tui explicitly", () => { + // The parent may have been started as `atomic-agent` with no args. + expect(agentArgv(input({ argv: ["/usr/local/bin/node", "/opt/a.js"] }))).toContain( + "tui", + ); + }); +}); + +describe("buildTerminalLaunch — macOS", () => { + it("drives Terminal.app through osascript, cd'ing into the working dir", () => { + const launch = buildTerminalLaunch(input()); + expect(launch).not.toBeNull(); + expect(launch?.cmd).toBe("osascript"); + expect(launch?.label).toBe("Terminal"); + const script = launch?.args[1] ?? ""; + expect(script).toContain('tell application "Terminal" to do script'); + expect(script).toContain("cd '/home/val/work'"); + expect(script).toContain("/opt/atomic/dist/cli/index.js"); + expect(script).toContain("tui"); + expect(launch?.args[3]).toContain("activate"); + }); + + it("uses iTerm when the operator already lives in iTerm", () => { + const launch = buildTerminalLaunch( + input({ env: { TERM_PROGRAM: "iTerm.app" } }), + ); + expect(launch?.label).toBe("iTerm"); + expect(launch?.args[1]).toContain('tell application "iTerm"'); + }); + + it("carries a non-default state dir into the new window", () => { + // The spawned terminal starts a login shell and inherits nothing — + // without this the second window would use a different state dir. + const launch = buildTerminalLaunch( + input({ env: { ATOMIC_AGENT_STATE_DIR: "/tmp/state dir" } }), + ); + expect(launch?.args[1]).toContain( + "ATOMIC_AGENT_STATE_DIR='/tmp/state dir'", + ); + }); + + it("escapes quotes in paths for both the shell and AppleScript layers", () => { + const launch = buildTerminalLaunch(input({ cwd: `/home/o'brien/work` })); + const script = launch?.args[1] ?? ""; + // POSIX single-quote escaping, with its backslash doubled by the + // AppleScript escaper so the shell still sees exactly one. + expect(script).toContain(`cd '/home/o'\\\\''brien/work'`); + // And nothing unescaped can close the AppleScript string literal. + const body = script.slice(script.indexOf("do script ") + "do script ".length); + expect(body.slice(1, -1)).not.toMatch(/(^|[^\\])"/); + }); +}); + +describe("buildTerminalLaunch — Linux", () => { + it("returns null when no emulator is installed", () => { + // Headless box: report it, never throw into the render loop. + expect(buildTerminalLaunch(input({ platform: "linux" }))).toBeNull(); + }); + + it("prefers gnome-terminal's `--` argv shape", () => { + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "gnome-terminal" }), + ); + expect(launch?.cmd).toBe("gnome-terminal"); + expect(launch?.args[0]).toBe("--"); + expect(launch?.args[1]).toBe("sh"); + }); + + it("falls back to xterm when nothing better exists", () => { + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "xterm" }), + ); + expect(launch?.cmd).toBe("xterm"); + expect(launch?.args[0]).toBe("-e"); + }); + + it("honours $ATOMIC_AGENT_TERMINAL over the probe order", () => { + const launch = buildTerminalLaunch( + input({ + platform: "linux", + env: { ATOMIC_AGENT_TERMINAL: "foot", TERMINAL: "xterm" }, + hasBinary: () => true, + }), + ); + expect(launch?.cmd).toBe("foot"); + expect(launch?.args).toEqual(["-e", "sh", "-c", expect.any(String)]); + }); + + it("keeps the window alive after the agent exits", () => { + // `-e` closes the window the moment the command returns, which would + // eat a startup error before anyone could read it. + const launch = buildTerminalLaunch( + input({ platform: "linux", hasBinary: (n) => n === "xterm" }), + ); + expect(launch?.args.at(-1)).toContain('exec "${SHELL:-sh}"'); + }); +}); + +describe("buildTerminalLaunch — Windows", () => { + it("opens a new Windows Terminal window when wt.exe is present", () => { + const launch = buildTerminalLaunch( + input({ + platform: "win32", + hasBinary: (n) => n === "wt.exe", + cwd: "C:\\work", + }), + ); + expect(launch?.cmd).toBe("wt.exe"); + expect(launch?.args.slice(0, 5)).toEqual(["-w", "-1", "nt", "-d", "C:\\work"]); + expect(launch?.args).toContain("tui"); + }); + + it("falls back to a `start`-ed cmd.exe that stays open", () => { + const launch = buildTerminalLaunch( + input({ platform: "win32", cwd: "C:\\work" }), + ); + expect(launch?.cmd).toBe("cmd.exe"); + expect(launch?.args.slice(0, 5)).toEqual([ + "/c", + "start", + "atomic-agent", + "cmd", + "/k", + ]); + }); +}); diff --git a/src/tui/build-terminal-launch.ts b/src/tui/build-terminal-launch.ts new file mode 100644 index 00000000..3c5f126d --- /dev/null +++ b/src/tui/build-terminal-launch.ts @@ -0,0 +1,198 @@ +/** + * Resolves "open a new OS terminal window running atomic-agent" into a + * concrete `{cmd, args}` for the current platform. Pure on purpose: the + * PATH probe and the spawn both arrive as inputs, so every branch is + * unit-reachable without touching the machine. + */ + +export interface TerminalLaunch { + readonly cmd: string; + readonly args: readonly string[]; + /** Human name of the terminal being opened, for the chat confirmation. */ + readonly label: string; +} + +export interface TerminalLaunchInput { + readonly platform: NodeJS.Platform; + /** `process.execPath` of the running agent. */ + readonly execPath: string; + /** `process.argv` of the running agent. */ + readonly argv: readonly string[]; + /** `isSea()` — a SEA build has no script path in argv. */ + readonly isSea: boolean; + /** Working directory the new window should start in. */ + readonly cwd: string; + readonly env: Readonly>; + /** `true` when `name` resolves to an executable on PATH. */ + readonly hasBinary: (name: string) => boolean; +} + +interface LinuxTerminal { + readonly bin: string; + readonly label: string; + /** Wraps a `sh -c`-able command line into this emulator's argv shape. */ + readonly args: (command: string) => readonly string[]; +} + +/** + * Probed in order. `-e` is the near-universal spelling; gnome-terminal + * deprecated it in favour of `--`, and kitty takes the command bare. + */ +const LINUX_TERMINALS: readonly LinuxTerminal[] = [ + { + bin: "gnome-terminal", + label: "gnome-terminal", + args: (command) => ["--", "sh", "-c", command], + }, + { + bin: "konsole", + label: "konsole", + args: (command) => ["-e", "sh", "-c", command], + }, + { + bin: "xfce4-terminal", + label: "xfce4-terminal", + args: (command) => ["-e", `sh -c ${shellQuote(command)}`], + }, + { bin: "kitty", label: "kitty", args: (command) => ["sh", "-c", command] }, + { + bin: "alacritty", + label: "alacritty", + args: (command) => ["-e", "sh", "-c", command], + }, + { + bin: "wezterm", + label: "wezterm", + args: (command) => ["start", "--", "sh", "-c", command], + }, + { + bin: "x-terminal-emulator", + label: "x-terminal-emulator", + args: (command) => ["-e", "sh", "-c", command], + }, + { bin: "xterm", label: "xterm", args: (command) => ["-e", "sh", "-c", command] }, +]; + +/** + * Returns `null` — never throws — when the platform offers nothing we + * know how to drive (a headless Linux box with no emulator installed is + * the realistic case). The caller turns that into one warn line. + */ +export function buildTerminalLaunch( + input: TerminalLaunchInput, +): TerminalLaunch | null { + switch (input.platform) { + case "darwin": + return darwinLaunch(input); + case "win32": + return win32Launch(input); + default: + return posixLaunch(input); + } +} + +/** + * The argv the child needs to re-enter the TUI. Mirrors the SEA + * reasoning in `tui-command.ts`'s self-update relaunch: a SEA binary is + * its own entry point, plain node needs the script path back. `tui` is + * always explicit so the new window lands in the UI regardless of how + * the parent process was invoked. + */ +export function agentArgv(input: TerminalLaunchInput): readonly string[] { + const scriptPath = input.isSea ? undefined : input.argv[1]; + return scriptPath + ? [input.execPath, scriptPath, "tui"] + : [input.execPath, "tui"]; +} + +/** + * A freshly spawned terminal starts a login shell and does **not** + * inherit our environment, so a non-default state dir has to travel + * inside the command line — otherwise the second window silently talks + * to a different `~/.atomic-agent`. + */ +function posixCommandLine(input: TerminalLaunchInput): string { + const stateDir = input.env.ATOMIC_AGENT_STATE_DIR; + const prefix = stateDir + ? `ATOMIC_AGENT_STATE_DIR=${shellQuote(stateDir)} ` + : ""; + const agent = agentArgv(input).map(shellQuote).join(" "); + return `cd ${shellQuote(input.cwd)} && ${prefix}${agent}`; +} + +function darwinLaunch(input: TerminalLaunchInput): TerminalLaunch { + // Terminal.app is always installed; iTerm only when the operator is + // already living in it. Both keep the shell alive after the agent + // exits, so errors stay on screen. + const app = input.env.TERM_PROGRAM === "iTerm.app" ? "iTerm" : "Terminal"; + const script = escapeAppleScript(posixCommandLine(input)); + return { + cmd: "osascript", + args: [ + "-e", + `tell application "${app}" to do script "${script}"`, + "-e", + `tell application "${app}" to activate`, + ], + label: app === "iTerm" ? "iTerm" : "Terminal", + }; +} + +function posixLaunch(input: TerminalLaunchInput): TerminalLaunch | null { + // `-e` closes the window the moment the agent exits, which would eat + // a startup error before anyone could read it; drop into a shell in + // the same directory instead. + const command = `${posixCommandLine(input)}; exec "\${SHELL:-sh}"`; + const preferred = + input.env.ATOMIC_AGENT_TERMINAL ?? input.env.TERMINAL ?? null; + if (preferred && input.hasBinary(preferred)) { + const known = LINUX_TERMINALS.find((t) => t.bin === preferred); + return { + cmd: preferred, + args: known ? known.args(command) : ["-e", "sh", "-c", command], + label: preferred, + }; + } + const found = LINUX_TERMINALS.find((t) => input.hasBinary(t.bin)); + if (!found) return null; + return { cmd: found.bin, args: found.args(command), label: found.label }; +} + +function win32Launch(input: TerminalLaunchInput): TerminalLaunch { + const agent = agentArgv(input); + if (input.hasBinary("wt.exe")) { + // `-w -1` opens a new window rather than a tab in the existing one. + return { + cmd: "wt.exe", + args: ["-w", "-1", "nt", "-d", input.cwd, ...agent], + label: "Windows Terminal", + }; + } + const stateDir = input.env.ATOMIC_AGENT_STATE_DIR; + const prefix = stateDir ? `set "ATOMIC_AGENT_STATE_DIR=${stateDir}" && ` : ""; + const command = `${prefix}${agent.map(cmdQuote).join(" ")}`; + return { + cmd: "cmd.exe", + // `/k` keeps the console open after the agent exits, matching the + // POSIX branches. The empty title argument is required by `start`. + args: ["/c", "start", "atomic-agent", "cmd", "/k", command], + label: "Command Prompt", + }; +} + +/** POSIX single-quote quoting — safe for every byte except NUL. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function cmdQuote(value: string): string { + return /[\s&|<>^]/.test(value) ? `"${value}"` : value; +} + +/** + * AppleScript string literal escaping. Backslash first, then the quote — + * reversing the order would double-escape the backslashes we just added. + */ +function escapeAppleScript(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts index 98563a41..c3ba2483 100644 --- a/src/tui/commands/slash-command-handler.test.ts +++ b/src/tui/commands/slash-command-handler.test.ts @@ -86,6 +86,18 @@ describe("dispatchSlashCommand", () => { expect(result.triggerSessionPicker).toBe(false); }); + it("signals triggerNewWindow for /window and its alias", () => { + // `/new` restarts the session in place; `/window` is the OS-level + // sibling of Ctrl+N — the two must never be confused. + for (const buffer of ["/window", "/newwindow"]) { + const result = dispatchSlashCommand(buffer); + expect(result.triggerNewWindow).toBe(true); + expect(result.triggerSessionNew).toBe(false); + expect(result.forwardAsMessage).toBe(false); + } + expect(dispatchSlashCommand("/new").triggerNewWindow).toBe(false); + }); + it("opens the Memory tab for bare /memory", () => { const result = dispatchSlashCommand("/memory"); expect(result.triggerMemoryDump).toBe(false); diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..5c93e7da 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -28,6 +28,8 @@ export interface SlashDispatchResult { readonly triggerSessionPicker: boolean; /** When true the caller should ask the orchestrator to start a fresh session. */ readonly triggerSessionNew: boolean; + /** When true the caller should open a new OS terminal window (`/window`). */ + readonly triggerNewWindow: boolean; /** When true the caller should ask the orchestrator to dump the user profile. */ readonly triggerMemoryDump: boolean; /** When true the caller should ask the orchestrator to list the skill catalog in chat. */ @@ -107,6 +109,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, @@ -124,6 +127,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, @@ -204,6 +208,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([], { triggerSessionPicker: true }); case "new": return pureActions([], { triggerSessionNew: true }); + case "window": + return pureActions([], { triggerNewWindow: true }); case "tools": return dispatchToolsSub(parsed.args); case "skills": @@ -268,6 +274,7 @@ function pureActions( triggerQuit: false, triggerSessionPicker: false, triggerSessionNew: false, + triggerNewWindow: false, triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..e83bd7b2 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -56,6 +56,12 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ { name: "session", description: "show current session id" }, { name: "sessions", description: "open session picker to switch threads" }, { name: "new", description: "start a fresh session (keeps warm runtime)" }, + { + name: "window", + description: + "open a new terminal window running atomic-agent (ctrl+n)", + aliases: ["newwindow"], + }, { name: "skills", description: diff --git a/src/tui/open-terminal-window.test.ts b/src/tui/open-terminal-window.test.ts new file mode 100644 index 00000000..8f71dde6 --- /dev/null +++ b/src/tui/open-terminal-window.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + openAgentTerminalWindow, + openTerminalWindow, + isOnPath, + type SpawnedTerminal, + type TerminalSpawn, +} from "./open-terminal-window.js"; +import type { TerminalLaunchInput } from "./build-terminal-launch.js"; + +/** Child stub that replays one lifecycle event on `once`. */ +function fakeChild(event: "spawn" | "error", error?: Error) { + const unref = vi.fn(); + const child: SpawnedTerminal = { + once(name: string, listener: (...args: never[]) => void) { + if (name === event) { + // Deliver asynchronously, like the real emitter. + queueMicrotask(() => + (listener as unknown as (arg?: Error) => void)(error), + ); + } + return child; + }, + unref, + }; + return { child, unref }; +} + +const LAUNCH = { cmd: "osascript", args: ["-e", "…"], label: "Terminal" }; + +describe("openTerminalWindow", () => { + it("spawns detached, unrefs, and reports the terminal name", async () => { + const { child, unref } = fakeChild("spawn"); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { cwd: "/w", spawn }); + expect(result).toEqual({ ok: true, label: "Terminal" }); + expect(spawn).toHaveBeenCalledWith("osascript", ["-e", "…"], { + detached: true, + stdio: "ignore", + cwd: "/w", + }); + // Detached + unref'd: quitting this agent must not kill the new window. + expect(unref).toHaveBeenCalledTimes(1); + }); + + it("returns the spawn error instead of throwing", async () => { + const { child } = fakeChild("error", new Error("spawn osascript ENOENT")); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn }); + expect(result).toEqual({ + ok: false, + reason: "osascript: spawn osascript ENOENT", + }); + }); + + it("survives a synchronous throw from spawn", async () => { + const spawn = vi.fn(() => { + throw new Error("EACCES"); + }) as unknown as TerminalSpawn; + const result = await openTerminalWindow(LAUNCH, { spawn }); + expect(result).toEqual({ ok: false, reason: "osascript: EACCES" }); + }); +}); + +describe("openAgentTerminalWindow", () => { + const base: TerminalLaunchInput = { + platform: "linux", + execPath: "/usr/bin/node", + argv: ["/usr/bin/node", "/opt/a.js"], + isSea: false, + cwd: "/w", + env: {}, + hasBinary: () => false, + }; + + it("explains itself when the box has no terminal emulator", async () => { + const spawn = vi.fn() as unknown as TerminalSpawn; + const result = await openAgentTerminalWindow(base, { spawn }); + expect(result.ok).toBe(false); + expect(result.ok === false && result.reason).toContain( + "ATOMIC_AGENT_TERMINAL", + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("spawns the resolved emulator in the working directory", async () => { + const { child } = fakeChild("spawn"); + const spawn = vi.fn(() => child) as unknown as TerminalSpawn; + const result = await openAgentTerminalWindow( + { ...base, hasBinary: (n) => n === "xterm" }, + { spawn }, + ); + expect(result).toEqual({ ok: true, label: "xterm" }); + expect(spawn).toHaveBeenCalledWith( + "xterm", + expect.arrayContaining(["-e", "sh", "-c"]), + expect.objectContaining({ cwd: "/w", detached: true }), + ); + }); +}); + +describe("isOnPath", () => { + it("finds a binary that exists on PATH", () => { + expect(isOnPath("sh", { PATH: "/nope:/bin:/usr/bin" })).toBe(true); + }); + + it("misses one that does not", () => { + expect(isOnPath("definitely-not-a-real-binary", { PATH: "/bin" })).toBe(false); + }); + + it("treats an empty PATH as a miss rather than an error", () => { + expect(isOnPath("sh", {})).toBe(false); + }); +}); diff --git a/src/tui/open-terminal-window.ts b/src/tui/open-terminal-window.ts new file mode 100644 index 00000000..629159c4 --- /dev/null +++ b/src/tui/open-terminal-window.ts @@ -0,0 +1,137 @@ +import { spawn } from "node:child_process"; +import { statSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { + buildTerminalLaunch, + type TerminalLaunch, + type TerminalLaunchInput, +} from "./build-terminal-launch.js"; + +/** + * Opens a detached OS terminal window running a fresh `atomic-agent tui` + * (Ctrl+N / `/window`). The spawn is injectable so the unit tests never + * pop a window, and every failure comes back as a value — a broken + * emulator must not take the render loop down with it. + */ + +export type OpenTerminalWindowResult = + | { readonly ok: true; readonly label: string } + | { readonly ok: false; readonly reason: string }; + +/** Structural slice of `ChildProcess` this module actually uses. */ +export interface SpawnedTerminal { + once(event: string, listener: (...args: never[]) => void): unknown; + unref(): void; +} + +export type TerminalSpawn = ( + cmd: string, + args: readonly string[], + options: { detached: boolean; stdio: "ignore"; cwd?: string }, +) => SpawnedTerminal; + +export interface OpenTerminalWindowOptions { + readonly cwd?: string; + readonly spawn?: TerminalSpawn; +} + +export async function openTerminalWindow( + launch: TerminalLaunch, + options: OpenTerminalWindowOptions = {}, +): Promise { + const spawnFn = options.spawn ?? (spawn as unknown as TerminalSpawn); + let child: SpawnedTerminal; + try { + child = spawnFn(launch.cmd, launch.args, { + detached: true, + stdio: "ignore", + ...(options.cwd ? { cwd: options.cwd } : {}), + }); + } catch (err) { + return { ok: false, reason: `${launch.cmd}: ${errorMessage(err)}` }; + } + return await new Promise((resolve) => { + let settled = false; + const settle = (result: OpenTerminalWindowResult): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once("error", ((err: unknown) => { + settle({ ok: false, reason: `${launch.cmd}: ${errorMessage(err)}` }); + }) as (...args: never[]) => void); + child.once("spawn", (() => { + // Detached + unref'd: the new window outlives this process, so + // quitting the parent agent does not kill the one we just opened. + try { + child.unref(); + } catch { + // A fake/limited child without unref is not a failure. + } + settle({ ok: true, label: launch.label }); + }) as (...args: never[]) => void); + }); +} + +/** Build + open in one call. Returns the "nothing to open" reason as a value. */ +export async function openAgentTerminalWindow( + input: TerminalLaunchInput, + options: OpenTerminalWindowOptions = {}, +): Promise { + const launch = buildTerminalLaunch(input); + if (launch === null) { + return { + ok: false, + reason: + "no terminal emulator found — set $ATOMIC_AGENT_TERMINAL to the one you use", + }; + } + return await openTerminalWindow(launch, { cwd: input.cwd, ...options }); +} + +/** + * Snapshot of the running process in the shape `buildTerminalLaunch` + * wants. `isSeaBuild` is passed in rather than read from `node:sea` + * here: that module is unresolvable under vitest's bundler, and this + * file must stay unit-testable. + */ +export function currentTerminalLaunchInput( + cwd: string, + isSeaBuild: boolean, +): TerminalLaunchInput { + return { + platform: process.platform, + execPath: process.execPath, + argv: process.argv, + isSea: isSeaBuild, + cwd, + env: process.env, + hasBinary: isOnPath, + }; +} + +/** + * PATH probe without shelling out to `which` (which does not exist on + * Windows and would cost a process per candidate emulator). An absolute + * or relative path is checked as given. + */ +export function isOnPath( + name: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (name.includes("/") || name.includes("\\")) return isExecutableFile(name); + const entries = (env.PATH ?? "").split(delimiter).filter((p) => p.length > 0); + return entries.some((dir) => isExecutableFile(join(dir, name))); +} + +function isExecutableFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index e7542555..752fb173 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -169,6 +169,7 @@ export function runSlashCommand( } if (result.triggerSessionPicker) callbacks.onSessionPickerRequested?.(); if (result.triggerSessionNew) callbacks.onSessionNewRequested?.(); + if (result.triggerNewWindow) callbacks.onNewWindowRequested?.(); if (result.triggerMemoryDump) callbacks.onMemoryDumpRequested?.(); if (result.triggerSkillCatalogDump) callbacks.onSkillCatalogRequested?.(); if (result.persistLlamaUrl) { diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..43a60506 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -331,6 +331,11 @@ export interface TuiAppCallbacks { onUpdateConfirmed?(): void; /** Self-update settled: user pressed a key to re-exec the new binary. */ onUpdateRestart?(): void; + /** + * Ctrl+N / `/window`: open a new OS terminal window running a fresh + * `atomic-agent tui` in the same working directory. + */ + onNewWindowRequested?(): void; } export interface TuiAppProps { diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 132a37c2..69f90be0 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -25,6 +25,10 @@ import { isManagedModeReadyOnDisk, runLocalModelsStartupGateIfNeeded, } from "./run-local-models-config-wizard.js"; +import { + currentTerminalLaunchInput, + openAgentTerminalWindow, +} from "./open-terminal-window.js"; import { makeTuiEventBus, TuiApp } from "./tui-app.js"; import { detectTerminalBackground, @@ -213,6 +217,7 @@ export async function tuiCommand(args: string[]): Promise { onSessionPickerRequested: () => orchestrator.openSessionPicker(), onSessionSwitchRequested: (id) => orchestrator.switchSession(id), onSessionNewRequested: () => orchestrator.newSession(), + onNewWindowRequested: () => openNewAgentWindow(parsed.workingDir, bus), onMemoryDumpRequested: () => orchestrator.dumpProfile(), onSkillCatalogRequested: () => orchestrator.dumpSkillCatalog(), onPersistLlamaUrl: (nextUrl) => persistLlamaUrl(nextUrl, bus, orchestrator), @@ -469,6 +474,36 @@ export async function tuiCommand(args: string[]): Promise { return orchestrator.exitCode; } +/** + * Ctrl+N / `/window`: launch a second agent in a new OS terminal window. + * Fire-and-forget — the result is reported into the chat log either way, + * because a silently ignored keystroke is the worst possible outcome + * here (the operator cannot tell "not implemented" from "nothing + * happened"). + */ +function openNewAgentWindow( + workingDir: string, + bus: ReturnType, +): void { + void (async () => { + const result = await openAgentTerminalWindow( + currentTerminalLaunchInput(workingDir, isSea()), + ); + if (result.ok) { + bus.emit({ + type: "system_message", + text: `opened a new atomic-agent window (${result.label})`, + }); + return; + } + bus.emit({ + type: "system_message", + variant: "warn", + text: `could not open a new terminal window: ${result.reason}`, + }); + })(); +} + function persistThemeChoice( themeName: string, bus: ReturnType, From 1fe88d3ddcb3dc406efa97af913f5da7c920a377 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:14:37 +0300 Subject: [PATCH 02/57] fix(tui): keep the editor live during a turn and queue what you type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Enter used to make the input dead until the turn finished: `canAcceptMessage` (status === "idle") gated both the submit pipeline and the `disabled` prop on `PromptShell`, so `MultiLineEditor` dropped every keystroke. You could not draft the next message, let alone send one. The queue this needs has existed all along and was unreachable: `ChatOrchestrator.sendMessage` buffers into `this.queue` whenever a turn is in flight and `runOneTurn` drains it FIFO. Nothing ever got that far because the UI rejected the submit first. - Split the selector: `canAcceptMessage` still means "a new turn may start now"; the new `canTypeMessage` (status !== "quitting") gates the editor. Typing is allowed for the whole run. - A submit made mid-run dispatches the new `message_queued` action, not `message_submitted`. That distinction is load-bearing — `message_submitted` calls `startNewRun`, which wipes `feed`, `reasoning` and `streamingToolCards` and would blank the screen of the turn the operator is reading. - The orchestrator re-publishes its queue on every push, drain, clear, session switch and quit via `queue_changed`, so the UI mirrors what will actually run instead of tracking an optimistic copy. - Parked messages render as a dim strip above the prompt (`QueuedMessages`), the hint strip advertises what Enter does mid-run plus how many messages are parked, and `/queue` lists them while `/queue clear` drops them. Runtime, agent loop and prompt are untouched — this is a TUI-layer fix. Co-Authored-By: Claude Opus 5 --- src/tui/chat-loop-reducer.test.ts | 40 +++++- src/tui/chat-orchestrator.test.ts | 128 ++++++++++++++++++++ src/tui/chat-orchestrator.ts | 26 ++++ src/tui/commands/slash-command-handler.ts | 26 ++++ src/tui/commands/slash-commands.ts | 5 + src/tui/components/hotkey-hint.test.tsx | 19 +++ src/tui/components/hotkey-hint.tsx | 11 +- src/tui/components/queued-messages.test.tsx | 36 ++++++ src/tui/components/queued-messages.tsx | 61 ++++++++++ src/tui/index.ts | 1 + src/tui/reduce-ui-actions.test.ts | 56 +++++++++ src/tui/reduce-ui-actions.ts | 13 ++ src/tui/submit-handler.test.ts | 115 ++++++++++++++++++ src/tui/submit-handler.ts | 56 ++++++++- src/tui/tui-action.ts | 11 ++ src/tui/tui-app.tsx | 7 +- src/tui/tui-command.ts | 1 + src/tui/tui-state.ts | 29 ++++- 18 files changed, 632 insertions(+), 9 deletions(-) create mode 100644 src/tui/chat-orchestrator.test.ts create mode 100644 src/tui/components/queued-messages.test.tsx create mode 100644 src/tui/components/queued-messages.tsx diff --git a/src/tui/chat-loop-reducer.test.ts b/src/tui/chat-loop-reducer.test.ts index a645c57c..3ebbe33f 100644 --- a/src/tui/chat-loop-reducer.test.ts +++ b/src/tui/chat-loop-reducer.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { reduceTuiState } from "./agent-event-reducer.js"; import { apply, fakeSession } from "./test-fixtures.js"; import type { TuiAction } from "./tui-action.js"; -import { canAcceptMessage, createInitialTuiState } from "./tui-state.js"; +import { + canAcceptMessage, + canTypeMessage, + createInitialTuiState, +} from "./tui-state.js"; describe("chat loop", () => { it("should update inputValue on input_changed", () => { @@ -303,3 +307,37 @@ describe("chat loop", () => { expect(next.runHistory[0]?.durationMs).toBeGreaterThan(0); }); }); + +describe("queued submissions", () => { + it("may be typed while a turn is running", () => { + const running = reduceTuiState(createInitialTuiState(fakeSession()), { + type: "message_submitted", + }); + expect(canAcceptMessage(running)).toBe(false); + expect(canTypeMessage(running)).toBe(true); + }); + + it("does not wipe the live turn's feed the way message_submitted does", () => { + // This is the regression the separate action exists for: reusing + // `message_submitted` for a mid-run send called startNewRun and + // blanked the screen the operator was reading. + const initial = createInitialTuiState(fakeSession()); + const running = apply(initial, [ + { type: "message_submitted" }, + { type: "agent_event", event: { type: "step_started", stepIndex: 0 } }, + { type: "assistant_delta", text: "partial answer" }, + ]); + expect(running.feed.length).toBeGreaterThan(0); + + const afterQueue = reduceTuiState(running, { + type: "message_queued", + text: "one more thing", + } as TuiAction); + + expect(afterQueue.feed).toEqual(running.feed); + expect(afterQueue.streamingAssistantText).toBe("partial answer"); + expect(afterQueue.status).toBe("running"); + expect(afterQueue.queuedMessages).toEqual(["one more thing"]); + expect(afterQueue.inputValue).toBe(""); + }); +}); diff --git a/src/tui/chat-orchestrator.test.ts b/src/tui/chat-orchestrator.test.ts new file mode 100644 index 00000000..b0de4028 --- /dev/null +++ b/src/tui/chat-orchestrator.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createEmptySessionState } from "../session/session-state.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { ChatOrchestrator } from "./chat-orchestrator.js"; +import { makeTuiEventBus } from "./make-event-bus.js"; +import type { TuiAction } from "./tui-action.js"; + +interface Deferred { + promise: Promise<{ session: ReturnType; reason: string; stepCount: number }>; + resolve: () => void; +} + +function session(id = "s1") { + return createEmptySessionState({ id, workingDir: "/tmp" }); +} + +function deferred(id: string): Deferred { + let resolve!: () => void; + const promise = new Promise<{ + session: ReturnType; + reason: string; + stepCount: number; + }>((res) => { + resolve = () => res({ session: session(id), reason: "reply", stepCount: 1 }); + }); + return { promise, resolve }; +} + +/** + * Minimal `AgentRuntime` stand-in. Every sub-orchestrator the + * `ChatOrchestrator` constructor builds only stores references and + * subscribes to the bus, so nothing here needs to do I/O. + */ +function stubRuntime(runTurn: (text: string) => Promise): AgentRuntime { + return { + createSession: () => session(), + runTurn: (_s: unknown, text: string) => runTurn(text), + sessionStore: { listRecent: () => [], load: () => null }, + approvals: { clearSessionGrants: () => undefined }, + config: { update: { checkOnStartup: false, repo: "x/y" }, tracing: { trace: { dir: "/tmp", enabled: false } } }, + profileStore: { list: () => [] }, + skillCatalog: [], + } as unknown as AgentRuntime; +} + +describe("ChatOrchestrator message queue", () => { + it("runs the first message and parks the second until the first settles", async () => { + const first = deferred("s1"); + const second = deferred("s1"); + const seen: string[] = []; + const runTurn = vi.fn((text: string) => { + seen.push(text); + return (seen.length === 1 ? first : second).promise; + }); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + }); + + orchestrator.sendMessage("first"); + orchestrator.sendMessage("second"); + expect(seen).toEqual(["first"]); + expect(queueSnapshots(actions).at(-1)).toEqual(["second"]); + + first.resolve(); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(seen).toEqual(["first", "second"]); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + second.resolve(); + await second.promise; + }); + + it("clearQueue drops parked messages without touching the running turn", async () => { + const first = deferred("s1"); + const runTurn = vi.fn(() => first.promise); + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator(stubRuntime(runTurn), bus, { + maxSteps: 5, + llamaUrl: "http://127.0.0.1:8080", + }); + + orchestrator.sendMessage("running"); + orchestrator.sendMessage("parked-a"); + orchestrator.sendMessage("parked-b"); + expect(queueSnapshots(actions).at(-1)).toEqual(["parked-a", "parked-b"]); + + orchestrator.clearQueue(); + expect(queueSnapshots(actions).at(-1)).toEqual([]); + expect(runTurn).toHaveBeenCalledTimes(1); + + first.resolve(); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + // Nothing left to drain — the cleared queue really is empty. + expect(runTurn).toHaveBeenCalledTimes(1); + }); + + it("clearQueue on an empty queue does not spam the bus", () => { + const bus = makeTuiEventBus(); + const actions: TuiAction[] = []; + bus.subscribe((a) => actions.push(a)); + const orchestrator = new ChatOrchestrator( + stubRuntime(() => new Promise(() => undefined)), + bus, + { maxSteps: 5, llamaUrl: "http://127.0.0.1:8080" }, + ); + orchestrator.clearQueue(); + expect(queueSnapshots(actions)).toHaveLength(0); + }); +}); + +function queueSnapshots(actions: readonly TuiAction[]): readonly string[][] { + return actions + .filter((a): a is Extract => + a.type === "queue_changed", + ) + .map((a) => [...a.queued]); +} diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 959af6fb..69216a5b 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -259,6 +259,7 @@ export class ChatOrchestrator { } this.session = loaded; this.queue.length = 0; + this.emitQueue(); // Session grants are point exceptions scoped to the session that // granted them; a switch must not carry them into the next one. this.runtime.approvals.clearSessionGrants(); @@ -349,6 +350,7 @@ export class ChatOrchestrator { } this.session = this.runtime.createSession(); this.queue.length = 0; + this.emitQueue(); // A fresh session starts with no point exceptions: grants never // outlive the session that created them. this.runtime.approvals.clearSessionGrants(); @@ -371,11 +373,33 @@ export class ChatOrchestrator { this.ensureSession(); if (this.currentController) { this.queue.push(text); + this.emitQueue(); return; } void this.runOneTurn(text); } + /** + * Drop every parked message without touching the running turn + * (`/queue clear`). No-op on an empty queue so the TUI is not spammed + * with redundant `queue_changed` frames. + */ + clearQueue(): void { + if (this.queue.length === 0) return; + this.queue.length = 0; + this.emitQueue(); + } + + /** + * Re-publish the pending-message queue to the TUI. The orchestrator is + * the source of truth — the reducer mirrors this list rather than + * tracking pushes and drains on its own, so an optimistic UI insert can + * never drift from what will actually run. + */ + private emitQueue(): void { + this.bus.emit({ type: "queue_changed", queued: [...this.queue] }); + } + private async runOneTurn(text: string): Promise { if (!this.session) return; const controller = new AbortController(); @@ -401,6 +425,7 @@ export class ChatOrchestrator { if (this.currentController === controller) this.currentController = null; } const next = this.queue.shift(); + if (next !== undefined) this.emitQueue(); if (next !== undefined && !this.quitting) { void this.runOneTurn(next); } @@ -479,6 +504,7 @@ export class ChatOrchestrator { if (this.quitting) return; this.quitting = true; this.queue.length = 0; + this.emitQueue(); this.currentController?.abort(); } diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..5476c644 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -82,6 +82,13 @@ export interface SlashDispatchResult { * the privacy orchestrator. */ readonly analyticsVerb?: "enable" | "disable" | "status"; + /** + * `/queue` side-effect. `list` renders the parked messages into chat — + * the listing needs `TuiState`, which this pure dispatcher does not + * have, so the caller formats it. `clear` additionally asks the + * orchestrator to drop its own copy of the queue. + */ + readonly queueVerb?: "list" | "clear"; /** * `/privacy level <1..5>` side-effect (with `/privacy approve on|off` * kept as aliases for 5 and 1): move the approval ladder to an @@ -148,6 +155,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([{ type: "chat_cleared" }], { systemMessage: "chat cleared", }); + case "queue": + return dispatchQueueSub(parsed.args); case "abort": return pureActions([{ type: "abort_requested" }], { triggerAbort: true, @@ -255,6 +264,22 @@ function formatSlashCommandHelp(): string { return ["slash commands:", ...lines].join("\n"); } +/** + * `/queue` — bare lists what is parked, `clear` (alias `drop`) empties + * it. The reducer action is dispatched optimistically so the strip above + * the prompt disappears immediately; `ChatOrchestrator.clearQueue` then + * re-publishes the authoritative empty queue. + */ +function dispatchQueueSub(args: string): SlashDispatchResult { + const verb = args.trim().toLowerCase(); + if (verb === "clear" || verb === "drop") { + return pureActions([{ type: "queue_changed", queued: [] }], { + queueVerb: "clear", + }); + } + return pureActions([], { queueVerb: "list" }); +} + function pureActions( actions: readonly TuiAction[], overrides: Partial< @@ -283,6 +308,7 @@ function pureActions( setThemeName: undefined, telegramVerb: undefined, analyticsVerb: undefined, + queueVerb: undefined, approvalLevelSet: undefined, ...overrides, }; diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..79013021 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -34,6 +34,11 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ }, { name: "clear", description: "clear chat transcript (keeps session)" }, { name: "abort", description: "abort the running turn" }, + { + name: "queue", + description: + "messages parked behind the running turn: `/queue` (list) | `/queue clear`", + }, { 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"] }, diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 8611ee66..50386173 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -116,3 +116,22 @@ describe("HotkeyHint scroll key spelling per platform", () => { expect(out).not.toContain(MAC_SCROLL_KEY); }); }); + +describe("HotkeyHint queue affordances", () => { + it("advertises what Enter does now that the editor stays live mid-run", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).toContain("queue message"); + }); + + it("shows how many messages are parked behind the turn", () => { + const out = renderHint( + chatState({ status: "running", queuedMessages: ["a", "b"] }), + ); + expect(out).toContain("2 parked"); + }); + + it("hides the parked chip when the queue is empty", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).not.toContain("parked"); + }); +}); diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..da763cf3 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -67,15 +67,22 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { } if (state.status === "running") { // A long streaming answer is exactly when the operator wants to - // scroll back, so the hint rides along with abort. - return [ + // scroll back, so the hint rides along with abort. The editor stays + // live during a run, so advertise what Enter does now — and how many + // messages are already parked behind the turn. + const chips: HotkeyChip[] = [ { key: SCROLL_KEY, label: "scroll" }, + { key: "\u23ce", label: "queue message" }, { key: "esc", label: "abort" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "abort", }, ]; + if (state.queuedMessages.length > 0) { + chips.push({ key: "/queue", label: `${state.queuedMessages.length} parked` }); + } + return chips; } if (state.uiMode === "debug") { // Ctrl+B still cycles panels but is unadvertised: it duplicated the diff --git a/src/tui/components/queued-messages.test.tsx b/src/tui/components/queued-messages.test.tsx new file mode 100644 index 00000000..c2ab959a --- /dev/null +++ b/src/tui/components/queued-messages.test.tsx @@ -0,0 +1,36 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { previewOf, QueuedMessages } from "./queued-messages.js"; + +describe("QueuedMessages", () => { + it("renders nothing when the queue is empty", () => { + const { lastFrame } = render(); + expect(lastFrame()?.trim()).toBe(""); + }); + + it("lists parked messages one per row", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("run the tests"); + expect(frame).toContain("then deploy"); + }); + + it("collapses everything past the third row into a counter", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("and 2 more queued"); + expect(frame).not.toContain("queued: d"); + }); + + it("flattens newlines so a multi-line message stays one row", () => { + expect(previewOf("first\nsecond", 40)).toBe("first second"); + }); + + it("elides a preview past the width budget", () => { + expect(previewOf("x".repeat(50), 10)).toBe(`${"x".repeat(9)}…`); + }); +}); diff --git a/src/tui/components/queued-messages.tsx b/src/tui/components/queued-messages.tsx new file mode 100644 index 00000000..6e4017b8 --- /dev/null +++ b/src/tui/components/queued-messages.tsx @@ -0,0 +1,61 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { theme } from "../theme/theme.js"; + +interface QueuedMessagesProps { + /** Messages the operator submitted while a turn was still running. */ + queued: readonly string[]; + /** Terminal width available to the strip; used to elide long previews. */ + width?: number; +} + +/** How many rows we render before collapsing the rest into a counter. */ +const MAX_VISIBLE_ROWS = 3; +/** Fallback preview width when the caller does not know the terminal size. */ +const DEFAULT_PREVIEW_WIDTH = 60; + +/** + * Dim strip rendered directly above the prompt listing messages that are + * parked behind the running turn. It exists because the queue used to be + * invisible: `ChatOrchestrator` has always buffered submissions made while + * a turn was in flight, but nothing on screen told the operator that their + * message had been accepted rather than swallowed. + * + * Renders nothing when the queue is empty so the prompt does not jump by a + * row on every turn boundary. + */ +export function QueuedMessages({ + queued, + width, +}: QueuedMessagesProps): ReactElement | null { + if (queued.length === 0) return null; + const previewWidth = Math.max(20, (width ?? DEFAULT_PREVIEW_WIDTH) - 8); + const visible = queued.slice(0, MAX_VISIBLE_ROWS); + const hidden = queued.length - visible.length; + return ( + + {visible.map((text, idx) => ( + + {" "} + {theme.glyphs.dotSeparator} queued: {previewOf(text, previewWidth)} + + ))} + {hidden > 0 ? ( + + {" "} + {theme.glyphs.dotSeparator} …and {hidden} more queued + + ) : null} + + ); +} + +/** + * Single-line preview: newlines become spaces (the strip is one row per + * message) and anything past `max` is elided. + */ +export function previewOf(text: string, max: number): string { + const flat = text.replace(/\s+/g, " ").trim(); + if (flat.length <= max) return flat; + return `${flat.slice(0, Math.max(1, max - 1))}…`; +} diff --git a/src/tui/index.ts b/src/tui/index.ts index 229903db..ae58c97e 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -5,6 +5,7 @@ export { reduceTuiState } from "./agent-event-reducer.js"; export type { TuiAction } from "./tui-action.js"; export { canAcceptMessage, + canTypeMessage, createInitialTuiState, DEFAULT_RING_BUFFER_SIZE, } from "./tui-state.js"; diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index e02f93a9..099ad2fa 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -85,3 +85,59 @@ describe("reduceUiAction theme picker", () => { expect(closed?.themePickerOriginal).toBe(""); }); }); + +describe("reduceUiAction message_queued", () => { + it("parks the message and clears the editor", () => { + const state = createInitialTuiState(SESSION); + const next = reduceUiAction( + { ...state, inputValue: "draft" }, + { type: "message_queued", text: "draft" }, + ); + expect(next?.queuedMessages).toEqual(["draft"]); + expect(next?.inputValue).toBe(""); + }); + + it("appends in submission order", () => { + const state = createInitialTuiState(SESSION); + const first = reduceUiAction(state, { type: "message_queued", text: "a" }); + const second = reduceUiAction(first!, { type: "message_queued", text: "b" }); + expect(second?.queuedMessages).toEqual(["a", "b"]); + }); + + it("leaves the running turn's state alone", () => { + // The whole point of a separate action: `message_submitted` calls + // startNewRun, which would blank the feed of the turn in flight. + const state = { + ...createInitialTuiState(SESSION), + status: "running" as const, + currentStep: 3, + runStartedAt: 1234, + }; + const next = reduceUiAction(state, { type: "message_queued", text: "x" }); + expect(next?.status).toBe("running"); + expect(next?.currentStep).toBe(3); + expect(next?.runStartedAt).toBe(1234); + }); + + it("mirrors the orchestrator queue on queue_changed", () => { + const state = createInitialTuiState(SESSION); + const seeded = reduceUiAction(state, { type: "message_queued", text: "a" }); + const next = reduceUiAction(seeded!, { + type: "queue_changed", + queued: [], + }); + expect(next?.queuedMessages).toEqual([]); + }); + + it("drops the queue when the session is switched", () => { + const state = createInitialTuiState(SESSION); + const seeded = reduceUiAction(state, { type: "message_queued", text: "a" }); + const next = reduceUiAction(seeded!, { + type: "session_switched", + sessionId: "s2", + workingDir: "/tmp", + messages: [], + }); + expect(next?.queuedMessages).toEqual([]); + }); +}); diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..2c2e60d0 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -94,6 +94,18 @@ export function reduceUiAction( return navigateInputHistory(state, action.delta); case "input_history_reset": return { ...state, inputHistoryCursor: null }; + case "message_queued": + return { + ...state, + queuedMessages: [...state.queuedMessages, action.text], + inputValue: "", + inputHistoryCursor: null, + slashPaletteOpen: false, + slashQuery: "", + slashPaletteCursor: 0, + }; + case "queue_changed": + return { ...state, queuedMessages: [...action.queued] }; case "chat_cleared": return { ...state, @@ -210,6 +222,7 @@ export function reduceUiAction( sidebarSection: "sessions", sidebarCursor: 0, sidebarTasksCursor: 0, + queuedMessages: [], }; default: return null; diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 86a178e6..964e3e38 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -128,3 +128,118 @@ describe("handleEditorSubmit", () => { expect(onApprovalLevelSetRequested).toHaveBeenCalledTimes(3); }); }); + +describe("handleEditorSubmit while a turn is running", () => { + function runningState(): TuiState { + return { ...createInitialTuiState(fakeSession()), status: "running" }; + } + + it("queues the message instead of dropping it", () => { + const dispatched: Array<{ type: string; text?: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "and also check the logs", + runningState(), + ((a: { type: string; text?: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("and also check the logs"); + expect(dispatched).toContainEqual({ + type: "message_queued", + text: "and also check the logs", + }); + }); + + it("never dispatches message_submitted mid-run (it would wipe the live turn)", () => { + const dispatched: Array<{ type: string }> = []; + handleEditorSubmit( + "second thought", + runningState(), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks(), + ); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(false); + }); + + it("drops the submit entirely once the app is quitting", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "too late", + { ...createInitialTuiState(fakeSession()), status: "quitting" }, + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).not.toHaveBeenCalled(); + expect(dispatched).toHaveLength(0); + }); + + it("still starts a turn immediately when idle", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "go", + createInitialTuiState(fakeSession()), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(true); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(false); + expect(onMessageSubmitted).toHaveBeenCalledWith("go"); + }); +}); + +describe("/queue", () => { + it("lists the parked messages in the transcript", () => { + const state: TuiState = { + ...createInitialTuiState(fakeSession()), + status: "running", + queuedMessages: ["first", "second"], + }; + const messages: string[] = []; + handleEditorSubmit( + "/queue", + state, + ((a: { type: string; text?: string }) => { + if (a.type === "system_message" && a.text) messages.push(a.text); + }) as never, + stubCallbacks(), + ); + const joined = messages.join("\n"); + expect(joined).toContain("queue (2 messages)"); + expect(joined).toContain("1. first"); + expect(joined).toContain("2. second"); + }); + + it("clear empties the reducer slice and tells the orchestrator", () => { + const state: TuiState = { + ...createInitialTuiState(fakeSession()), + status: "running", + queuedMessages: ["first"], + }; + const dispatched: Array<{ type: string; queued?: readonly string[] }> = []; + const onQueueClearRequested = vi.fn(); + handleEditorSubmit( + "/queue clear", + state, + ((a: { type: string; queued?: readonly string[] }) => + dispatched.push(a)) as never, + stubCallbacks({ onQueueClearRequested }), + ); + expect(onQueueClearRequested).toHaveBeenCalledTimes(1); + expect(dispatched).toContainEqual({ type: "queue_changed", queued: [] }); + }); + + it("says so when there is nothing parked", () => { + const messages: string[] = []; + handleEditorSubmit( + "/queue", + createInitialTuiState(fakeSession()), + ((a: { type: string; text?: string }) => { + if (a.type === "system_message" && a.text) messages.push(a.text); + }) as never, + stubCallbacks(), + ); + expect(messages.join("\n")).toContain("queue: (empty)"); + }); +}); diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index e7542555..b77aecba 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -11,7 +11,11 @@ import type { TuiAction } from "./tui-action.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; import { isThemeName, setActiveTheme, THEME_NAMES, THEMES } from "./theme/theme.js"; import type { TuiAppCallbacks } from "./tui-app.js"; -import { canAcceptMessage, type TuiState } from "./tui-state.js"; +import { + canAcceptMessage, + canTypeMessage, + type TuiState, +} from "./tui-state.js"; type Dispatch = (action: TuiAction) => void; @@ -80,7 +84,15 @@ export function handleEditorSubmit( return; } - if (!canAcceptMessage(state)) return; + // A turn is already in flight: park the message instead of dropping + // it. `ChatOrchestrator.sendMessage` has always buffered submissions + // made while busy — this is the path that finally reaches that queue. + if (!canAcceptMessage(state)) { + if (!canTypeMessage(state)) return; + dispatch({ type: "message_queued", text: trimmed }); + callbacks.onMessageSubmitted(trimmed); + return; + } dispatch({ type: "message_submitted" }); callbacks.onMessageSubmitted(trimmed); } @@ -162,6 +174,7 @@ export function runSlashCommand( } if (result.clearBuffer) dispatch({ type: "input_changed", value: "" }); dispatch({ type: "slash_palette_closed" }); + if (result.queueVerb) runQueueVerb(result.queueVerb, state, dispatch, callbacks); if (result.triggerAbort) callbacks.onAbort(); if (result.triggerQuit) { callbacks.onAbort(); @@ -217,6 +230,45 @@ export function runSlashCommand( } } +/** + * `/queue` needs the live queue to render, which `dispatchSlashCommand` + * (a pure buffer -> result function) cannot see. The listing is built + * here from `TuiState`; `clear` also tells the orchestrator to drop its + * own copy so the two never diverge. + */ +function runQueueVerb( + verb: NonNullable, + state: TuiState, + dispatch: Dispatch, + callbacks: TuiAppCallbacks, +): void { + if (verb === "clear") { + callbacks.onQueueClearRequested?.(); + const line = + state.queuedMessages.length === 0 + ? "queue: already empty" + : `queue: dropped ${state.queuedMessages.length} parked message${ + state.queuedMessages.length === 1 ? "" : "s" + }`; + dispatch({ type: "runtime_info", line }); + dispatch({ type: "system_message", text: line }); + return; + } + const text = formatQueueListing(state.queuedMessages); + dispatch({ type: "runtime_info", line: text.split("\n")[0] ?? text }); + dispatch({ type: "system_message", text }); +} + +/** Multi-line `/queue` listing for the chat transcript. */ +export function formatQueueListing(queued: readonly string[]): string { + if (queued.length === 0) { + return "queue: (empty) \u2014 messages sent while a turn is running are parked here"; + } + const header = `queue (${queued.length} message${queued.length === 1 ? "" : "s"})`; + const lines = queued.map((text, i) => ` ${i + 1}. ${text.replace(/\s+/g, " ").trim()}`); + return [header, ...lines].join("\n"); +} + function runTelegramVerb( verb: NonNullable, callbacks: TuiAppCallbacks, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..2a5a9a3f 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -58,6 +58,17 @@ export type TuiAction = * `user_message` agent event, so the action carries no payload. */ | { type: "message_submitted" } + /** + * The operator pressed Enter while a turn was still running. Unlike + * `message_submitted` this must NOT reset the run state — the turn in + * flight owns `feed` / `reasoning` / `streamingToolCards` and wiping + * them mid-run would blank the screen the operator is reading. We only + * clear the editor and record the message as parked; the orchestrator + * confirms with `queue_changed` once it has actually buffered it. + */ + | { type: "message_queued"; text: string } + /** Orchestrator re-published its pending-message queue (push/drain/clear). */ + | { type: "queue_changed"; queued: readonly string[] } | { type: "quit_requested" } | { type: "loaded_skill"; diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..f3309450 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -17,6 +17,7 @@ import { DebugPane } from "./components/debug-pane.js"; import { HotkeyHint } from "./components/hotkey-hint.js"; import { LlmHealthBadge } from "./components/llm-health-badge.js"; import { PromptShell } from "./components/prompt-shell.js"; +import { QueuedMessages } from "./components/queued-messages.js"; import { SessionPicker } from "./components/session-picker.js"; import { ThemePicker } from "./components/theme-picker.js"; import { @@ -42,6 +43,7 @@ import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { canAcceptMessage, + canTypeMessage, createInitialTuiState, DEFAULT_RING_BUFFER_SIZE, type InitialTuiLayoutOptions, @@ -79,6 +81,8 @@ export interface TuiAppCallbacks { onAbort(): void; onQuit(): void; onMessageSubmitted(message: string): void; + /** Drop every message parked behind the running turn (`/queue clear`). */ + onQueueClearRequested?(): 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. */ @@ -795,6 +799,7 @@ export function TuiApp({ ) : null} + { }); }, onMessageSubmitted: (text) => orchestrator.sendMessage(text), + onQueueClearRequested: () => orchestrator.clearQueue(), onSessionPickerRequested: () => orchestrator.openSessionPicker(), onSessionSwitchRequested: (id) => orchestrator.switchSession(id), onSessionNewRequested: () => orchestrator.newSession(), diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index aee4377f..5940c31e 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -416,17 +416,39 @@ export interface TuiState { * loses sight of the freshest reply. */ chatScrollOffset: number; + /** + * Messages the operator submitted while a turn was still running, in + * submission order. Mirrors `ChatOrchestrator`'s internal queue — the + * orchestrator is the source of truth and re-publishes the list via + * `queue_changed` on every mutation; this slice exists so the prompt + * can show what is parked without reaching into the orchestrator. + */ + queuedMessages: readonly string[]; } /** - * Derived selector: can the user submit a new chat message right now? - * Used by both the input component (disable when busy) and the - * orchestrator (reject submissions sent while a turn is still in flight). + * Derived selector: can a new turn start *right now*? Used by the + * submit pipeline to decide between running the message immediately and + * parking it behind the turn in flight. */ export function canAcceptMessage(state: TuiState): boolean { return state.status === "idle"; } +/** + * Derived selector: may the operator put characters into the editor? + * + * Deliberately weaker than {@link canAcceptMessage}. The editor used to + * be disabled for the whole duration of a turn, which meant a running + * agent swallowed every keystroke — you could not even draft the next + * message, let alone send it. Typing is now allowed whenever the app is + * not tearing down; a submission made while busy is queued rather than + * dropped (see `handleEditorSubmit`). + */ +export function canTypeMessage(state: TuiState): boolean { + return state.status !== "quitting"; +} + export const DEFAULT_RING_BUFFER_SIZE = 500; export interface InitialTuiLayoutOptions { @@ -523,5 +545,6 @@ export function createInitialTuiState( sidebarCursor: 0, sidebarTasksCursor: 0, chatScrollOffset: 0, + queuedMessages: [], }; } From 2afd5895d5345e7a390ce21b78406810e5e765d1 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:16:01 +0300 Subject: [PATCH 03/57] feat(llm): more cloud providers and a refreshed model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things kept models out of reach. 1. No preset for most vendors. PROVIDER_PRESETS gains eight entries — Anthropic, Hyperbolic, Moonshot AI, Novita AI, Perplexity, Qwen (DashScope), SambaNova — all resolving to the existing `openai-compatible` kind, so there is no new provider kind and no config-schema change. Every base URL was probed live: 200 with a `data` array, or 401 while a bogus sibling path on the same host answers 404. z.ai, Cohere and DeepInfra were dropped for failing that bar rather than shipped half-working. 2. `scoreChat` in the OpenRouter fetcher returned -1 for every `anthropic/*` id and everything matching /gemini/i, which deleted ~40 currently served models — the whole Claude 5 and Gemini 3.x lines — from the picker with no operator override. Vendor is a ranking input now, not a gate; the models this agent is tuned for still sort to the top. 3. The bundled catalogs were stale snapshots. Both are regenerated from the vendors' public model endpoints (2026-08-19): OpenRouter grows 18 -> 55 chat rows with real context windows, capabilities and USD/1M pricing; aimlapi grows 13 -> 32, including the vendor-prefixed Claude and Gemini ids it serves on `openai/chat-completions`. The OpenRouter chat rows move into two sibling modules (frontier / open-weight) and the duplicated row builders collapse into `model-catalog-entry.ts`, keeping every file inside the 300-line rule. Tests that pinned the old exclusions now pin the opposite, with a comment saying why; new ones cover row integrity and picker-order sync. The `qwen/qwen3.7-max` price expectation in llm-panel-selectors moved $1.25/$3.75 -> $1.48/$4.42, which is what OpenRouter charges today. --- AGENTS.md | 2 + .../aimlapi/aimlapi-models-catalog.test.ts | 28 +- .../aimlapi/aimlapi-models-catalog.ts | 193 ++++++++++---- src/llm/provider/model-catalog-entry.ts | 65 +++++ .../fetch-openrouter-chat-catalog.test.ts | 7 +- .../fetch-openrouter-chat-catalog.ts | 28 +- .../openrouter-frontier-chat-models.ts | 168 ++++++++++++ .../openrouter-models-catalog.test.ts | 41 ++- .../openrouter/openrouter-models-catalog.ts | 252 ++++++------------ .../openrouter-open-weight-chat-models.ts | 246 +++++++++++++++++ src/tui/llm-panel/llm-panel-selectors.test.ts | 5 +- src/tui/providers/provider-presets.test.ts | 29 ++ src/tui/providers/provider-presets.ts | 59 +++- 13 files changed, 876 insertions(+), 247 deletions(-) create mode 100644 src/llm/provider/model-catalog-entry.ts create mode 100644 src/llm/provider/openrouter/openrouter-frontier-chat-models.ts create mode 100644 src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..7a8edd50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,8 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register - **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`. - **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider). - **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools. +- **Vendor presets** ([src/tui/providers/provider-presets.ts](src/tui/providers/provider-presets.ts)) — 19 named cloud/local endpoints (Anthropic, Groq, Moonshot, Perplexity, Qwen/DashScope, SambaNova, …) that all resolve to the existing `openai-compatible` kind with `baseUrl` prefilled. Adding a vendor is a preset entry, not a provider kind. The bar for a new entry: `/v1/models` answers 200 with a `data` array, or 401/403 **while a bogus sibling path on the same host answers 404** — a gateway that rejects everything before routing proves nothing. +- **Bundled catalogs** — `OPENROUTER_MODELS_CATALOG` (split across `openrouter-frontier-chat-models.ts` / `openrouter-open-weight-chat-models.ts`) and `AIMLAPI_MODELS_CATALOG` are offline snapshots regenerated from each vendor's public `/models` endpoint; the shared row builders live in [model-catalog-entry.ts](src/llm/provider/model-catalog-entry.ts). Refresh = re-pull the endpoint, remap (`context_length`, `input_modalities` → vision, `supported_parameters` → tools, price × 1e6 → USD/1M) and update the date in each file header. `scoreChat` in the OpenRouter fetcher **ranks** vendors; it must not gate them — the Anthropic/Gemini exclusions it used to carry hid ~40 served models from the picker. ### Bootstrap wiring diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts index b4cf05d3..008e9877 100644 --- a/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts +++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.test.ts @@ -48,7 +48,33 @@ describe("AIMLAPI_MODELS_CATALOG", () => { } }); - it("retires all legacy Anthropic Claude and Google Gemini ids", () => { + it("lists the vendor-prefixed Claude and Gemini ids aimlapi serves today", () => { + // The catalog used to carry no Claude and no Gemini row at all. Both + // are on aimlapi's `openai/chat-completions` surface under + // vendor-prefixed ids, so the provider can reach them; only the old + // unprefixed spellings below are actually gone. + for (const id of [ + "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5", + "google/gemini-3.7-flash", + "google/gemini-3.5-flash", + ]) { + expect(AIMLAPI_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(AIMLAPI_CHAT_MODEL_ORDER).toContain(id); + } + }); + + it("keeps every chat row on the picker order, without duplicates", () => { + expect(new Set(AIMLAPI_CHAT_MODEL_ORDER).size).toBe( + AIMLAPI_CHAT_MODEL_ORDER.length, + ); + const chatIds = [...AIMLAPI_MODELS_CATALOG] + .filter(([, entry]) => entry.kind === "chat") + .map(([id]) => id); + expect([...AIMLAPI_CHAT_MODEL_ORDER].sort()).toEqual([...chatIds].sort()); + }); + + it("keeps the retired unprefixed Claude and Gemini ids out", () => { const retired = [ "claude-opus-4-8", "claude-sonnet-4-6", diff --git a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts index d53468e5..375c34dc 100644 --- a/src/llm/provider/aimlapi/aimlapi-models-catalog.ts +++ b/src/llm/provider/aimlapi/aimlapi-models-catalog.ts @@ -1,51 +1,5 @@ import type { ModelCatalogEntry } from "../model-resolver.js"; - -type ChatModelSpec = { - id: string; - contextWindow: number; - supportsVision: boolean; - supportsTools?: "basic" | "parallel"; - supportsPromptCache?: boolean; -}; - -type EmbeddingModelSpec = { - id: string; - contextWindow: number; - dim?: number; -}; - -function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "chat", - contextWindow: spec.contextWindow, - supportsVision: spec.supportsVision, - supportsTools: spec.supportsTools ?? "parallel", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - }, - ]; -} - -function embeddingModel( - spec: EmbeddingModelSpec, -): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "embedding", - contextWindow: spec.contextWindow, - ...(spec.dim !== undefined ? { dim: spec.dim } : {}), - supportsVision: false, - supportsTools: "none", - supportsPromptCache: false, - reasoningFormat: "none", - }, - ]; -} +import { chatModel, embeddingModel } from "../model-catalog-entry.js"; /** * Static fallback catalog for aimlapi.com. @@ -57,13 +11,20 @@ function embeddingModel( * `contextWindow` / `supportsVision` / `supportsTools` for ids that * have been hand-verified against the live API. * - * Curated down to current-generation chat models only — legacy OpenAI - * (gpt-4o / gpt-4.1 / o-series), all Anthropic Claude, and all Google - * Gemini ids were retired. Every id here was verified against - * `https://api.aimlapi.com/v1/models` with `type === "chat-completion"`. - * Models that only expose `type: "responses"` (`openai/gpt-5-pro`, - * `openai/gpt-5-3-codex`, etc.) are intentionally excluded — they 404 - * on `/v1/chat/completions`. + * Curated down to current-generation chat models only: legacy OpenAI + * (gpt-4o / gpt-4.1 / o-series) and the unprefixed `claude-*` / + * `google/gemini-2.x` ids stay retired because aimlapi no longer serves + * them. Claude and Gemini themselves are back — aimlapi lists them under + * vendor-prefixed ids (`anthropic/claude-opus-5`, + * `google/gemini-3.7-flash`) on the `openai/chat-completions` surface, + * so they work through this provider like any other row. + * + * Every id here was re-verified on 2026-08-19 against + * `https://api.aimlapi.com/v1/models` with `type === + * "openai/chat-completions"`. Models that only expose `type: + * "responses"` (`openai/gpt-5-pro`, `openai/gpt-5-3-codex`) or only + * `anthropic/messages` are intentionally excluded — they 404 on + * `/v1/chat/completions`. */ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = new Map([ @@ -112,6 +73,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 2_000_000, supportsVision: true, }), + chatModel({ + id: "x-ai/grok-4-6", + contextWindow: 500_000, + supportsVision: true, + }), // DeepSeek chatModel({ id: "deepseek/deepseek-v4-flash", @@ -131,6 +97,11 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 262_144, supportsVision: false, }), + chatModel({ + id: "moonshot/kimi-k3", + contextWindow: 1_048_576, + supportsVision: false, + }), // ByteDance Seed chatModel({ id: "bytedance/dola-seed-2-0-pro", @@ -143,6 +114,97 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = contextWindow: 524_288, supportsVision: false, }), + // Anthropic Claude (verified `openai/chat-completions`, not the + // `anthropic/messages` surface that 404s on /v1/chat/completions) + chatModel({ + id: "anthropic/claude-opus-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-sonnet-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-fable-5", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-opus-4-8", + contextWindow: 1_000_000, + supportsVision: true, + }), + chatModel({ + id: "anthropic/claude-haiku-4.5", + contextWindow: 200_000, + supportsVision: true, + }), + // Google Gemini + chatModel({ + id: "google/gemini-3.7-flash", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.5-flash", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.5-flash-lite", + contextWindow: 1_048_576, + supportsVision: true, + }), + chatModel({ + id: "google/gemini-3.1-pro-preview", + contextWindow: 1_000_000, + supportsVision: true, + }), + // Alibaba Qwen + chatModel({ + id: "alibaba/qwen3.8-max", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3.7-max", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3.6-flash", + contextWindow: 1_000_000, + supportsVision: false, + }), + chatModel({ + id: "alibaba/qwen3-vl-plus", + contextWindow: 262_144, + supportsVision: true, + }), + // Zhipu GLM + chatModel({ + id: "zhipu/glm-5-3", + contextWindow: 1_024_000, + supportsVision: false, + }), + chatModel({ + id: "zhipu/glm-5.2", + contextWindow: 1_000_000, + supportsVision: false, + }), + // Mistral + chatModel({ + id: "mistralai/mistral-large-2512", + contextWindow: 262_144, + supportsVision: false, + }), + chatModel({ + id: "mistralai/mistral-medium-3-5", + contextWindow: 262_144, + supportsVision: false, + }), // Embeddings (verified against `/v1/models`) embeddingModel({ id: "text-embedding-3-small", @@ -169,7 +231,9 @@ export const AIMLAPI_MODELS_CATALOG: ReadonlyMap = }), ]); -/** TUI chat-picker order when offline. Verified ids only. */ +/** + * TUI chat-picker order when offline: catalog order, verified ids only. + */ export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [ "openai/gpt-5.5-2026-04-23", "openai/gpt-5.4-2026-03-05", @@ -179,11 +243,30 @@ export const AIMLAPI_CHAT_MODEL_ORDER: readonly string[] = [ "openai/gpt-oss-20b", "x-ai/grok-4-3", "x-ai/grok-4-fast-reasoning", + "x-ai/grok-4-6", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", "moonshot/kimi-k2-7-code", + "moonshot/kimi-k3", "bytedance/dola-seed-2-0-pro", "minimax/minimax-m3", + "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5", + "anthropic/claude-fable-5", + "anthropic/claude-opus-4-8", + "anthropic/claude-haiku-4.5", + "google/gemini-3.7-flash", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.1-pro-preview", + "alibaba/qwen3.8-max", + "alibaba/qwen3.7-max", + "alibaba/qwen3.6-flash", + "alibaba/qwen3-vl-plus", + "zhipu/glm-5-3", + "zhipu/glm-5.2", + "mistralai/mistral-large-2512", + "mistralai/mistral-medium-3-5", ]; /** diff --git a/src/llm/provider/model-catalog-entry.ts b/src/llm/provider/model-catalog-entry.ts new file mode 100644 index 00000000..8ed65410 --- /dev/null +++ b/src/llm/provider/model-catalog-entry.ts @@ -0,0 +1,65 @@ +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * Row builders shared by the bundled provider catalogs. + * + * OpenRouter and aimlapi both ship a static `ReadonlyMap` and both used to declare their own private + * `chatModel` / `embeddingModel` helpers. The two copies had already + * drifted — one defaulted `supportsTools` to `"parallel"`, the other + * hard-coded it — so the shared version keeps every field explicit and + * lets each catalog omit what its API genuinely does not publish + * (`pricing` is absent from the aimlapi payload, so aimlapi rows carry + * no price rather than a made-up one). + */ + +export type ChatModelSpec = { + readonly id: string; + readonly contextWindow: number; + readonly supportsVision: boolean; + readonly supportsTools?: "none" | "basic" | "parallel" | "strict"; + readonly supportsPromptCache?: boolean; + readonly pricing?: { readonly input: number; readonly output: number }; +}; + +export type EmbeddingModelSpec = { + readonly id: string; + readonly contextWindow: number; + readonly dim?: number; + readonly pricing?: { readonly input: number; readonly output: number }; +}; + +export type CatalogRow = readonly [string, ModelCatalogEntry]; + +export function chatModel(spec: ChatModelSpec): CatalogRow { + return [ + spec.id, + { + id: spec.id, + kind: "chat", + contextWindow: spec.contextWindow, + supportsVision: spec.supportsVision, + supportsTools: spec.supportsTools ?? "parallel", + supportsPromptCache: spec.supportsPromptCache ?? false, + reasoningFormat: "none", + ...(spec.pricing ? { pricing: spec.pricing } : {}), + }, + ]; +} + +export function embeddingModel(spec: EmbeddingModelSpec): CatalogRow { + return [ + spec.id, + { + id: spec.id, + kind: "embedding", + contextWindow: spec.contextWindow, + ...(spec.dim !== undefined ? { dim: spec.dim } : {}), + supportsVision: false, + supportsTools: "none", + supportsPromptCache: false, + reasoningFormat: "none", + ...(spec.pricing ? { pricing: spec.pricing } : {}), + }, + ]; +} diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts index c0479104..0e079a68 100644 --- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts +++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.test.ts @@ -29,7 +29,7 @@ describe("refreshOpenRouterChatCatalogFromApi", () => { vi.unstubAllGlobals(); }); - it("filters out Anthropic and keeps tool-capable models", async () => { + it("keeps Anthropic alongside every other tool-capable model", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ @@ -78,7 +78,10 @@ describe("refreshOpenRouterChatCatalogFromApi", () => { expect(picks.some((p) => p.id === "openrouter/auto")).toBe(true); expect(picks.some((p) => p.id === "qwen/qwen3.6-35b-a3b")).toBe(true); expect(picks.some((p) => p.id === "qwen/qwen3.5-35b-a3b")).toBe(false); - expect(picks.some((p) => p.id.startsWith("anthropic/"))).toBe(false); + // Was `toBe(false)`: `scoreChat` used to return -1 for every + // `anthropic/*` id, which hid the whole Claude line from the picker. + // Vendor is a ranking input now, not a gate. + expect(picks.some((p) => p.id === "anthropic/claude-sonnet-4")).toBe(true); }); it("keeps every advertised model instead of a capped head", async () => { diff --git a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts index 510e0be2..15f3ca67 100644 --- a/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts +++ b/src/llm/provider/openrouter/fetch-openrouter-chat-catalog.ts @@ -49,10 +49,26 @@ function hasTools(m: OpenRouterApiModel): boolean { return readAdvertisedTools(m) ?? true; } +/** + * Ranking, not gatekeeping. + * + * This function used to return -1 for every `anthropic/*` id and + * everything matching `/gemini/i`, which removed ~40 currently served + * models — the whole Claude 5 and Gemini 3.x lines — from the picker + * with no way for an operator to get them back. Nothing in the runtime + * needs that: both families speak the same OpenAI-shaped + * `/v1/chat/completions` OpenRouter exposes for everything else, and + * `native_tools` transport is what the picker already requires via + * `hasTools`. The exclusions are gone; the families are scored instead, + * so the models this agent is tuned for still sort to the top. + * + * A negative score is now reserved for rows that genuinely cannot be + * used: non-chat surfaces (embeddings, rerank, TTS) and models that + * explicitly advertise no tool support. + */ function scoreChat(m: OpenRouterApiModel): number { const id = m.id ?? ""; - if (!id || id.startsWith("anthropic/")) return -1; - if (/gemini/i.test(id)) return -1; + if (!id) return -1; if (/qwen3\.5/i.test(id)) return -1; if (/embed|rerank|moderation|ocr|tts|transcribe/i.test(id)) return -1; if (!hasTools(m)) return -1; @@ -62,6 +78,10 @@ function scoreChat(m: OpenRouterApiModel): number { else if (ctx >= 200_000) s += 5; if (/qwen3\.7|qwen3\.6/i.test(id)) s += 20; if (/gpt-5\./i.test(id)) s += 15; + if (/claude-(opus|sonnet|fable|haiku)-5|claude-opus-4\.8/i.test(id)) s += 18; + else if (id.startsWith("anthropic/")) s += 6; + if (/gemini-3\./i.test(id)) s += 14; + else if (/gemini/i.test(id)) s += 4; if (/deepseek.*v4|deepseek.*v3/i.test(id)) s += 10; if (/kimi-k2\.6/i.test(id)) s += 12; else if (/kimi-k2/i.test(id)) s += 8; @@ -146,8 +166,8 @@ let inFlight: Promise | null = null; /** * Pull the public OpenRouter model list and rebuild the TUI picker - * (non-Anthropic, `tools`-capable chat models). Falls back to the static - * catalog on network/parse errors. + * (every `tools`-capable chat model OpenRouter advertises). Falls back to + * the static catalog on network/parse errors. * * Concurrent callers share one request: the TUI triggers this from both * the panel prefetch and the wizard's picker step, and doubling the diff --git a/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts new file mode 100644 index 00000000..f100ff52 --- /dev/null +++ b/src/llm/provider/openrouter/openrouter-frontier-chat-models.ts @@ -0,0 +1,168 @@ +import { chatModel, type CatalogRow } from "../model-catalog-entry.js"; + +/** + * Hosted frontier chat models on OpenRouter — the vendors that only ship + * behind an API. + * + * Generated from `https://openrouter.ai/api/v1/models` on 2026-08-19 and + * hand-curated down to the current generation of each family: every row's + * `contextWindow`, `supportsVision` (`architecture.input_modalities` + * contains `image`), `supportsPromptCache` (`pricing.input_cache_read` is + * published) and `pricing` (USD per 1M tokens) comes from that response, + * and every id advertises `tools` in `supported_parameters`. + * + * Anthropic and Gemini rows live here because they are no longer filtered + * out — see the note on `scoreChat` in `fetch-openrouter-chat-catalog.ts`. + */ +export const OPENROUTER_FRONTIER_CHAT_MODELS: readonly CatalogRow[] = [ + // Anthropic — Claude 5 / 4.8 + chatModel({ + id: "anthropic/claude-opus-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + chatModel({ + id: "anthropic/claude-opus-5-fast", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 10, output: 50 }, + }), + chatModel({ + id: "anthropic/claude-sonnet-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 10 }, + }), + chatModel({ + id: "anthropic/claude-fable-5", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 10, output: 50 }, + }), + chatModel({ + id: "anthropic/claude-opus-4.8", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + chatModel({ + id: "anthropic/claude-haiku-4.5", + contextWindow: 200_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1, output: 5 }, + }), + // Google — Gemini 3.x + chatModel({ + id: "google/gemini-3.7-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.375, output: 1.875 }, + }), + chatModel({ + id: "google/gemini-3.6-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.75, output: 3.75 }, + }), + chatModel({ + id: "google/gemini-3.5-flash", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1.5, output: 9 }, + }), + chatModel({ + id: "google/gemini-3.5-flash-lite", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.3, output: 2.5 }, + }), + chatModel({ + id: "google/gemini-3.1-pro-preview", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 12 }, + }), + // OpenAI — GPT-5.x + chatModel({ + id: "openai/gpt-5.6-sol", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 15 }, + }), + chatModel({ + id: "openai/gpt-5.6-terra", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 12 }, + }), + chatModel({ + id: "openai/gpt-5.6-luna", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.2, output: 1.2 }, + }), + chatModel({ + id: "openai/gpt-5.5", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 30 }, + }), + chatModel({ + id: "openai/gpt-5.4", + contextWindow: 1_050_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 15 }, + }), + chatModel({ + id: "openai/gpt-5.4-mini", + contextWindow: 400_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.75, output: 4.5 }, + }), + chatModel({ + id: "openai/gpt-5.4-nano", + contextWindow: 400_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.2, output: 1.25 }, + }), + // xAI — Grok 4.x + chatModel({ + id: "x-ai/grok-4.6", + contextWindow: 500_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "x-ai/grok-4.5", + contextWindow: 500_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "x-ai/grok-4.3", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 1.25, output: 2.5 }, + }),]; diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts index d87d16e5..05a87b45 100644 --- a/src/llm/provider/openrouter/openrouter-models-catalog.test.ts +++ b/src/llm/provider/openrouter/openrouter-models-catalog.test.ts @@ -5,23 +5,46 @@ import { } from "./openrouter-models-catalog.js"; describe("OPENROUTER_MODELS_CATALOG", () => { - it("does not list Anthropic chat models", () => { - for (const [id, entry] of OPENROUTER_MODELS_CATALOG) { - if (entry.kind !== "chat") continue; - expect(id.startsWith("anthropic/")).toBe(false); + it("lists the current Anthropic chat models", () => { + // The previous snapshot asserted the opposite: no `anthropic/*` row + // was allowed here, mirroring the vendor filter that used to sit in + // `scoreChat`. Both are gone — OpenRouter serves Claude on the same + // OpenAI-shaped chat-completions surface as everything else, so + // hiding it only cost operators the models they asked for. + for (const id of ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5"]) { + expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id); + } + }); + + it("lists the current Gemini chat models", () => { + for (const id of ["google/gemini-3.7-flash", "google/gemini-3.5-flash"]) { + expect(OPENROUTER_MODELS_CATALOG.get(id)?.kind).toBe("chat"); + expect(OPENROUTER_CHAT_MODEL_ORDER).toContain(id); } }); - it("does not list Gemini chat models", () => { + it("gives every chat row a positive context window and a price", () => { for (const [id, entry] of OPENROUTER_MODELS_CATALOG) { if (entry.kind !== "chat") continue; - expect(/gemini/i.test(id)).toBe(false); - } - for (const id of OPENROUTER_CHAT_MODEL_ORDER) { - expect(/gemini/i.test(id)).toBe(false); + expect(entry.contextWindow, id).toBeGreaterThan(0); + expect(entry.pricing, id).toBeDefined(); + expect(entry.pricing!.input, id).toBeGreaterThanOrEqual(0); + expect(entry.pricing!.output, id).toBeGreaterThanOrEqual(0); } }); + it("keeps the picker order free of duplicates and in sync with the map", () => { + // The chat rows now come from two sibling modules, so a copy/paste + // between them would otherwise land silently as a duplicate key. + const order = OPENROUTER_CHAT_MODEL_ORDER; + expect(new Set(order).size).toBe(order.length); + const chatIds = [...OPENROUTER_MODELS_CATALOG] + .filter(([, entry]) => entry.kind === "chat") + .map(([id]) => id); + expect([...order].sort()).toEqual([...chatIds].sort()); + }); + it("orders TUI chat picks with openrouter/auto first", () => { expect(OPENROUTER_CHAT_MODEL_ORDER[0]).toBe("openrouter/auto"); for (const id of OPENROUTER_CHAT_MODEL_ORDER) { diff --git a/src/llm/provider/openrouter/openrouter-models-catalog.ts b/src/llm/provider/openrouter/openrouter-models-catalog.ts index 61a314ce..012de6aa 100644 --- a/src/llm/provider/openrouter/openrouter-models-catalog.ts +++ b/src/llm/provider/openrouter/openrouter-models-catalog.ts @@ -1,205 +1,109 @@ import type { ModelCatalogEntry } from "../model-resolver.js"; - -type Price = { input: number; output: number }; - -type ChatModelSpec = { - id: string; - contextWindow: number; - supportsVision: boolean; - supportsPromptCache?: boolean; - pricing: Price; -}; - -type EmbeddingModelSpec = ChatModelSpec & { - dim: number; -}; - -function chatModel(spec: ChatModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "chat", - contextWindow: spec.contextWindow, - supportsVision: spec.supportsVision, - supportsTools: "parallel", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - pricing: spec.pricing, - }, - ]; -} - -function embeddingModel(spec: EmbeddingModelSpec): readonly [string, ModelCatalogEntry] { - return [ - spec.id, - { - id: spec.id, - kind: "embedding", - contextWindow: spec.contextWindow, - dim: spec.dim, - supportsVision: false, - supportsTools: "none", - supportsPromptCache: spec.supportsPromptCache ?? false, - reasoningFormat: "none", - pricing: spec.pricing, - }, - ]; -} +import { embeddingModel } from "../model-catalog-entry.js"; +import { OPENROUTER_FRONTIER_CHAT_MODELS } from "./openrouter-frontier-chat-models.js"; +import { OPENROUTER_OPEN_WEIGHT_CHAT_MODELS } from "./openrouter-open-weight-chat-models.js"; /** - * Static fallback catalog (May 2026 OpenRouter slugs). The TUI wizard - * prefers {@link refreshOpenRouterChatCatalogFromApi} when online; this - * map backs offline runs and `resolveModel` metadata. + * Static fallback catalog, regenerated from the public OpenRouter model + * list on 2026-08-19. The TUI wizard prefers + * {@link refreshOpenRouterChatCatalogFromApi} when online; this map backs + * offline runs and `resolveModel` metadata (context window, capabilities, + * price per 1M tokens). + * + * The chat rows live in two sibling files — hosted frontier models and + * open-weight ones — to stay inside the 300-line limit. Embedding rows + * stay here; there are two of them and OpenRouter has not changed their + * pricing since the previous snapshot. */ export const OPENROUTER_MODELS_CATALOG: ReadonlyMap = new Map([ - chatModel({ - id: "openrouter/auto", - contextWindow: 2_000_000, - supportsVision: true, - pricing: { input: 0, output: 0 }, - }), - chatModel({ - id: "qwen/qwen3.7-max", - contextWindow: 1_000_000, - supportsVision: false, - pricing: { input: 1.25, output: 3.75 }, - }), - chatModel({ - id: "qwen/qwen3.6-35b-a3b", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 0.15, output: 1 }, - }), - chatModel({ - id: "qwen/qwen3.6-flash", - contextWindow: 1_000_000, - supportsVision: true, - pricing: { input: 0.19, output: 1.13 }, - }), - chatModel({ - id: "openai/gpt-5.5", - contextWindow: 1_050_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 5, output: 30 }, - }), - chatModel({ - id: "openai/gpt-5.4", - contextWindow: 1_050_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 2.5, output: 15 }, - }), - chatModel({ - id: "openai/gpt-5.4-mini", - contextWindow: 400_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 0.75, output: 4.5 }, - }), - chatModel({ - id: "openai/gpt-5.4-nano", - contextWindow: 400_000, - supportsVision: true, - supportsPromptCache: true, - pricing: { input: 0.2, output: 1.25 }, - }), - chatModel({ - id: "x-ai/grok-4.3", - contextWindow: 1_000_000, - supportsVision: true, - pricing: { input: 1.25, output: 2.5 }, - }), - chatModel({ - id: "deepseek/deepseek-v4-flash", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 0.1, output: 0.2 }, - }), - chatModel({ - id: "deepseek/deepseek-v4-pro", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 0.43, output: 0.87 }, - }), - chatModel({ - id: "moonshotai/kimi-k2.7-code", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 0.95, output: 4.0 }, - }), - chatModel({ - id: "mistralai/mistral-medium-3-5", - contextWindow: 262_144, - supportsVision: true, - pricing: { input: 1.5, output: 7.5 }, - }), - chatModel({ - id: "minimax/minimax-m3", - contextWindow: 1_048_576, - supportsVision: true, - pricing: { input: 0.3, output: 1.2 }, - }), - chatModel({ - id: "minimax/minimax-m2.7", - contextWindow: 204_800, - supportsVision: false, - pricing: { input: 0.28, output: 1.2 }, - }), - chatModel({ - id: "z-ai/glm-4.7-flash", - contextWindow: 202_752, - supportsVision: false, - pricing: { input: 0.06, output: 0.4 }, - }), - chatModel({ - id: "z-ai/glm-5.2", - contextWindow: 1_048_576, - supportsVision: false, - pricing: { input: 1, output: 4 }, - }), - chatModel({ - id: "z-ai/glm-5.1", - contextWindow: 202_752, - supportsVision: false, - pricing: { input: 0.98, output: 3.08 }, - }), + [ + "openrouter/auto", + { + id: "openrouter/auto", + kind: "chat", + contextWindow: 2_000_000, + supportsVision: true, + supportsTools: "parallel", + supportsPromptCache: false, + reasoningFormat: "none", + // Routed: the price is whatever model OpenRouter picks. + pricing: { input: 0, output: 0 }, + }, + ], + ...OPENROUTER_FRONTIER_CHAT_MODELS, + ...OPENROUTER_OPEN_WEIGHT_CHAT_MODELS, embeddingModel({ id: "openai/text-embedding-3-small", contextWindow: 8192, dim: 1536, - supportsVision: false, pricing: { input: 0.02, output: 0 }, }), embeddingModel({ id: "openai/text-embedding-3-large", contextWindow: 8192, dim: 3072, - supportsVision: false, pricing: { input: 0.13, output: 0 }, }), ]); -/** Static TUI order when the live API fetch is unavailable. */ +/** + * Static TUI order when the live API fetch is unavailable: the curated + * catalog order, `openrouter/auto` first. + */ export const OPENROUTER_CHAT_MODEL_ORDER: readonly string[] = [ "openrouter/auto", - "qwen/qwen3.7-max", - "qwen/qwen3.6-35b-a3b", - "qwen/qwen3.6-flash", + "anthropic/claude-opus-5", + "anthropic/claude-opus-5-fast", + "anthropic/claude-sonnet-5", + "anthropic/claude-fable-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-haiku-4.5", + "google/gemini-3.7-flash", + "google/gemini-3.6-flash", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.1-pro-preview", + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", "openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.4-mini", "openai/gpt-5.4-nano", + "x-ai/grok-4.6", + "x-ai/grok-4.5", "x-ai/grok-4.3", - "deepseek/deepseek-v4-flash", + "qwen/qwen3.8-max", + "qwen/qwen3.8-2.4t-a95b", + "qwen/qwen3.8-27b", + "qwen/qwen3.7-max", + "qwen/qwen3.7-plus", + "qwen/qwen3.7-flash", + "qwen/qwen3.6-35b-a3b", + "qwen/qwen3.6-flash", + "qwen/qwen3-coder-plus", "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "moonshotai/kimi-k3", "moonshotai/kimi-k2.7-code", - "mistralai/mistral-medium-3-5", - "minimax/minimax-m3", - "minimax/minimax-m2.7", - "z-ai/glm-4.7-flash", + "moonshotai/kimi-k2.6", + "z-ai/glm-5.3", "z-ai/glm-5.2", "z-ai/glm-5.1", -]; + "z-ai/glm-4.7-flash", + "minimax/minimax-m3", + "minimax/minimax-m2.7", + "mistralai/mistral-large-2512", + "mistralai/mistral-medium-3-5", + "mistralai/ministral-8b-2512", + "meta-llama/llama-4-maverick", + "meta-llama/llama-4-scout", + "meta-llama/llama-3.3-70b-instruct", + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3.5-lightning", + "amazon/nova-premier-v1", + "amazon/nova-2-lite-v1", + "bytedance-seed/seed-2.0-code",]; diff --git a/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts new file mode 100644 index 00000000..514dfe92 --- /dev/null +++ b/src/llm/provider/openrouter/openrouter-open-weight-chat-models.ts @@ -0,0 +1,246 @@ +import { chatModel, type CatalogRow } from "../model-catalog-entry.js"; + +/** + * Open-weight chat models on OpenRouter — families whose weights are + * published, served here by whichever provider OpenRouter routes to. + * + * Same provenance as the frontier list: generated from + * `https://openrouter.ai/api/v1/models` on 2026-08-19, curated to the + * current generation of each family, `tools`-capable only. + */ +export const OPENROUTER_OPEN_WEIGHT_CHAT_MODELS: readonly CatalogRow[] = [ + // Qwen + chatModel({ + id: "qwen/qwen3.8-max", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "qwen/qwen3.8-2.4t-a95b", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 2, output: 6 }, + }), + chatModel({ + id: "qwen/qwen3.8-27b", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.45, output: 3.2 }, + }), + chatModel({ + id: "qwen/qwen3.7-max", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 1.475, output: 4.425 }, + }), + chatModel({ + id: "qwen/qwen3.7-plus", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.32, output: 1.28 }, + }), + chatModel({ + id: "qwen/qwen3.7-flash", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.13 }, + }), + chatModel({ + id: "qwen/qwen3.6-35b-a3b", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.14, output: 1 }, + }), + chatModel({ + id: "qwen/qwen3.6-flash", + contextWindow: 1_000_000, + supportsVision: true, + pricing: { input: 0.188, output: 1.125 }, + }), + chatModel({ + id: "qwen/qwen3-coder-plus", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.65, output: 3.25 }, + }), + // DeepSeek + chatModel({ + id: "deepseek/deepseek-v4-pro", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.66, output: 1.98 }, + }), + chatModel({ + id: "deepseek/deepseek-v4-flash", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.083, output: 0.165 }, + }), + // Moonshot AI — Kimi + chatModel({ + id: "moonshotai/kimi-k3", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 3, output: 15 }, + }), + chatModel({ + id: "moonshotai/kimi-k2.7-code", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.71, output: 3.5 }, + }), + chatModel({ + id: "moonshotai/kimi-k2.6", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.95, output: 4 }, + }), + // Z.ai — GLM + chatModel({ + id: "z-ai/glm-5.3", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 1.4, output: 4.4 }, + }), + chatModel({ + id: "z-ai/glm-5.2", + contextWindow: 1_048_576, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.966, output: 3.036 }, + }), + chatModel({ + id: "z-ai/glm-5.1", + contextWindow: 204_800, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.966, output: 3.036 }, + }), + chatModel({ + id: "z-ai/glm-4.7-flash", + contextWindow: 202_752, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.06, output: 0.4 }, + }), + // MiniMax + chatModel({ + id: "minimax/minimax-m3", + contextWindow: 1_048_576, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.3, output: 1.2 }, + }), + chatModel({ + id: "minimax/minimax-m2.7", + contextWindow: 204_800, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.3, output: 1.2 }, + }), + // Mistral + chatModel({ + id: "mistralai/mistral-large-2512", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.5, output: 1.5 }, + }), + chatModel({ + id: "mistralai/mistral-medium-3-5", + contextWindow: 262_144, + supportsVision: true, + pricing: { input: 1.5, output: 7.5 }, + }), + chatModel({ + id: "mistralai/ministral-8b-2512", + contextWindow: 262_144, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0.15, output: 0.15 }, + }), + // Meta — Llama + chatModel({ + id: "meta-llama/llama-4-maverick", + contextWindow: 1_048_576, + supportsVision: true, + pricing: { input: 0.2, output: 0.8 }, + }), + chatModel({ + id: "meta-llama/llama-4-scout", + contextWindow: 1_310_720, + supportsVision: true, + pricing: { input: 0.1, output: 0.3 }, + }), + chatModel({ + id: "meta-llama/llama-3.3-70b-instruct", + contextWindow: 131_072, + supportsVision: false, + pricing: { input: 0.1, output: 0.32 }, + }), + // OpenAI gpt-oss (open weights) + chatModel({ + id: "openai/gpt-oss-120b", + contextWindow: 131_072, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.17 }, + }), + chatModel({ + id: "openai/gpt-oss-20b", + contextWindow: 131_072, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.03, output: 0.13 }, + }), + // NVIDIA — Nemotron + chatModel({ + id: "nvidia/nemotron-3-ultra-550b-a55b", + contextWindow: 512_288, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.6, output: 3.6 }, + }), + chatModel({ + id: "nvidia/nemotron-3.5-lightning", + contextWindow: 1_000_000, + supportsVision: false, + supportsPromptCache: true, + pricing: { input: 0.08, output: 0.2 }, + }), + // Amazon — Nova + chatModel({ + id: "amazon/nova-premier-v1", + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 2.5, output: 12.5 }, + }), + chatModel({ + id: "amazon/nova-2-lite-v1", + contextWindow: 1_000_000, + supportsVision: true, + pricing: { input: 0.3, output: 2.5 }, + }), + // ByteDance — Seed + chatModel({ + id: "bytedance-seed/seed-2.0-code", + contextWindow: 262_144, + supportsVision: true, + pricing: { input: 0.5, output: 3 }, + }),]; diff --git a/src/tui/llm-panel/llm-panel-selectors.test.ts b/src/tui/llm-panel/llm-panel-selectors.test.ts index d4c73db2..3cd70c08 100644 --- a/src/tui/llm-panel/llm-panel-selectors.test.ts +++ b/src/tui/llm-panel/llm-panel-selectors.test.ts @@ -77,7 +77,10 @@ describe("llm-panel selectors", () => { providerId: "openrouter", modelId: "qwen/qwen3.7-max", active: true, - enterEffect: expect.stringContaining("$1.25/$3.75"), + // Price comes from the bundled catalog, refreshed from the live + // OpenRouter list on 2026-08-19 ($1.475/$4.425 per 1M, shown to + // two decimals). + enterEffect: expect.stringContaining("$1.48/$4.42"), }), ); const activeCloud = cloudRows.find( diff --git a/src/tui/providers/provider-presets.test.ts b/src/tui/providers/provider-presets.test.ts index 47f7276d..16a35ffe 100644 --- a/src/tui/providers/provider-presets.test.ts +++ b/src/tui/providers/provider-presets.test.ts @@ -40,6 +40,33 @@ describe("PROVIDER_PRESETS", () => { expect(findProviderPreset("nous")).toBeDefined(); }); + it("offers Anthropic as a first-class preset", () => { + // Claude was the one major vendor with no route into the agent at + // all: no preset, and both aggregator catalogs filtered it out. + // Anthropic's OpenAI-compatible endpoint needs no new provider kind. + const preset = findProviderPreset("anthropic"); + expect(preset?.baseUrl).toBe("https://api.anthropic.com"); + expect(preset?.envVar).toBe("ANTHROPIC_API_KEY"); + expect(preset?.local).toBeUndefined(); + }); + + it("names every hosted vendor preset after its own service", () => { + // Each of these answers `/v1/models` — 200 with a `data` + // array, or 401 while the same host 404s a bogus sibling path. + const expected: Record = { + anthropic: "https://api.anthropic.com", + dashscope: "https://dashscope-intl.aliyuncs.com/compatible-mode", + hyperbolic: "https://api.hyperbolic.xyz", + moonshot: "https://api.moonshot.ai", + novita: "https://api.novita.ai/openai", + perplexity: "https://api.perplexity.ai", + sambanova: "https://api.sambanova.ai", + }; + for (const [id, baseUrl] of Object.entries(expected)) { + expect(findProviderPreset(id)?.baseUrl, id).toBe(baseUrl); + } + }); + it("marks LM Studio as local", () => { expect(findProviderPreset("lmstudio")?.local).toBe(true); }); @@ -75,6 +102,8 @@ describe("PROVIDER_PRESETS", () => { ); expect(keyless).toContain("nous"); expect(keyless).toContain("ollama-cloud"); + expect(keyless).toContain("novita"); + expect(keyless).toContain("sambanova"); }); it("returns undefined for an unknown id", () => { diff --git a/src/tui/providers/provider-presets.ts b/src/tui/providers/provider-presets.ts index 011eb992..25dee62a 100644 --- a/src/tui/providers/provider-presets.ts +++ b/src/tui/providers/provider-presets.ts @@ -9,7 +9,13 @@ * * Every URL below was verified live: each answers `/v1/models` with an * OpenAI-shaped payload (200 with a `data` array, or 401/403 asking for - * a key, which confirms the path exists). + * a key, which confirms the path exists). A 401 only counts when the + * same host answers 404 for a bogus sibling path — gateways that reject + * every request before routing (z.ai, Cohere's compatibility root) prove + * nothing about `/v1/models` and are deliberately absent. So is anything + * whose model list does not live under `/v1/models`: DeepInfra + * serves it at `/v1/openai/models`, which this convention cannot express. + * Re-verified 2026-08-19. */ export interface ProviderPreset { /** Stable id used as the provider entry id when adding. */ @@ -53,6 +59,13 @@ export interface ProviderPreset { * list reads predictably; a test enforces this. */ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ + { + id: "anthropic", + label: "Anthropic (Claude)", + baseUrl: "https://api.anthropic.com", + envVar: "ANTHROPIC_API_KEY", + note: "Claude models through Anthropic's OpenAI-compatible endpoint", + }, { id: "cerebras", label: "Cerebras", @@ -81,6 +94,13 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ envVar: "GROQ_API_KEY", note: "very fast inference on open-weight models", }, + { + id: "hyperbolic", + label: "Hyperbolic", + baseUrl: "https://api.hyperbolic.xyz", + envVar: "HYPERBOLIC_API_KEY", + note: "open-weight models on rented GPUs", + }, { id: "lmstudio", label: "LM Studio (local)", @@ -96,6 +116,13 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ envVar: "MISTRAL_API_KEY", note: "Mistral models direct from the vendor", }, + { + id: "moonshot", + label: "Moonshot AI (Kimi)", + baseUrl: "https://api.moonshot.ai", + envVar: "MOONSHOT_API_KEY", + note: "Kimi models direct from the vendor", + }, { id: "nous", label: "Nous Research", @@ -104,6 +131,14 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ listsModelsWithoutKey: true, note: "open-weight models, 350+ ids listed without a key", }, + { + id: "novita", + label: "Novita AI", + baseUrl: "https://api.novita.ai/openai", + envVar: "NOVITA_API_KEY", + listsModelsWithoutKey: true, + note: "hosted open-weight catalog, models listed without a key", + }, { id: "ollama", label: "Ollama (local)", @@ -120,6 +155,28 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ listsModelsWithoutKey: true, note: "hosted Ollama, models listed without a key", }, + { + id: "perplexity", + label: "Perplexity", + baseUrl: "https://api.perplexity.ai", + envVar: "PERPLEXITY_API_KEY", + note: "Sonar models with live web grounding", + }, + { + id: "dashscope", + label: "Qwen (DashScope)", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode", + envVar: "DASHSCOPE_API_KEY", + note: "Qwen models direct from Alibaba Cloud (international endpoint)", + }, + { + id: "sambanova", + label: "SambaNova", + baseUrl: "https://api.sambanova.ai", + envVar: "SAMBANOVA_API_KEY", + listsModelsWithoutKey: true, + note: "open-weight models on custom silicon; models listed without a key", + }, { id: "together", label: "Together AI", From ec0316a26e6d859c2c28e316b8ce902fb50f473f Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 04:19:45 +0300 Subject: [PATCH 04/57] =?UTF-8?q?feat(config):=20llm.runMode=20block=20?= =?UTF-8?q?=E2=80=94=20local=20|=20cloud=20|=20fusion=20with=20a=20fusion?= =?UTF-8?q?=20cloud-share=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the config foundation for an operator run mode without changing any behaviour: nothing calls `resolveRunMode` outside its tests yet. `llm.runMode` sits beside `llm.fallback` rather than under `agent` (loop budgets) or at the top level: `llm` has a single writer, and the mode and `activeTextProvider` must move in one write + cache-reset cycle or they can disagree. `activeTextProvider` stays authoritative — `runMode.mode` is additive. The effective mode is derived from which provider is active, and a stored `fusion` is honoured only when the cloud leg is the active one. So an operator who switches provider by hand in Manage → LLM simply drops out of fusion on the next read: no reconciliation step, and no state that lies about what is running. It also means fusion pins the cloud provider as the fallback chain's primary, so `resolveFallbackChain` hoists it to the head and appends local at the tail with no changes of its own. `fusion.cloudShare` is documented as a dial, not a quota: it moves the cutoff on a bounded per-step complexity score rather than promising that N% of steps reach the cloud. Degradation is explicit and reported rather than silent — cloud/fusion without a cloud provider stays local, fusion without a local provider runs cloud-only, and a pinned `toolTransport` warns without downgrading. USER_CONFIG_VERSION 37 → 38. No migration code: `runMode` is an optional sub-key of an already-optional block, so absence is exactly the v37 behaviour; the bump only records the schema change. Verified: npm run lint and npm run build clean; npm test 4094 passed / 8 failed, the same 6 files / 8 tests that already fail on main @ 667dae1 (stale banner + tui-app fixtures, the `localModels.embeddings.url` fixture, a dev-machine-specific fs-glob path, and send-message-concurrency). --- src/config/config-schema.test.ts | 8 + src/config/config-schema.ts | 17 +- src/config/index.ts | 8 + src/config/llm-config.ts | 17 +- src/config/llm-run-mode-config.test.ts | 107 +++++++++++ src/config/llm-run-mode-config.ts | 170 ++++++++++++++++++ src/config/load-config.ts | 1 + src/llm/index.ts | 9 + src/llm/provider/registry/provider-types.ts | 7 + src/llm/run-mode/index.ts | 7 + src/llm/run-mode/resolve-run-mode.test.ts | 152 ++++++++++++++++ src/llm/run-mode/resolve-run-mode.ts | 131 ++++++++++++++ src/llm/run-mode/run-mode-degradation.test.ts | 44 +++++ src/llm/run-mode/run-mode-degradation.ts | 23 +++ 14 files changed, 695 insertions(+), 6 deletions(-) create mode 100644 src/config/llm-run-mode-config.test.ts create mode 100644 src/config/llm-run-mode-config.ts create mode 100644 src/llm/run-mode/index.ts create mode 100644 src/llm/run-mode/resolve-run-mode.test.ts create mode 100644 src/llm/run-mode/resolve-run-mode.ts create mode 100644 src/llm/run-mode/run-mode-degradation.test.ts create mode 100644 src/llm/run-mode/run-mode-degradation.ts diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..848ec16d 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -992,4 +992,12 @@ describe("parseUserConfigFile", () => { }), ).toThrow(/timeoutMs/); }); + it("upgrades a v37 file to the current version untouched", () => { + // v38 only ADDED the optional `llm.runMode` sub-key, so a v37 file + // needs no migration code — absence already is the v37 behaviour. + const parsed = parseUserConfigFile({ version: 37 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(USER_CONFIG_VERSION).toBe(38); + expect(parsed.llm?.runMode).toBeUndefined(); + }); }); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e1102371..a146d807 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -4,6 +4,7 @@ import { parseUserLlmFileConfig, type UserLlmFileConfig, } from "./llm-config.js"; +import type { UserLlmRunModeConfig } from "./llm-run-mode-config.js"; export type { ApprovalLevel } from "../approval/approval-level.js"; import type { DotenvLoadResult } from "./load-dotenv.js"; @@ -768,6 +769,14 @@ export interface AtomicAgentConfig { probeThrottleMs?: number; failureWindowMs?: number; }; + /** + * Operator run mode: `local` (llama-server only), `cloud` (cloud + * provider only) or `fusion` (cloud orchestrates, local executes). + * `activeTextProvider` stays authoritative — this block is additive + * and is reconciled by `resolveRunMode`. See AGENTS.md §"Run modes + * (Local / Cloud / Fusion)". + */ + runMode?: UserLlmRunModeConfig; }; } @@ -1412,7 +1421,12 @@ export interface UserConfigFile { // is absent, a legacy `approvalRequired: false` maps to level 5 and // `true`/absent maps to level 1 — both preserve the old behaviour // exactly. The legacy key is never written back. -export const USER_CONFIG_VERSION = 37 as const; +// v38: new optional `llm.runMode` block — the operator run mode +// (`local` | `cloud` | `fusion`) plus the fusion cloud-share dial and +// the sub-runner target. Absence IS the v37 behaviour: it is an +// optional sub-key of an already-optional block, so no migration code +// exists; the bump only records the schema change. +export const USER_CONFIG_VERSION = 38 as const; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1529,6 +1543,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 34, 35, 36, + 37, USER_CONFIG_VERSION, ]; diff --git a/src/config/index.ts b/src/config/index.ts index 312d7bcb..b9073de1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -41,6 +41,14 @@ export { type UserLlmFallbackConfig, type UserLlmProviderEntry, } from "./llm-config.js"; +export { + DEFAULT_FUSION_CLOUD_SHARE, + parseLlmRunModeConfig, + type RunModeName, + type RunModeSubRunners, + type UserLlmFusionConfig, + type UserLlmRunModeConfig, +} from "./llm-run-mode-config.js"; export type { DotenvLoadResult, DotenvReadFailure, diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index ed3f3a6c..60abc59d 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -1,4 +1,8 @@ import { ConfigValidationError } from "./config-validation-error.js"; +import { + parseLlmRunModeConfig, + type UserLlmRunModeConfig, +} from "./llm-run-mode-config.js"; export type UserLlmToolTransport = "auto" | "grammar" | "native_tools"; @@ -53,6 +57,7 @@ export type UserLlmFileConfig = { toolTransport: UserLlmToolTransport; providers: UserLlmProviderEntry[]; fallback?: UserLlmFallbackConfig; + runMode?: UserLlmRunModeConfig; }; const PROVIDER_ID_RE = /^[a-z][a-z0-9-]{0,31}$/; @@ -343,14 +348,15 @@ export function parseUserLlmFileConfig( "expected auto|grammar|native_tools", ); } + const providerIds = new Set(providers.map((p) => p.id)); const fallback = obj.fallback === undefined || obj.fallback === null ? undefined - : parseLlmFallbackConfig( - obj.fallback, - new Set(providers.map((p) => p.id)), - "llm.fallback", - ); + : parseLlmFallbackConfig(obj.fallback, providerIds, "llm.fallback"); + const runMode = + obj.runMode === undefined || obj.runMode === null + ? undefined + : parseLlmRunModeConfig(obj.runMode, providerIds, "llm.runMode"); return { activeTextProvider, @@ -358,5 +364,6 @@ export function parseUserLlmFileConfig( toolTransport: toolTransportRaw, providers, ...(fallback ? { fallback } : {}), + ...(runMode ? { runMode } : {}), }; } diff --git a/src/config/llm-run-mode-config.test.ts b/src/config/llm-run-mode-config.test.ts new file mode 100644 index 00000000..81836607 --- /dev/null +++ b/src/config/llm-run-mode-config.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { parseUserConfigFile, USER_CONFIG_VERSION } from "./config-schema.js"; +import { DEFAULT_FUSION_CLOUD_SHARE } from "./llm-run-mode-config.js"; + +/** Two-provider file (one local leg, one cloud leg) plus a runMode block. */ +const withRunMode = (runMode: unknown) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "openrouter", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" }, + ], + runMode, + }, +}); + +describe("llm-run-mode-config", () => { + it("round-trips a full runMode block", () => { + const parsed = parseUserConfigFile( + withRunMode({ + mode: "fusion", + localProvider: "local-llama", + cloudProvider: "openrouter", + fusion: { cloudShare: 65, subRunners: "follow" }, + }), + ); + expect(parsed.llm?.runMode).toEqual({ + mode: "fusion", + localProvider: "local-llama", + cloudProvider: "openrouter", + fusion: { cloudShare: 65, subRunners: "follow" }, + }); + }); + + it("omits runMode entirely when not configured", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + ], + }, + }); + expect(parsed.llm?.runMode).toBeUndefined(); + }); + + it("accepts a bare mode and leaves the dial to its default", () => { + const parsed = parseUserConfigFile(withRunMode({ mode: "local" })); + expect(parsed.llm?.runMode).toEqual({ mode: "local" }); + // The default is applied by `resolveRunMode`, never written into the + // file — an absent dial must stay absent so the default can move. + expect(parsed.llm?.runMode?.fusion).toBeUndefined(); + expect(DEFAULT_FUSION_CLOUD_SHARE).toBe(40); + }); + + it("rejects an unknown mode", () => { + expect(() => parseUserConfigFile(withRunMode({ mode: "hybrid" }))).toThrow( + /llm\.runMode\.mode/, + ); + }); + + it("rejects a pinned leg that names an unconfigured provider", () => { + expect(() => + parseUserConfigFile(withRunMode({ cloudProvider: "anthropic" })), + ).toThrow(/llm\.runMode\.cloudProvider/); + expect(() => + parseUserConfigFile(withRunMode({ localProvider: "ollama" })), + ).toThrow(/llm\.runMode\.localProvider/); + }); + + it("accepts the inclusive cloudShare bounds", () => { + for (const cloudShare of [0, 100]) { + const parsed = parseUserConfigFile(withRunMode({ fusion: { cloudShare } })); + expect(parsed.llm?.runMode?.fusion?.cloudShare).toBe(cloudShare); + } + }); + + it("rejects a cloudShare outside 0-100 or non-integer", () => { + for (const bad of [-1, 101, 42.5, "40", null]) { + expect(() => + parseUserConfigFile(withRunMode({ fusion: { cloudShare: bad } })), + ).toThrow(/llm\.runMode\.fusion\.cloudShare/); + } + }); + + it("rejects an unknown subRunners target", () => { + expect(() => + parseUserConfigFile(withRunMode({ fusion: { subRunners: "remote" } })), + ).toThrow(/llm\.runMode\.fusion\.subRunners/); + }); + + it("rejects a non-object runMode or fusion block", () => { + expect(() => parseUserConfigFile(withRunMode("fusion"))).toThrow( + /llm\.runMode/, + ); + expect(() => parseUserConfigFile(withRunMode({ fusion: [] }))).toThrow( + /llm\.runMode\.fusion/, + ); + }); +}); diff --git a/src/config/llm-run-mode-config.ts b/src/config/llm-run-mode-config.ts new file mode 100644 index 00000000..65535c2d --- /dev/null +++ b/src/config/llm-run-mode-config.ts @@ -0,0 +1,170 @@ +import { ConfigValidationError } from "./config-validation-error.js"; + +/** + * Operator-facing run mode. Names the *pair* of providers a turn is + * allowed to use, not a single model: + * + * - `local` — the configured llama-server provider only. + * - `cloud` — the configured cloud provider only. + * - `fusion` — cloud orchestrates, local executes. See + * AGENTS.md §"Run modes (Local / Cloud / Fusion)". + */ +export type RunModeName = "local" | "cloud" | "fusion"; + +/** + * Where fusion sends the memory sub-runners (reflection, link + * generation, curation votes, query rewriting, distillation). + * + * `local` (default) keeps them on the executor: they are cold-path + * structured-JSON jobs that ride the reserved reflection slot and are + * already KV-warm locally, so routing them to the cloud multiplies + * per-turn cost for no user-visible latency win. `cloud` sends them to + * the orchestrator; `follow` reuses whatever the last main-loop step + * used. + */ +export type RunModeSubRunners = "local" | "cloud" | "follow"; + +export type UserLlmFusionConfig = { + /** + * How much of a turn leans on the cloud orchestrator, 0-100. + * + * This is a DIAL, NOT A QUOTA. It does not promise that N% of steps + * reach the cloud; it moves the cutoff on a bounded per-step + * complexity score (`src/agent/routing/compute-step-complexity.ts`): + * a step routes to the cloud when `score >= 100 - cloudShare`. `0` + * behaves exactly like `local`, `100` exactly like `cloud`. + * + * Resist "fixing" this 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. + */ + cloudShare?: number; + subRunners?: RunModeSubRunners; +}; + +export type UserLlmRunModeConfig = { + mode?: RunModeName; + /** Pin the local leg. Default: the first `llama-server`-kind provider. */ + localProvider?: string; + /** Pin the cloud leg. Default: the first non-`llama-server` provider. */ + cloudProvider?: string; + fusion?: UserLlmFusionConfig; +}; + +/** Default cloud share when fusion is selected without an explicit dial. */ +export const DEFAULT_FUSION_CLOUD_SHARE = 40; + +const RUN_MODE_NAMES: readonly RunModeName[] = ["local", "cloud", "fusion"]; +const SUB_RUNNER_TARGETS: readonly RunModeSubRunners[] = [ + "local", + "cloud", + "follow", +]; + +function parseKnownProviderId( + raw: unknown, + providerIds: ReadonlySet, + field: string, +): string { + if (typeof raw !== "string" || raw.length === 0) { + throw new ConfigValidationError(field, "expected non-empty string"); + } + if (!providerIds.has(raw)) { + throw new ConfigValidationError( + field, + `unknown provider id ${JSON.stringify(raw)}`, + ); + } + return raw; +} + +function parseCloudShare(raw: unknown, field: string): number { + if (typeof raw !== "number" || !Number.isInteger(raw)) { + throw new ConfigValidationError(field, "expected an integer 0-100"); + } + if (raw < 0 || raw > 100) { + throw new ConfigValidationError( + field, + `expected an integer 0-100, got ${raw}`, + ); + } + return raw; +} + +function parseFusion( + raw: unknown, + field: string, +): UserLlmFusionConfig { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const out: UserLlmFusionConfig = {}; + if (obj.cloudShare !== undefined) { + out.cloudShare = parseCloudShare(obj.cloudShare, `${field}.cloudShare`); + } + if (obj.subRunners !== undefined) { + const target = obj.subRunners; + if ( + typeof target !== "string" || + !SUB_RUNNER_TARGETS.includes(target as RunModeSubRunners) + ) { + throw new ConfigValidationError( + `${field}.subRunners`, + `expected ${SUB_RUNNER_TARGETS.join("|")}`, + ); + } + out.subRunners = target as RunModeSubRunners; + } + return out; +} + +/** + * Validate the `llm.runMode` block. `providerIds` is the set of ids the + * sibling `llm.providers` array declares — a pinned leg that names a + * provider which does not exist is a config error, not a silent + * degradation, because the operator meant something specific. + */ +export function parseLlmRunModeConfig( + raw: unknown, + providerIds: ReadonlySet, + field: string, +): UserLlmRunModeConfig { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const out: UserLlmRunModeConfig = {}; + + if (obj.mode !== undefined) { + const mode = obj.mode; + if ( + typeof mode !== "string" || + !RUN_MODE_NAMES.includes(mode as RunModeName) + ) { + throw new ConfigValidationError( + `${field}.mode`, + `expected ${RUN_MODE_NAMES.join("|")}`, + ); + } + out.mode = mode as RunModeName; + } + if (obj.localProvider !== undefined) { + out.localProvider = parseKnownProviderId( + obj.localProvider, + providerIds, + `${field}.localProvider`, + ); + } + if (obj.cloudProvider !== undefined) { + out.cloudProvider = parseKnownProviderId( + obj.cloudProvider, + providerIds, + `${field}.cloudProvider`, + ); + } + if (obj.fusion !== undefined) { + out.fusion = parseFusion(obj.fusion, `${field}.fusion`); + } + return out; +} diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 49f9a958..60c3871b 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -507,5 +507,6 @@ function mapUserLlmToRuntime( }; }), ...(llm.fallback ? { fallback: llm.fallback } : {}), + ...(llm.runMode ? { runMode: llm.runMode } : {}), }; } diff --git a/src/llm/index.ts b/src/llm/index.ts index 775baf9a..2ec45121 100644 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -62,3 +62,12 @@ export type { VisionRequest, VisionResult, } from "./provider/index.js"; +export { + describeRunModeDegradation, + resolveRunMode, +} from "./run-mode/index.js"; +export type { + ResolvedRunMode, + RunModeDegradation, + RunModeDegradationReason, +} from "./run-mode/index.js"; diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index 9aa7de62..f2b87cbb 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -1,4 +1,5 @@ import type { AtomicAgentConfig } from "../../../config/index.js"; +import type { UserLlmRunModeConfig } from "../../../config/llm-run-mode-config.js"; import type { LlamaServerClient } from "../../llama-server-client.js"; import type { ModelProfile } from "../../model-profile.js"; import type { StructuredLogger } from "../../../tracing/index.js"; @@ -84,6 +85,11 @@ export type ResolvedLlmConfig = { providers: LlmProviderConfigEntry[]; toolTransport: "auto" | "grammar" | "native_tools"; fallback?: LlmFallbackConfig; + /** + * Operator run mode. Absent on the synthesized local-only config + * below, where `local` is the only reachable mode by construction. + */ + runMode?: UserLlmRunModeConfig; }; const factories = new Map(); @@ -112,6 +118,7 @@ export function resolveLlmConfig(config: AtomicAgentConfig): ResolvedLlmConfig { providers: [...llm.providers], toolTransport: llm.toolTransport, ...(llm.fallback ? { fallback: llm.fallback } : {}), + ...(llm.runMode ? { runMode: llm.runMode } : {}), }; } return { diff --git a/src/llm/run-mode/index.ts b/src/llm/run-mode/index.ts new file mode 100644 index 00000000..bb3fa636 --- /dev/null +++ b/src/llm/run-mode/index.ts @@ -0,0 +1,7 @@ +export { resolveRunMode } from "./resolve-run-mode.js"; +export type { + ResolvedRunMode, + RunModeDegradation, + RunModeDegradationReason, +} from "./resolve-run-mode.js"; +export { describeRunModeDegradation } from "./run-mode-degradation.js"; diff --git a/src/llm/run-mode/resolve-run-mode.test.ts b/src/llm/run-mode/resolve-run-mode.test.ts new file mode 100644 index 00000000..8c3bcee9 --- /dev/null +++ b/src/llm/run-mode/resolve-run-mode.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js"; +import { resolveRunMode } from "./resolve-run-mode.js"; + +const LOCAL = { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:8080" }; +const CLOUD = { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" }; + +function config(over: Partial = {}): ResolvedLlmConfig { + return { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama", + providers: [{ ...LOCAL }, { ...CLOUD }], + toolTransport: "auto", + ...over, + }; +} + +describe("resolveRunMode", () => { + it("derives local from the active provider when no runMode block exists", () => { + const r = resolveRunMode(config()); + expect(r.stored).toBeNull(); + expect(r.effective).toBe("local"); + expect(r.primaryProviderId).toBe("local-llama"); + expect(r.degraded).toBeNull(); + }); + + it("derives cloud from a cloud active provider", () => { + const r = resolveRunMode(config({ activeTextProvider: "openrouter" })); + expect(r.effective).toBe("cloud"); + expect(r.primaryProviderId).toBe("openrouter"); + }); + + it("discovers both legs by provider kind", () => { + const r = resolveRunMode(config()); + expect(r.localProviderId).toBe("local-llama"); + expect(r.cloudProviderId).toBe("openrouter"); + }); + + it("honours explicitly pinned legs over kind discovery", () => { + const r = resolveRunMode( + config({ + providers: [{ ...LOCAL }, { ...CLOUD }, { id: "aimlapi", kind: "aimlapi" }], + runMode: { cloudProvider: "aimlapi" }, + }), + ); + expect(r.cloudProviderId).toBe("aimlapi"); + }); + + it("resolves fusion when both legs exist and the cloud leg is active", () => { + const r = resolveRunMode( + config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("fusion"); + // Fusion pins the cloud leg as primary, which is what makes it the + // fallback chain's head and the local leg its `appendLocal` tail. + expect(r.primaryProviderId).toBe("openrouter"); + expect(r.degraded).toBeNull(); + }); + + it("defaults the fusion dial and sub-runner target", () => { + const r = resolveRunMode( + config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }), + ); + expect(r.fusion).toEqual({ cloudShare: 40, subRunners: "local" }); + }); + + it("carries an explicit fusion dial through", () => { + const r = resolveRunMode( + config({ + activeTextProvider: "openrouter", + runMode: { mode: "fusion", fusion: { cloudShare: 0, subRunners: "cloud" } }, + }), + ); + expect(r.fusion).toEqual({ cloudShare: 0, subRunners: "cloud" }); + }); + + // The non-contradiction rule: `activeTextProvider` is authoritative. + it("drops stored fusion back to derived when the operator switched provider by hand", () => { + const r = resolveRunMode( + config({ activeTextProvider: "local-llama", runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("local"); + // Not a degradation — nothing is broken, the operator simply moved. + expect(r.degraded).toBeNull(); + }); + + it("drops stored cloud back to local when the local provider is active", () => { + const r = resolveRunMode( + config({ activeTextProvider: "local-llama", runMode: { mode: "cloud" } }), + ); + expect(r.effective).toBe("local"); + expect(r.degraded).toBeNull(); + }); + + it("degrades cloud to local when no cloud provider is configured", () => { + const r = resolveRunMode( + config({ providers: [{ ...LOCAL }], runMode: { mode: "cloud" } }), + ); + expect(r.effective).toBe("local"); + expect(r.cloudProviderId).toBeNull(); + expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "cloud" }); + }); + + it("degrades fusion to local when no cloud provider is configured", () => { + const r = resolveRunMode( + config({ providers: [{ ...LOCAL }], runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("local"); + expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "fusion" }); + }); + + it("degrades fusion to cloud when no local provider is configured", () => { + const r = resolveRunMode( + config({ + providers: [{ ...CLOUD }], + activeTextProvider: "openrouter", + activeEmbeddingProvider: "openrouter", + runMode: { mode: "fusion" }, + }), + ); + expect(r.effective).toBe("cloud"); + expect(r.localProviderId).toBeNull(); + expect(r.degraded).toEqual({ reason: "no-local-provider", requested: "fusion" }); + }); + + it("warns but still runs fusion when the tool transport is pinned", () => { + const r = resolveRunMode( + config({ + activeTextProvider: "openrouter", + toolTransport: "grammar", + runMode: { mode: "fusion" }, + }), + ); + expect(r.effective).toBe("fusion"); + expect(r.degraded).toEqual({ + reason: "tool-transport-pinned", + requested: "fusion", + }); + }); + + it("assumes local when the active provider id resolves to nothing", () => { + // A broken file must never silently start spending cloud tokens. + const r = resolveRunMode(config({ activeTextProvider: "ghost" })); + expect(r.effective).toBe("local"); + }); + + it("never returns an empty primaryProviderId", () => { + const r = resolveRunMode(config({ providers: [], activeTextProvider: "ghost" })); + expect(r.primaryProviderId).toBe("ghost"); + }); +}); diff --git a/src/llm/run-mode/resolve-run-mode.ts b/src/llm/run-mode/resolve-run-mode.ts new file mode 100644 index 00000000..95aac11a --- /dev/null +++ b/src/llm/run-mode/resolve-run-mode.ts @@ -0,0 +1,131 @@ +import type { + RunModeName, + RunModeSubRunners, +} from "../../config/llm-run-mode-config.js"; +import { DEFAULT_FUSION_CLOUD_SHARE } from "../../config/llm-run-mode-config.js"; +import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js"; + +/** Provider kind that identifies the local leg. */ +const LOCAL_PROVIDER_KIND = "llama-server"; + +export type RunModeDegradationReason = + | "no-cloud-provider" + | "no-local-provider" + | "tool-transport-pinned"; + +export type RunModeDegradation = { + reason: RunModeDegradationReason; + /** The mode the operator asked for, before degradation. */ + requested: RunModeName; +}; + +export type ResolvedRunMode = { + /** What the config file says, or `null` when the block is absent. */ + stored: RunModeName | null; + /** What the runtime will actually do. */ + effective: RunModeName; + localProviderId: string | null; + cloudProviderId: string | null; + /** + * The provider that must be `llm.activeTextProvider` for `effective` + * to hold. Never empty — falls back to the configured active provider + * when neither leg resolves. + */ + primaryProviderId: string; + fusion: { cloudShare: number; subRunners: RunModeSubRunners }; + degraded: RunModeDegradation | null; +}; + +/** + * Project the `llm.runMode` block onto the providers that actually + * exist. + * + * `llm.activeTextProvider` stays AUTHORITATIVE — `runMode.mode` is + * purely additive. The effective mode is derived from which provider is + * active, and a stored `fusion` is only honoured when the cloud leg is + * the active one: + * + * ``` + * derived = kindOf(activeTextProvider) === "llama-server" ? "local" : "cloud" + * effective = stored === "fusion" && bothLegsExist && active === cloudId + * ? "fusion" : derived + * ``` + * + * That rule is what keeps the two keys from ever contradicting each + * other: an operator who switches provider by hand in Manage → LLM + * simply drops out of fusion on the next read, with no reconciliation + * step and no state that lies about what is running. It also means + * fusion pins the CLOUD provider as the fallback chain's primary, so + * `resolveFallbackChain` hoists it to the head and appends local at the + * tail with no changes of its own. + */ +export function resolveRunMode( + resolved: ResolvedLlmConfig, +): ResolvedRunMode { + const runMode = resolved.runMode; + const stored = runMode?.mode ?? null; + + const localProviderId = + runMode?.localProvider ?? + resolved.providers.find((p) => p.kind === LOCAL_PROVIDER_KIND)?.id ?? + null; + const cloudProviderId = + runMode?.cloudProvider ?? + resolved.providers.find((p) => p.kind !== LOCAL_PROVIDER_KIND)?.id ?? + null; + + const activeKind = resolved.providers.find( + (p) => p.id === resolved.activeTextProvider, + )?.kind; + // An unresolvable active provider means a broken config; assume local + // so a broken file can never silently start spending cloud tokens. + const derived: RunModeName = + activeKind === undefined || activeKind === LOCAL_PROVIDER_KIND + ? "local" + : "cloud"; + + const fusion = { + cloudShare: runMode?.fusion?.cloudShare ?? DEFAULT_FUSION_CLOUD_SHARE, + subRunners: runMode?.fusion?.subRunners ?? ("local" as RunModeSubRunners), + }; + + let effective: RunModeName = derived; + let degraded: RunModeDegradation | null = null; + + if (stored === "fusion") { + if (cloudProviderId === null) { + degraded = { reason: "no-cloud-provider", requested: stored }; + } else if (localProviderId === null) { + degraded = { reason: "no-local-provider", requested: stored }; + } else if (resolved.activeTextProvider === cloudProviderId) { + // Both legs exist AND the cloud leg is the active provider, which + // is what makes the cloud provider the fallback chain's primary. + effective = "fusion"; + if (resolved.toolTransport !== "auto") { + // Not a downgrade: fusion still runs, but a pinned transport + // sends one leg the wrong wire shape (grammar to a native-tools + // provider or vice versa), so the operator has to know. + degraded = { reason: "tool-transport-pinned", requested: stored }; + } + } + // else: the operator switched the active provider by hand, so we + // simply report `derived`. That is the non-contradiction rule + // working, not a degradation — nothing to warn about. + } else if (stored === "cloud" && cloudProviderId === null) { + degraded = { reason: "no-cloud-provider", requested: stored }; + } + + const primaryProviderId = + (effective === "local" ? localProviderId : cloudProviderId) ?? + resolved.activeTextProvider; + + return { + stored, + effective, + localProviderId, + cloudProviderId, + primaryProviderId, + fusion, + degraded, + }; +} diff --git a/src/llm/run-mode/run-mode-degradation.test.ts b/src/llm/run-mode/run-mode-degradation.test.ts new file mode 100644 index 00000000..ff0f4ceb --- /dev/null +++ b/src/llm/run-mode/run-mode-degradation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { describeRunModeDegradation } from "./run-mode-degradation.js"; + +describe("describeRunModeDegradation", () => { + it("names the orchestrator when fusion has no cloud leg", () => { + const msg = describeRunModeDegradation({ + reason: "no-cloud-provider", + requested: "fusion", + }); + expect(msg).toContain("Fusion needs a cloud orchestrator"); + expect(msg).toContain("Staying on local"); + }); + + it("uses the plain cloud wording when cloud mode has no cloud leg", () => { + const msg = describeRunModeDegradation({ + reason: "no-cloud-provider", + requested: "cloud", + }); + expect(msg).toContain("Cloud mode needs a cloud provider"); + expect(msg).not.toContain("Fusion"); + }); + + it("names the executor when fusion has no local leg", () => { + expect( + describeRunModeDegradation({ reason: "no-local-provider", requested: "fusion" }), + ).toContain("Fusion needs a local executor"); + }); + + it("explains a pinned tool transport as a warning, not a downgrade", () => { + const msg = describeRunModeDegradation({ + reason: "tool-transport-pinned", + requested: "fusion", + }); + expect(msg).toContain("llm.toolTransport"); + expect(msg).not.toContain("Staying on local"); + }); + + it("points every degradation at a way to fix it", () => { + expect( + describeRunModeDegradation({ reason: "no-cloud-provider", requested: "cloud" }), + ).toContain("/llm"); + }); +}); diff --git a/src/llm/run-mode/run-mode-degradation.ts b/src/llm/run-mode/run-mode-degradation.ts new file mode 100644 index 00000000..acdbdb99 --- /dev/null +++ b/src/llm/run-mode/run-mode-degradation.ts @@ -0,0 +1,23 @@ +import type { RunModeDegradation } from "./resolve-run-mode.js"; + +/** + * Operator-visible sentence for a run-mode degradation. + * + * Kept out of `resolveRunMode` so the resolver stays a pure projection + * and the wording can be asserted on its own — and so the TUI, the CLI + * and the HTTP surface all say exactly the same thing. + */ +export function describeRunModeDegradation( + degraded: RunModeDegradation, +): string { + switch (degraded.reason) { + case "no-cloud-provider": + return degraded.requested === "fusion" + ? "Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)." + : "Cloud mode needs a cloud provider — none is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)."; + case "no-local-provider": + return "Fusion needs a local executor — no llama-server provider is configured. Running cloud-only."; + case "tool-transport-pinned": + return 'Fusion works best with llm.toolTransport "auto" — it is pinned, so one leg will get the wrong wire format.'; + } +} From 42d290919ce17d7d9731da28754e3a86c0604ee5 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:21:41 +0300 Subject: [PATCH 05/57] feat(tui): start page adapts to terminal size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splash needed ~90 columns and ~29 rows before it could render honestly, and Ink 7 overlaps rather than clips an over-tall frame, so anything smaller garbled the start page. The right rail made it worse: it appeared at 100 columns and took a flat 30, leaving 70 for artwork that wanted 87, and without flexShrink={0} Yoga let the chat column claw columns back out of it — a 100-column terminal rendered an 18-wide rail with a truncated "(no sessions ye…". Add src/tui/layout.ts as the shared geometry both sides read: rail visibility and a proportional rail width, the chat width left over, the chat viewport rows (the old CHROME_ROWS from chat-log.tsx, plus a correction for narrow terminals where the status bar, hint strip and prompt placeholder wrap), and a per-pane row budget for the rail. Add src/tui/components/splash-fit.ts for the breakpoints: which mark to draw, whether the wordmark and tagline fit, how many tips survive, the label column width, and whether descriptions are full, terse or dropped. It is React-free so the table can be unit-tested directly. The mark now comes in three sizes — the original 34x20 art, a 17x10 reduction and a single-line fallback — and the tip list drops entries from its tail after collapsing descriptions to terse copy. The rail keeps the width it is given, derives preview truncation from that width, and both its panes use the shared computeRowWindow, so the Tasks pane finally scrolls to its cursor instead of slicing the first five rows. Also fixes two tests that were already failing on main: splash-banner asserted the pre-#97 tip list, and chat-log asserted the banner text "Local-First AI Agent" for a component that renders "Local AI-First Agent". --- src/tui/components/chat-log.test.tsx | 5 +- src/tui/components/chat-log.tsx | 15 +- src/tui/components/logo-fit.test.ts | 35 ++++ src/tui/components/logo.tsx | 119 ++++++++--- src/tui/components/sidebar.test.tsx | 79 +++++++ src/tui/components/sidebar.tsx | 117 ++++++++--- src/tui/components/splash-banner.test.tsx | 51 ++++- src/tui/components/splash-banner.tsx | 75 +++++-- src/tui/components/splash-fit.render.test.tsx | 80 ++++++++ src/tui/components/splash-fit.test.ts | 112 ++++++++++ src/tui/components/splash-fit.ts | 192 ++++++++++++++++++ src/tui/layout.test.ts | 91 +++++++++ src/tui/layout.ts | 141 +++++++++++++ src/tui/tui-app.test.tsx | 6 +- src/tui/tui-app.tsx | 26 ++- 15 files changed, 1034 insertions(+), 110 deletions(-) create mode 100644 src/tui/components/logo-fit.test.ts create mode 100644 src/tui/components/splash-fit.render.test.tsx create mode 100644 src/tui/components/splash-fit.test.ts create mode 100644 src/tui/components/splash-fit.ts create mode 100644 src/tui/layout.test.ts create mode 100644 src/tui/layout.ts diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx index 8cd25f51..1bfda8fc 100644 --- a/src/tui/components/chat-log.test.tsx +++ b/src/tui/components/chat-log.test.tsx @@ -25,7 +25,10 @@ describe("ChatLog", () => { const state = createInitialTuiState(BASE_SESSION); const { lastFrame } = render(); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Local-First AI Agent"); + // The mark shrinks with the surface, so assert on the plus bar the + // artwork always keeps rather than on a wordmark that only a tall + // terminal earns. See `splash-fit.render.test.tsx`. + expect(text).toContain(":::"); expect(text).toContain("/help"); }); diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index 09f0e081..bc020054 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -1,6 +1,7 @@ import { Box, Text, measureElement, type DOMElement } from "ink"; import { useEffect, useRef, useState, type ReactElement } from "react"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { computeChatViewportRows } from "../layout.js"; import type { TuiAction } from "../tui-action.js"; import type { ChatMessage, TuiState } from "../tui-state.js"; import { theme } from "../theme/theme.js"; @@ -16,15 +17,6 @@ import { ThinkingIndicator } from "./thinking-indicator.js"; import { ToolCard } from "./tool-card.js"; import { UserBubble } from "./user-bubble.js"; -/** - * Rows of "chrome" outside the chat surface: status bar + prompt - * meta-row + prompt input + prompt tail-cap + hotkey hint + a small - * safety pad. Used to convert `terminal.rows` into the chat-area - * viewport height. Slightly conservative — better to leave one empty - * row than to clip the prompt. - */ -const CHROME_ROWS = 8; - interface ChatLogProps { state: TuiState; /** @@ -89,7 +81,10 @@ export function ChatLog({ state, dispatch }: ChatLogProps): ReactElement { // All hooks must run unconditionally — only the JSX branches on // `isEmpty`. Compute viewport / measured-K / clamp regardless, // even when the early return for the splash branch fires below. - const viewport = Math.max(5, terminalSize.rows - CHROME_ROWS); + const viewport = computeChatViewportRows( + terminalSize.rows, + terminalSize.columns, + ); // First-frame fallback for `K` until the post-mount `measureElement` // call returns the truth. Estimates are unreliable (text wraps, Yoga // collapses some margins, reasoning blocks expand mid-turn) so we diff --git a/src/tui/components/logo-fit.test.ts b/src/tui/components/logo-fit.test.ts new file mode 100644 index 00000000..d44767b8 --- /dev/null +++ b/src/tui/components/logo-fit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { LOGO_ART, TAGLINE, WORDMARK_ROWS } from "./logo.js"; +import { LOGO_METRICS, WORDMARK_WIDTH, type LogoVariant } from "./splash-fit.js"; + +function measure(rows: readonly string[]): { width: number; height: number } { + return { + width: rows.reduce((acc, row) => Math.max(acc, row.length), 0), + height: rows.length, + }; +} + +/** + * `splash-fit.ts` picks a mark from numbers it keeps in `LOGO_METRICS`; + * the artwork itself lives in `logo.tsx`. If the two ever drift the + * breakpoints silently start lying, so measure the real rows here. + */ +describe("logo artwork", () => { + const variants: readonly LogoVariant[] = ["full", "small", "mini"]; + + it.each(variants)("matches the declared metrics for %s", (variant) => { + expect(measure(LOGO_ART[variant])).toEqual(LOGO_METRICS[variant]); + }); + + it("orders the variants strictly smallest-last", () => { + expect(LOGO_METRICS.full.width).toBeGreaterThan(LOGO_METRICS.small.width); + expect(LOGO_METRICS.small.width).toBeGreaterThan(LOGO_METRICS.mini.width); + expect(LOGO_METRICS.full.height).toBeGreaterThan(LOGO_METRICS.small.height); + expect(LOGO_METRICS.small.height).toBeGreaterThan(LOGO_METRICS.mini.height); + }); + + it("matches the declared wordmark width and keeps the tagline narrower", () => { + expect(measure(WORDMARK_ROWS).width).toBe(WORDMARK_WIDTH); + expect(TAGLINE.length).toBeLessThanOrEqual(WORDMARK_WIDTH); + }); +}); diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx index 42fd4015..2996c6ac 100644 --- a/src/tui/components/logo.tsx +++ b/src/tui/components/logo.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import type { LogoVariant } from "./splash-fit.js"; /** * Atomic-plus mark + `ATOMIC AGENT` wordmark, rendered side-by-side and @@ -8,35 +9,38 @@ import { theme } from "../theme/theme.js"; * can be reused in any centered "home" layout (e.g. the empty-chat * landing surface) without copying the row data. * - * Rendered as plain Ink primitives — no animations, no alpha. Use the - * `compact` variant in narrow layouts where the wordmark would wrap. + * Rendered as plain Ink primitives — no animations, no alpha. The mark + * comes in three sizes so the same component can serve a 200-column + * desktop terminal and a 40-column SSH window: `full` (34×20), `small` + * (17×10) and `mini` (a single 14-column line). `SplashBanner` picks + * one via `computeSplashFit`; callers that just want the classic + * artwork can keep using the defaults. */ export interface LogoProps { + /** Which mark to draw. Defaults to the full 34×20 artwork. */ + variant?: LogoVariant; + /** + * Legacy switch for "mark only, no wordmark". Still honoured so + * existing callers keep working; prefer `wordmark={false}`. + */ compact?: boolean; + /** Draw the `ATOMIC AGENT` wordmark beside the mark. */ + wordmark?: boolean; + /** Draw the "Local AI-First Agent" tagline under the wordmark. */ + tagline?: boolean; } -export function Logo({ compact = false }: LogoProps): ReactElement { - return ( - - - {compact ? null : ( - - - - - Local AI-First Agent - - - - )} - - ); -} - -function LogoMark(): ReactElement { +/** + * Mark artwork keyed by variant. `full` is the original drawing; the + * others preserve its silhouette — upper-left flare, full-width cross + * bar, tapering lower-right tail — at roughly half scale and as a + * single line. `splash-fit.ts` mirrors these dimensions in + * `LOGO_METRICS`; `logo-fit.test.ts` fails if the two ever disagree. + */ +export const LOGO_ART: Readonly> = { // Leading padding has been uniformly trimmed so the middle bar sits // at column 0 — keeps the art within ~34 columns for narrow terminals. - const rows: readonly string[] = [ + full: [ " -:::::::--", " -::::::::-", " -:::::::::-", @@ -57,11 +61,68 @@ function LogoMark(): ReactElement { " -::::::::=", " +--------*", " %%%%%%", - ]; + ], + small: [ + " -:::--", + " -::::-", + " -:::::-", + " -:::::::-", + "-:::::::::::::::-", + ":::::::::::::::::", + "=-----:::::::::-=", + " @@@@@*-::::-=#%", + " -::::-*", + " +----*", + ], + mini: ["+ ATOMIC AGENT"], +}; + +export const WORDMARK_ROWS: readonly string[] = [ + "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀", + "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ", +]; + +export const TAGLINE = "Local AI-First Agent"; + +export function Logo({ + variant = "full", + compact = false, + wordmark, + tagline, +}: LogoProps): ReactElement { + const showWordmark = wordmark ?? !compact; + const showTagline = tagline ?? showWordmark; + if (variant === "mini") { + return ( + + {LOGO_ART.mini[0]} + + ); + } + return ( + + + {showWordmark || showTagline ? ( + + {showWordmark ? : null} + {showTagline ? ( + + + {TAGLINE} + + + ) : null} + + ) : null} + + ); +} + +function LogoMark({ variant }: { variant: LogoVariant }): ReactElement { return ( - {rows.map((row, idx) => ( - + {LOGO_ART[variant].map((row, idx) => ( + {row} ))} @@ -70,14 +131,10 @@ function LogoMark(): ReactElement { } function WordMark(): ReactElement { - const rows: readonly string[] = [ - "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀", - "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ", - ]; return ( - {rows.map((row, idx) => ( - + {WORDMARK_ROWS.map((row, idx) => ( + {row} ))} diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx index 89ec7b05..2e7a20e6 100644 --- a/src/tui/components/sidebar.test.tsx +++ b/src/tui/components/sidebar.test.tsx @@ -147,4 +147,83 @@ describe("Sidebar", () => { expect(text).toContain("running task"); expect(text).toContain("pending task"); }); + it("honours the per-pane row budget instead of a fixed 10/5 split", () => { + const manySessions = Array.from({ length: 12 }, (_, idx) => ({ + ...SESSIONS[0]!, + sessionId: `s-${idx}`, + preview: `session number ${idx}`, + })); + const manyTasks = Array.from({ length: 8 }, (_, idx) => + taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }), + ); + const { lastFrame } = render( + , + ); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("session number 2"); + expect(text).not.toContain("session number 3"); + expect(text).toContain("task number 1"); + expect(text).not.toContain("task number 2"); + // Both panes admit what they are hiding. + expect(text).toContain("9 more"); + expect(text).toContain("6 more"); + // Two headers + 3 sessions + 2 tasks + 2 "more" rows + blank row. + expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(10); + }); + + it("scrolls the Tasks pane to keep the cursor visible", () => { + const manyTasks = Array.from({ length: 8 }, (_, idx) => + taskRow({ id: `t-${idx}`, userMessage: `task number ${idx}` }), + ); + const { lastFrame } = render( + , + ); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("task number 7"); + expect(text).not.toContain("task number 0"); + // The chevron sits on the selected row, not on whatever row 0 is. + expect(text).toMatch(/▸ [^\n]*task number 7/); + }); + + it("narrows the previews with the rail rather than overflowing it", () => { + const long = [{ ...SESSIONS[0]!, preview: "a very long session preview indeed" }]; + const { lastFrame } = render( + , + ); + const widest = strip(lastFrame() ?? "") + .split("\n") + .reduce((acc, line) => Math.max(acc, line.replace(/\s+$/, "").length), 0); + expect(widest).toBeLessThanOrEqual(24); + expect(strip(lastFrame() ?? "")).toContain("…"); + }); }); diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index f7ce46b0..ef83e736 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { computeRowWindow } from "../row-window.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; @@ -17,10 +18,26 @@ export interface SidebarProps { activeSection: SidebarSection; /** Whether the sidebar owns keyboard focus right now. */ focused: boolean; + /** + * Row budget for each pane, normally derived from the terminal height + * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep + * the pre-adaptive behaviour for callers that do not measure. + */ + maxSessionRows?: number; + maxTaskRows?: number; } -const MAX_SESSION_ROWS = 10; -const MAX_TASK_ROWS = 5; +const DEFAULT_MAX_SESSION_ROWS = 10; +const DEFAULT_MAX_TASK_ROWS = 5; + +/** + * Cells each list row spends before the preview text: the border, the + * two padding columns, the selection chevron and the status marker, + * plus the spaces between them. + */ +const ROW_CHROME_COLUMNS = 7; +/** Never squeeze a preview below this — an ellipsis alone helps nobody. */ +const MIN_PREVIEW_COLUMNS = 6; /** * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and @@ -29,6 +46,8 @@ const MAX_TASK_ROWS = 5; * `app-key-bindings.ts`); the sidebar component itself is purely * presentational and never measures the terminal directly so the * same component works under ink-testing-library's static viewport. + * Its width and per-pane row budgets arrive as props from `TuiApp`, + * which owns the terminal measurement. * * Focus is layered: `focused` toggles the section header colour for * the active pane, and `activeSection` decides which pane gets the @@ -45,12 +64,22 @@ export function Sidebar(props: SidebarProps): ReactElement { tasksCursor, activeSection, focused, + maxSessionRows = DEFAULT_MAX_SESSION_ROWS, + maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + const previewWidth = Math.max( + MIN_PREVIEW_COLUMNS, + width - ROW_CHROME_COLUMNS, + ); + // `flexShrink={0}`: Yoga shrinks flex children by default, so a wide + // chat column used to steal columns back from the rail — which made + // the width the splash was told to plan for a lie. return ( @@ -75,6 +106,8 @@ export function Sidebar(props: SidebarProps): ReactElement { tasks={tasks} cursor={tasksCursor} focused={tasksActive} + maxRows={maxTaskRows} + previewWidth={previewWidth} /> ); @@ -98,6 +131,8 @@ interface SessionsListProps { cursor: number; focused: boolean; currentSessionId: string | null; + maxRows: number; + previewWidth: number; } function SessionsList({ @@ -105,17 +140,20 @@ function SessionsList({ cursor, focused, currentSessionId, + maxRows, + previewWidth, }: SessionsListProps): ReactElement { if (sessions.length === 0) { return ( - (no sessions yet) + + (no sessions yet) + ); } - const clamped = Math.max(0, Math.min(cursor, sessions.length - 1)); - const windowStart = computeWindowStart(clamped, sessions.length, MAX_SESSION_ROWS); - const visible = sessions.slice(windowStart, windowStart + MAX_SESSION_ROWS); - const visibleCursor = clamped - windowStart; - const hiddenAfter = Math.max(0, sessions.length - windowStart - visible.length); + const window = computeRowWindow(sessions.length, cursor, maxRows); + const visible = sessions.slice(window.start, window.start + window.count); + const visibleCursor = + Math.max(0, Math.min(cursor, sessions.length - 1)) - window.start; return ( {visible.map((entry, idx) => ( @@ -124,11 +162,10 @@ function SessionsList({ entry={entry} selected={focused && idx === visibleCursor} current={entry.sessionId === currentSessionId} + previewWidth={previewWidth} /> ))} - {hiddenAfter > 0 ? ( - ↓ {hiddenAfter} more - ) : null} + ); } @@ -137,10 +174,16 @@ interface SessionRowProps { entry: SessionPickerEntry; selected: boolean; current: boolean; + previewWidth: number; } -function SessionRow({ entry, selected, current }: SessionRowProps): ReactElement { - const preview = truncate(entry.preview, 28); +function SessionRow({ + entry, + selected, + current, + previewWidth, +}: SessionRowProps): ReactElement { + const preview = truncate(entry.preview, previewWidth); const marker = current ? theme.glyphs.assistantMarker : " "; const chevron = selected ? theme.glyphs.chevronRight : " "; return ( @@ -158,15 +201,28 @@ interface TasksListProps { tasks: readonly TaskSummaryRow[]; cursor: number; focused: boolean; + maxRows: number; + previewWidth: number; } -function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { +function TasksList({ + tasks, + cursor, + focused, + maxRows, + previewWidth, +}: TasksListProps): ReactElement { if (tasks.length === 0) { - return (no active tasks); + return ( + + (no active tasks) + + ); } - const clamped = Math.max(0, Math.min(cursor, tasks.length - 1)); - const visible = tasks.slice(0, MAX_TASK_ROWS); - const visibleCursor = Math.min(clamped, visible.length - 1); + const window = computeRowWindow(tasks.length, cursor, maxRows); + const visible = tasks.slice(window.start, window.start + window.count); + const visibleCursor = + Math.max(0, Math.min(cursor, tasks.length - 1)) - window.start; return ( {visible.map((row, idx) => ( @@ -174,8 +230,10 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { key={row.id} row={row} selected={focused && idx === visibleCursor} + previewWidth={previewWidth} /> ))} + ); } @@ -183,10 +241,11 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { interface TaskRowProps { row: TaskSummaryRow; selected: boolean; + previewWidth: number; } -function TaskRow({ row, selected }: TaskRowProps): ReactElement { - const preview = truncate(row.userMessage, 24); +function TaskRow({ row, selected, previewWidth }: TaskRowProps): ReactElement { + const preview = truncate(row.userMessage, previewWidth); const chevron = selected ? theme.glyphs.chevronRight : " "; const badge = statusBadge(row); return ( @@ -200,6 +259,16 @@ function TaskRow({ row, selected }: TaskRowProps): ReactElement { ); } +/** "↓ N more" footer, or nothing at all when the tail is visible. */ +function MoreRow({ hidden }: { hidden: number }): ReactElement | null { + if (hidden <= 0) return null; + return ( + + ↓ {hidden} more + + ); +} + /** * One-glyph status indicator: ● running, ○ pending, ↻ recurring (when * the task is not currently running or queued), · for the rare other @@ -216,11 +285,5 @@ function truncate(text: string, max: number): string { const oneLine = text.replace(/\s+/g, " ").trim(); if (oneLine.length === 0) return "(empty)"; if (oneLine.length <= max) return oneLine; - return `${oneLine.slice(0, max - 1)}…`; -} - -function computeWindowStart(cursor: number, total: number, size: number): number { - if (total <= size) return 0; - if (cursor < size) return 0; - return Math.min(cursor - size + 1, total - size); + return `${oneLine.slice(0, Math.max(1, max - 1))}…`; } diff --git a/src/tui/components/splash-banner.test.tsx b/src/tui/components/splash-banner.test.tsx index a96f3c9b..cb4804db 100644 --- a/src/tui/components/splash-banner.test.tsx +++ b/src/tui/components/splash-banner.test.tsx @@ -4,14 +4,18 @@ import { SplashBanner } from "./splash-banner.js"; function strip(value: string): string { return value - .replace(/\u001b\[[0-9;]*m/g, "") - .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); + .replace(/\[[0-9;]*m/g, "") + .replace(/\]8;;[^]*/g, ""); +} + +function frameAt(columns: number, rows: number): string { + const { lastFrame } = render(); + return strip(lastFrame() ?? ""); } describe("SplashBanner", () => { - it("renders the plus-mark middle bar and the wordmark", () => { - const { lastFrame } = render(); - const frame = strip(lastFrame() ?? ""); + it("renders the plus-mark middle bar and the wordmark on a roomy surface", () => { + const frame = frameAt(96, 40); // Middle bar of the plus — longest uninterrupted `:` run in the art. expect(frame).toContain("::::::::::::::::::::::::::::::::::"); // Both halves of the `ATOMIC AGENT` half-block wordmark. @@ -21,14 +25,41 @@ describe("SplashBanner", () => { }); it("advertises the core slash commands and hotkeys", () => { - const { lastFrame } = render(); - const frame = strip(lastFrame() ?? ""); + const frame = frameAt(96, 40); expect(frame).toContain("/help"); expect(frame).toContain("/sessions"); expect(frame).toContain("/new"); - expect(frame).toContain("/observe"); - expect(frame).toContain("/manage"); - expect(frame).toContain("/run"); + expect(frame).toContain("/model"); + expect(frame).toContain("/tasks"); + expect(frame).toContain("/import"); expect(frame).toContain("Ctrl+C"); }); + + it("keeps the most useful tips when the surface is too short for all of them", () => { + const frame = frameAt(96, 16); + expect(frame).toContain("/help"); + expect(frame).toContain("/sessions"); + // The tail of the list is what gives way first. + expect(frame).not.toContain("/import"); + }); + + it("swaps in terse descriptions on a narrow surface", () => { + const frame = frameAt(44, 20); + expect(frame).toContain("/help"); + expect(frame).toContain("all commands"); + expect(frame).not.toContain("list all slash commands"); + }); + + it("still shows a brand mark and a tip on a 40x12 window", () => { + const frame = frameAt(38, 4); + expect(frame).toContain("+ ATOMIC AGENT"); + expect(frame).toContain("Enter"); + }); + + it("measures the terminal itself when no size is given", () => { + const { lastFrame } = render(); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain(":::"); + expect(frame).toContain("/help"); + }); }); diff --git a/src/tui/components/splash-banner.tsx b/src/tui/components/splash-banner.tsx index 64fe5ddc..fcf610ef 100644 --- a/src/tui/components/splash-banner.tsx +++ b/src/tui/components/splash-banner.tsx @@ -1,7 +1,17 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { Logo } from "./logo.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { computeChatViewportRows, computeChatWidth } from "../layout.js"; import { theme } from "../theme/theme.js"; +import { Logo } from "./logo.js"; +import { + computeSplashFit, + SPLASH_TIPS, + type SplashFit, + type SplashSize, + type SplashTip, + type TipDescriptions, +} from "./splash-fit.js"; /** * Welcome screen shown in place of an empty chat-log. Renders the brand @@ -9,41 +19,68 @@ import { theme } from "../theme/theme.js"; * spacers, with a compact tip-list underneath that surfaces the most * useful slash commands and hotkeys. * + * Everything on it is sized against the live terminal: the mark shrinks + * (34×20 → 17×10 → one line) as the window narrows or shortens, the tip + * list drops entries from its tail, and the tip descriptions collapse to + * terse copy before disappearing entirely. See `splash-fit.ts` for the + * breakpoints — this component only renders the plan it is handed. + * * Visibility is decided by the parent (`ChatLog`) based on * `messages.length === 0`, so restoring a historical session via * `/sessions` swaps the banner out for the transcript. */ -export function SplashBanner(): ReactElement { +export interface SplashBannerProps { + /** + * Explicit surface size, bypassing the terminal measurement. Only + * used by tests — ink-testing-library's stdout stub reports a fixed + * 100×0, which would pin every rendered frame to one breakpoint. + */ + size?: SplashSize; +} + +export function SplashBanner({ size }: SplashBannerProps = {}): ReactElement { + const terminal = useTerminalSize(); + const surface: SplashSize = size ?? { + columns: computeChatWidth(terminal.columns), + rows: computeChatViewportRows(terminal.rows, terminal.columns), + }; + const fit = computeSplashFit(surface); + const tips = SPLASH_TIPS.slice(0, fit.tipCount); return ( - - - - - - - - - - - + + {tips.length > 0 ? ( + + {tips.map((tip) => ( + + ))} + + ) : null} ); } interface TipProps { - left: string; - right: string; + tip: SplashTip; + fit: SplashFit; } -function Tip({ left, right }: TipProps): ReactElement { +function Tip({ tip, fit }: TipProps): ReactElement { + const label = + fit.labelWidth > 0 ? tip.label.padEnd(fit.labelWidth, " ") : tip.label; return ( - + {theme.glyphs.bullet} - {left.padEnd(24, " ")} - {right} + {label} + {description(tip, fit.descriptions)} ); } + +function description(tip: SplashTip, mode: TipDescriptions): string { + if (mode === "full") return tip.description; + if (mode === "short") return tip.short; + return ""; +} diff --git a/src/tui/components/splash-fit.render.test.tsx b/src/tui/components/splash-fit.render.test.tsx new file mode 100644 index 00000000..fbfe2f24 --- /dev/null +++ b/src/tui/components/splash-fit.render.test.tsx @@ -0,0 +1,80 @@ +import { render } from "ink-testing-library"; +import { Box } from "ink"; +import { describe, expect, it } from "vitest"; +import { computeChatViewportRows, computeChatWidth } from "../layout.js"; +import { SplashBanner } from "./splash-banner.js"; + +function lines(frame: string): string[] { + return frame + .replace(/\[[0-9;]*m/g, "") + .split("\n") + .map((line) => line.replace(/\s+$/, "")); +} + +/** + * Regression guard for the "small window garbles the start page" bug, + * modelled on `manage-panel-fit.test.tsx`. Ink 7 does NOT clip a frame + * taller than the terminal — it overlaps earlier lines — and it wraps a + * line wider than the surface into confetti. The splash therefore has + * to plan its own size, and the plan has to survive contact with Yoga. + * + * ink-testing-library pins its stdout at 100 columns and reports no + * rows at all, so each case renders `SplashBanner` at an explicit + * surface size inside a `Box` of that width — the same geometry the + * chat column hands it in production. + */ +const TERMINALS: ReadonlyArray<{ columns: number; rows: number }> = [ + { columns: 40, rows: 12 }, + { columns: 60, rows: 20 }, + { columns: 80, rows: 24 }, + { columns: 100, rows: 30 }, + { columns: 100, rows: 50 }, +]; + +describe("SplashBanner fit", () => { + it.each(TERMINALS)("fits a $columns x $rows terminal", (terminal) => { + const size = { + columns: computeChatWidth(terminal.columns), + rows: computeChatViewportRows(terminal.rows), + }; + const { lastFrame } = render( + + + , + ); + const rendered = lines(lastFrame() ?? ""); + const widest = rendered.reduce((acc, line) => Math.max(acc, line.length), 0); + expect(widest).toBeLessThanOrEqual(size.columns); + expect(rendered.length).toBeLessThanOrEqual(size.rows); + // A splash with no recognisable brand mark is not a splash. + expect(rendered.join("\n")).toMatch(/ATOMIC AGENT|:::/); + }); + + it("renders the full artwork, wordmark and every tip when there is room", () => { + const size = { columns: 96, rows: 40 }; + const { lastFrame } = render( + + + , + ); + const frame = lines(lastFrame() ?? "").join("\n"); + expect(frame).toContain("::::::::::::::::::::::::::::::::::"); + expect(frame).toContain("▄▀█ ▀█▀ █▀█"); + expect(frame).toContain("Local AI-First Agent"); + expect(frame).toContain("/import"); + }); + + it("collapses to the one-line mark and bare labels on a tiny surface", () => { + const size = { columns: 24, rows: 10 }; + const { lastFrame } = render( + + + , + ); + const frame = lines(lastFrame() ?? "").join("\n"); + expect(frame).toContain("+ ATOMIC AGENT"); + expect(frame).not.toContain("▄▀█ ▀█▀ █▀█"); + expect(frame).toContain("/help"); + expect(frame).not.toContain("list all slash commands"); + }); +}); diff --git a/src/tui/components/splash-fit.test.ts b/src/tui/components/splash-fit.test.ts new file mode 100644 index 00000000..af670e66 --- /dev/null +++ b/src/tui/components/splash-fit.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + computeSplashFit, + LOGO_METRICS, + SPLASH_TIPS, + type LogoVariant, +} from "./splash-fit.js"; + +const SIZE_ORDER: readonly LogoVariant[] = ["mini", "small", "full"]; + +describe("computeSplashFit", () => { + it("gives a roomy terminal the full artwork, the wordmark and every tip", () => { + expect(computeSplashFit({ columns: 92, rows: 40 })).toEqual({ + logo: "full", + wordmark: true, + tagline: true, + tipCount: SPLASH_TIPS.length, + labelWidth: 24, + descriptions: "full", + }); + }); + + it("drops the wordmark before the mark when the surface narrows", () => { + // 82 inner columns — one short of mark + gap + wordmark. + const fit = computeSplashFit({ columns: 86, rows: 40 }); + expect(fit.logo).toBe("full"); + expect(fit.wordmark).toBe(false); + expect(fit.tagline).toBe(false); + }); + + it("shrinks the mark when the surface is too short for the tall artwork", () => { + // A 100x24 terminal leaves the chat surface 73x16. + expect(computeSplashFit({ columns: 73, rows: 16 })).toEqual({ + logo: "small", + wordmark: false, + tagline: false, + tipCount: 5, + labelWidth: 24, + descriptions: "full", + }); + }); + + it("falls back to the one-line mark and terse copy on a small window", () => { + expect(computeSplashFit({ columns: 38, rows: 12 })).toEqual({ + logo: "mini", + wordmark: false, + tagline: false, + tipCount: SPLASH_TIPS.length, + labelWidth: 10, + descriptions: "short", + }); + }); + + it("keeps bare labels when there is no room for any description", () => { + const fit = computeSplashFit({ columns: 20, rows: 10 }); + expect(fit.logo).toBe("mini"); + expect(fit.descriptions).toBe("none"); + expect(fit.labelWidth).toBe(0); + expect(fit.tipCount).toBeGreaterThan(0); + }); + + it("drops the tip list entirely rather than overflow a two-row surface", () => { + expect(computeSplashFit({ columns: 92, rows: 2 })).toMatchObject({ + logo: "mini", + tipCount: 0, + descriptions: "none", + }); + }); + + it("survives a degenerate surface without going negative", () => { + const fit = computeSplashFit({ columns: 0, rows: 0 }); + expect(fit.tipCount).toBe(0); + expect(fit.labelWidth).toBe(0); + expect(fit.logo).toBe("mini"); + }); + + it("plans a layout that fits the surface it was given", () => { + for (let columns = 10; columns <= 200; columns += 3) { + for (let rows = 2; rows <= 60; rows += 3) { + const fit = computeSplashFit({ columns, rows }); + const height = + LOGO_METRICS[fit.logo].height + (fit.tipCount > 0 ? 1 + fit.tipCount : 0); + expect(height).toBeLessThanOrEqual(Math.max(rows, LOGO_METRICS.mini.height)); + expect(fit.tipCount).toBeGreaterThanOrEqual(0); + expect(fit.labelWidth).toBeGreaterThanOrEqual(0); + if (fit.wordmark) expect(fit.logo).toBe("full"); + } + } + }); + + it("never shrinks the mark as the terminal gets wider", () => { + let previous = 0; + for (let columns = 10; columns <= 200; columns += 1) { + const rank = SIZE_ORDER.indexOf(computeSplashFit({ columns, rows: 60 }).logo); + expect(rank).toBeGreaterThanOrEqual(previous); + previous = rank; + } + }); + + it("never shows fewer tips as the terminal grows, for a fixed mark", () => { + // Across a variant change the count legitimately drops: a taller + // window buys a taller mark, which is paid for in tip rows. Within + // one variant the list may only grow. + const perVariant = new Map(); + for (let rows = 2; rows <= 80; rows += 1) { + const { logo, tipCount } = computeSplashFit({ columns: 92, rows }); + expect(tipCount).toBeGreaterThanOrEqual(perVariant.get(logo) ?? 0); + perVariant.set(logo, tipCount); + } + expect(perVariant.get("full")).toBe(SPLASH_TIPS.length); + }); +}); diff --git a/src/tui/components/splash-fit.ts b/src/tui/components/splash-fit.ts new file mode 100644 index 00000000..362b01aa --- /dev/null +++ b/src/tui/components/splash-fit.ts @@ -0,0 +1,192 @@ +/** + * Fit maths for the start-page splash — which brand mark to draw, how + * many tips to keep, and how wide the tip columns may be for a given + * chat-surface size. + * + * The splash used to be a fixed 83×20 mark plus eight fixed tip rows, + * i.e. it needed 90 columns and ~29 rows no matter what the terminal + * offered. Ink 7 does not clip an over-tall frame — it overlaps + * earlier lines (see `../row-window.ts`) — so a short window garbled + * the whole start page, and a narrow one wrapped the artwork into + * confetti. + * + * The mark has priority over the tip list: a window that grows tall + * enough for a bigger mark spends its new rows on the artwork first, so + * the tip count can legitimately drop across a variant change. Within a + * variant the list only ever grows. + * + * This module is deliberately React-free so the breakpoints can be + * unit-tested as a table instead of through rendered frames. + */ + +export type LogoVariant = "full" | "small" | "mini"; + +export interface SplashSize { + columns: number; + rows: number; +} + +export type TipDescriptions = "full" | "short" | "none"; + +export interface SplashFit { + /** Which brand mark to draw. */ + logo: LogoVariant; + /** Whether the `ATOMIC AGENT` wordmark sits beside the mark. */ + wordmark: boolean; + /** Whether the "Local AI-First Agent" tagline is drawn. */ + tagline: boolean; + /** How many tips fit, taken from the head of `SPLASH_TIPS`. */ + tipCount: number; + /** Padded width of the tip label column (0 when unpadded). */ + labelWidth: number; + /** Which description text to pair with each tip label. */ + descriptions: TipDescriptions; +} + +export interface SplashTip { + label: string; + /** Roomy copy, used when the surface can carry it. */ + description: string; + /** Terse copy for narrow surfaces. */ + short: string; +} + +/** + * Start-page tips in priority order — the tail is dropped first when + * the surface runs out of rows, so the entries that keep a first-run + * operator moving have to come first. + */ +export const SPLASH_TIPS: readonly SplashTip[] = [ + { + label: "Enter", + description: "submit message to the agent", + short: "send message", + }, + { + label: "/help", + description: "list all slash commands", + short: "all commands", + }, + { + label: "/sessions", + description: "switch to a previous thread", + short: "past threads", + }, + { label: "/new", description: "start a fresh session", short: "new session" }, + { label: "/model", description: "change the chat model", short: "pick model" }, + { + label: "/tasks", + description: "jump to the Tasks tab (cron + ingress UI)", + short: "Tasks tab", + }, + { + label: "/import", + description: "open the Import tab (Hermes migration)", + short: "Hermes import", + }, + { + label: "Ctrl+C ×2", + description: "quit (once aborts a running turn)", + short: "quit", + }, +]; + +interface LogoMetrics { + width: number; + height: number; +} + +/** + * Rendered footprint of each mark, in cells. Kept beside the art in + * `logo.tsx` by `logo-fit.test.ts`, which re-measures the row data and + * fails if the two ever drift apart. + */ +export const LOGO_METRICS: Readonly> = { + full: { width: 34, height: 20 }, + small: { width: 17, height: 10 }, + mini: { width: 14, height: 1 }, +}; + +/** `ATOMIC AGENT` half-block wordmark, plus the gap that precedes it. */ +export const WORDMARK_WIDTH = 46; +const WORDMARK_GAP = 3; + +/** `paddingX` on the splash container. */ +const SPLASH_PADDING_X = 2; +/** `" • "` in front of every tip label. */ +const TIP_PREFIX_WIDTH = 4; +/** Roomy tip-label column, matching the pre-adaptive layout. */ +const TIP_LABEL_WIDE = 24; +/** Tips are worth keeping only if a few of them survive together. */ +const MIN_TIPS = 3; +/** One blank row separates the mark from the tip list. */ +const TIP_LIST_MARGIN_ROWS = 1; + +const VARIANTS_WIDEST_FIRST: readonly LogoVariant[] = ["full", "small", "mini"]; + +/** Width at which the mark and the wordmark fit side by side. */ +const FULL_WITH_WORDMARK_WIDTH = + LOGO_METRICS.full.width + WORDMARK_GAP + WORDMARK_WIDTH; + +function maxLength(values: readonly string[]): number { + return values.reduce((acc, value) => Math.max(acc, value.length), 0); +} + +/** + * Resolve the splash layout for a chat surface of `size`. + * + * `size` is the space the splash itself owns — already net of the root + * padding, the right rail and the prompt chrome (see `../layout.ts`). + * Width picks the mark, height then downgrades it until at least + * {@link MIN_TIPS} tips can sit underneath, and whatever rows are left + * decide how much of the tip list survives. + */ +export function computeSplashFit(size: SplashSize): SplashFit { + const inner = Math.max(0, size.columns - SPLASH_PADDING_X * 2); + const rows = Math.max(0, size.rows); + + let index = VARIANTS_WIDEST_FIRST.findIndex( + (variant) => LOGO_METRICS[variant].width <= inner, + ); + if (index === -1) index = VARIANTS_WIDEST_FIRST.length - 1; + while ( + index < VARIANTS_WIDEST_FIRST.length - 1 && + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height + + TIP_LIST_MARGIN_ROWS + + MIN_TIPS > + rows + ) { + index += 1; + } + const logo = VARIANTS_WIDEST_FIRST[index]!; + + // The wordmark is a 46-column luxury; it only rides along with the + // full mark, and only once both fit side by side. + const wordmark = logo === "full" && inner >= FULL_WITH_WORDMARK_WIDTH; + const tagline = wordmark; + + const spare = rows - LOGO_METRICS[logo].height - TIP_LIST_MARGIN_ROWS; + const tipCount = Math.max(0, Math.min(SPLASH_TIPS.length, spare)); + const visible = SPLASH_TIPS.slice(0, tipCount); + + if (visible.length === 0) { + return { logo, wordmark, tagline, tipCount: 0, labelWidth: 0, descriptions: "none" }; + } + + const longestLabel = maxLength(visible.map((tip) => tip.label)); + const longestFull = maxLength(visible.map((tip) => tip.description)); + const longestShort = maxLength(visible.map((tip) => tip.short)); + const tightLabel = longestLabel + 1; + const budget = inner - TIP_PREFIX_WIDTH; + + if (budget >= TIP_LABEL_WIDE + longestFull) { + return { logo, wordmark, tagline, tipCount, labelWidth: TIP_LABEL_WIDE, descriptions: "full" }; + } + if (budget >= tightLabel + longestFull) { + return { logo, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "full" }; + } + if (budget >= tightLabel + longestShort) { + return { logo, wordmark, tagline, tipCount, labelWidth: tightLabel, descriptions: "short" }; + } + return { logo, wordmark, tagline, tipCount, labelWidth: 0, descriptions: "none" }; +} diff --git a/src/tui/layout.test.ts b/src/tui/layout.test.ts new file mode 100644 index 00000000..e0dd0a91 --- /dev/null +++ b/src/tui/layout.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + computeChatViewportRows, + computeChatWidth, + computeSidebarRowBudget, + computeSidebarWidth, + isSidebarVisible, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_COLUMNS, + SIDEBAR_MIN_WIDTH, +} from "./layout.js"; + +describe("isSidebarVisible", () => { + it("collapses the rail one column below the threshold", () => { + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS - 1)).toBe(false); + expect(isSidebarVisible(SIDEBAR_MIN_COLUMNS)).toBe(true); + }); +}); + +describe("computeSidebarWidth", () => { + it("scales with the terminal between the two clamps", () => { + expect(computeSidebarWidth(100)).toBe(25); + expect(computeSidebarWidth(120)).toBe(30); + }); + + it("never leaves the [min, max] band", () => { + expect(computeSidebarWidth(60)).toBe(SIDEBAR_MIN_WIDTH); + expect(computeSidebarWidth(400)).toBe(SIDEBAR_MAX_WIDTH); + for (let columns = 20; columns <= 400; columns += 1) { + const width = computeSidebarWidth(columns); + expect(width).toBeGreaterThanOrEqual(SIDEBAR_MIN_WIDTH); + expect(width).toBeLessThanOrEqual(SIDEBAR_MAX_WIDTH); + } + }); +}); + +describe("computeChatWidth", () => { + it("only subtracts the rail once it is actually drawn", () => { + expect(computeChatWidth(80)).toBe(78); + expect(computeChatWidth(100)).toBe(100 - 2 - 25); + expect(computeChatWidth(120)).toBe(120 - 2 - 30); + }); + + it("grows monotonically with the terminal", () => { + let previous = 0; + for (let columns = 40; columns <= 400; columns += 1) { + const width = computeChatWidth(columns); + expect(width).toBeGreaterThanOrEqual(0); + // The rail appearing at 100 columns is the one allowed step back. + if (columns !== SIDEBAR_MIN_COLUMNS) { + expect(width).toBeGreaterThanOrEqual(previous); + } + previous = width; + } + }); +}); + +describe("computeSidebarRowBudget", () => { + it("splits the usable height roughly 2:1 in favour of sessions", () => { + const budget = computeSidebarRowBudget(24); + expect(budget.sessions).toBe(10); + expect(budget.tasks).toBe(5); + expect(computeSidebarRowBudget(16).sessions).toBe(6); + expect(computeSidebarRowBudget(16).tasks).toBe(3); + }); + + it("keeps both panes alive on a very short window", () => { + for (let rows = 4; rows <= 12; rows += 1) { + const budget = computeSidebarRowBudget(rows); + expect(budget.sessions).toBeGreaterThanOrEqual(1); + expect(budget.tasks).toBeGreaterThanOrEqual(1); + } + }); + + it("stops growing once the caps are reached", () => { + expect(computeSidebarRowBudget(200)).toEqual({ sessions: 10, tasks: 5 }); + }); +}); + +describe("computeChatViewportRows", () => { + it("reserves the prompt chrome but never returns less than five rows", () => { + expect(computeChatViewportRows(40)).toBe(32); + expect(computeChatViewportRows(10)).toBe(4); + expect(computeChatViewportRows(2)).toBe(4); + }); + + it("reserves more chrome on a narrow terminal, where it wraps", () => { + expect(computeChatViewportRows(24, 45)).toBe(12); + expect(computeChatViewportRows(24, 80)).toBe(16); + }); +}); diff --git a/src/tui/layout.ts b/src/tui/layout.ts new file mode 100644 index 00000000..ac416d8c --- /dev/null +++ b/src/tui/layout.ts @@ -0,0 +1,141 @@ +/** + * Shared terminal-geometry maths for the chat shell. + * + * Two components need to agree on how the terminal width is carved up: + * `TuiApp` decides whether the right rail is drawn and how wide it is, + * and `SplashBanner` has to know how much room is left for the brand + * artwork. Keeping the arithmetic here means the splash can never + * disagree with the rail about where the boundary sits. + * + * Nothing in this module touches React or `process.stdout` — callers + * pass the size they already read via `useTerminalSize()`. + */ + +/** `paddingLeft` on the TUI root box (`tui-app.tsx`). */ +export const ROOT_PADDING_LEFT = 2; + +/** + * Minimum terminal width (in columns) at which the right-rail sidebar + * is rendered. Narrower terminals collapse the layout back to the + * single-column form so cramped sessions over SSH stay usable. Picked + * to match opencode's threshold. + */ +export const SIDEBAR_MIN_COLUMNS = 100; + +/** Narrowest rail that still fits a chevron, a badge and a preview. */ +export const SIDEBAR_MIN_WIDTH = 24; +/** Widest rail — beyond this the previews stop gaining information. */ +export const SIDEBAR_MAX_WIDTH = 34; +/** Share of the terminal the rail is allowed to claim. */ +const SIDEBAR_WIDTH_RATIO = 0.25; + +/** + * Rows the rail spends on its own chrome before a single list row is + * drawn: the status bar above it, the two section headers, the blank + * row between the panes, a "↓ N more" footer per pane and one row of + * slack. + */ +const SIDEBAR_CHROME_ROWS = 7; + +/** + * Rows of "chrome" outside the chat surface: status bar + prompt + * meta-row + prompt input + prompt tail-cap + hotkey hint + a small + * safety pad. Used to convert `terminal.rows` into the chat-area + * viewport height. Slightly conservative — better to leave one empty + * row than to clip the prompt. + */ +export const CHROME_ROWS = 8; + +/** + * Below this width the status bar, the hotkey hint strip and the prompt + * placeholder all start wrapping onto extra lines, so the chat surface + * gets less room than `CHROME_ROWS` alone would suggest. Measured + * against the real TUI at 45 columns: the status bar takes 2 rows, the + * hint strip 3, and the longer rotating placeholders push the prompt to + * 2 — hence one row of slack on top of the three observed. + */ +const NARROW_COLUMNS = 60; +const NARROW_CHROME_EXTRA = 4; + +/** Floor for the chat viewport — below this nothing readable survives. */ +const MIN_VIEWPORT_ROWS = 4; + +/** Row caps at which extra height stops buying useful context. */ +const SIDEBAR_MAX_SESSION_ROWS = 10; +const SIDEBAR_MAX_TASK_ROWS = 5; + +export interface SidebarRowBudget { + sessions: number; + tasks: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** Whether the terminal is wide enough to carry the right rail. */ +export function isSidebarVisible(columns: number): boolean { + return columns >= SIDEBAR_MIN_COLUMNS; +} + +/** + * Rail width as a share of the terminal rather than a flat 30 columns. + * A flat width left a 100-column terminal with only 70 columns of chat + * — not enough for the full-size brand artwork — while a 200-column + * terminal got a rail that looked stranded. + */ +export function computeSidebarWidth(columns: number): number { + return clamp( + Math.round(columns * SIDEBAR_WIDTH_RATIO), + SIDEBAR_MIN_WIDTH, + SIDEBAR_MAX_WIDTH, + ); +} + +/** + * Columns available to the chat column (and therefore to the splash) + * once the root padding and the rail have taken their share. + */ +export function computeChatWidth(columns: number): number { + const rail = isSidebarVisible(columns) ? computeSidebarWidth(columns) : 0; + return Math.max(0, columns - ROOT_PADDING_LEFT - rail); +} + +/** + * Split the rail's usable height between the Sessions and Tasks panes, + * roughly 2:1 in favour of sessions. Both panes keep at least one row + * so neither header is ever left dangling over an empty pane, and both + * stay under the caps that used to be hard-coded in `sidebar.tsx`. + * + * Ink 7 does not clip a frame taller than the terminal — it overlaps + * earlier lines (see `row-window.ts`) — so this budget is what keeps a + * short window from garbling the rail. + */ +export function computeSidebarRowBudget(rows: number): SidebarRowBudget { + const usable = Math.max(2, rows - SIDEBAR_CHROME_ROWS); + const sessions = clamp( + Math.ceil((usable * 2) / 3), + 1, + SIDEBAR_MAX_SESSION_ROWS, + ); + const tasks = clamp(usable - sessions, 1, SIDEBAR_MAX_TASK_ROWS); + return { sessions, tasks }; +} + +/** + * Rows the chat surface actually gets once the status bar, prompt and + * hint strip have taken theirs. Both `ChatLog` (scroll viewport) and + * `SplashBanner` (fit budget) read the same number so the splash can + * never plan for more rows than the surface it is rendered into. + * + * `columns` is optional so existing callers keep the wide-terminal + * behaviour; pass it to get the narrow-terminal correction. + */ +export function computeChatViewportRows( + rows: number, + columns = Number.POSITIVE_INFINITY, +): number { + const chrome = + CHROME_ROWS + (columns < NARROW_COLUMNS ? NARROW_CHROME_EXTRA : 0); + return Math.max(MIN_VIEWPORT_ROWS, rows - chrome); +} diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 480215dc..12019148 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -44,7 +44,11 @@ describe("TuiApp (smoke)", () => { expect(text).toContain("Run"); expect(text).toContain("Observe"); expect(text).toContain("Manage"); - expect(text).toContain("Local AI-First Agent"); + // The splash mark scales with the window; ink-testing-library's + // 100-column stdout reports no rows, so the fallback 80x24 surface + // gets the compact mark rather than the wordmark + tagline. Assert + // on what every size keeps. See `components/splash-fit.render.test.tsx`. + expect(text).toContain(":::"); expect(text).toContain("commands"); unmount(); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..35a38e64 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -35,6 +35,11 @@ import { UpdateModal } from "./components/update-modal.js"; import { UpdateIndicator } from "./components/update-indicator.js"; import { UpdateRestartPrompt } from "./components/update-restart-prompt.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; +import { + computeSidebarRowBudget, + computeSidebarWidth, + isSidebarVisible, +} from "./layout.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; import { handleEditorSubmit } from "./submit-handler.js"; @@ -345,15 +350,6 @@ export interface TuiAppProps { const DEFAULT_MAX_VISIBLE_ROWS = 14; const CTRL_C_WINDOW_MS = 1500; -/** - * Minimum terminal width (in columns) at which the right-rail sidebar - * is rendered. Narrower terminals collapse the layout back to the - * single-column form so cramped sessions over SSH stay usable. Picked - * to match opencode's threshold. - */ -const SIDEBAR_MIN_COLUMNS = 100; -const SIDEBAR_WIDTH = 30; - /** * Rotating placeholder pool shown in the prompt's empty state. Phrasing * intentionally nudges the operator toward concrete actions the agent @@ -482,7 +478,13 @@ export function TuiApp({ state.uiMode === "debug" && state.activeTab === "privacy"; const terminalSize = useTerminalSize(); const sidebarVisible = - state.uiMode === "chat" && terminalSize.columns >= SIDEBAR_MIN_COLUMNS; + state.uiMode === "chat" && isSidebarVisible(terminalSize.columns); + // The rail takes a share of the terminal rather than a flat 30 + // columns, and its two panes get a row budget cut from the terminal + // height — Ink 7 overlaps rather than clips an over-tall frame, so + // an unbudgeted rail garbles short windows. + const sidebarWidth = computeSidebarWidth(terminalSize.columns); + const sidebarRows = computeSidebarRowBudget(terminalSize.rows); const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = !state.pendingApproval && @@ -817,7 +819,9 @@ export function TuiApp({ {sidebarVisible ? ( Date: Wed, 19 Aug 2026 04:24:49 +0300 Subject: [PATCH 06/57] =?UTF-8?q?feat(runtime):=20mid-turn=20steering=20?= =?UTF-8?q?=E2=80=94=20reach=20the=20turn=20that=20is=20already=20running?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-session FIFO is the right answer for *starting* turns and the wrong one for *correcting* one. Today a message sent while the agent is working cannot reach the model at all until the turn closes, so an operator watching it head the wrong way has only one lever: abort. `SteeringInbox` is the out-of-band channel for that, and deliberately not a second queue — there is still exactly one path into `AgentLoop.runTurn`: - `runtime.steer(sessionId, text)` returns false and queues nothing when the session has no turn in flight. "Not steered" is a signal the caller acts on (fall back to runTurn, or to its own queue), never a silent drop. - `AgentLoop` drains the inbox at the top of every step, so the effect lands at the next step boundary — never mid-inference, never mid-tool-call. A turn parked in a long shell call will not react until that call returns; that is inherent, not a bug. - Each drained message is recorded as a real `user` ConversationTurn. The transcript must not lie about what the operator said. `packConversation` already pins the last user turn visible and `findCurrentMacroTurnStart` folds it into the macro-turn in progress. - The text is ALSO repeated in `### notice`, composed with (not over) whatever the loop detector left there. The duplication is deliberate: `### notice` is the last block before `### respond`, which is the one place a 30B local model reliably acts on. Long pastes are clipped inline and point back at the transcript copy. Tail-only, so no KV cache invalidation. - Nothing is lost. A message pushed after the loop's final drain — the last inference, or a turn cancelled before it stepped — comes back on `RunTurnResult.undelivered` for the caller to re-route. Shutdown clears the inbox so a stale steer cannot resurface in a later process. - Bounded at 16 pending per session; push refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. No producer is wired up yet — the TUI gesture and the sidecar/HTTP endpoints land separately. Without the `steeringInbox` dep the loop is byte-identical to before, pinned by a test. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 19 +- src/agent/agent-loop-steering.test.ts | 282 ++++++++++++++++++++++++++ src/agent/agent-loop.ts | 89 +++++++- src/agent/steer-notice.test.ts | 50 +++++ src/agent/steer-notice.ts | 52 +++++ src/runtime/bootstrap.test.ts | 85 ++++++++ src/runtime/bootstrap.ts | 39 ++++ src/runtime/steering-inbox.test.ts | 79 ++++++++ src/runtime/steering-inbox.ts | 85 ++++++++ 9 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 src/agent/agent-loop-steering.test.ts create mode 100644 src/agent/steer-notice.test.ts create mode 100644 src/agent/steer-notice.ts create mode 100644 src/runtime/steering-inbox.test.ts create mode 100644 src/runtime/steering-inbox.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..944a2a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This is the source-of-truth for automated contributors (LLM agents, codegen, etc ## Architectural invariants 1. **Project ≠ Prompt.** Session state, compressed tool results, and world snapshots live outside the model; the prompt is always a small slice. -2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on. +2. **Stable prefix.** The prompt is `buildStablePrefix` (persona + `### rules` + skill catalog under `### skills` + `### tools` + `### capabilities` + `### instructions`) followed by a **variable tail** in mutability order: `### loaded-skills` (optional) → `### loaded-tools` (optional) → `### profile` (optional) → `### memory-index` (optional) → `### session-facts` (optional) → `### recalled` (optional) → `### world` → `### conversation` → optional `### notice` (written by the no-progress loop detector and by mid-turn steering, composed in that order) → `### respond` (+ optional reasoning prefill). Only the stable-prefix bytes must stay stable within a session for KV-cache — this is what `cache_prompt + slot_id` on `llama-server` relies on. 3. **One inference per step.** No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON **array** of `1..N` tool calls (`[{tool, args}, ...]`); a "solo" step is just a length-1 array (`[{...}]`). `N` is capped by `agent.maxParallelToolCalls` (default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline. 4. **Grammar-constrained tool calls.** The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to **array-only** (`root ::= tool-call-array`) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (`qwen-think`, `gemma4-think`) prepend a `...` / `<|channel>thought...` block to the array; the seam between the close sentinel and the leading `[` of the array routes through a dedicated **bounded** `prelude-trail-ws ::= ( [ \t\n\r] ){0,8}` rule rather than the global unbounded `ws`. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts) "bounds the whitespace between the reasoning-close sentinel and the tool-call array". @@ -166,7 +166,7 @@ Speculative batching (the runtime guessing that the model "should" have batched | `src/tracing/` | Structured logger + metrics + trace recorder (`src/tracing/trace/`) | | `src/replay/` | Trace-based replay: drift detection + optional LLM re-inference | | `src/memory/` | Memory fabric: ProfileStore (key/value facts, pinned + contextual) + MemoryStore (FTS5 freeform notes) + async end-of-turn reflection that writes into both. See [MEMORY.md](MEMORY.md). | -| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`). See §"Concurrency contract". | +| `src/runtime/` | `bootstrap.ts` (assembles `AgentRuntime`) + `turn-controller.ts` (per-session FIFO queue + per-session event hook map; the **only** path into `AgentLoop.runTurn`) + `steering-inbox.ts` (out-of-band per-session mailbox for messages that arrive mid-turn). See §"Concurrency contract" and §"Mid-turn steering". | | `src/tasks/` | Durable queue of deferred `runTurn` submissions: `TaskStore` (SQLite), `TaskRunner` (drain + retry/backoff), `task-backoff`, `task-schedule` (cron / interval / at resolver). See §"Durable tasks" and §"Background autonomy". | | `src/scheduler/` | One-process `Scheduler` (single `setInterval`) that polls `TaskStore.listDue` via `TaskRunner.runDue`. The **only** periodic timer in the runtime. See §"Background autonomy". | | `src/http/route-webhooks.ts` + `webhook-template.ts` + `webhook-session-store.ts` | Generic `POST /api/webhooks/:name` ingress. Always materialises into a `TaskRecord`, never calls `runTurn` directly. See §"Background autonomy". | @@ -982,6 +982,7 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w | `ProfileStore` / `MemoryStore` / `SessionStore` | Anything holding a handle | **Yes** — all three use `better-sqlite3`, which is **synchronous**: there is no race window between read and write inside a single statement, so concurrent sessions are safe. **This is a load-bearing assumption.** Replacing the driver with an async one would require a redesign. | | `ReflectionRunner.pending` | Per-session `Map` | **Yes** — reflection on session A is never aborted by reflection on session B. `agent-loop.runTurn` calls `reflectionRunner.abortPending({ sessionId: state.id })` at the start of every turn so a stale reflection from the previous same-session turn cannot race the next one. `abortPending()` with no argument cancels every in-flight reflection (used at runtime shutdown). | | Trace recorder | Per-session, dispatched via `AsyncLocalStorage` | **Yes** — no global pointer to mix traces across sessions. | +| `SteeringInbox` | Per-session `Map`, drained only by the turn running on that session | **Yes** — a steer on session A is invisible to session B, and only one turn per session can drain (`TurnController` invariant 1). | ### What the scheduler / webhook paths may and may not assume @@ -991,10 +992,24 @@ Every entry point into the runtime — CLI, TUI, HTTP, sidecar, scheduler, and w - **May not** assume exclusive browser ownership across sessions; the browser is shared at process scope (see table). - **Must not** hold a stale `SessionState` reference between `enqueue` and `run`. `executeTurn` writes its result to `sessionStore`; the correct pattern is to **re-read the latest session inside the queued callback** (see [src/sidecar/main.ts](src/sidecar/main.ts) `send_message` for the canonical example). +### Mid-turn steering + +Per-session FIFO is correct for *starting* turns and wrong for *correcting* one. An operator who watches the agent head the wrong way should not have to abort the turn or wait it out to say "no, do X instead". `SteeringInbox` ([src/runtime/steering-inbox.ts](src/runtime/steering-inbox.ts)) is the out-of-band channel for that, and it is deliberately **not** a second queue: + +- **It never starts a turn.** `runtime.steer(sessionId, text)` returns `false` when `turnController.isBusy(sessionId)` is false, and queues nothing. A `false` return means "not steered" — the caller falls back to `runTurn` or to its own pending-message queue. There is still exactly one path into `AgentLoop.runTurn`. +- **It lands at a step boundary.** `AgentLoop.runTurn` drains the inbox at the top of every step, before building that step's prompt. Effect is visible one step later at the earliest — never mid-inference, never mid-tool-call. A turn parked in a long `os.shell.run` will not react until that call returns. +- **It writes to the transcript.** Each drained message is recorded as a real `user` `ConversationTurn`. The transcript must reflect what the operator actually said; `packConversation` already guarantees the last `user` turn stays visible, and `findCurrentMacroTurnStart` treats the steer as part of the macro-turn in progress. Note this **does** count toward reflection segmentation cadence (`state.turnCount` is untouched, but the turn list grows) — a steer is a real user message, so that is the intended reading. +- **It shares `### notice` with the loop detector.** Both write the one-shot notice slot; `composeSteerNotice` ([src/agent/steer-notice.ts](src/agent/steer-notice.ts)) appends rather than overwrites, loop-detector text first. The message text is repeated inside `### notice` even though it is already in `### conversation`: the notice sits immediately before `### respond`, which is the block small local models reliably act on. Long pastes are clipped inline and point back at the transcript copy. +- **Nothing is silently lost.** A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on `RunTurnResult.undelivered`. Callers MUST re-route it (the TUI pushes it onto its pending-message queue). `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process. +- **Bounded.** `MAX_PENDING_STEERS` (16) per session; `push` refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. + +Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) and the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts). + ### Extension points - `TurnController.isBusy(sessionId)` / `busySessionIds()` — observability hook for UI and scheduler. - `TurnController.emit(sessionId, event)` — single dispatch path for `AgentLoopEvent` to the per-session hook. +- `runtime.steer(sessionId, text)` — fold a message into the turn already running on that session. Returns `false` (and queues nothing) when the session is idle. See §"Mid-turn steering". - `runtime.executeTurn(session, msg, opts)` — bypasses the queue. Used by sidecar from inside an already-acquired `enqueue` callback so it does not deadlock against itself. CLI / TUI / HTTP go through the public `runtime.runTurn` instead. ### Risk (acknowledged) diff --git a/src/agent/agent-loop-steering.test.ts b/src/agent/agent-loop-steering.test.ts new file mode 100644 index 00000000..2d2a0457 --- /dev/null +++ b/src/agent/agent-loop-steering.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AgentLoop, type AgentLoopEvent } from "./agent-loop.js"; +import { buildDefaultToolRegistry } from "../tools/index.js"; +import { SlotManager } from "../llm/slot-manager.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import { SteeringInbox } from "../runtime/steering-inbox.js"; +import type { CompletionResult } from "../llm/llama-server-client.js"; +import type { + CapabilitiesSummary, + SkillCatalogEntry, + ToolDescriptor, +} from "../prompt/stable-prefix.js"; + +/** + * Mid-turn steering. Pins: + * - A message pushed while the turn is running reaches the NEXT + * step's prompt as a `### notice` block — never the step already + * in flight, and never a later turn. + * - It is also recorded as a real `user` turn, so the transcript + * does not lie about what the operator said. + * - The loop-detector's own one-shot notice is composed with, not + * clobbered by, a steer landing in the same step. + * - Nothing is ever silently lost: a message that arrives too late + * to be drained comes back on `RunTurnResult.undelivered`, on the + * normal path and on the cancelled path alike. + * - Without a `steeringInbox` dep the loop behaves exactly as before. + */ + +function makeCompletion(content: string): CompletionResult { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; +} + +const TOOLS: ToolDescriptor[] = [ + { name: "finish", summary: "Finish the session.", argsSchema: '{"summary": string}' }, +]; + +const CAPS: CapabilitiesSummary = { + platform: "darwin", + arch: "arm64", + browserChannel: "chrome", + workingDir: "/work", + hasClipboard: true, + hasWmctrl: false, + hasNotifications: true, +}; + +const SKILLS: SkillCatalogEntry[] = []; + +const NOOP = JSON.stringify({ tool: "noop", args: {} }); +const REPLY = JSON.stringify({ tool: "reply", args: { text: "done" } }); + +interface Harness { + loop: AgentLoop; + tails: string[]; + events: AgentLoopEvent[]; +} + +function buildLoop(opts: { + inbox?: SteeringInbox; + onStep?: (stepIndex: number) => void; + steps?: number; +}): Harness { + const tails: string[] = []; + const events: AgentLoopEvent[] = []; + const totalSteps = opts.steps ?? 2; + let calls = 0; + const registry = buildDefaultToolRegistry(); + // A trivial non-terminal tool so the turn takes more than one step — + // steering only exists between step boundaries, so a one-step turn + // could not exercise it. + registry.register({ + name: "noop", + description: "does nothing", + readonly: true, + run: async () => ({ + tool: "noop", + status: "ok" as const, + summary: "noop", + details: {}, + truncated: false, + }), + }); + const loop = new AgentLoop({ + registry, + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: async () => { + calls += 1; + opts.onStep?.(calls - 1); + return makeCompletion(calls < totalSteps ? NOOP : REPLY); + }, + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + ...(opts.inbox ? { steeringInbox: opts.inbox } : {}), + onEvent: (event) => { + events.push(event); + if (event.type === "llm_event" && event.event.type === "prompt_captured") { + tails.push(event.event.tail); + } + }, + }); + return { loop, tails, events }; +} + +describe("AgentLoop mid-turn steering", () => { + let workingDir: string; + + beforeEach(() => { + workingDir = mkdtempSync(join(tmpdir(), "atomic-agent-steer-")); + }); + + afterEach(() => { + rmSync(workingDir, { recursive: true, force: true }); + }); + + it("folds a message sent during step 0 into step 1's prompt", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + // Pushed while step 0's inference is in flight — the realistic + // shape of "the operator typed while the agent was working". + onStep: (step) => { + if (step === 0) inbox.push("s-steer", "actually, check the logs first"); + }, + }); + const session = createEmptySessionState({ id: "s-steer", workingDir }); + await loop.runTurn(session, { + userMessage: "do the thing", + maxSteps: 4, + signal: new AbortController().signal, + }); + + expect(tails).toHaveLength(2); + // Step 0 was already committed when the message arrived. + expect(tails[0]).not.toContain("actually, check the logs first"); + expect(tails[1]).toContain("### notice"); + expect(tails[1]).toContain("actually, check the logs first"); + }); + + it("does not leak the notice into the step after that", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + steps: 3, + onStep: (step) => { + if (step === 0) inbox.push("s-once", "one-shot please"); + }, + }); + await loop.runTurn(createEmptySessionState({ id: "s-once", workingDir }), { + userMessage: "go", + maxSteps: 4, + signal: new AbortController().signal, + }); + // The NOTICE is one-shot. The message itself stays visible in + // `### conversation` forever — it is a real user turn, and that is + // the point — so assert on the notice framing, not on the text. + expect(tails[1]).toContain("### notice"); + expect(tails[1]).toMatch(/Take it into account before your next action/); + expect(tails[2]).not.toMatch(/Take it into account before your next action/); + expect(tails[2]).toContain("one-shot please"); + }); + + it("records the steer as a real user turn and emits steer_applied", async () => { + const inbox = new SteeringInbox(); + const { loop, events } = buildLoop({ + inbox, + onStep: (step) => { + if (step === 0) inbox.push("s-turn", "and use the staging db"); + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-turn", workingDir }), + { userMessage: "deploy", maxSteps: 4, signal: new AbortController().signal }, + ); + + const userTurns = result.session.turns.filter((t) => t.kind === "user"); + expect(userTurns.map((t) => (t as { text: string }).text)).toEqual([ + "deploy", + "and use the staging db", + ]); + expect(events).toContainEqual({ + type: "steer_applied", + text: "and use the staging db", + stepIndex: 1, + }); + }); + + it("delivers several messages queued between two steps in one notice", async () => { + const inbox = new SteeringInbox(); + const { loop, tails } = buildLoop({ + inbox, + onStep: (step) => { + if (step === 0) { + inbox.push("s-multi", "first correction"); + inbox.push("s-multi", "second correction"); + } + }, + }); + await loop.runTurn(createEmptySessionState({ id: "s-multi", workingDir }), { + userMessage: "go", + maxSteps: 4, + signal: new AbortController().signal, + }); + expect(tails[1]).toContain("first correction"); + expect(tails[1]).toContain("second correction"); + expect(tails[1]).toContain("2 new messages"); + }); + + it("hands back a message that arrived too late to be drained", async () => { + const inbox = new SteeringInbox(); + const { loop } = buildLoop({ + inbox, + // Pushed during the FINAL inference: the loop terminates on this + // step's `reply`, so no further step boundary exists to drain it. + onStep: (step) => { + if (step === 1) inbox.push("s-late", "too late to steer"); + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-late", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.reason).toBe("reply"); + expect(result.undelivered).toEqual(["too late to steer"]); + // And it really is gone from the inbox — it is the caller's now. + expect(inbox.peek("s-late")).toEqual([]); + }); + + it("hands back pending messages when the turn is cancelled", async () => { + const inbox = new SteeringInbox(); + const controller = new AbortController(); + const { loop } = buildLoop({ + inbox, + steps: 5, + onStep: (step) => { + if (step === 0) { + inbox.push("s-cancel", "never delivered"); + controller.abort(); + } + }, + }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-cancel", workingDir }), + { userMessage: "go", maxSteps: 4, signal: controller.signal }, + ); + expect(result.undelivered).toEqual(["never delivered"]); + }); + + it("returns no undelivered messages on an ordinary turn", async () => { + const { loop } = buildLoop({ inbox: new SteeringInbox() }); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-plain", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.undelivered).toEqual([]); + }); + + it("behaves exactly as before when no inbox is wired in", async () => { + const { loop, tails } = buildLoop({}); + const result = await loop.runTurn( + createEmptySessionState({ id: "s-none", workingDir }), + { userMessage: "go", maxSteps: 4, signal: new AbortController().signal }, + ); + expect(result.reason).toBe("reply"); + expect(result.undelivered).toEqual([]); + for (const tail of tails) expect(tail).not.toContain("### notice"); + }); +}); diff --git a/src/agent/agent-loop.ts b/src/agent/agent-loop.ts index d4367270..5abbb265 100644 --- a/src/agent/agent-loop.ts +++ b/src/agent/agent-loop.ts @@ -48,6 +48,7 @@ import { formatForcedLoopReply, } from "./loop-detector.js"; import type { BatchLoopSignal } from "./batch-executor.js"; +import { composeSteerNotice } from "./steer-notice.js"; import { getConfig } from "../config/index.js"; import type { AgentMetrics } from "../tracing/agent-metrics.js"; import type { StructuredLogger } from "../tracing/structured-logger.js"; @@ -164,6 +165,14 @@ export interface AgentLoopDependencies { */ lessonLifecycle?: LessonLifecycleHook; onEvent?: (event: AgentLoopEvent) => void; + /** + * Out-of-band channel for user messages that arrive while this turn is + * already running (`SteeringInbox`). Drained at the top of every step + * and folded into that step's `### notice`; see §"Mid-turn steering" + * in AGENTS.md. Absent in tests and in surfaces that do not offer + * steering, in which case the loop behaves exactly as before. + */ + steeringInbox?: SteeringDrain; metrics?: AgentMetrics; logger?: StructuredLogger; } @@ -247,6 +256,15 @@ export interface LessonLifecycleHook { }): void; } +/** + * Read side of the steering inbox as the loop needs it. Declared + * structurally (like {@link MemoryContextProvider}) so `src/agent/` does + * not import from `src/runtime/`, which imports it. + */ +export interface SteeringDrain { + drain(sessionId: string): readonly string[]; +} + export interface RunTurnOptions { maxSteps: number; signal: AbortSignal; @@ -264,6 +282,13 @@ export type AgentLoopReason = export type AgentLoopEvent = | { type: "user_message"; text: string } + /** + * A message the user sent mid-turn was folded into the prompt for + * step `stepIndex`. Distinct from `user_message`, which marks the + * message that *started* the turn — UIs render this one inline in the + * running turn rather than as the opening of a new one. + */ + | { type: "steer_applied"; text: string; stepIndex: number } | { type: "turn_started"; turnIndex: number } | { type: "turn_finished"; @@ -326,6 +351,14 @@ export interface RunTurnResult { session: SessionState; reason: AgentLoopReason; stepCount: number; + /** + * Steering messages that were pushed but never reached a step — the + * turn ended (or was cancelled) before the loop could drain them. + * Callers MUST re-route these, normally onto their own message queue, + * otherwise a message the user watched being accepted vanishes. Empty + * on every ordinary turn. + */ + undelivered?: readonly string[]; } export class AgentLoop { @@ -451,6 +484,27 @@ export class AgentLoop { } this.deps.onEvent?.({ type: "step_started", stepIndex: i }); const started = Date.now(); + // Mid-turn steering: anything the user sent since the previous + // step boundary joins this step's prompt. It is recorded as a + // real `user` turn (the transcript must reflect what was said, + // and `packConversation` always keeps the last user turn visible) + // AND repeated in `### notice`, which is the tail-most block the + // model reads before `### respond`. `composeSteerNotice` appends + // to whatever the loop detector already left in `pendingNotice` + // rather than overwriting it — both nudges matter. + const steered = this.deps.steeringInbox?.drain(state.id) ?? []; + for (const text of steered) { + state = recordTurn(state, userTurn(text)); + this.deps.onEvent?.({ type: "steer_applied", text, stepIndex: i }); + } + if (steered.length > 0) { + pendingNotice = composeSteerNotice(pendingNotice, steered); + this.deps.logger?.info("mid-turn steering applied", { + sessionId: state.id, + stepIndex: i, + count: steered.length, + }); + } const noticeForThisStep = pendingNotice; pendingNotice = undefined; try { @@ -708,7 +762,12 @@ export class AgentLoop { stepCount: stepsTaken, durationMs, }); - return { session: state, reason: "cancelled", stepCount: stepsTaken }; + return { + session: state, + reason: "cancelled", + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; } // Symmetric with the cancelled path above: set terminal state, // emit `loop_completed` + `turn_finished`, increment turnCount, @@ -739,7 +798,12 @@ export class AgentLoop { // returned earlier without calling the hook (cancellation // carries neither success nor failure signal). invokeLessonLifecycle(this.deps, state.id, surfacedLessonIds, "failure"); - return { session: state, reason: "failed", stepCount: stepsTaken }; + return { + session: state, + reason: "failed", + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; } } @@ -890,7 +954,26 @@ export class AgentLoop { } } - return { session: state, reason, stepCount: stepsTaken }; + return { + session: state, + reason, + stepCount: stepsTaken, + undelivered: this.flushSteering(state.id), + }; + } + + /** + * Empty the steering inbox on the way out of a turn. + * + * A message pushed after the loop's last drain — during the final + * inference, or at any point in a turn that was cancelled before it + * stepped — would otherwise sit in the inbox until some unrelated + * later turn happened to pick it up, out of order and out of context. + * Handing it back to the caller keeps "the message you sent always + * goes somewhere" true on every exit path. + */ + private flushSteering(sessionId: string): readonly string[] { + return this.deps.steeringInbox?.drain(sessionId) ?? []; } } diff --git a/src/agent/steer-notice.test.ts b/src/agent/steer-notice.test.ts new file mode 100644 index 00000000..7c2682bc --- /dev/null +++ b/src/agent/steer-notice.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { composeSteerNotice, formatSteerNotice } from "./steer-notice.js"; + +describe("formatSteerNotice", () => { + it("carries the message text verbatim", () => { + const out = formatSteerNotice(["stop and just summarise"]); + expect(out).toContain("stop and just summarise"); + }); + + it("tells the model the message may cancel what it was doing", () => { + const out = formatSteerNotice(["never mind"]); + expect(out).toMatch(/change or cancel/); + }); + + it("pluralises when several arrived in one step", () => { + const out = formatSteerNotice(["one", "two"]); + expect(out).toContain("2 new messages"); + expect(out).toContain("- one"); + expect(out).toContain("- two"); + }); + + it("clips a huge paste and points at the full copy in the transcript", () => { + const out = formatSteerNotice(["x".repeat(5000)]); + expect(out.length).toBeLessThan(1000); + expect(out).toContain("### conversation"); + }); + + it("returns empty for no messages", () => { + expect(formatSteerNotice([])).toBe(""); + }); +}); + +describe("composeSteerNotice", () => { + it("keeps an existing loop-detector notice and appends the steer below it", () => { + const out = composeSteerNotice("### repeat detected: os.fs.read", ["stop"]); + expect(out).toContain("### repeat detected: os.fs.read"); + expect(out).toContain("stop"); + expect(out!.indexOf("repeat detected")).toBeLessThan(out!.indexOf("stop")); + }); + + it("passes the existing notice through untouched when nothing was steered", () => { + expect(composeSteerNotice("loop!", [])).toBe("loop!"); + expect(composeSteerNotice(undefined, [])).toBeUndefined(); + }); + + it("is just the steer block when there was no prior notice", () => { + const out = composeSteerNotice(undefined, ["go left"]); + expect(out).toBe(formatSteerNotice(["go left"])); + }); +}); diff --git a/src/agent/steer-notice.ts b/src/agent/steer-notice.ts new file mode 100644 index 00000000..605b20da --- /dev/null +++ b/src/agent/steer-notice.ts @@ -0,0 +1,52 @@ +/** + * Renders mid-turn user messages into the `### notice` block of the next + * step's prompt. + * + * The block is deliberately imperative and deliberately redundant: the + * same text also lands in `### conversation` as a real `user` turn (the + * transcript must not lie about what the operator said), but + * `### conversation` is a long scroll and the models this runtime + * targets are small. `### notice` sits immediately before + * `### respond`, which is the one place a 30B local model reliably + * reads, so the message is repeated there with an instruction attached. + */ + +/** + * Per-message inline cap. A pasted stack trace should not evict the rest + * of the tail from the token budget — past this the model is pointed at + * the full copy in `### conversation`. + */ +const MAX_INLINE_CHARS = 600; + +/** + * Fold `messages` into an existing one-shot notice (the loop detector + * writes to the same slot). The loop-detector text comes first: it + * describes what the model just did wrong, which is context for how to + * act on the new instruction. + */ +export function composeSteerNotice( + existing: string | undefined, + messages: readonly string[], +): string | undefined { + if (messages.length === 0) return existing; + const block = formatSteerNotice(messages); + if (existing === undefined || existing.length === 0) return block; + return `${existing}\n\n${block}`; +} + +/** The steering block on its own, without the loop-detector prefix. */ +export function formatSteerNotice(messages: readonly string[]): string { + if (messages.length === 0) return ""; + const header = + messages.length === 1 + ? "The user sent a new message while you were working. Take it into account before your next action — it may change or cancel what you were doing:" + : `The user sent ${messages.length} new messages while you were working. Take them into account before your next action — they may change or cancel what you were doing:`; + const body = messages.map((m) => `- ${clip(m)}`).join("\n"); + return `${header}\n${body}`; +} + +function clip(text: string): string { + const flat = text.trim(); + if (flat.length <= MAX_INLINE_CHARS) return flat; + return `${flat.slice(0, MAX_INLINE_CHARS)}… (full text is the last user turn in ### conversation)`; +} diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index 66f28fe1..c0fbeb8c 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -918,3 +918,88 @@ describe("createAgentRuntime", () => { } }); }); + +describe("createAgentRuntime steering", () => { + let stateDir: string; + let workingDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-runtime-steer-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-cwd-steer-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + }); + + it("refuses to steer a session with no turn in flight", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + try { + const session = runtime.createSession(); + // Nothing is running: steering would silently vanish, so the + // caller is told "no" and can fall back to a normal turn. + expect(runtime.steer(session.id, "hello?")).toBe(false); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + } finally { + await runtime.shutdown(); + } + }); + + it("accepts a steer while a turn holds the session lock", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + try { + const session = runtime.createSession(); + let release!: () => void; + const held = new Promise((res) => { + release = res; + }); + const inFlight = runtime.turnController.enqueue({ + sessionId: session.id, + origin: "tui", + run: async () => { + expect(runtime.steer(session.id, "change course")).toBe(true); + expect(runtime.steeringInbox.peek(session.id)).toEqual([ + "change course", + ]); + await held; + return null; + }, + }); + release(); + await inFlight; + // Still pending: only the agent loop drains it. + expect(runtime.steeringInbox.drain(session.id)).toEqual(["change course"]); + } finally { + await runtime.shutdown(); + } + }); + + it("drops pending steers on shutdown", async () => { + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: new FakeBackend(), skipLlamaHealthCheck: true }, + }); + const session = runtime.createSession(); + runtime.steeringInbox.push(session.id, "stale"); + await runtime.shutdown(); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + }); +}); + diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 68000664..0be68a46 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -11,6 +11,7 @@ import { import type { LlmStreamParams } from "../agent/step-executor.js"; import { TurnController } from "./turn-controller.js"; +import { SteeringInbox } from "./steering-inbox.js"; import type { TurnEventHook, TurnOrigin } from "./turn-controller.js"; import type { ChannelStatus } from "./channel-status.js"; @@ -293,6 +294,25 @@ export interface AgentRuntime { * funnels through this controller internally. */ readonly turnController: TurnController; + /** + * Out-of-band channel for messages sent to a session whose turn is + * already running. `TurnController` is strictly FIFO by design, so a + * mid-turn message would otherwise have to wait for the turn to + * close; the inbox lets it reach the model at the next step boundary + * instead. Prefer {@link AgentRuntime.steer} over touching this + * directly — it checks that a turn is actually in flight. + */ + readonly steeringInbox: SteeringInbox; + /** + * Fold `text` into the turn currently running on `sessionId`. + * + * Returns `false` — and queues nothing — when the session has no turn + * in flight, or when the inbox for that session is full. A `false` + * return means "not steered": the caller is expected to fall back to + * a normal `runTurn`, or to its own message queue. Never starts a + * turn on its own. + */ + steer(sessionId: string, text: string): boolean; /** * Durable user-profile store. Present even when * `memory.profile.enabled` is `false`, because the store owns the @@ -659,6 +679,7 @@ export async function createAgentRuntime( * pointer. */ const turnContext = new AsyncLocalStorage<{ sessionId: string }>(); + const steeringInbox = new SteeringInbox(); const turnController = new TurnController({ onHookError: (err, ctxInfo) => { logger.warn("turn event hook threw", { @@ -1753,6 +1774,8 @@ export async function createAgentRuntime( slotManager, grammar, llmComplete, + // Mid-turn steering: the loop drains this at every step boundary. + steeringInbox, ...(llmCompleteStream ? { llmCompleteStream } : {}), toolDescriptors: effectiveToolDescriptors, capabilities, @@ -1851,6 +1874,9 @@ export async function createAgentRuntime( const shutdown = async (): Promise => { if (shutdownCalled) return; shutdownCalled = true; + // Nothing will drain the inbox after this point; drop pending + // steers so a message cannot resurface in a later process. + steeringInbox.clearAll(); // Cancel any in-flight reflection before tearing down the profile // store — otherwise a late-arriving completion could try to write // into a closed SQLite connection. @@ -2071,6 +2097,17 @@ export async function createAgentRuntime( }); }; + /** + * Public entry point for mid-turn steering. Deliberately does NOT + * enqueue: the whole point is to reach the turn that is already + * running, and going through `turnController` would put the message + * behind it. + */ + const steer = (sessionId: string, text: string): boolean => { + if (!turnController.isBusy(sessionId)) return false; + return steeringInbox.push(sessionId, text); + }; + const runTurn = async ( session: SessionState, userMessage: string, @@ -2404,6 +2441,8 @@ export async function createAgentRuntime( slotManager, sessionStore, turnController, + steeringInbox, + steer, profileStore, notesStore, lessonStore, diff --git a/src/runtime/steering-inbox.test.ts b/src/runtime/steering-inbox.test.ts new file mode 100644 index 00000000..dbabb62e --- /dev/null +++ b/src/runtime/steering-inbox.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { MAX_PENDING_STEERS, SteeringInbox } from "./steering-inbox.js"; + +describe("SteeringInbox", () => { + it("drains what was pushed, in order", () => { + const inbox = new SteeringInbox(); + expect(inbox.push("s1", "first")).toBe(true); + expect(inbox.push("s1", "second")).toBe(true); + expect(inbox.drain("s1")).toEqual(["first", "second"]); + }); + + it("empties the slot on drain so one message is delivered once", () => { + const inbox = new SteeringInbox(); + inbox.push("s1", "only"); + expect(inbox.drain("s1")).toEqual(["only"]); + expect(inbox.drain("s1")).toEqual([]); + }); + + it("returns an empty array for a session that was never pushed to", () => { + expect(new SteeringInbox().drain("nobody")).toEqual([]); + }); + + it("keeps sessions isolated", () => { + const inbox = new SteeringInbox(); + inbox.push("a", "for-a"); + inbox.push("b", "for-b"); + expect(inbox.drain("a")).toEqual(["for-a"]); + expect(inbox.drain("b")).toEqual(["for-b"]); + }); + + it("trims and rejects blank text", () => { + const inbox = new SteeringInbox(); + expect(inbox.push("s1", " ")).toBe(false); + expect(inbox.push("s1", "\n\t")).toBe(false); + expect(inbox.push("s1", " padded ")).toBe(true); + expect(inbox.drain("s1")).toEqual(["padded"]); + }); + + it("refuses past the per-session cap instead of dropping the oldest", () => { + const inbox = new SteeringInbox(); + for (let i = 0; i < MAX_PENDING_STEERS; i += 1) { + expect(inbox.push("s1", `m${i}`)).toBe(true); + } + // A refusal is the signal the caller needs to park the message + // somewhere else; silently evicting m0 would lose it. + expect(inbox.push("s1", "overflow")).toBe(false); + const drained = inbox.drain("s1"); + expect(drained).toHaveLength(MAX_PENDING_STEERS); + expect(drained[0]).toBe("m0"); + expect(drained).not.toContain("overflow"); + }); + + it("accepts again once the cap is drained", () => { + const inbox = new SteeringInbox(); + for (let i = 0; i < MAX_PENDING_STEERS; i += 1) inbox.push("s1", `m${i}`); + expect(inbox.push("s1", "nope")).toBe(false); + inbox.drain("s1"); + expect(inbox.push("s1", "yes")).toBe(true); + }); + + it("peek does not consume", () => { + const inbox = new SteeringInbox(); + inbox.push("s1", "held"); + expect(inbox.peek("s1")).toEqual(["held"]); + expect(inbox.peek("s1")).toEqual(["held"]); + expect(inbox.drain("s1")).toEqual(["held"]); + }); + + it("clear drops one session, clearAll drops every session", () => { + const inbox = new SteeringInbox(); + inbox.push("a", "x"); + inbox.push("b", "y"); + inbox.clear("a"); + expect(inbox.peek("a")).toEqual([]); + expect(inbox.peek("b")).toEqual(["y"]); + inbox.clearAll(); + expect(inbox.peek("b")).toEqual([]); + }); +}); diff --git a/src/runtime/steering-inbox.ts b/src/runtime/steering-inbox.ts new file mode 100644 index 00000000..fca2dd2c --- /dev/null +++ b/src/runtime/steering-inbox.ts @@ -0,0 +1,85 @@ +/** + * Per-session mailbox for user messages that arrive **while a turn is + * already running**. + * + * The runtime has exactly one ordered path into `AgentLoop.runTurn` + * (`TurnController`, per-session FIFO), and that is deliberate: two + * concurrent turns on one session would race the browser, the slot + * manager and the transcript. But FIFO also means a message sent + * mid-turn cannot reach the model until the current turn closes, which + * is the wrong answer when the operator is watching the agent walk off + * a cliff and wants to redirect it *now*. + * + * This inbox is the out-of-band channel for exactly that. It does not + * start turns and it does not touch the queue: `AgentLoop` drains it at + * the top of every step and folds the text into that step's `### notice` + * block. The effect lands at the next **step** boundary — never + * mid-inference, and never mid-tool-call. + * + * Ownership mirrors `TurnController`: one instance per runtime, keyed by + * session id, and cross-session isolated by construction. + */ + +/** + * Maximum messages held for one session before `push` starts refusing. + * A turn stuck in a long tool call can be steered a handful of times + * before the model gets a chance to read any of them; past that the + * caller should queue instead of piling more onto one prompt. Refusing + * is safer than dropping the oldest — the caller learns the message did + * not land and can park it. + */ +export const MAX_PENDING_STEERS = 16; + +/** Narrow read side, so `AgentLoop` never sees the mutating surface. */ +export interface SteeringDrain { + drain(sessionId: string): readonly string[]; +} + +export class SteeringInbox implements SteeringDrain { + private readonly bySession = new Map(); + + /** + * Queue a message for the turn currently running on `sessionId`. + * Returns `false` when the text is blank or the per-session cap is + * reached — callers treat that as "not steered, park it instead". + */ + push(sessionId: string, text: string): boolean { + const trimmed = text.trim(); + if (trimmed.length === 0) return false; + const pending = this.bySession.get(sessionId); + if (pending === undefined) { + this.bySession.set(sessionId, [trimmed]); + return true; + } + if (pending.length >= MAX_PENDING_STEERS) return false; + pending.push(trimmed); + return true; + } + + /** + * Take everything pending for `sessionId` and empty the slot. Always + * returns an array (possibly empty) so callers never branch on + * `undefined`. + */ + drain(sessionId: string): readonly string[] { + const pending = this.bySession.get(sessionId); + if (pending === undefined || pending.length === 0) return []; + this.bySession.delete(sessionId); + return pending; + } + + /** Non-destructive read, for UI badges and tests. */ + peek(sessionId: string): readonly string[] { + return this.bySession.get(sessionId) ?? []; + } + + /** Discard pending messages for one session (session switch / abort). */ + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } + + /** Discard everything (runtime shutdown). */ + clearAll(): void { + this.bySession.clear(); + } +} From d07be40ffd9dfc07f3c825cdde2466f1610514ee Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:28:56 +0300 Subject: [PATCH 07/57] fix(tui): reject an empty API key on the provider wizard key screen --- .../providers-wizard-key-bindings.test.ts | 85 +++++++++++- .../providers-wizard-key-bindings.ts | 44 ++----- .../providers/providers-wizard-target.test.ts | 124 ++++++++++++++++++ src/tui/providers/providers-wizard-target.ts | 100 ++++++++++++++ 4 files changed, 321 insertions(+), 32 deletions(-) create mode 100644 src/tui/providers/providers-wizard-target.test.ts create mode 100644 src/tui/providers/providers-wizard-target.ts diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index b50c4862..2edea7bb 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -112,6 +112,7 @@ describe("handleProvidersWizardKey", () => { wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard).toMatchObject({ kind: "gemini", phase: "api_key" }); + for (const ch of "gk") wizard = next(wizard, ch, emptyKey()); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.phase).toBe("chat_model_line"); expect(wizard.phase).not.toBe("base_url"); @@ -522,12 +523,92 @@ describe("handleProvidersWizardKey", () => { wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("openai-compatible"); expect(wizard.presetId).toBeNull(); - // No key typed, Enter through the key screen: a hand-added compat - // endpoint still has to declare its base URL. + // A hand-added compat endpoint still has to declare its base URL + // after the key screen. + for (const ch of "ck") wizard = next(wizard, ch, emptyKey()); wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.phase).toBe("base_url"); }); + describe("empty API key", () => { + // The gate reads the environment, so a key left over from the host + // shell would silently satisfy the screen under test. + const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "ATOMIC_AGENT_OPENAI_API_KEY", + ] as const; + const saved = new Map(); + beforeEach(() => { + for (const key of ENV_KEYS) { + saved.set(key, process.env[key]); + delete process.env[key]; + } + }); + afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + saved.clear(); + }); + + it("keeps Enter on the key screen when nothing was typed", () => { + let wizard = createProvidersWizardState("add"); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ kind: "openrouter", phase: "api_key" }); + + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + expect(wizard.error).toContain("API key required"); + expect(wizard.error).toContain("OPENROUTER_API_KEY"); + }); + + it("refuses a whitespace-only key", () => { + let wizard = createProvidersWizardState("add", { kind: "aimlapi" }); + wizard = { ...wizard, phase: "api_key" }; + for (const ch of " ") wizard = next(wizard, ch, emptyKey()); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("api_key"); + expect(wizard.error).toContain("API key required"); + }); + + it("clears the message on the next keystroke", () => { + let wizard = createProvidersWizardState("add", { kind: "openrouter" }); + wizard = { ...wizard, phase: "api_key" }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.error).not.toBeNull(); + + wizard = next(wizard, "s", emptyKey()); + expect(wizard.error).toBeNull(); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("pick_chat_model"); + }); + + it("lets a keyless local preset through with no key at all", () => { + // LM Studio has no key to type; the wizard skips the screen + // entirely and the gate must not reintroduce it. + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: presetRowIndex("lmstudio") }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ + presetId: "lmstudio", + phase: "chat_model_line", + }); + }); + + it("accepts an empty screen when the service's key is already in .env", () => { + process.env.OPENROUTER_API_KEY = "sk-or-env"; + let wizard = createProvidersWizardState("add", { kind: "openrouter" }); + wizard = { ...wizard, phase: "api_key" }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.phase).toBe("pick_chat_model"); + }); + }); + it("Esc from a preset returns to the provider list, not out of the wizard", () => { let wizard = createProvidersWizardState("add"); // Nous is keyless, so the wizard lands straight on chat_model_line. diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index 3cb5084a..df634a62 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,11 +1,8 @@ import type { Key } from "ink"; -import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; import { getCachedOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { getCachedGeminiModels } from "../../llm/provider/gemini/fetch-gemini-models.js"; -import { normalizeOpenAiBaseUrl } from "../../llm/provider/openai/normalize-openai-base-url.js"; import { PICK_WINDOW } from "../components/wizard-pick-list.js"; import { findProviderPreset } from "./provider-presets.js"; -import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; import { advanceWizardPhase, clampCursor, @@ -19,35 +16,13 @@ import { presetNeedsKeyScreen, } from "./providers-wizard-phases.js"; import { createProvidersWizardState } from "./providers-wizard-state.js"; +import { + apiKeyForWizard, + apiKeyPhaseError, + baseUrlForWizard, +} from "./providers-wizard-target.js"; import type { ProvidersWizardState } from "./providers-wizard-state.js"; -/** Normalized so the fetch, the cache key and the displayed URL always agree. */ -export function baseUrlForWizard(wizard: ProvidersWizardState): string { - return ( - normalizeOpenAiBaseUrl(wizard.baseUrlLine) || OPENAI_COMPAT_DEFAULT_BASE_URL - ); -} - -/** - * Typed key wins; otherwise a key already in the environment needs no - * retyping. The fallback probe resolves the preset's own variable when - * one is selected — reading the shared compat variable for Groq would - * hand the wrong service's key to the model-list fetch. - */ -export function apiKeyForWizard( - wizard: ProvidersWizardState, -): string | undefined { - const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; - return ( - wizard.apiKeyBuffer.trim() || - resolveLlmProviderApiKey({ - id: "openai-compatible", - kind: "openai-compatible", - ...(preset ? { apiKeyEnvVar: preset.envVar } : {}), - }) - ); -} - /** * Chat model ids discovered from `{baseUrl}/v1/models`. Empty once the operator * types anything — a typed id is a deliberate override, so the picker steps @@ -140,6 +115,15 @@ export function handleProvidersWizardKey( if (wizard.phase === "api_key") { if (key.return) { + // An empty key is refused here rather than at the end of the + // wizard: leaving this screen blank used to cost the operator the + // model picker and the save round-trip before anything said so. + // Services that genuinely have no key (local servers, keyless + // listing) opt out through `apiKeyPhaseError`. + const missing = apiKeyPhaseError(wizard); + if (missing) { + return { handled: true, wizard: { ...wizard, error: missing } }; + } return { handled: true, wizard: advanceWizardPhase(wizard), diff --git a/src/tui/providers/providers-wizard-target.test.ts b/src/tui/providers/providers-wizard-target.test.ts new file mode 100644 index 00000000..5178ee29 --- /dev/null +++ b/src/tui/providers/providers-wizard-target.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + apiKeyForWizard, + apiKeyPhaseError, + envHintForWizard, + wizardKeyIsOptional, +} from "./providers-wizard-target.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; + +const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "ATOMIC_AGENT_OPENAI_API_KEY", + "GROQ_API_KEY", + "LMSTUDIO_API_KEY", + "OLLAMA_API_KEY", + "NOUS_API_KEY", + "OLLAMA_CLOUD_API_KEY", +] as const; + +function wizardFor( + kind: ProvidersWizardKind, + presetId?: string, +): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + ...(presetId ? { presetId } : {}), + }; +} + +describe("apiKeyPhaseError", () => { + beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + it("refuses an empty key for every service that needs one", () => { + for (const wizard of [ + wizardFor("openrouter"), + wizardFor("aimlapi"), + wizardFor("gemini"), + wizardFor("openai-compatible"), + wizardFor("openai-compatible", "groq"), + ]) { + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + } + }); + + it("names the service's own env var in the message", () => { + expect(apiKeyPhaseError(wizardFor("openrouter"))).toContain( + "OPENROUTER_API_KEY", + ); + expect(apiKeyPhaseError(wizardFor("openai-compatible", "groq"))).toContain( + "GROQ_API_KEY", + ); + }); + + it("treats a whitespace-only buffer as empty", () => { + // What a mis-paste leaves behind. Accepting it would write " " to + // .env and present it to the provider as a key. + const wizard = { ...wizardFor("openrouter"), apiKeyBuffer: " " }; + expect(apiKeyPhaseError(wizard)).toContain("API key required"); + }); + + it("accepts a typed key", () => { + const wizard = { ...wizardFor("openrouter"), apiKeyBuffer: "sk-or-typed" }; + expect(apiKeyPhaseError(wizard)).toBeNull(); + }); + + it("accepts an empty buffer when the service's key is already in .env", () => { + process.env.OPENROUTER_API_KEY = "sk-or-from-env"; + expect(apiKeyPhaseError(wizardFor("openrouter"))).toBeNull(); + }); + + it("does not let another service's key satisfy the screen", () => { + // The lookup used to run as a fixed openai-compatible entry, so an + // unrelated OPENAI_API_KEY answered for OpenRouter, AI/ML API and + // Gemini alike — an empty key screen that looked satisfied. + process.env.OPENAI_API_KEY = "sk-openai"; + expect(apiKeyPhaseError(wizardFor("openrouter"))).toContain( + "API key required", + ); + expect(apiKeyPhaseError(wizardFor("aimlapi"))).toContain("API key required"); + expect(apiKeyPhaseError(wizardFor("gemini"))).toContain("API key required"); + expect(apiKeyForWizard(wizardFor("openrouter"))).toBeUndefined(); + }); + + it("still resolves the shared variable for the manual compat entry", () => { + process.env.OPENAI_API_KEY = "sk-openai"; + expect(apiKeyPhaseError(wizardFor("openai-compatible"))).toBeNull(); + expect(apiKeyForWizard(wizardFor("openai-compatible"))).toBe("sk-openai"); + }); + + it("leaves keyless services alone", () => { + for (const presetId of ["lmstudio", "ollama", "nous", "ollama-cloud"]) { + const wizard = wizardFor("openai-compatible", presetId); + expect(wizardKeyIsOptional(wizard)).toBe(true); + expect(apiKeyPhaseError(wizard)).toBeNull(); + } + }); +}); + +describe("envHintForWizard", () => { + it("names the preset's variable, not the shared compat one", () => { + expect(envHintForWizard(wizardFor("openai-compatible", "groq"))).toBe( + "GROQ_API_KEY", + ); + expect(envHintForWizard(wizardFor("gemini"))).toBe("GEMINI_API_KEY"); + expect(envHintForWizard(wizardFor("openai-compatible"))).toBe( + "OPENAI_COMPAT_API_KEY", + ); + }); +}); diff --git a/src/tui/providers/providers-wizard-target.ts b/src/tui/providers/providers-wizard-target.ts new file mode 100644 index 00000000..e30bca0b --- /dev/null +++ b/src/tui/providers/providers-wizard-target.ts @@ -0,0 +1,100 @@ +/** + * What the wizard's current state says about the *service* being + * configured: which endpoint it points at, which key it would use, and + * whether that key is allowed to be missing. + * + * Split out of `providers-wizard-key-bindings.ts`, which sits on the + * 300-line limit: the key screen needs these answers before it can + * refuse an empty key, and so does every other surface that renders the + * wizard. + */ + +import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; +import type { UserLlmProviderEntry } from "../../config/llm-config.js"; +import { normalizeOpenAiBaseUrl } from "../../llm/provider/openai/normalize-openai-base-url.js"; +import { findProviderPreset } from "./provider-presets.js"; +import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; +import type { ProvidersWizardState } from "./providers-wizard-state.js"; + +/** Normalized so the fetch, the cache key and the displayed URL always agree. */ +export function baseUrlForWizard(wizard: ProvidersWizardState): string { + return ( + normalizeOpenAiBaseUrl(wizard.baseUrlLine) || OPENAI_COMPAT_DEFAULT_BASE_URL + ); +} + +/** + * The config entry this wizard run would produce, reduced to what key + * resolution needs. Built from the selected kind — not a fixed + * `openai-compatible` shape — so `resolveLlmProviderApiKey` reads the + * variable this service actually uses. Probing with the compat entry + * made an unrelated `OPENAI_API_KEY` answer for OpenRouter, AI/ML API + * and Gemini alike: the wrong service's key, presented as this one's. + */ +function keyLookupEntryForWizard( + wizard: ProvidersWizardState, +): UserLlmProviderEntry { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + const kind = wizard.kind ?? "openai-compatible"; + return { + id: wizard.providerId ?? preset?.id ?? kind, + kind, + ...(preset ? { apiKeyEnvVar: preset.envVar } : {}), + }; +} + +/** + * Typed key wins; otherwise a key already in the environment needs no + * retyping. + */ +export function apiKeyForWizard( + wizard: ProvidersWizardState, +): string | undefined { + return ( + wizard.apiKeyBuffer.trim() || + resolveLlmProviderApiKey(keyLookupEntryForWizard(wizard)) + ); +} + +/** + * Env var named on the key screen. A preset names its own variable; + * naming the shared compat one there would promise Groq's key a home it + * does not use. + */ +export function envHintForWizard(wizard: ProvidersWizardState): string { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + if (preset) return preset.envVar; + if (wizard.kind === "openrouter") return "OPENROUTER_API_KEY"; + if (wizard.kind === "aimlapi") return "AIMLAPI_API_KEY"; + if (wizard.kind === "gemini") return "GEMINI_API_KEY"; + return "OPENAI_COMPAT_API_KEY"; +} + +/** + * `true` when saving without any key is a legitimate outcome: servers on + * the operator's own machine have no key at all, and keyless-listing + * services work before one is entered. Both save with an empty key and + * send requests without an Authorization header. + */ +export function wizardKeyIsOptional(wizard: ProvidersWizardState): boolean { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + return Boolean(preset && (preset.local || preset.listsModelsWithoutKey)); +} + +/** + * Why the key screen cannot be left yet, or `null` when it can. + * + * The check used to happen only at the end of the wizard, in + * `saveProviderWizardToConfig`: the operator picked a model, waited for + * the save, and only then learned the first screen was blank. Refusing + * here costs one keystroke instead. Whitespace counts as empty — it is + * what a mis-paste leaves behind, and it would otherwise be written to + * `.env` as a real key. + */ +export function apiKeyPhaseError( + wizard: ProvidersWizardState, +): string | null { + if (wizardKeyIsOptional(wizard)) return null; + if (apiKeyForWizard(wizard)) return null; + return `API key required — paste the key, or set ${envHintForWizard(wizard)} in .env first`; +} From 664ddb4f066d82c98ffee9f0879767ea1d5b78f0 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:29:11 +0300 Subject: [PATCH 08/57] feat(sidecar,http): steering API so hosts can redirect a running turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the runtime's mid-turn steering on the two surfaces the desktop shell talks to. Both handlers deliberately bypass `turnController.enqueue` — enqueueing would park the message behind the very turn it is meant to redirect, which is the whole bug. Sidecar (NDJSON): - `steer_message` request `{sessionId, text}` -> `{steered}`. `false` when the session is not the active one or has no turn in flight; the host's cue to fall back to `send_message`. - `steer_applied` event when a message reaches the model, carrying the step index, so hosts can render it inline in the running turn instead of as the start of a new one. - `steer_undelivered` event, emitted from `send_message` for anything `RunTurnResult.undelivered` hands back. The sidecar has no queue of its own, so a late steer goes to the host rather than nowhere. HTTP (`atomic-agent serve`): - `POST /api/sessions/{id}/steer` with `{text}`. - `200 {steered:true}` while a turn is in flight. - `409` when the session is idle, naming `/v1/chat/completions` as the right endpoint. Refusing beats accepting a message nothing will read. - `429` when the per-session inbox is full. - `400` on a missing or blank `text`. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 +- src/http/route-sessions.test.ts | 99 +++++++++++++++++++ src/http/route-sessions.ts | 66 ++++++++++++- src/http/route-table.ts | 6 ++ src/sidecar/index.ts | 1 + src/sidecar/main.ts | 28 ++++++ src/sidecar/sidecar-events.ts | 38 ++++++++ src/sidecar/steer-message.test.ts | 157 ++++++++++++++++++++++++++++++ 8 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/sidecar/steer-message.test.ts diff --git a/AGENTS.md b/AGENTS.md index 944a2a4a..b2d621cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1003,7 +1003,9 @@ Per-session FIFO is correct for *starting* turns and wrong for *correcting* one. - **Nothing is silently lost.** A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on `RunTurnResult.undelivered`. Callers MUST re-route it (the TUI pushes it onto its pending-message queue). `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process. - **Bounded.** `MAX_PENDING_STEERS` (16) per session; `push` refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. -Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) and the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts). +**Host surfaces.** The sidecar exposes it as the `steer_message` NDJSON request (`{sessionId, text}` -> `{steered}`) plus the `steer_applied` / `steer_undelivered` events; `serve` exposes `POST /api/sessions/{id}/steer` with body `{text}` — `200 {steered:true}`, `409` when the session is idle (the message is refused, not swallowed — retry with `POST /v1/chat/completions`), `429` when the inbox is full. Neither handler goes through `turnController.enqueue`: enqueueing would park the message behind the turn it is meant to redirect. + +Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts), [src/sidecar/steer-message.test.ts](src/sidecar/steer-message.test.ts) (resolves while a turn is blocked mid-inference) and the `POST /api/sessions/{id}/steer` cases in [src/http/route-sessions.test.ts](src/http/route-sessions.test.ts). ### Extension points diff --git a/src/http/route-sessions.test.ts b/src/http/route-sessions.test.ts index 82424173..18ef9e8e 100644 --- a/src/http/route-sessions.test.ts +++ b/src/http/route-sessions.test.ts @@ -59,3 +59,102 @@ describe("/api/sessions", () => { expect(second.status).toBe(200); }); }); + +describe("POST /api/sessions/{id}/steer", () => { + let harness: Harness; + + beforeEach(async () => { + harness = await startTestHarness(); + }); + + afterEach(async () => { + await harness.cleanup(); + }); + + async function steer( + sessionId: string, + body: unknown, + ): Promise { + return fetch(`${harness.baseUrl}/api/sessions/${sessionId}/steer`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + /** Hold the session lock so `turnController.isBusy` is true. */ + async function whileBusy( + sessionId: string, + fn: () => Promise, + ): Promise { + let release!: () => void; + const held = new Promise((res) => { + release = res; + }); + let result!: T; + const turn = harness.runtime.turnController.enqueue({ + sessionId, + origin: "http", + run: async () => { + result = await fn(); + release(); + await held; + return null; + }, + }); + await turn; + return result; + } + + it("accepts a steer while the session has a turn in flight", async () => { + const session = harness.runtime.createSession(); + const response = await whileBusy(session.id, () => + steer(session.id, { text: "actually, stop and summarise" }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + steered: true, + sessionId: session.id, + }); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([ + "actually, stop and summarise", + ]); + }); + + it("409s on an idle session instead of silently swallowing the message", async () => { + const session = harness.runtime.createSession(); + const response = await steer(session.id, { text: "anyone home?" }); + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { message: string } }; + expect(body.error.message).toContain("/v1/chat/completions"); + expect(harness.runtime.steeringInbox.peek(session.id)).toEqual([]); + }); + + it("409s for a session id that never existed", async () => { + const response = await steer("s-nope", { text: "hello" }); + expect(response.status).toBe(409); + }); + + it("rejects a missing or blank text", async () => { + const session = harness.runtime.createSession(); + expect((await steer(session.id, {})).status).toBe(400); + expect((await steer(session.id, { text: " " })).status).toBe(400); + expect((await steer(session.id, { text: 42 })).status).toBe(400); + }); + + it("429s once the per-session inbox is full", async () => { + const session = harness.runtime.createSession(); + const statuses = await whileBusy(session.id, async () => { + const out: number[] = []; + // 16 fit (MAX_PENDING_STEERS); the 17th must be refused rather + // than evicting one the operator already saw accepted. + for (let i = 0; i < 17; i += 1) { + out.push((await steer(session.id, { text: `m${i}` })).status); + } + return out; + }); + expect(statuses.slice(0, 16).every((s) => s === 200)).toBe(true); + expect(statuses[16]).toBe(429); + }); +}); + diff --git a/src/http/route-sessions.ts b/src/http/route-sessions.ts index ad3ddca7..27ccf1d0 100644 --- a/src/http/route-sessions.ts +++ b/src/http/route-sessions.ts @@ -1,5 +1,10 @@ import { openaiError } from "./openai-errors.js"; -import { sendError, sendJson, type HttpHandler } from "./request-context.js"; +import { + readJsonBody, + sendError, + sendJson, + type HttpHandler, +} from "./request-context.js"; /** * `GET /api/sessions` — list recent sessions in the current working @@ -61,6 +66,65 @@ export function createGetSessionHandler(): HttpHandler { }; } +/** + * `POST /api/sessions/{id}/steer` — fold `{ text }` into the turn + * already running on that session. + * + * This is NOT a way to send a message: it never starts a turn and never + * queues behind one (see §"Mid-turn steering" in AGENTS.md). When the + * session is idle there is nothing to steer, and the caller is told so + * with `409` rather than having the message silently disappear — the + * correct follow-up is `POST /v1/chat/completions`. `429` means the + * per-session steering inbox is full; the turn has not read any of them + * yet, so piling on more would only bloat one prompt. + */ +export function createSteerSessionHandler(): HttpHandler { + return async (req, res, ctx) => { + const id = ctx.params.id; + if (!id) { + sendError(res, 400, openaiError("session id is required")); + return; + } + let body: Record; + try { + body = await readJsonBody>(req); + } catch (err) { + sendError( + res, + 400, + openaiError(err instanceof Error ? err.message : "invalid body"), + ); + return; + } + const text = body.text; + if (typeof text !== "string" || text.trim().length === 0) { + sendError(res, 400, openaiError("text must be a non-empty string")); + return; + } + if (!ctx.runtime.turnController.isBusy(id)) { + sendError( + res, + 409, + openaiError( + `session ${id} has no turn in flight — send the message with POST /v1/chat/completions instead`, + ), + ); + return; + } + if (!ctx.runtime.steer(id, text)) { + sendError( + res, + 429, + openaiError( + `steering inbox for session ${id} is full — the running turn has not consumed the pending messages yet`, + ), + ); + return; + } + sendJson(res, 200, { steered: true, sessionId: id }); + }; +} + /** * `DELETE /api/sessions/{id}` — purge the session row. Idempotent: * returns 200 whether or not the row existed so orchestrators can diff --git a/src/http/route-table.ts b/src/http/route-table.ts index 93e9cf1f..582f0d9f 100644 --- a/src/http/route-table.ts +++ b/src/http/route-table.ts @@ -19,6 +19,7 @@ import { createDeleteSessionHandler, createGetSessionHandler, createListSessionsHandler, + createSteerSessionHandler, } from "./route-sessions.js"; import { createApprovalEventsHandler, @@ -61,6 +62,11 @@ export function buildRouteTable(): RouteDefinition[] { { method: "GET", path: "/api/sessions", handler: createListSessionsHandler() }, { method: "GET", path: "/api/sessions/{id}", handler: createGetSessionHandler() }, { method: "DELETE", path: "/api/sessions/{id}", handler: createDeleteSessionHandler() }, + { + method: "POST", + path: "/api/sessions/{id}/steer", + handler: createSteerSessionHandler(), + }, { method: "POST", path: "/api/approval/resolve", handler: createResolveApprovalHandler() }, { method: "GET", path: "/api/events", handler: createApprovalEventsHandler() }, { method: "POST", path: "/api/tasks", handler: createCreateTaskHandler() }, diff --git a/src/sidecar/index.ts b/src/sidecar/index.ts index ef08bfa8..a1ab0e96 100644 --- a/src/sidecar/index.ts +++ b/src/sidecar/index.ts @@ -20,6 +20,7 @@ export type { StartSessionPayload, RunStepPayload, SendMessagePayload, + SteerMessagePayload, CancelPayload, ApprovalResponsePayload, GetSessionPayload, diff --git a/src/sidecar/main.ts b/src/sidecar/main.ts index f3429d2b..30d54dda 100644 --- a/src/sidecar/main.ts +++ b/src/sidecar/main.ts @@ -13,6 +13,7 @@ import type { CancelPayload, GetSessionPayload, SendMessagePayload, + SteerMessagePayload, SkillInstallPayload, SkillUninstallPayload, StartSessionPayload, @@ -144,6 +145,13 @@ export async function bootstrapSidecar(): Promise<{ text: event.text, }); break; + case "steer_applied": + protocol.emitEvent("steer_applied", { + sessionId, + text: event.text, + stepIndex: event.stepIndex, + }); + break; case "turn_started": protocol.emitEvent("turn_started", { sessionId, @@ -310,6 +318,12 @@ export async function bootstrapSidecar(): Promise<{ }, }); active = { ...active, session: result.session }; + // A steer that arrived too late to be drained must not vanish. The + // sidecar has no queue of its own, so surface it to the host, which + // can decide to re-send it as a normal message. + for (const text of result.undelivered ?? []) { + protocol.emitEvent("steer_undelivered", { sessionId, text }); + } return { reason: result.reason, turnCount: result.session.turnCount, @@ -332,6 +346,20 @@ export async function bootstrapSidecar(): Promise<{ }, ); + router.register( + "steer_message", + (request) => { + const { sessionId, text } = request.payload; + if (!active || active.session.id !== sessionId) return { steered: false }; + // Deliberately NOT routed through `turnController.enqueue`: the + // point of steering is to reach the turn that already holds the + // session lock, and enqueueing would put it behind that turn. + // `runtime.steer` returns false when nothing is running, which is + // the host's cue to call `send_message` instead. + return { steered: active.runtime.steer(sessionId, text) }; + }, + ); + router.register( "cancel", (request) => { diff --git a/src/sidecar/sidecar-events.ts b/src/sidecar/sidecar-events.ts index ad9e4f8f..38f7b1c5 100644 --- a/src/sidecar/sidecar-events.ts +++ b/src/sidecar/sidecar-events.ts @@ -10,6 +10,7 @@ export type HostRequestType = | "start_session" | "run_step" | "send_message" + | "steer_message" | "cancel" | "approval_response" | "get_session" @@ -26,6 +27,8 @@ export type SidecarEventType = | "tool_call_started" | "tool_call_result" | "user_message" + | "steer_applied" + | "steer_undelivered" | "assistant_reply" | "assistant_delta" | "reasoning_delta" @@ -91,6 +94,18 @@ export interface SendMessagePayload { maxSteps?: number; } +/** + * Fold a message into the turn already running on `sessionId`. Unlike + * {@link SendMessagePayload} this never starts a turn and never queues + * behind one — see §"Mid-turn steering" in AGENTS.md. The response's + * `steered: false` means the session was idle (or the inbox was full) + * and the host should fall back to `send_message`. + */ +export interface SteerMessagePayload { + sessionId: string; + text: string; +} + export interface CancelPayload { sessionId: string; } @@ -176,6 +191,29 @@ export interface UserMessagePayload { text: string; } +/** + * A mid-turn message reached the model at `stepIndex`. Distinct from + * `user_message`, which marks the message that opened the turn — hosts + * render this one inline inside the running turn. + */ +export interface SteerAppliedPayload { + sessionId: string; + text: string; + stepIndex: number; +} + +/** + * A steer was accepted but the turn ended before the loop could drain + * it (it landed during the final inference, or the turn was cancelled). + * The host owns it now — re-send it as a `send_message` if it still + * makes sense. Emitted rather than silently dropped so "the message you + * sent always goes somewhere" holds on this surface too. + */ +export interface SteerUndeliveredPayload { + sessionId: string; + text: string; +} + export interface AssistantReplyPayload { sessionId: string; text: string; diff --git a/src/sidecar/steer-message.test.ts b/src/sidecar/steer-message.test.ts new file mode 100644 index 00000000..caaf817f --- /dev/null +++ b/src/sidecar/steer-message.test.ts @@ -0,0 +1,157 @@ +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { CompletionResult } from "../llm/llama-server-client.js"; +import { resetConfigCache } from "../config/index.js"; +import { createAgentRuntime } from "../runtime/bootstrap.js"; +import type { AgentRuntime } from "../runtime/bootstrap.js"; +import { FakeBrowserBackend } from "../http/test-harness.js"; + +/** + * Mirrors the production `steer_message` handler in + * `src/sidecar/main.ts` — same convention as + * `send-message-concurrency.test.ts`, which mirrors `send_message` + * rather than driving the stdin protocol. + * + * The property under test is the one that makes steering different from + * every other host request: it must NOT go through + * `turnController.enqueue`. Enqueueing would park the message behind + * the very turn it is meant to redirect, which is the bug this whole + * feature exists to avoid. + */ +function makeSteerHandler(runtime: AgentRuntime, activeSessionId: string) { + return (sessionId: string, text: string): { steered: boolean } => { + if (activeSessionId !== sessionId) return { steered: false }; + return { steered: runtime.steer(sessionId, text) }; + }; +} + +describe("sidecar steer_message", () => { + let stateDir: string; + let workingDir: string; + let runtime: AgentRuntime; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-state-")); + workingDir = mkdtempSync(join(tmpdir(), "atomic-sidecar-steer-cwd-")); + mkdirSync(join(workingDir, ".atomic-agent", "skills"), { recursive: true }); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + process.env.ATOMIC_AGENT_GRAMMARS_DIR = join(process.cwd(), "grammars"); + resetConfigCache(); + }); + + afterEach(async () => { + if (runtime) await runtime.shutdown(); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(workingDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + resetConfigCache(); + }); + + it("resolves immediately while a turn holds the session, and lands in that turn", async () => { + const enters: string[] = []; + let releaseFirst: (() => void) | null = null; + const llamaComplete = async (params: { + sessionId: string; + }): Promise => { + if (params.sessionId.startsWith("reflection:")) return reply("ignored"); + enters.push("user-turn"); + if (enters.length === 1) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return reply("done"); + }; + + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete, + }, + }); + + const session = runtime.createSession({ metadata: { source: "steer-test" } }); + const steer = makeSteerHandler(runtime, session.id); + + const turn = runtime.runTurn(session, "start working", { + origin: "sidecar", + }); + + const deadline = Date.now() + 5_000; + while (enters.length < 1 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(enters).toEqual(["user-turn"]); + + // The turn is blocked inside its inference. A queued handler would + // hang here; steering must answer now. + expect(steer(session.id, "actually, do it differently")).toEqual({ + steered: true, + }); + expect(runtime.steeringInbox.peek(session.id)).toEqual([ + "actually, do it differently", + ]); + + releaseFirst?.(); + await turn; + }); + + it("refuses when the session is idle so the host falls back to send_message", async () => { + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => reply("done"), + }, + }); + const session = runtime.createSession(); + const steer = makeSteerHandler(runtime, session.id); + expect(steer(session.id, "hello?")).toEqual({ steered: false }); + expect(runtime.steeringInbox.peek(session.id)).toEqual([]); + }); + + it("refuses for a session that is not the active one", async () => { + runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { + browserBackend: new FakeBrowserBackend(), + skipLlamaHealthCheck: true, + llamaComplete: async () => reply("done"), + }, + }); + const active = runtime.createSession(); + const other = runtime.createSession(); + const steer = makeSteerHandler(runtime, active.id); + expect(steer(other.id, "wrong session")).toEqual({ steered: false }); + expect(runtime.steeringInbox.peek(other.id)).toEqual([]); + }); +}); + +function reply(text: string): CompletionResult { + return { + content: JSON.stringify({ tool: "reply", args: { text } }), + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens: 1, + predictedTokens: 1, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: null, + }; +} From 2657e7fa813e0cefee7c468b654e2e5052ec495d Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:30:14 +0300 Subject: [PATCH 09/57] fix(tui): point the wizard screen at the shared provider-target helpers --- src/tui/components/providers-wizard.tsx | 28 +++++++------------------ 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index c713409d..69851a3a 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -10,11 +10,13 @@ import { getCachedOpenRouterChatPicks, refreshOpenRouterChatCatalogFromApi, } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; +import { listCompatChatModelPicks } from "../providers/providers-wizard-key-bindings.js"; import { apiKeyForWizard, baseUrlForWizard, - listCompatChatModelPicks, -} from "../providers/providers-wizard-key-bindings.js"; + envHintForWizard, + wizardKeyIsOptional, +} from "../providers/providers-wizard-target.js"; import { theme } from "../theme/theme.js"; import { findProviderPreset } from "../providers/provider-presets.js"; import { @@ -60,20 +62,6 @@ const KIND_OPTIONS = KIND_ROW_ORDER.map((row) => ({ label: labelForKindRow(row), })); -/** - * Env var named on the key screen. A preset names its own variable; - * naming the shared compat one there would promise Groq's key a home it - * does not use. - */ -function envHintForWizard(w: ProvidersWizardState): string { - const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; - if (preset) return preset.envVar; - if (w.kind === "openrouter") return "OPENROUTER_API_KEY"; - if (w.kind === "aimlapi") return "AIMLAPI_API_KEY"; - if (w.kind === "gemini") return "GEMINI_API_KEY"; - return "OPENAI_COMPAT_API_KEY"; -} - /** Service name for headings: the preset label wins over the raw kind. */ function providerLabelForWizard(w: ProvidersWizardState): string { const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; @@ -296,13 +284,11 @@ export function ProvidersWizard(props: { if (w.phase === "api_key") { const envHint = envHintForWizard(w); - const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; // Local servers and keyless-listing services save with an empty key; // promising ".env only" here would contradict their own list rows. - const emptyMeans = - preset && (preset.local || preset.listsModelsWithoutKey) - ? "Optional for this service — leave empty to connect without a key." - : "Leave empty only if the key is already in .env."; + const emptyMeans = wizardKeyIsOptional(w) + ? "Optional for this service — leave empty to connect without a key." + : "Leave empty only if the key is already in .env."; return ( Date: Wed, 19 Aug 2026 04:33:24 +0300 Subject: [PATCH 10/57] feat(tui): verify a cloud API key is active and funded before saving it --- src/tui/providers/describe-verify-outcome.ts | 40 +++++ src/tui/providers/is-local-provider-url.ts | 22 +++ src/tui/providers/providers-actions.ts | 1 + src/tui/providers/providers-key-bindings.ts | 4 + .../providers/providers-orchestrator.test.ts | 113 ++++++++++++++ src/tui/providers/providers-orchestrator.ts | 40 +++++ src/tui/providers/providers-reducer.ts | 15 ++ .../providers-wizard-key-bindings.ts | 7 + .../providers/providers-wizard-target.test.ts | 52 +++++++ src/tui/providers/providers-wizard-target.ts | 112 +++++++++++++- .../verify-wizard-before-save.test.ts | 142 ++++++++++++++++++ .../providers/verify-wizard-before-save.ts | 51 +++++++ 12 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 src/tui/providers/describe-verify-outcome.ts create mode 100644 src/tui/providers/is-local-provider-url.ts create mode 100644 src/tui/providers/verify-wizard-before-save.test.ts create mode 100644 src/tui/providers/verify-wizard-before-save.ts diff --git a/src/tui/providers/describe-verify-outcome.ts b/src/tui/providers/describe-verify-outcome.ts new file mode 100644 index 00000000..90904aae --- /dev/null +++ b/src/tui/providers/describe-verify-outcome.ts @@ -0,0 +1,40 @@ +/** + * One sentence per verification outcome, in the same voice the cloud + * error path already uses (`humanizeOpenAiHttpError`): who answered, + * what happened, what to do about it. A raw `http 402` on the key screen + * reads as a product failure when it is in fact an empty account. + */ + +import type { ProviderVerifyResult } from "../../llm/provider/verify/index.js"; + +export function describeProviderVerifyOutcome( + result: ProviderVerifyResult, + label: string, +): string { + const who = `"${label}"`; + const model = result.probedModel ? ` (tested with ${result.probedModel})` : ""; + switch (result.status) { + case "ok": + return `${who} accepted the key${model}.`; + case "invalid_key": + return `${who} rejected this key${statusSuffix(result)}. Check it belongs to ${label}, then paste it again.`; + case "no_balance": + return `${who} accepted the key but has no usable balance${statusSuffix(result)}. Top up the account or enable billing, then try again.`; + case "rate_limited": + return `${who} is rate-limiting this key right now — the key itself works. Saved without a completed test.`; + case "model_unavailable": + return `${who} has no model this check could run${model}. Saved without a live test.`; + case "timeout": + return `${who} did not answer the key check in time. Saved unverified — the key was not tested.`; + case "unreachable": + return `Could not reach ${who} to test the key. Saved unverified — check the connection or the base URL.`; + case "cancelled": + return `Key check cancelled. Nothing was saved.`; + default: + return `${who} failed the key check${statusSuffix(result)}. Saved unverified — this looks like the provider, not your key.`; + } +} + +function statusSuffix(result: ProviderVerifyResult): string { + return result.httpStatus === null ? "" : ` (${result.httpStatus})`; +} diff --git a/src/tui/providers/is-local-provider-url.ts b/src/tui/providers/is-local-provider-url.ts new file mode 100644 index 00000000..393a3efb --- /dev/null +++ b/src/tui/providers/is-local-provider-url.ts @@ -0,0 +1,22 @@ +/** + * Whether a base URL points at the operator's own machine. Local servers + * have no API key and no account behind them, so every check that exists + * to protect a cloud account has to step aside for them. + */ +export function isLocalProviderUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + let host: string; + try { + host = new URL(baseUrl).hostname; + } catch { + return false; + } + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host === "[::1]" || + host.endsWith(".localhost") + ); +} diff --git a/src/tui/providers/providers-actions.ts b/src/tui/providers/providers-actions.ts index c4497784..de2e0cda 100644 --- a/src/tui/providers/providers-actions.ts +++ b/src/tui/providers/providers-actions.ts @@ -81,6 +81,7 @@ export type ProvidersAction = | { type: "providers_wizard_closed" } | { type: "providers_wizard_submit_started" } | { type: "providers_wizard_failed"; error: string } + | { type: "providers_wizard_verify_cancelled" } | { type: "providers_wizard_succeeded" } | { type: "providers_remove_opened"; id: string } | { type: "providers_remove_closed" } diff --git a/src/tui/providers/providers-key-bindings.ts b/src/tui/providers/providers-key-bindings.ts index 30b56c11..7766433f 100644 --- a/src/tui/providers/providers-key-bindings.ts +++ b/src/tui/providers/providers-key-bindings.ts @@ -31,6 +31,10 @@ export function handleProvidersTabKey( return true; } if ("wizard" in result) { + if ("cancelSubmit" in result && result.cancelSubmit) { + callbacks.onProvidersWizardSubmitCancel?.(); + return true; + } if ("submit" in result && result.submit) { void callbacks.onProvidersWizardSubmit?.(result.wizard); return true; diff --git a/src/tui/providers/providers-orchestrator.test.ts b/src/tui/providers/providers-orchestrator.test.ts index 36e85c75..d687b9d7 100644 --- a/src/tui/providers/providers-orchestrator.test.ts +++ b/src/tui/providers/providers-orchestrator.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; import type { AtomicAgentConfig } from "../../config/index.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { ProvidersWizardState } from "./providers-wizard-state.js"; vi.mock("../../config/index.js", async (importOriginal) => { const original = await importOriginal(); @@ -236,3 +238,114 @@ describe("ProvidersOrchestrator.ensureInlineModels", () => { ); }); }); + +describe("ProvidersOrchestrator.completeWizard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + function wizardFor(kind: "openrouter" | "aimlapi"): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + apiKeyBuffer: "sk-wizard-key", + }; + } + + function fakeRuntime() { + return { + providerRegistry: { + setActive: vi.fn(async () => {}), + listIds: () => [] as string[], + }, + reloadLlmProvider: vi.fn(async () => {}), + reloadLlmProviders: vi.fn(async () => {}), + } as unknown as AgentRuntime; + } + + it("refuses to save a key the provider will not honour", async () => { + currentConfig = configWithGemini(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: "Insufficient credits" }), { + status: 402, + }), + ), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const runtime = fakeRuntime(); + const orchestrator = new ProvidersOrchestrator(runtime, bus as never); + + await orchestrator.completeWizard(wizardFor("openrouter")); + + const types = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(types).toContain("providers_wizard_failed"); + expect(types).not.toContain("providers_wizard_succeeded"); + // Nothing reloaded means nothing was written: the save never ran. + expect(runtime.reloadLlmProviders).not.toHaveBeenCalled(); + expect(runtime.reloadLlmProvider).not.toHaveBeenCalled(); + }); + + it("reports the failure in words the operator can act on", async () => { + currentConfig = configWithGemini(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: "No auth credentials found" }), { + status: 401, + }), + ), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const orchestrator = new ProvidersOrchestrator(fakeRuntime(), bus as never); + + await orchestrator.completeWizard(wizardFor("aimlapi")); + + const failure = bus.emit.mock.calls + .map((call) => call[0] as { type: string; error?: string }) + .find((action) => action.type === "providers_wizard_failed"); + expect(failure?.error).toContain("rejected this key"); + }); + + it("hands the cancel back to the wizard while a check is in flight", async () => { + currentConfig = configWithGemini(); + let releaseFetch: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseFetch = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + await gate; + return new Response("{}", { status: 401 }); + }), + ); + const { ProvidersOrchestrator } = await importFreshOrchestrator(); + const bus = fakeBus(); + const runtime = fakeRuntime(); + const orchestrator = new ProvidersOrchestrator(runtime, bus as never); + + const running = orchestrator.completeWizard(wizardFor("openrouter")); + await flush(); + // Still waiting on the provider: submitting is on, nothing saved. + const midTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(midTypes).toContain("providers_wizard_submit_started"); + expect(midTypes).not.toContain("providers_wizard_succeeded"); + + orchestrator.cancelWizardVerification(); + const cancelTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(cancelTypes).toContain("providers_wizard_verify_cancelled"); + + releaseFetch(); + await running; + expect(runtime.reloadLlmProviders).not.toHaveBeenCalled(); + // The late answer from the abandoned check stays quiet: the wizard is + // already back under the operator's hands. + const finalTypes = bus.emit.mock.calls.map((call) => (call[0] as { type: string }).type); + expect(finalTypes).not.toContain("providers_wizard_failed"); + }); +}); diff --git a/src/tui/providers/providers-orchestrator.ts b/src/tui/providers/providers-orchestrator.ts index 9fe38615..b84eeeef 100644 --- a/src/tui/providers/providers-orchestrator.ts +++ b/src/tui/providers/providers-orchestrator.ts @@ -26,6 +26,7 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; import { isProvidersAction } from "./providers-actions.js"; import type { ProviderRow } from "./providers-panel-state.js"; import { saveProviderWizardToConfig } from "./save-provider-wizard.js"; +import { verifyWizardBeforeSave } from "./verify-wizard-before-save.js"; import type { ProvidersWizardKind, ProvidersWizardState, @@ -42,6 +43,9 @@ export class ProvidersOrchestrator { /** Backs the inline model list's stale-response guard; see `ensureInlineModels`. */ private inlineModelsGeneration = 0; + /** Aborts the pre-save key check when the operator presses Esc. */ + private wizardVerifyAbort: AbortController | null = null; + constructor( private readonly runtime: AgentRuntime, private readonly bus: TuiEventBus & { emit(action: unknown): void }, @@ -323,9 +327,37 @@ export class ProvidersOrchestrator { } } + /** + * Abandon the key check a `completeWizard` call is waiting on. The + * wizard reopens for editing; nothing has been written by this point, + * because the check runs before the save. + */ + cancelWizardVerification(): void { + if (!this.wizardVerifyAbort) return; + this.wizardVerifyAbort.abort(); + this.wizardVerifyAbort = null; + this.bus.emit({ type: "providers_wizard_verify_cancelled" }); + } + async completeWizard(wizard: ProvidersWizardState): Promise { this.bus.emit({ type: "providers_wizard_submit_started" }); + const abort = new AbortController(); + this.wizardVerifyAbort = abort; try { + // The key is checked against the service before anything reaches + // disk: a dead or unfunded key used to be written to .env and made + // the active provider, and only failed on the first real message. + const gate = await verifyWizardBeforeSave(wizard, { signal: abort.signal }); + // A cancel already put the wizard back in an editable state; a + // late verdict from the abandoned check must not overwrite it. + if (abort.signal.aborted) return; + // The check is over; from here Esc has nothing to cancel and must + // not interrupt the save that follows. + this.wizardVerifyAbort = null; + if (!gate.proceed) { + this.bus.emit({ type: "providers_wizard_failed", error: gate.error }); + return; + } const built = saveProviderWizardToConfig(wizard); const exists = this.runtime.providerRegistry .listIds() @@ -339,6 +371,12 @@ export class ProvidersOrchestrator { await this.setActiveText(built.entry.id); this.bus.emit({ type: "providers_wizard_succeeded" }); + if (gate.warning) { + // Saved, but the key was never proven. Say so where the operator + // will see it rather than letting the first chat message find out. + this.bus.emit({ type: "providers_status", line: gate.warning }); + this.bus.emit({ type: "runtime_info", line: gate.warning }); + } this.bus.emit({ type: "runtime_info", line: `Active text provider: ${built.entry.id} (${built.entry.defaultChatModel ?? "default model"}). Chat uses cloud native tools now.`, @@ -349,6 +387,8 @@ export class ProvidersOrchestrator { type: "providers_wizard_failed", error: wrapLlmConfigError(err), }); + } finally { + if (this.wizardVerifyAbort === abort) this.wizardVerifyAbort = null; } } diff --git a/src/tui/providers/providers-reducer.ts b/src/tui/providers/providers-reducer.ts index 39acf506..f1aeac8a 100644 --- a/src/tui/providers/providers-reducer.ts +++ b/src/tui/providers/providers-reducer.ts @@ -104,6 +104,21 @@ export function reduceProvidersPanel( }, }, }; + case "providers_wizard_verify_cancelled": + // Back to an editable screen rather than a closed wizard: the + // operator abandoned the check, not the provider they were adding. + if (panel.wizard === null) return state; + return { + ...state, + providersPanel: { + ...panel, + wizard: { + ...panel.wizard, + submitting: false, + error: "Key check cancelled — press Enter to try again.", + }, + }, + }; case "providers_wizard_succeeded": return { ...state, diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index df634a62..68ace6ff 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -81,6 +81,7 @@ function nextListCursor( export type ProvidersWizardKeyResult = | { handled: true; wizard: ProvidersWizardState; submit?: false } | { handled: true; wizard: ProvidersWizardState; submit: true } + | { handled: true; wizard: ProvidersWizardState; cancelSubmit: true } | { handled: true; closed: true } | { handled: false }; @@ -90,6 +91,12 @@ export function handleProvidersWizardKey( wizard: ProvidersWizardState, ): ProvidersWizardKeyResult { if (wizard.submitting) { + // Esc is the one key that survives the lockout. Submitting now waits + // on the provider answering a key check, and a service that has gone + // quiet must not hold the wizard until it times out. + if (key.escape) { + return { handled: true, wizard, cancelSubmit: true }; + } return { handled: true, wizard }; } if (key.escape) { diff --git a/src/tui/providers/providers-wizard-target.test.ts b/src/tui/providers/providers-wizard-target.test.ts index 5178ee29..cf85f043 100644 --- a/src/tui/providers/providers-wizard-target.test.ts +++ b/src/tui/providers/providers-wizard-target.test.ts @@ -4,6 +4,7 @@ import { apiKeyForWizard, apiKeyPhaseError, envHintForWizard, + verifyTargetForWizard, wizardKeyIsOptional, } from "./providers-wizard-target.js"; import { createProvidersWizardState } from "./providers-wizard-state.js"; @@ -122,3 +123,54 @@ describe("envHintForWizard", () => { ); }); }); + +describe("verifyTargetForWizard", () => { + beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + }); + + function withKey( + kind: ProvidersWizardKind, + presetId?: string, + ): ProvidersWizardState { + return { ...wizardFor(kind, presetId), apiKeyBuffer: "sk-test" }; + } + + it("bills the check as this app on OpenRouter", () => { + const target = verifyTargetForWizard(withKey("openrouter")); + expect(target).not.toBeNull(); + expect(target?.baseUrl).toBe("https://openrouter.ai/api"); + expect(target?.extraHeaders?.["X-Title"]).toBe("Atomic Agent"); + // Never the free router: it answers on a key with no credit. + expect(target?.probeModels[0]).not.toBe("openrouter/auto"); + }); + + it("knows Gemini's compatibility prefix", () => { + const target = verifyTargetForWizard(withKey("gemini")); + expect(target?.apiPathPrefix).toBe("/v1beta/openai"); + }); + + it("has nothing to check for keyless and local providers", () => { + expect(verifyTargetForWizard(withKey("openai-compatible", "lmstudio"))).toBeNull(); + expect( + verifyTargetForWizard({ + ...withKey("openai-compatible"), + baseUrlLine: "http://localhost:1234", + }), + ).toBeNull(); + expect(verifyTargetForWizard(wizardFor("openrouter"))).toBeNull(); + }); + + it("probes the endpoint and model the operator is about to save", () => { + const target = verifyTargetForWizard({ + ...withKey("openai-compatible"), + baseUrlLine: "https://vllm.example", + chatModelLine: "my-model", + }); + expect(target?.baseUrl).toBe("https://vllm.example"); + expect(target?.probeModels).toEqual(["my-model"]); + }); +}); diff --git a/src/tui/providers/providers-wizard-target.ts b/src/tui/providers/providers-wizard-target.ts index e30bca0b..8d8ed2f5 100644 --- a/src/tui/providers/providers-wizard-target.ts +++ b/src/tui/providers/providers-wizard-target.ts @@ -11,10 +11,34 @@ import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; import type { UserLlmProviderEntry } from "../../config/llm-config.js"; +import { DEFAULT_AIMLAPI_BASE } from "../../llm/provider/aimlapi/aimlapi-provider.js"; +import { + DEFAULT_GEMINI_BASE, + GEMINI_API_PATH_PREFIX, +} from "../../llm/provider/gemini/gemini-provider.js"; +import { getCachedOpenAiCompatModelsForBaseUrl } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { normalizeOpenAiBaseUrl } from "../../llm/provider/openai/normalize-openai-base-url.js"; +import { + DEFAULT_OPENROUTER_BASE, + OPENROUTER_APP_CATEGORIES, + OPENROUTER_APP_REFERER, + OPENROUTER_APP_TITLE, +} from "../../llm/provider/openrouter/openrouter-provider.js"; +import { pickProbeModels } from "../../llm/provider/verify/index.js"; +import type { ProviderVerifyTarget } from "../../llm/provider/verify/index.js"; +import { isLocalProviderUrl } from "./is-local-provider-url.js"; import { findProviderPreset } from "./provider-presets.js"; -import { OPENAI_COMPAT_DEFAULT_BASE_URL } from "./providers-model-options.js"; -import type { ProvidersWizardState } from "./providers-wizard-state.js"; +import { + AIMLAPI_DEFAULT_CHAT_MODEL, + GEMINI_DEFAULT_CHAT_MODEL, + OPENAI_COMPAT_DEFAULT_BASE_URL, + OPENAI_COMPAT_DEFAULT_CHAT_MODEL, + OPENROUTER_DEFAULT_CHAT_MODEL, +} from "./providers-model-options.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; /** Normalized so the fetch, the cache key and the displayed URL always agree. */ export function baseUrlForWizard(wizard: ProvidersWizardState): string { @@ -98,3 +122,87 @@ export function apiKeyPhaseError( if (apiKeyForWizard(wizard)) return null; return `API key required — paste the key, or set ${envHintForWizard(wizard)} in .env first`; } + +/** Service name for headings and for every sentence about a failure. */ +export function providerLabelForWizard(wizard: ProvidersWizardState): string { + const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; + return preset?.label ?? wizard.kind ?? "provider"; +} + +/** The model this wizard run is about to save, before any defaulting. */ +function chosenModelForWizard(wizard: ProvidersWizardState): string { + const typed = wizard.chatModelLine.trim(); + if (wizard.selectedChatModelId) return wizard.selectedChatModelId; + if (typed.length > 0) return typed; + if (wizard.kind === "openrouter") return OPENROUTER_DEFAULT_CHAT_MODEL; + if (wizard.kind === "aimlapi") return AIMLAPI_DEFAULT_CHAT_MODEL; + if (wizard.kind === "gemini") return GEMINI_DEFAULT_CHAT_MODEL; + return OPENAI_COMPAT_DEFAULT_CHAT_MODEL; +} + +function endpointForKind( + kind: ProvidersWizardKind, + wizard: ProvidersWizardState, +): { baseUrl: string; apiPathPrefix: string; extraHeaders?: Record } { + if (kind === "openrouter") { + return { + baseUrl: DEFAULT_OPENROUTER_BASE, + apiPathPrefix: "/v1", + // The same attribution the real provider sends, so the check is + // billed and rate-limited as this app rather than as a stranger. + extraHeaders: { + "HTTP-Referer": OPENROUTER_APP_REFERER, + "X-Title": OPENROUTER_APP_TITLE, + "X-OpenRouter-Categories": OPENROUTER_APP_CATEGORIES, + }, + }; + } + if (kind === "aimlapi") { + return { baseUrl: DEFAULT_AIMLAPI_BASE, apiPathPrefix: "/v1" }; + } + if (kind === "gemini") { + return { + baseUrl: DEFAULT_GEMINI_BASE, + apiPathPrefix: GEMINI_API_PATH_PREFIX, + }; + } + return { baseUrl: baseUrlForWizard(wizard), apiPathPrefix: "/v1" }; +} + +/** + * What to send the credential check, or `null` when there is nothing + * worth checking: a service that legitimately has no key, a screen with + * no key resolved (the key-screen gate already refused that), or a + * server on this machine, which has no account to be wrong about. + */ +export function verifyTargetForWizard( + wizard: ProvidersWizardState, +): ProviderVerifyTarget | null { + const kind = wizard.kind; + if (!kind) return null; + if (wizardKeyIsOptional(wizard)) return null; + const apiKey = apiKeyForWizard(wizard)?.trim(); + if (!apiKey) return null; + + const endpoint = endpointForKind(kind, wizard); + if (isLocalProviderUrl(endpoint.baseUrl)) return null; + + const listed = + kind === "openai-compatible" + ? getCachedOpenAiCompatModelsForBaseUrl(endpoint.baseUrl) + : undefined; + const probeModels = pickProbeModels({ + kind, + selectedModelId: chosenModelForWizard(wizard), + ...(listed ? { listedModelIds: listed } : {}), + }); + + return { + label: providerLabelForWizard(wizard), + baseUrl: endpoint.baseUrl, + apiPathPrefix: endpoint.apiPathPrefix, + apiKey, + probeModels, + ...(endpoint.extraHeaders ? { extraHeaders: endpoint.extraHeaders } : {}), + }; +} diff --git a/src/tui/providers/verify-wizard-before-save.test.ts b/src/tui/providers/verify-wizard-before-save.test.ts new file mode 100644 index 00000000..6e28f7d9 --- /dev/null +++ b/src/tui/providers/verify-wizard-before-save.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { verifyWizardBeforeSave } from "./verify-wizard-before-save.js"; +import { createProvidersWizardState } from "./providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "./providers-wizard-state.js"; + +const ENV_KEYS = [ + "OPENROUTER_API_KEY", + "AIMLAPI_API_KEY", + "GEMINI_API_KEY", + "OPENAI_COMPAT_API_KEY", + "OPENAI_API_KEY", + "LMSTUDIO_API_KEY", +] as const; + +function wizard( + kind: ProvidersWizardKind, + overrides: Partial = {}, +): ProvidersWizardState { + return { + ...createProvidersWizardState("add", { kind }), + phase: "api_key", + apiKeyBuffer: "sk-test-key", + ...overrides, + }; +} + +function stubChatCompletions(status: number, body: unknown): ReturnType { + const fetchMock = vi.fn(async () => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; +}); +afterEach(() => { + vi.unstubAllGlobals(); + for (const key of ENV_KEYS) delete process.env[key]; +}); + +describe("verifyWizardBeforeSave", () => { + it("lets a working key through with nothing to report", async () => { + stubChatCompletions(200, { choices: [{ message: { content: "" } }] }); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate).toEqual({ proceed: true, warning: null }); + }); + + it("stops a key the provider rejects", async () => { + stubChatCompletions(401, { error: "No auth credentials found" }); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate.proceed).toBe(false); + if (!gate.proceed) { + expect(gate.error).toContain("rejected this key"); + } + }); + + it("stops a key with no balance behind it", async () => { + // The whole reason the probe spends a token instead of listing + // models: a drained account answers every listing perfectly well. + stubChatCompletions(402, { error: "Insufficient credits" }); + const gate = await verifyWizardBeforeSave(wizard("aimlapi")); + expect(gate.proceed).toBe(false); + if (!gate.proceed) { + expect(gate.error).toContain("no usable balance"); + } + }); + + it("saves with a warning when the provider cannot be reached", async () => { + // An offline laptop or a corporate proxy must still be able to + // finish the wizard; the key is simply unproven. + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed"); + }), + ); + const gate = await verifyWizardBeforeSave(wizard("gemini")); + expect(gate.proceed).toBe(true); + if (gate.proceed) { + expect(gate.warning).toContain("Saved unverified"); + } + }); + + it("saves with a warning when the key is merely throttled", async () => { + stubChatCompletions(429, "slow down"); + const gate = await verifyWizardBeforeSave(wizard("openrouter")); + expect(gate.proceed).toBe(true); + if (gate.proceed) { + expect(gate.warning).toContain("rate-limiting"); + } + }); + + it("never calls out for a keyless local provider", async () => { + const fetchMock = stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave( + wizard("openai-compatible", { + presetId: "lmstudio", + apiKeyBuffer: "", + baseUrlLine: "http://localhost:1234", + }), + ); + expect(gate).toEqual({ proceed: true, warning: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never calls out for a hand-typed endpoint on this machine", async () => { + const fetchMock = stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave( + wizard("openai-compatible", { baseUrlLine: "http://127.0.0.1:8000" }), + ); + expect(gate).toEqual({ proceed: true, warning: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends the key to the service the operator picked", async () => { + const fetchMock = stubChatCompletions(200, { choices: [] }); + await verifyWizardBeforeSave(wizard("gemini")); + // Gemini's OpenAI-compatible surface lives under /v1beta/openai. + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + ); + }); + + it("stops when the operator cancels the check", async () => { + const controller = new AbortController(); + controller.abort(); + stubChatCompletions(200, {}); + const gate = await verifyWizardBeforeSave(wizard("openrouter"), { + signal: controller.signal, + }); + expect(gate.proceed).toBe(false); + }); +}); diff --git a/src/tui/providers/verify-wizard-before-save.ts b/src/tui/providers/verify-wizard-before-save.ts new file mode 100644 index 00000000..f713fece --- /dev/null +++ b/src/tui/providers/verify-wizard-before-save.ts @@ -0,0 +1,51 @@ +/** + * The gate every provider save passes through. + * + * Both save paths — the Providers/LLM panel wizard and the first-run + * onboarding screen — call this before `saveProviderWizardToConfig`, so + * neither can quietly persist a key the other would have refused. + * + * Only a dead key and an empty account stop a save. A timeout, an + * unreachable host or a throttled key say nothing about whether the key + * is good, and refusing them would leave an operator behind a proxy, or + * on a plane, with no way to configure the agent at all — so those save + * and say so. + */ + +import { + isBlockingVerifyStatus, + verifyProviderKey, +} from "../../llm/provider/verify/index.js"; +import { describeProviderVerifyOutcome } from "./describe-verify-outcome.js"; +import { verifyTargetForWizard } from "./providers-wizard-target.js"; +import type { ProvidersWizardState } from "./providers-wizard-state.js"; + +export type WizardVerifyGate = + | { readonly proceed: true; readonly warning: string | null } + | { readonly proceed: false; readonly error: string }; + +export async function verifyWizardBeforeSave( + wizard: ProvidersWizardState, + opts: { signal?: AbortSignal } = {}, +): Promise { + const target = verifyTargetForWizard(wizard); + // Nothing to check: a keyless local server, or a service that saves + // without a key on purpose. Both keep the behaviour they had before. + if (!target) return { proceed: true, warning: null }; + + const result = await verifyProviderKey(target, { + ...(opts.signal ? { signal: opts.signal } : {}), + }); + const sentence = describeProviderVerifyOutcome(result, target.label); + + if (result.status === "cancelled") { + return { proceed: false, error: sentence }; + } + if (isBlockingVerifyStatus(result.status)) { + return { proceed: false, error: sentence }; + } + return { + proceed: true, + warning: result.status === "ok" ? null : sentence, + }; +} From 8cc31a9b66d324c1faf74dd64bf78f8b29c4a549 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:35:15 +0300 Subject: [PATCH 11/57] feat(llm): add the cloud credential check module --- .../verify/classify-verify-response.test.ts | 98 +++++++++ .../verify/classify-verify-response.ts | 91 ++++++++ src/llm/provider/verify/index.ts | 20 ++ .../provider/verify/pick-probe-models.test.ts | 109 ++++++++++ src/llm/provider/verify/pick-probe-models.ts | 84 ++++++++ .../verify/verify-provider-key.test.ts | 164 +++++++++++++++ .../provider/verify/verify-provider-key.ts | 198 ++++++++++++++++++ src/llm/provider/verify/verify-types.ts | 67 ++++++ 8 files changed, 831 insertions(+) create mode 100644 src/llm/provider/verify/classify-verify-response.test.ts create mode 100644 src/llm/provider/verify/classify-verify-response.ts create mode 100644 src/llm/provider/verify/index.ts create mode 100644 src/llm/provider/verify/pick-probe-models.test.ts create mode 100644 src/llm/provider/verify/pick-probe-models.ts create mode 100644 src/llm/provider/verify/verify-provider-key.test.ts create mode 100644 src/llm/provider/verify/verify-provider-key.ts create mode 100644 src/llm/provider/verify/verify-types.ts diff --git a/src/llm/provider/verify/classify-verify-response.test.ts b/src/llm/provider/verify/classify-verify-response.test.ts new file mode 100644 index 00000000..b7eb3608 --- /dev/null +++ b/src/llm/provider/verify/classify-verify-response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { OpenAiHttpError } from "../openai/openai-http.js"; +import { + classifyVerifyResponse, + classifyVerifyTransportError, +} from "./classify-verify-response.js"; + +describe("classifyVerifyResponse", () => { + it("treats any 2xx as proof the key is live and funded", () => { + expect(classifyVerifyResponse(200, "{}")).toEqual({ + kind: "status", + status: "ok", + }); + }); + + it("reads 402 as an empty account", () => { + expect(classifyVerifyResponse(402, "Payment Required")).toEqual({ + kind: "status", + status: "no_balance", + }); + }); + + it("separates a dead key from a drained one on 401/403", () => { + expect(classifyVerifyResponse(401, "No auth credentials found")).toEqual({ + kind: "status", + status: "invalid_key", + }); + // Prepaid services answer 403 with a perfectly valid key once the + // credit is gone; refusing it as "wrong key" would send the operator + // hunting for a new one. + expect( + classifyVerifyResponse(403, '{"error":"insufficient credits"}'), + ).toEqual({ kind: "status", status: "no_balance" }); + }); + + it("keeps a bare 429 soft and a quota 429 hard", () => { + expect(classifyVerifyResponse(429, "slow down")).toEqual({ + kind: "status", + status: "rate_limited", + }); + expect( + classifyVerifyResponse(429, '{"error":{"code":"insufficient_quota"}}'), + ).toEqual({ kind: "status", status: "no_balance" }); + }); + + it("reads Gemini's 400 for a bad key as a bad key", () => { + // The OpenAI-compatible Gemini surface answers 400 INVALID_ARGUMENT + // where every other service answers 401. + expect( + classifyVerifyResponse( + 400, + '{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}', + ), + ).toEqual({ kind: "status", status: "invalid_key" }); + }); + + it("asks for the other token field instead of blaming the key", () => { + expect( + classifyVerifyResponse( + 400, + "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", + ), + ).toEqual({ kind: "retry_token_field" }); + }); + + it("moves to the next candidate when the model is the problem", () => { + expect(classifyVerifyResponse(404, "no such model")).toEqual({ + kind: "retry_next_model", + }); + expect( + classifyVerifyResponse(400, '{"error":"The model `x` does not exist"}'), + ).toEqual({ kind: "retry_next_model" }); + }); + + it("falls back to a provider fault for anything else", () => { + expect(classifyVerifyResponse(503, "upstream unavailable")).toEqual({ + kind: "status", + status: "provider_error", + }); + }); +}); + +describe("classifyVerifyTransportError", () => { + it("tells our own deadline apart from an unreachable host", () => { + const timedOut = new OpenAiHttpError("t", null, "u", true, null, "p"); + expect(classifyVerifyTransportError(timedOut)).toBe("timeout"); + + const network = new OpenAiHttpError("n", null, "u", false, null, "p"); + expect(classifyVerifyTransportError(network)).toBe("unreachable"); + }); + + it("reports an abort as a cancellation", () => { + const abort = new Error("aborted"); + abort.name = "AbortError"; + expect(classifyVerifyTransportError(abort)).toBe("cancelled"); + }); +}); diff --git a/src/llm/provider/verify/classify-verify-response.ts b/src/llm/provider/verify/classify-verify-response.ts new file mode 100644 index 00000000..9e08abc1 --- /dev/null +++ b/src/llm/provider/verify/classify-verify-response.ts @@ -0,0 +1,91 @@ +/** + * Turning one HTTP answer into a verdict about the key. + * + * Providers disagree on how they say "no money" and "wrong key": OpenAI + * sends 429 `insufficient_quota`, OpenRouter 402, Anthropic-style + * gateways 403 with billing wording, and Gemini answers a 400 for a bad + * key rather than a 401. The status code alone is therefore not enough, + * so the body is consulted for wording before falling back to the code. + */ + +import { OpenAiHttpError } from "../openai/openai-http.js"; +import type { ProviderVerifyStatus } from "./verify-types.js"; + +export type VerifyResponseVerdict = + | { readonly kind: "status"; readonly status: ProviderVerifyStatus } + /** Same model, resend with the other max-tokens field. */ + | { readonly kind: "retry_token_field" } + /** This model is unusable for this key; try the next candidate. */ + | { readonly kind: "retry_next_model" }; + +const BILLING_WORDING = + /insufficient|quota|credit|billing|payment|balance|top ?up|out of funds|resource[_ ]exhausted/; +const KEY_WORDING = + /api[_ ]?key|unauthenticated|unauthorized|invalid authentication|permission denied/; +const MISSING_MODEL_WORDING = + /model.{0,40}(not found|does not exist|is not available|unknown|unsupported|invalid)|(not found|unknown|unsupported).{0,20}model/; +const TOKEN_FIELD_WORDING = /max_tokens|max_completion_tokens/; + +export function classifyVerifyResponse( + httpStatus: number, + body: string, +): VerifyResponseVerdict { + if (httpStatus >= 200 && httpStatus < 300) { + // A completion came back, so the account could pay for the token it + // just spent. That is the whole point of probing with a paid model. + return { kind: "status", status: "ok" }; + } + const text = body.toLowerCase(); + + if (httpStatus === 402) return verdict("no_balance"); + + if (httpStatus === 401 || httpStatus === 403) { + // Services that bill by prepaid credit answer 401/403 once the + // balance is gone, with a key that is otherwise perfectly valid. + return verdict(BILLING_WORDING.test(text) ? "no_balance" : "invalid_key"); + } + + if (httpStatus === 429) { + // Only a quota/credit refusal is a money problem. A bare 429 is the + // provider asking us to slow down, which proves the key works. + return verdict(BILLING_WORDING.test(text) ? "no_balance" : "rate_limited"); + } + + if (httpStatus === 404) return { kind: "retry_next_model" }; + + if (httpStatus === 400) { + // Gemini's OpenAI-compatible surface answers 400 INVALID_ARGUMENT + // for a bad key instead of 401. + if (KEY_WORDING.test(text)) return verdict("invalid_key"); + if (BILLING_WORDING.test(text)) return verdict("no_balance"); + if (MISSING_MODEL_WORDING.test(text)) return { kind: "retry_next_model" }; + // Newer OpenAI models reject `max_tokens` and want + // `max_completion_tokens`; that is our request being wrong, not the + // key, so the same model gets one more chance with the other field. + if (TOKEN_FIELD_WORDING.test(text)) return { kind: "retry_token_field" }; + } + + return verdict("provider_error"); +} + +/** A thrown transport failure, which says nothing about the key itself. */ +export function classifyVerifyTransportError(err: unknown): ProviderVerifyStatus { + if (err instanceof OpenAiHttpError) { + if (err.timedOut) return "timeout"; + if (err.status === null) return "unreachable"; + return "provider_error"; + } + if (isAbortError(err)) return "cancelled"; + return "unreachable"; +} + +export function isAbortError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === "AbortError" || err.name === "TimeoutError") + ); +} + +function verdict(status: ProviderVerifyStatus): VerifyResponseVerdict { + return { kind: "status", status }; +} diff --git a/src/llm/provider/verify/index.ts b/src/llm/provider/verify/index.ts new file mode 100644 index 00000000..a100e465 --- /dev/null +++ b/src/llm/provider/verify/index.ts @@ -0,0 +1,20 @@ +export { + classifyVerifyResponse, + classifyVerifyTransportError, + type VerifyResponseVerdict, +} from "./classify-verify-response.js"; +export { + cheapestPaidOpenRouterModel, + pickProbeModels, +} from "./pick-probe-models.js"; +export { + PROVIDER_VERIFY_TIMEOUT_MS, + verifyProviderKey, +} from "./verify-provider-key.js"; +export { + isBlockingVerifyStatus, + type ProviderVerifyKind, + type ProviderVerifyResult, + type ProviderVerifyStatus, + type ProviderVerifyTarget, +} from "./verify-types.js"; diff --git a/src/llm/provider/verify/pick-probe-models.test.ts b/src/llm/provider/verify/pick-probe-models.test.ts new file mode 100644 index 00000000..b5f7a63b --- /dev/null +++ b/src/llm/provider/verify/pick-probe-models.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function importFresh(): Promise< + typeof import("./pick-probe-models.js") +> { + // The OpenRouter catalog caches at module scope, so a test that primes + // it would otherwise leak into the next one. + vi.resetModules(); + return import("./pick-probe-models.js"); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("pickProbeModels", () => { + it("never probes OpenRouter with a free model", async () => { + // A zero-cost model answers 200 on a key with no credit at all, + // which is exactly the case the check exists to catch. + const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh(); + const cheapest = cheapestPaidOpenRouterModel(); + expect(cheapest).not.toBeNull(); + expect(cheapest).not.toBe("openrouter/auto"); + expect(cheapest).not.toContain(":free"); + + const picks = pickProbeModels({ kind: "openrouter" }); + expect(picks[0]).toBe(cheapest); + }); + + it("keeps the free rows of a live catalog out of the choice", async () => { + const { refreshOpenRouterChatCatalogFromApi } = await import( + "../openrouter/fetch-openrouter-chat-catalog.js" + ); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { + id: "vendor/free-model:free", + name: "Free", + context_length: 128_000, + pricing: { prompt: "0", completion: "0" }, + supported_parameters: ["tools"], + }, + { + id: "vendor/cheap-model", + name: "Cheap", + context_length: 128_000, + pricing: { prompt: "0.0000001", completion: "0.0000002" }, + supported_parameters: ["tools"], + }, + ], + }), + })), + ); + await refreshOpenRouterChatCatalogFromApi(); + + const { cheapestPaidOpenRouterModel } = await import( + "./pick-probe-models.js" + ); + expect(cheapestPaidOpenRouterModel()).toBe("vendor/cheap-model"); + }); + + it("adds the operator's own pick as the fallback candidate", async () => { + const { pickProbeModels, cheapestPaidOpenRouterModel } = await importFresh(); + const picks = pickProbeModels({ + kind: "openrouter", + selectedModelId: "vendor/picked", + }); + expect(picks).toEqual([cheapestPaidOpenRouterModel(), "vendor/picked"]); + }); + + it("probes the chosen model where the catalog has no prices", async () => { + const { pickProbeModels } = await importFresh(); + const { AIMLAPI_DEFAULT_CHAT_MODEL } = await import( + "../aimlapi/aimlapi-models-catalog.js" + ); + const { GEMINI_DEFAULT_CHAT_MODEL } = await import( + "../gemini/gemini-provider.js" + ); + + expect( + pickProbeModels({ kind: "aimlapi", selectedModelId: "openai/gpt-5-nano" }), + ).toEqual(["openai/gpt-5-nano", AIMLAPI_DEFAULT_CHAT_MODEL]); + expect(pickProbeModels({ kind: "gemini" })).toEqual([ + GEMINI_DEFAULT_CHAT_MODEL, + ]); + }); + + it("uses the discovered list for an arbitrary compatible endpoint", async () => { + const { pickProbeModels } = await importFresh(); + expect( + pickProbeModels({ + kind: "openai-compatible", + selectedModelId: " ", + listedModelIds: ["local-a", "local-b"], + }), + ).toEqual(["local-a"]); + expect( + pickProbeModels({ + kind: "openai-compatible", + selectedModelId: "typed-id", + listedModelIds: ["local-a"], + }), + ).toEqual(["typed-id", "local-a"]); + }); +}); diff --git a/src/llm/provider/verify/pick-probe-models.ts b/src/llm/provider/verify/pick-probe-models.ts new file mode 100644 index 00000000..349c8f38 --- /dev/null +++ b/src/llm/provider/verify/pick-probe-models.ts @@ -0,0 +1,84 @@ +/** + * Which model the credential check should spend a token on. + * + * The check has to prove the account can actually pay, so a free model + * is the wrong instrument: `openrouter/auto` and every `:free` slug + * answer 200 on a key with zero credit, which would turn the balance + * check into a formality. Where the catalog carries prices we take the + * cheapest *paid* model; where it does not, the model the operator just + * chose is the honest probe — it is the one they are about to use. + */ + +import { AIMLAPI_DEFAULT_CHAT_MODEL } from "../aimlapi/aimlapi-models-catalog.js"; +import { GEMINI_DEFAULT_CHAT_MODEL } from "../gemini/gemini-provider.js"; +import { listOpenRouterChatPicks } from "../openrouter/fetch-openrouter-chat-catalog.js"; +import type { ProviderVerifyKind } from "./verify-types.js"; + +/** More than two candidates would turn a check into a shopping trip. */ +const MAX_PROBE_MODELS = 2; + +export function pickProbeModels(input: { + kind: ProviderVerifyKind; + /** The model the wizard is about to save, when it knows one. */ + selectedModelId?: string | null; + /** Ids already listed from `/v1/models`, when that call was made. */ + listedModelIds?: readonly string[]; +}): readonly string[] { + const selected = input.selectedModelId?.trim() || null; + const listed = input.listedModelIds?.filter((id) => id.length > 0) ?? []; + + if (input.kind === "openrouter") { + return dedupe([cheapestPaidOpenRouterModel(), selected]); + } + if (input.kind === "aimlapi") { + // The AI/ML API catalog carries no prices, so there is nothing to + // rank; the operator's own pick is the closest thing to a known cost. + return dedupe([selected, AIMLAPI_DEFAULT_CHAT_MODEL]); + } + if (input.kind === "gemini") { + return dedupe([selected, GEMINI_DEFAULT_CHAT_MODEL]); + } + // An arbitrary OpenAI-compatible endpoint has no catalog we can price, + // and its `/v1/models` list is already on hand from the model step. + return dedupe([selected, listed[0] ?? null]); +} + +/** + * Cheapest OpenRouter chat model with a non-zero input price, from the + * live catalog when it has been fetched and the static one otherwise. + * Ties break on output price, then id, so the choice is stable across + * runs rather than dependent on catalog order. + */ +export function cheapestPaidOpenRouterModel(): string | null { + let best: { id: string; input: number; output: number } | null = null; + for (const pick of listOpenRouterChatPicks()) { + const pricing = pick.entry.pricing; + if (!pricing || !(pricing.input > 0)) continue; + const candidate = { + id: pick.id, + input: pricing.input, + output: pricing.output ?? 0, + }; + if (!best || isCheaper(candidate, best)) best = candidate; + } + return best?.id ?? null; +} + +function isCheaper( + a: { id: string; input: number; output: number }, + b: { id: string; input: number; output: number }, +): boolean { + if (a.input !== b.input) return a.input < b.input; + if (a.output !== b.output) return a.output < b.output; + return a.id.localeCompare(b.id) < 0; +} + +function dedupe(ids: readonly (string | null)[]): readonly string[] { + const out: string[] = []; + for (const id of ids) { + if (!id || out.includes(id)) continue; + out.push(id); + if (out.length === MAX_PROBE_MODELS) break; + } + return out; +} diff --git a/src/llm/provider/verify/verify-provider-key.test.ts b/src/llm/provider/verify/verify-provider-key.test.ts new file mode 100644 index 00000000..a35f80ef --- /dev/null +++ b/src/llm/provider/verify/verify-provider-key.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; + +import { verifyProviderKey } from "./verify-provider-key.js"; +import type { ProviderVerifyTarget } from "./verify-types.js"; + +function target( + overrides: Partial = {}, +): ProviderVerifyTarget { + return { + label: "testprov", + baseUrl: "https://api.example.com", + apiPathPrefix: "/v1", + apiKey: "sk-secret-key", + probeModels: ["cheap-model"], + ...overrides, + }; +} + +function response(body: unknown, status = 200): Response { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function bodyOf(call: Parameters[]): Record { + return JSON.parse(String((call[1] as RequestInit).body)) as Record< + string, + unknown + >; +} + +describe("verifyProviderKey", () => { + it("spends one token on the cheapest model and reports ok", async () => { + const fetchImpl = vi.fn(async () => response({ choices: [] })); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result).toMatchObject({ status: "ok", probedModel: "cheap-model" }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://api.example.com/v1/chat/completions"); + expect( + (init.headers as Record).authorization, + ).toBe("Bearer sk-secret-key"); + expect(bodyOf(fetchImpl.mock.calls[0] as never)).toMatchObject({ + model: "cheap-model", + max_tokens: 1, + stream: false, + }); + }); + + it("does not retry a refused key", async () => { + // The shared HTTP client retries three times with backoff; a key + // check must answer at the first no. + const fetchImpl = vi.fn(async () => + response({ error: "No auth credentials found" }, 401), + ); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result.status).toBe("invalid_key"); + expect(result.httpStatus).toBe(401); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("reports an empty account", async () => { + const fetchImpl = vi.fn(async () => response("Insufficient credits", 402)); + const result = await verifyProviderKey(target(), { fetchImpl }); + expect(result.status).toBe("no_balance"); + }); + + it("falls back to the second candidate when the first is gone", async () => { + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string }; + return body.model === "gone-model" + ? response({ error: "no such model" }, 404) + : response({ choices: [] }); + }); + const result = await verifyProviderKey( + target({ probeModels: ["gone-model", "live-model"] }), + { fetchImpl }, + ); + + expect(result).toMatchObject({ status: "ok", probedModel: "live-model" }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("resends with max_completion_tokens when the model demands it", async () => { + const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + return "max_tokens" in body + ? response( + { error: "Unsupported parameter: 'max_tokens'. Use 'max_completion_tokens'." }, + 400, + ) + : response({ choices: [] }); + }); + const result = await verifyProviderKey(target(), { fetchImpl }); + + expect(result.status).toBe("ok"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(bodyOf(fetchImpl.mock.calls[1] as never)).toMatchObject({ + max_completion_tokens: 1, + }); + }); + + it("gives up after three requests", async () => { + const fetchImpl = vi.fn(async () => response({ error: "not found" }, 404)); + const result = await verifyProviderKey( + target({ probeModels: ["a", "b"] }), + { fetchImpl }, + ); + + expect(result.status).toBe("model_unavailable"); + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(3); + }); + + it("reports our own deadline as a timeout, not a bad key", async () => { + const fetchImpl = vi.fn( + (_url: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }), + ); + const result = await verifyProviderKey(target(), { + fetchImpl: fetchImpl as unknown as typeof fetch, + timeoutMs: 10, + }); + expect(result.status).toBe("timeout"); + }); + + it("reports a caller abort as a cancellation", async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = vi.fn(async () => response({ choices: [] })); + const result = await verifyProviderKey(target(), { + fetchImpl, + signal: controller.signal, + }); + expect(result.status).toBe("cancelled"); + }); + + it("reports an unreachable host without blaming the key", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const result = await verifyProviderKey(target(), { fetchImpl }); + expect(result.status).toBe("unreachable"); + }); + + it("never puts the key in the reported detail", async () => { + const fetchImpl = vi.fn(async () => + response("Bearer sk-secret-key rejected", 403), + ); + const result = await verifyProviderKey( + target({ apiKey: "sk-secret-key" }), + { fetchImpl }, + ); + expect(result.detail).not.toContain("sk-secret-key"); + }); +}); diff --git a/src/llm/provider/verify/verify-provider-key.ts b/src/llm/provider/verify/verify-provider-key.ts new file mode 100644 index 00000000..8a9f8f10 --- /dev/null +++ b/src/llm/provider/verify/verify-provider-key.ts @@ -0,0 +1,198 @@ +/** + * Prove a cloud API key is usable before anything is written to disk. + * + * A key can be well-formed, present in `.env` and completely dead: wrong + * service, revoked, or attached to an account with no credit. `/v1/models` + * does not settle it — plenty of endpoints list models for an + * unauthenticated caller, and none of them charge for the listing. The + * only answer that proves both authentication and funds is a real + * completion, so this asks for exactly one token from the cheapest model + * available (see `pick-probe-models`). + */ + +import { + openAiFetch, + type OpenAiHttpDeps, +} from "../openai/openai-http.js"; +import { + classifyVerifyResponse, + classifyVerifyTransportError, + isAbortError, +} from "./classify-verify-response.js"; +import type { + ProviderVerifyResult, + ProviderVerifyStatus, + ProviderVerifyTarget, +} from "./verify-types.js"; + +/** + * Short on purpose. This runs while the operator watches a wizard, and + * a slow provider is a reason to save with a warning, not to freeze the + * screen for the 600s a normal completion is allowed. + */ +export const PROVIDER_VERIFY_TIMEOUT_MS = 8_000; + +/** model → other token field → next model. Never more than that. */ +const MAX_VERIFY_REQUESTS = 3; + +/** Provider error bodies are quoted back bounded, same cap as the HTTP layer. */ +const VERIFY_DETAIL_MAX_LEN = 300; + +export async function verifyProviderKey( + target: ProviderVerifyTarget, + opts: { + signal?: AbortSignal; + timeoutMs?: number; + fetchImpl?: typeof fetch; + } = {}, +): Promise { + const startedAt = Date.now(); + const models = target.probeModels.filter((id) => id.length > 0); + if (models.length === 0) { + return result("model_unavailable", null, null, "no model to test with", startedAt); + } + + const deps: OpenAiHttpDeps = { + baseUrl: target.baseUrl, + apiKey: target.apiKey, + extraHeaders: target.extraHeaders ?? {}, + requestTimeoutMs: opts.timeoutMs ?? PROVIDER_VERIFY_TIMEOUT_MS, + fetchImpl: opts.fetchImpl ?? fetch, + label: target.label, + }; + const path = `${target.apiPathPrefix}/chat/completions`; + + let requests = 0; + let tokenField: "max_tokens" | "max_completion_tokens" = "max_tokens"; + let lastVerdict: { + status: ProviderVerifyStatus; + model: string; + httpStatus: number; + detail: string; + } | null = null; + + for (const model of models) { + // The token-field retry is per model: an endpoint that wants + // `max_completion_tokens` wants it for the next candidate too. + for (;;) { + if (requests >= MAX_VERIFY_REQUESTS) { + return lastVerdict + ? result( + lastVerdict.status, + lastVerdict.model, + lastVerdict.httpStatus, + lastVerdict.detail, + startedAt, + target.apiKey, + ) + : result("model_unavailable", model, null, "no usable model", startedAt); + } + if (opts.signal?.aborted) { + return result("cancelled", model, null, "check cancelled", startedAt); + } + requests += 1; + + let res: Response; + try { + res = await openAiFetch( + deps, + path, + probeBody(model, tokenField), + { ...(opts.signal ? { signal: opts.signal } : {}) }, + false, + "POST", + ); + } catch (err) { + if (opts.signal?.aborted || isAbortError(err)) { + return result("cancelled", model, null, "check cancelled", startedAt); + } + const status = classifyVerifyTransportError(err); + return result( + status, + model, + null, + err instanceof Error ? err.message : String(err), + startedAt, + target.apiKey, + ); + } + + const body = res.ok ? "" : await readBounded(res); + const verdict = classifyVerifyResponse(res.status, body); + if (verdict.kind === "retry_token_field" && tokenField === "max_tokens") { + tokenField = "max_completion_tokens"; + continue; + } + if (verdict.kind === "retry_next_model" || verdict.kind === "retry_token_field") { + lastVerdict = { + status: "model_unavailable", + model, + httpStatus: res.status, + detail: body, + }; + break; + } + return result(verdict.status, model, res.status, body, startedAt, target.apiKey); + } + } + + return lastVerdict + ? result( + lastVerdict.status, + lastVerdict.model, + lastVerdict.httpStatus, + lastVerdict.detail, + startedAt, + target.apiKey, + ) + : result("model_unavailable", models[0] ?? null, null, "no usable model", startedAt); +} + +/** + * One token, no sampling, no tools. Hand-built rather than reusing + * `buildOpenAiChatBody`, which pulls token limits out of the config and + * adds tool plumbing a probe has no use for. + */ +function probeBody( + model: string, + tokenField: "max_tokens" | "max_completion_tokens", +): Record { + return { + model, + messages: [{ role: "user", content: "ping" }], + [tokenField]: 1, + temperature: 0, + stream: false, + }; +} + +async function readBounded(res: Response): Promise { + const text = await res.text().catch(() => ""); + return text.slice(0, VERIFY_DETAIL_MAX_LEN); +} + +function result( + status: ProviderVerifyStatus, + probedModel: string | null, + httpStatus: number | null, + detail: string, + startedAt: number, + apiKey = "", +): ProviderVerifyResult { + return { + status, + probedModel, + httpStatus, + detail: redactKey(detail, apiKey).slice(0, VERIFY_DETAIL_MAX_LEN), + latencyMs: Date.now() - startedAt, + }; +} + +/** + * Some providers echo the offending credential back in the error body, + * and this detail is headed for a status line and the log file. + */ +function redactKey(detail: string, apiKey: string): string { + if (apiKey.length < 8) return detail; + return detail.split(apiKey).join("***"); +} diff --git a/src/llm/provider/verify/verify-types.ts b/src/llm/provider/verify/verify-types.ts new file mode 100644 index 00000000..17ab313e --- /dev/null +++ b/src/llm/provider/verify/verify-types.ts @@ -0,0 +1,67 @@ +/** + * Shapes for the pre-save credential check: what to probe, and what the + * probe concluded. Kept free of config and UI imports so the check can + * run from the wizard, from onboarding, or from a future "test key" + * action without dragging any of them along. + */ + +/** The cloud kinds a key can be checked for. Local servers never carry one. */ +export type ProviderVerifyKind = + | "openrouter" + | "aimlapi" + | "gemini" + | "openai-compatible"; + +export type ProviderVerifyStatus = + /** The provider answered a real completion: the key is live and funded. */ + | "ok" + /** The provider does not recognize this key, or refuses it outright. */ + | "invalid_key" + /** The key authenticates but the account cannot pay for a token. */ + | "no_balance" + /** None of the probe models exist for this key; auth stays unproven. */ + | "model_unavailable" + /** Throttled right now — which itself proves the key authenticated. */ + | "rate_limited" + /** No HTTP response at all: DNS, refused connection, TLS, offline. */ + | "unreachable" + /** Our own deadline fired before the provider answered. */ + | "timeout" + /** The provider failed in a way that says nothing about the key. */ + | "provider_error" + /** The operator (or the caller) aborted the check. */ + | "cancelled"; + +export interface ProviderVerifyTarget { + /** Service name for user-facing wording ("OpenRouter", "Groq"). */ + readonly label: string; + /** API root without the version prefix, already normalized. */ + readonly baseUrl: string; + /** Version prefix the service uses: `/v1`, Gemini's `/v1beta/openai`. */ + readonly apiPathPrefix: string; + /** Trimmed key. A target is never built without one. */ + readonly apiKey: string; + /** Ordered candidates; at most the first two are tried. */ + readonly probeModels: readonly string[]; + readonly extraHeaders?: Record; +} + +export interface ProviderVerifyResult { + readonly status: ProviderVerifyStatus; + /** The model the verdict came from, `null` when nothing was answered. */ + readonly probedModel: string | null; + readonly httpStatus: number | null; + /** Bounded provider text for the status line and logs; never the key. */ + readonly detail: string; + readonly latencyMs: number; +} + +/** + * The two verdicts that must stop a save. Everything else is a report: + * a machine behind a proxy, an offline laptop or a throttled key still + * has to be configurable, and refusing there would strand the operator + * with no way to enter a key at all. + */ +export function isBlockingVerifyStatus(status: ProviderVerifyStatus): boolean { + return status === "invalid_key" || status === "no_balance"; +} From 5ddb8494e9c1b6c6f29ce2cfabad3655d790af7d Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:36:18 +0300 Subject: [PATCH 12/57] feat(models): ranked multi-term model search, in the TUI and the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search was one case-insensitive `includes` over the model id (`filterModelIds`), shared by the /model picker and the Cloud pane. That is fine for the 18 rows the bundled catalog used to hold and useless against the 300-400 the live OpenRouter list returns: "claude vision" is not a substring of anything, and there was no way to search cloud models from outside the TUI at all. `src/llm/provider/model-search.ts` is now the one scorer: - terms are split on whitespace and ANDed, so each word narrows; - a term matches the id, the vendor prefix, or a tag derived from the catalog entry — `vision`/`text`, `tools`, `cache`, context shorthand (`1m`, `200k`), `free`/`cheap`/`routed`. Price tags mirror the rendered price, so `openrouter/auto` is `routed`, never `free`; - a term that matches nothing as a substring still matches as an ordered subsequence, ranked last, so typos land somewhere; - results are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order — the picker re-runs this per keystroke and rows must not jitter. `filterModelIds` keeps its name and signature and delegates, gaining an optional catalog lookup so the Cloud pane can search on capabilities for curated kinds. No new keybindings or state: `f`, `cloudModelFilter` and the existing reducer actions carry it. `atomic-agent models search [--provider id] [--limit n] [--json] [--refresh]` puts the same search on the CLI, over every configured provider's catalog plus, with --refresh, the live lists. It prints the same context/price/capability strings as the picker — those formatters moved to `src/llm/provider/format-model-details.ts` so the CLI does not import from `src/tui/`. No match exits 1 with one line, per #145. Two notes for review: `parseLlmProviderEntry` silently drops `userModels`, so that source can only be reached programmatically today (covered by a direct `collectHits` test rather than a config fixture); and the OpenRouter fetcher still filters Anthropic and Gemini out of the live catalog, so those stay unsearchable until that is lifted. --- AGENTS.md | 1 + README.md | 11 + src/cli/index.ts | 2 +- src/cli/models-command.ts | 10 + src/cli/models-search-command.test.ts | 209 ++++++++++++++++ src/cli/models-search-command.ts | 228 ++++++++++++++++++ src/llm/provider/format-model-details.ts | 57 +++++ src/llm/provider/model-search.test.ts | 187 ++++++++++++++ src/llm/provider/model-search.ts | 156 ++++++++++++ src/tui/llm-panel/llm-panel-row-builders.ts | 2 + .../providers-inline-models-flow.test.ts | 51 ++++ src/tui/providers/providers-model-options.ts | 66 ++--- src/tui/providers/providers-panel-state.ts | 20 +- 13 files changed, 949 insertions(+), 51 deletions(-) create mode 100644 src/cli/models-search-command.test.ts create mode 100644 src/cli/models-search-command.ts create mode 100644 src/llm/provider/format-model-details.ts create mode 100644 src/llm/provider/model-search.test.ts create mode 100644 src/llm/provider/model-search.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..8db202a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register - **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`. - **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider). - **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools. +- **Model search** ([src/llm/provider/model-search.ts](src/llm/provider/model-search.ts)) — one ranked, multi-term scorer over model ids plus catalog metadata (vendor, `vision`/`text`, `tools`, `cache`, context shorthand like `1m`, `free`/`cheap`/`routed`). Terms are ANDed, matches are ranked (exact id > id prefix > vendor > word start > substring > subsequence) and equal ranks keep input order so the picker does not jitter per keystroke. Used by `filterModelIds` (TUI modal picker + Cloud pane) and by `atomic-agent models search`. Row rendering is shared through [format-model-details.ts](src/llm/provider/format-model-details.ts) — do not re-implement the price/context/capability strings in a frontend. ### Bootstrap wiring diff --git a/README.md b/README.md index 1b8ff1ee..627ef536 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,17 @@ Managed mode downloads the backend, pulls GGUF models, selects the active model, The managed chat daemon stops when the last session exits, freeing the RAM and VRAM the model was holding; set `localModels.managed.stopOnExit: false` in `config.json` to keep the model warm between sessions. Daemons started standalone with `models start` are never touched. +Cloud models are searchable from the same command — by id, vendor, or capability, across every configured cloud provider: + +```bash +atomic-agent models search claude vision +atomic-agent models search free tools --json +atomic-agent models search "1m cache" --provider openrouter --limit 10 +atomic-agent models search kimi --refresh # pull live /models lists first +``` + +Every term has to match (`claude vision` is not a substring of any id), results are ranked best-first, and the same query works in the TUI Cloud pane — press `f`. +
diff --git a/src/cli/index.ts b/src/cli/index.ts index 22c11fd5..b0cb0510 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -97,7 +97,7 @@ const COMMANDS: CommandDescriptor[] = [ { name: "models", summary: - "Manage the local-LLM runtime + GGUF models (list|pull|use|status|start|stop|update|remove)", + "Manage the local-LLM runtime + GGUF models (list|pull|use|status|...) and search cloud models (search)", run: modelsCommand, }, { diff --git a/src/cli/models-command.ts b/src/cli/models-command.ts index 7d25b8c7..f1258172 100644 --- a/src/cli/models-command.ts +++ b/src/cli/models-command.ts @@ -14,6 +14,7 @@ import { runLocalModelsUseDevice, runLocalModelsUseEmbedding, } from "./models-handlers.js"; +import { runModelsSearch } from "./models-search-command.js"; const HELP = [ @@ -32,6 +33,12 @@ const HELP = " (stops daemon first; does not auto-restart)", " remove Delete a downloaded model (refuses if active + daemon running)", "", + "Cloud subcommands (no local runtime needed):", + " search Search configured cloud providers' models by id,", + " vendor and capability (`claude vision`, `free tools`,", + " `1m cache`). Flags: --provider --limit ", + " --json --refresh (pull live lists first)", + "", "GPU subcommands:", " devices List GPU devices (llama-server --list-devices); active marked with *", " use-device Set the managed daemon's GPU (auto-picks best discrete by default)", @@ -45,6 +52,7 @@ const HELP = "", "Examples:", " atomic-agent models list", + " atomic-agent models search claude vision", " atomic-agent models pull qwen-3.5-4b", " atomic-agent models use qwen-3.5-4b", " atomic-agent models pull-embedding nomic-embed-text-v1.5", @@ -63,6 +71,8 @@ export async function modelsCommand(args: string[]): Promise { } try { switch (sub) { + case "search": + return await runModelsSearch(args.slice(1)); case "list": return runLocalModelsList(); case "pull": diff --git a/src/cli/models-search-command.test.ts b/src/cli/models-search-command.test.ts new file mode 100644 index 00000000..ae239017 --- /dev/null +++ b/src/cli/models-search-command.test.ts @@ -0,0 +1,209 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getUserConfigPath } from "../config/config-file.js"; +import { resetConfigCache } from "../config/index.js"; +import { USER_CONFIG_VERSION } from "../config/config-schema.js"; + +import { + collectHits, + parseModelsSearchArgs, + runModelsSearch, +} from "./models-search-command.js"; + +describe("parseModelsSearchArgs", () => { + it("joins bare words into one query and reads the flags", () => { + const parsed = parseModelsSearchArgs([ + "claude", + "vision", + "--limit", + "5", + "--json", + "--provider", + "or", + ]); + expect(parsed).toEqual({ + query: "claude vision", + provider: "or", + limit: 5, + json: true, + refresh: false, + }); + }); + + it("rejects a non-positive limit and unknown flags", () => { + expect(() => parseModelsSearchArgs(["x", "--limit", "0"])).toThrow(/--limit/); + expect(() => parseModelsSearchArgs(["x", "--nope"])).toThrow(/unknown flag/); + }); +}); + +describe("runModelsSearch", () => { + let stateDir: string; + let out: string[]; + let err: string[]; + + function writeConfig(): void { + writeFileSync( + getUserConfigPath(stateDir), + JSON.stringify({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "or", + activeEmbeddingProvider: "or", + toolTransport: "auto", + providers: [ + { id: "or", kind: "openrouter", defaultChatModel: "openrouter/auto" }, + { + id: "vllm", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:8000", + defaultChatModel: "local/mistral", + }, + ], + }, + }), + "utf8", + ); + resetConfigCache(); + } + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-models-search-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + resetConfigCache(); + out = []; + err = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + out.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + err.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + vi.restoreAllMocks(); + }); + + it("finds catalog models by id and prints provider, context, price and caps", async () => { + writeConfig(); + const code = await runModelsSearch(["qwen"]); + expect(code).toBe(0); + expect(out.join("")).toMatch(/^or\s+qwen\//m); + expect(out.join("")).toMatch(/tools/); + }); + + it("ANDs terms across id and capability tags", async () => { + writeConfig(); + // The old TUI filter answered this with nothing: "qwen vision" is not + // a substring of any id. + expect(await runModelsSearch(["qwen", "vision", "--json"])).toBe(0); + const rows = JSON.parse(out.join("")) as { + id: string; + supportsVision: boolean; + }[]; + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.id).toMatch(/qwen/); + expect(row.supportsVision).toBe(true); + } + }); + + it("includes models an entry carries under userModels", async () => { + // Read straight off the entry: `parseLlmProviderEntry` currently + // drops `userModels` on the way out of config.json, so this path + // cannot be reached through a config fixture. + const hits = await collectHits( + [ + { + id: "vllm", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:8000", + userModels: [ + { + id: "local/mistral", + kind: "chat", + contextWindow: 32_000, + }, + ], + }, + ], + false, + ); + expect(hits).toEqual([{ providerId: "vllm", id: "local/mistral" }]); + }); + + it("narrows to one provider entry and caps the result count", async () => { + writeConfig(); + // `vllm` ships no bundled catalog, so restricting to it finds nothing + // to search rather than silently falling back to the other provider. + expect(await runModelsSearch(["--provider", "vllm", "qwen"])).toBe(1); + expect(err.join("")).toMatch(/no searchable cloud models/); + + out.length = 0; + expect(await runModelsSearch(["qwen", "--limit", "1"])).toBe(0); + expect(out.join("").trimEnd().split("\n")).toHaveLength(1); + }); + + it("exits 1 with one line — never a stack trace — when nothing matches", async () => { + writeConfig(); + expect(await runModelsSearch(["definitely-not-a-model"])).toBe(1); + expect(out.join("")).toBe(""); + expect(err.join("")).toMatch(/no model matches/); + }); + + it("exits 1 on a missing query or an unknown provider id", async () => { + writeConfig(); + expect(await runModelsSearch([])).toBe(1); + expect(err.join("")).toMatch(/expects a query/); + + err.length = 0; + expect(await runModelsSearch(["--provider", "nope", "qwen"])).toBe(1); + expect(err.join("")).toMatch(/no configured provider/); + }); + + it("says so instead of printing nothing when no provider ships a catalog", async () => { + // Default config: one local llama-server entry, no cloud catalog. + expect(await runModelsSearch(["qwen"])).toBe(1); + expect(err.join("")).toMatch(/no searchable cloud models/); + }); + + // Last in the file on purpose: a live refresh writes the fetcher's + // module-global pick cache, which outlives this test. + it("--refresh searches the live catalog, not just the bundled snapshot", async () => { + writeConfig(); + expect(await runModelsSearch(["brand-new-model"])).toBe(1); + + out.length = 0; + err.length = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { + id: "vendor/brand-new-model", + name: "Brand New", + context_length: 256_000, + pricing: { prompt: "0.000001", completion: "0.000004" }, + supported_parameters: ["tools"], + architecture: { input_modalities: ["text"] }, + }, + ], + }), + })), + ); + expect(await runModelsSearch(["brand-new-model", "--refresh"])).toBe(0); + expect(out.join("")).toContain("vendor/brand-new-model"); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/cli/models-search-command.ts b/src/cli/models-search-command.ts new file mode 100644 index 00000000..46bffca4 --- /dev/null +++ b/src/cli/models-search-command.ts @@ -0,0 +1,228 @@ +import { getConfig } from "../config/index.js"; +import { catalogForProvider } from "../llm/provider/catalog-for-provider.js"; +import { + formatCapabilitySummary, + formatContextWindow, + formatTokenPrice, +} from "../llm/provider/format-model-details.js"; +import type { ModelCatalogEntry } from "../llm/provider/model-resolver.js"; +import { searchModels } from "../llm/provider/model-search.js"; +import { fetchOpenAiCompatModels } from "../llm/provider/openai/fetch-openai-compat-models.js"; +import { + listAimlapiChatPicks, + refreshAimlapiChatCatalogFromApi, +} from "../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js"; +import { + listOpenRouterChatPicks, + refreshOpenRouterChatCatalogFromApi, +} from "../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; +import { resolveLlmConfig } from "../llm/provider/registry/provider-types.js"; +import type { LlmProviderConfigEntry } from "../llm/provider/registry/provider-types.js"; + +/** + * `atomic-agent models search ` — the cloud half of `models`. + * + * The rest of this command group manages local GGUF weights. Cloud + * models were only ever searchable from inside the TUI, which is no + * help when picking a `defaultChatModel` for a config file or checking + * what a provider charges. Same scorer as the TUI picker + * (`searchModels`), same rendering (`format-model-details`), so a query + * that works in one surface works in the other. + */ + +export type ModelSearchHit = { + providerId: string; + id: string; + entry?: ModelCatalogEntry | undefined; +}; + +export type ModelsSearchOptions = { + query: string; + provider: string | null; + limit: number; + json: boolean; + refresh: boolean; +}; + +const DEFAULT_LIMIT = 30; + +export function parseModelsSearchArgs(args: readonly string[]): ModelsSearchOptions { + const terms: string[] = []; + let provider: string | null = null; + let limit = DEFAULT_LIMIT; + let json = false; + let refresh = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === "--json") json = true; + else if (arg === "--refresh") refresh = true; + else if (arg === "--provider") provider = args[++i] ?? null; + else if (arg === "--limit") { + const raw = Number.parseInt(args[++i] ?? "", 10); + if (!Number.isFinite(raw) || raw <= 0) { + throw new Error("--limit expects a positive integer"); + } + limit = raw; + } else if (arg.startsWith("--")) { + throw new Error(`unknown flag: ${arg}`); + } else terms.push(arg); + } + if (provider !== null && provider.length === 0) { + throw new Error("--provider expects a provider id"); + } + return { query: terms.join(" "), provider, limit, json, refresh }; +} + +/** + * Every model this machine could reach, tagged with the provider entry + * it came from: the bundled catalog for curated kinds, plus whatever the + * entry carries under `userModels`. + * + * Note that `userModels` cannot currently arrive from `config.json` — + * `parseLlmProviderEntry` drops the field even though the schema, the + * `LlmProviderConfigEntry` type and `resolveModel` all support it. This + * reads whatever the entry actually holds rather than assuming the + * config parser is the only way one gets populated. + */ +export async function collectHits( + entries: readonly LlmProviderConfigEntry[], + refresh: boolean, +): Promise { + const hits: ModelSearchHit[] = []; + for (const entry of entries) { + if (refresh) await refreshCatalog(entry); + const seen = new Set(); + const add = (id: string, catalogEntry?: ModelCatalogEntry): void => { + if (seen.has(id)) return; + seen.add(id); + hits.push({ providerId: entry.id, id, entry: catalogEntry }); + }; + // Bundled snapshot first: it is curated, ordered, and the only + // source that carries embedding rows. + for (const [id, catalogEntry] of catalogForProvider(entry)) add(id, catalogEntry); + // Then whatever the live picker cache holds. `listXChatPicks` falls + // back to the same snapshot when nothing has been fetched, so this + // only ever adds ids — after `--refresh` it is the fresh catalog. + for (const pick of livePicks(entry)) add(pick.id, pick.entry); + for (const model of entry.userModels ?? []) add(model.id); + if (refresh) for (const id of await liveCompatModels(entry)) add(id); + } + return hits; +} + +/** + * A live refresh writes into each fetcher's module cache, which is what + * `catalogForProvider` reads through for curated kinds. Failures are + * silent on purpose: the bundled snapshot is still a useful answer, and + * a search should not fail because a vendor endpoint is down. + */ +async function refreshCatalog(entry: LlmProviderConfigEntry): Promise { + try { + if (entry.kind === "openrouter") await refreshOpenRouterChatCatalogFromApi(); + else if (entry.kind === "aimlapi") await refreshAimlapiChatCatalogFromApi(); + } catch { + /* keep the bundled snapshot */ + } +} + +function livePicks( + entry: LlmProviderConfigEntry, +): readonly { id: string; entry: ModelCatalogEntry }[] { + if (entry.kind === "openrouter") return listOpenRouterChatPicks(); + if (entry.kind === "aimlapi") return listAimlapiChatPicks(); + return []; +} + +async function liveCompatModels( + entry: LlmProviderConfigEntry, +): Promise { + if (!entry.baseUrl) return []; + if (entry.kind !== "openai-compatible" && entry.kind !== "qwen-openai-compatible") { + return []; + } + try { + return await fetchOpenAiCompatModels(entry.baseUrl, entry.apiKey); + } catch { + return []; + } +} + +function formatHit(hit: ModelSearchHit): string { + const entry = hit.entry; + const details = entry + ? [ + formatContextWindow(entry.contextWindow), + formatTokenPrice(hit.id, entry.pricing), + formatCapabilitySummary(entry), + ].join(" · ") + : "metadata unavailable"; + return `${hit.providerId.padEnd(14)} ${hit.id.padEnd(42)} ${details}`; +} + +export async function runModelsSearch(args: readonly string[]): Promise { + let options: ModelsSearchOptions; + try { + options = parseModelsSearchArgs(args); + } catch (err) { + process.stderr.write(`${(err as Error).message}\n`); + return 1; + } + if (options.query.length === 0) { + process.stderr.write( + "models search expects a query, e.g. `models search claude vision`\n", + ); + return 1; + } + + const resolved = resolveLlmConfig(getConfig()); + const entries = resolved.providers.filter((entry) => + options.provider === null ? true : entry.id === options.provider, + ); + if (options.provider !== null && entries.length === 0) { + process.stderr.write(`no configured provider with id "${options.provider}"\n`); + return 1; + } + + const hits = await collectHits(entries, options.refresh); + if (hits.length === 0) { + process.stderr.write( + "no searchable cloud models: the configured providers ship no catalog. " + + "Add an openrouter or aimlapi provider, or re-run with --refresh to " + + "pull a live /v1/models list.\n", + ); + return 1; + } + + const matches = searchModels(hits, options.query).slice(0, options.limit); + if (matches.length === 0) { + process.stderr.write(`no model matches ${JSON.stringify(options.query)}\n`); + return 1; + } + + if (options.json) { + process.stdout.write( + `${JSON.stringify( + matches.map((hit) => ({ + provider: hit.providerId, + id: hit.id, + ...(hit.entry + ? { + kind: hit.entry.kind, + contextWindow: hit.entry.contextWindow, + supportsVision: hit.entry.supportsVision, + supportsTools: hit.entry.supportsTools, + supportsPromptCache: hit.entry.supportsPromptCache, + ...(hit.entry.pricing ? { pricing: hit.entry.pricing } : {}), + } + : {}), + })), + null, + 2, + )}\n`, + ); + return 0; + } + + process.stdout.write(`${matches.map(formatHit).join("\n")}\n`); + return 0; +} diff --git a/src/llm/provider/format-model-details.ts b/src/llm/provider/format-model-details.ts new file mode 100644 index 00000000..daa4cbcf --- /dev/null +++ b/src/llm/provider/format-model-details.ts @@ -0,0 +1,57 @@ +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * How a catalog row is described to a human: context window, price per + * 1M tokens, capability summary. + * + * Lifted out of `src/tui/providers/providers-model-options.ts` so the + * `models search` CLI prints the same strings as the TUI picker without + * a CLI -> TUI import. `src/llm/` is the layer both frontends already + * depend on. + */ + +export function formatContextWindow(tokens: number): string { + if (tokens >= 1_000_000) { + const millions = tokens / 1_000_000; + return `${formatCompactNumber(millions)}M`; + } + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return `${tokens}`; +} + +export function formatTokenPrice( + modelId: string, + pricing: ModelCatalogEntry["pricing"], +): string { + if (!pricing) return "price unknown"; + if (modelId === "openrouter/auto") return "routed"; + if (pricing.input === 0 && pricing.output === 0) return "free"; + return `$${formatPrice(pricing.input)}/$${formatPrice(pricing.output)}`; +} + +export function formatEmbeddingTokenPrice( + pricing: ModelCatalogEntry["pricing"], +): string { + if (!pricing) return "$?"; + if (pricing.input === 0) return "free"; + return `$${formatPrice(pricing.input)}`; +} + +export function formatCapabilitySummary(entry: ModelCatalogEntry): string { + const modality = entry.supportsVision ? "vision" : "text"; + const tools = entry.supportsTools === "none" ? null : "tools"; + const cache = entry.supportsPromptCache ? "cache" : null; + return [modality, tools, cache].filter(Boolean).join(" · "); +} + +function formatCompactNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +export function formatPrice(value: number): string { + if (value === 0) return "0"; + if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} diff --git a/src/llm/provider/model-search.test.ts b/src/llm/provider/model-search.test.ts new file mode 100644 index 00000000..91fd07c3 --- /dev/null +++ b/src/llm/provider/model-search.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; + +import type { ModelCatalogEntry } from "./model-resolver.js"; +import { + modelSearchTags, + searchModelIds, + searchModels, + splitQueryTerms, +} from "./model-search.js"; + +function entry(over: Partial = {}): ModelCatalogEntry { + return { + id: over.id ?? "x", + kind: "chat", + contextWindow: 128_000, + supportsVision: false, + supportsTools: "parallel", + supportsPromptCache: false, + reasoningFormat: "none", + ...over, + } as ModelCatalogEntry; +} + +const CATALOG: readonly { id: string; entry: ModelCatalogEntry }[] = [ + { + id: "anthropic/claude-opus-5", + entry: entry({ + contextWindow: 1_000_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 5, output: 25 }, + }), + }, + { + id: "anthropic/claude-haiku-4.5", + entry: entry({ + contextWindow: 200_000, + supportsVision: true, + pricing: { input: 0.8, output: 4 }, + }), + }, + { + id: "qwen/qwen3.6-flash", + entry: entry({ contextWindow: 1_000_000, pricing: { input: 0.19, output: 1.13 } }), + }, + { + id: "openai/gpt-oss-20b", + entry: entry({ contextWindow: 131_072, pricing: { input: 0, output: 0 } }), + }, +]; + +const ids = (rows: readonly { id: string }[]): readonly string[] => + rows.map((row) => row.id); + +describe("splitQueryTerms", () => { + it("lowercases, trims and drops empty terms", () => { + expect(splitQueryTerms(" Claude VISION ")).toEqual(["claude", "vision"]); + expect(splitQueryTerms(" ")).toEqual([]); + }); +}); + +describe("searchModels", () => { + it("returns everything, in order, for an empty query", () => { + expect(searchModels(CATALOG, "")).toBe(CATALOG); + expect(searchModels(CATALOG, " ")).toBe(CATALOG); + }); + + it("keeps the old substring behaviour for a single term", () => { + expect(ids(searchModels(CATALOG, "claude"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + expect(ids(searchModels(CATALOG, "OPUS"))).toEqual(["anthropic/claude-opus-5"]); + }); + + it("ANDs multiple terms instead of matching the raw string", () => { + // "claude vision" is not a substring of any id — this is the query + // the old single-`includes` filter answered with an empty list. + expect(ids(searchModels(CATALOG, "claude vision"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + expect(ids(searchModels(CATALOG, "claude 1m"))).toEqual([ + "anthropic/claude-opus-5", + ]); + expect(searchModels(CATALOG, "claude qwen")).toEqual([]); + }); + + it("matches capability and price tags off the catalog entry", () => { + expect(ids(searchModels(CATALOG, "free"))).toEqual(["openai/gpt-oss-20b"]); + // The tag follows the rendered price, so a router row is "routed", + // never "free", and never "cheap" either. + const auto = [ + { id: "openrouter/auto", entry: entry({ pricing: { input: 0, output: 0 } }) }, + ]; + expect(ids(searchModels(auto, "routed"))).toEqual(["openrouter/auto"]); + expect(searchModels(auto, "free")).toEqual([]); + expect(searchModels(auto, "cheap")).toEqual([]); + expect(ids(searchModels(CATALOG, "cache"))).toEqual(["anthropic/claude-opus-5"]); + expect(ids(searchModels(CATALOG, "cheap"))).toEqual([ + "anthropic/claude-haiku-4.5", + "qwen/qwen3.6-flash", + ]); + }); + + it("matches the vendor prefix", () => { + expect(ids(searchModels(CATALOG, "anthropic"))).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + }); + + it("ranks exact ids and prefixes above buried substrings", () => { + const rows = [ + { id: "vendor/needs-opus-handling", entry: entry() }, + { id: "opus", entry: entry() }, + { id: "opus-mini", entry: entry() }, + ]; + expect(ids(searchModels(rows, "opus"))).toEqual([ + "opus", + "opus-mini", + "vendor/needs-opus-handling", + ]); + }); + + it("keeps input order between equally ranked rows", () => { + // The catalogs are hand-ordered and this runs on every keystroke, so + // equal matches must not shuffle under the cursor. + const rows = [ + { id: "a/model-one", entry: entry() }, + { id: "a/model-two", entry: entry() }, + { id: "a/model-three", entry: entry() }, + ]; + expect(ids(searchModels(rows, "model"))).toEqual([ + "a/model-one", + "a/model-two", + "a/model-three", + ]); + }); + + it("falls back to a subsequence match, ranked last", () => { + const rows = [ + { id: "openai/gpt-oss-20b", entry: entry() }, + { id: "vendor/gpt", entry: entry() }, + ]; + // "gpto" is nobody's substring; it is a subsequence of the first id. + expect(ids(searchModels(rows, "gpto"))).toEqual(["openai/gpt-oss-20b"]); + }); + + it("still matches ids with no catalog entry, on the id alone", () => { + const rows = [{ id: "some-local-model" }, { id: "other" }]; + expect(ids(searchModels(rows, "local"))).toEqual(["some-local-model"]); + // No entry means no tags, so a capability term cannot match. + expect(searchModels(rows, "vision")).toEqual([]); + }); +}); + +describe("modelSearchTags", () => { + it("derives tags from the entry and nothing else", () => { + expect(modelSearchTags(undefined)).toEqual([]); + expect( + modelSearchTags( + entry({ + contextWindow: 200_000, + supportsVision: true, + supportsPromptCache: true, + pricing: { input: 0, output: 0 }, + }), + ), + ).toEqual(["chat", "vision", "tools", "cache", "200k", "free"]); + }); +}); + +describe("searchModelIds", () => { + it("searches plain ids and uses the lookup for metadata when given", () => { + const all = CATALOG.map((row) => row.id); + const lookup = (id: string): ModelCatalogEntry | undefined => + CATALOG.find((row) => row.id === id)?.entry; + expect(searchModelIds(all, "vision", lookup)).toEqual([ + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ]); + // Without the lookup the same query has no metadata to match on. + expect(searchModelIds(all, "vision")).toEqual([]); + expect(searchModelIds(all, "")).toBe(all); + }); +}); diff --git a/src/llm/provider/model-search.ts b/src/llm/provider/model-search.ts new file mode 100644 index 00000000..44ff57ef --- /dev/null +++ b/src/llm/provider/model-search.ts @@ -0,0 +1,156 @@ +import { + formatContextWindow, + formatTokenPrice, +} from "./format-model-details.js"; +import type { ModelCatalogEntry } from "./model-resolver.js"; + +/** + * Ranked, multi-term search over model ids and their catalog metadata. + * + * The picker used to filter with one case-insensitive `includes` over + * the id, which is fine for 18 rows and useless for the 300-400 the + * live OpenRouter catalog returns: "the cheap Claude with vision" is + * not a substring of anything. Here a query is split into terms, every + * term has to match (AND), and a term may match the id, the vendor, or + * a capability tag derived from the catalog entry — so `claude vision`, + * `1m cache` and `free tools` all narrow the list. + * + * Matches are ranked, best first, and equal ranks keep input order: the + * bundled catalogs are hand-ordered and the picker re-runs this on + * every keystroke, so rows must not jitter between presses. + */ + +export type ModelSearchItem = { + readonly id: string; + readonly entry?: ModelCatalogEntry | undefined; +}; + +/** Metadata lookup for callers that hold ids and a catalog separately. */ +export type ModelEntryLookup = (id: string) => ModelCatalogEntry | undefined; + +/** + * Per-term match strength. Summed across terms into the row score, so a + * row matching one term exactly and another loosely still outranks a row + * that matches both loosely. + */ +const RANK = { + exactId: 6, + idPrefix: 5, + vendor: 4, + wordStart: 3, + substring: 2, + tag: 2, + subsequence: 1, + none: 0, +} as const; + +export function splitQueryTerms(query: string): readonly string[] { + return query.trim().toLowerCase().split(/\s+/).filter(Boolean); +} + +/** + * Searchable tags for a row: what an operator would type that is not + * part of the id. Everything here is derived from the catalog entry, so + * a row without metadata simply has fewer ways to be found. + */ +export function modelSearchTags( + entry: ModelCatalogEntry | undefined, + modelId?: string, +): readonly string[] { + if (!entry) return []; + const tags: string[] = [entry.kind]; + tags.push(entry.supportsVision ? "vision" : "text"); + if (entry.supportsTools !== "none") tags.push("tools"); + if (entry.supportsPromptCache) tags.push("cache"); + if (entry.contextWindow > 0) { + tags.push(formatContextWindow(entry.contextWindow).toLowerCase()); + } + // Price tags mirror what the row displays, so searching for what you + // can see works: `openrouter/auto` renders as "routed", not "free", + // even though its list price is zero. + const priceLabel = formatTokenPrice(modelId ?? entry.id, entry.pricing); + if (priceLabel === "free" || priceLabel === "routed") tags.push(priceLabel); + else if (entry.pricing && entry.pricing.input > 0 && entry.pricing.input < 1) { + tags.push("cheap"); + } + return tags; +} + +function rankTerm( + term: string, + id: string, + vendor: string, + tags: readonly string[], +): number { + if (id === term) return RANK.exactId; + if (id.startsWith(term)) return RANK.idPrefix; + if (vendor === term || vendor.startsWith(term)) return RANK.vendor; + const at = id.indexOf(term); + if (at >= 0) { + // A term that starts a word ("opus" in "claude-opus-5") is a better + // hit than one buried mid-token ("pus"). + const before = at === 0 ? "" : id[at - 1]!; + return at === 0 || /[^a-z0-9]/.test(before) ? RANK.wordStart : RANK.substring; + } + if (tags.includes(term)) return RANK.tag; + return isSubsequence(term, id) ? RANK.subsequence : RANK.none; +} + +/** Typo tolerance: every character of `term`, in order, somewhere in `id`. */ +function isSubsequence(term: string, id: string): boolean { + let i = 0; + for (const ch of id) { + if (ch === term[i]) i += 1; + if (i === term.length) return true; + } + return term.length === 0; +} + +export function scoreModel( + item: ModelSearchItem, + terms: readonly string[], +): number { + const id = item.id.toLowerCase(); + const slash = id.indexOf("/"); + const vendor = slash > 0 ? id.slice(0, slash) : ""; + const tags = modelSearchTags(item.entry, item.id); + let total = 0; + for (const term of terms) { + const rank = rankTerm(term, id, vendor, tags); + // AND semantics: one unmatched term drops the row entirely. + if (rank === RANK.none) return RANK.none; + total += rank; + } + return total; +} + +/** + * Rows matching `query`, best match first. An empty query returns + * `items` untouched — the caller renders the full catalog. + */ +export function searchModels( + items: readonly T[], + query: string, +): readonly T[] { + const terms = splitQueryTerms(query); + if (terms.length === 0) return items; + const scored: { item: T; score: number; index: number }[] = []; + items.forEach((item, index) => { + const score = scoreModel(item, terms); + if (score > 0) scored.push({ item, score, index }); + }); + scored.sort((a, b) => b.score - a.score || a.index - b.index); + return scored.map((row) => row.item); +} + +/** `searchModels` for callers that hold plain ids plus an optional catalog. */ +export function searchModelIds( + ids: readonly string[], + query: string, + lookup?: ModelEntryLookup, +): readonly string[] { + const terms = splitQueryTerms(query); + if (terms.length === 0) return ids; + const items = ids.map((id) => ({ id, entry: lookup?.(id) })); + return searchModels(items, query).map((item) => item.id); +} diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts index 4b542caa..9e55752f 100644 --- a/src/tui/llm-panel/llm-panel-row-builders.ts +++ b/src/tui/llm-panel/llm-panel-row-builders.ts @@ -7,6 +7,7 @@ import { getCachedGeminiModelsForPanel } from "../../llm/provider/gemini/fetch-g import { GEMINI_DEFAULT_CHAT_MODEL } from "../../llm/provider/gemini/gemini-provider.js"; import { filterModelIds, type ProviderRow } from "../providers/providers-panel-state.js"; import { + catalogEntryLookupForKind, formatAimlapiChatModelDetails, formatAimlapiEmbeddingModelDetails, formatOpenRouterChatModelDetails, @@ -101,6 +102,7 @@ export function selectCloudModelSection(state: TuiState): CloudModelSection { const filtered = filterModelIds( catalog.models, state.llmPanel.cloudModelFilter, + catalogEntryLookupForKind(provider.kind), ); return { provider, ...catalog, filtered, sectionStart }; } diff --git a/src/tui/providers/providers-inline-models-flow.test.ts b/src/tui/providers/providers-inline-models-flow.test.ts index 7f0366fb..058f903c 100644 --- a/src/tui/providers/providers-inline-models-flow.test.ts +++ b/src/tui/providers/providers-inline-models-flow.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; import type { AtomicAgentConfig } from "../../config/index.js"; import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { OPENROUTER_MODELS_CATALOG } from "../../llm/provider/openrouter/openrouter-models-catalog.js"; import { reduceTuiState } from "../agent-event-reducer.js"; import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; import { selectCloudModelSection } from "../llm-panel/llm-panel-row-builders.js"; @@ -77,6 +78,25 @@ function configWithNous(baseUrl: string): AtomicAgentConfig { } as AtomicAgentConfig; } +function configWithOpenRouter(): AtomicAgentConfig { + return { + llm: { + activeTextProvider: "or", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [ + { + id: "or", + kind: "openrouter", + apiKey: "sk-or-test", + model: "openrouter/auto", + defaultChatModel: "openrouter/auto", + }, + ], + }, + } as AtomicAgentConfig; +} + function configWithTwoProviders( nousBaseUrl: string, xaiBaseUrl: string, @@ -486,3 +506,34 @@ describe("inline list flow: bare /model end to end", () => { ]); }); }); + +describe("multi-term search in the Cloud pane", () => { + it("narrows a curated catalog on capability terms the id does not contain", () => { + // The bundled OpenRouter catalog backs this pane, so every row has + // metadata: `qwen vision` has to match on the entry, not the id. + currentConfig = configWithOpenRouter(); + const h = makeHarness(); + h.orchestrator.refresh(); + openLlmCloudTab(h, 0); + + expect(h.press("f")).toBe(true); + for (const ch of "qwen") h.press(ch); + const qwenOnly = modelRowIds(h.store.state); + expect(qwenOnly.length).toBeGreaterThan(1); + for (const id of qwenOnly) expect(id).toMatch(/qwen/); + + for (const ch of " vision") h.press(ch); + expect(h.store.state.llmPanel.cloudModelFilter).toBe("qwen vision"); + const narrowed = modelRowIds(h.store.state); + expect(narrowed.length).toBeGreaterThan(0); + expect(narrowed.length).toBeLessThan(qwenOnly.length); + for (const id of narrowed) { + expect(id).toMatch(/qwen/); + expect(OPENROUTER_MODELS_CATALOG.get(id)?.supportsVision).toBe(true); + } + + // A term nothing satisfies empties the list rather than ignoring it. + for (const ch of " free") h.press(ch); + expect(modelRowIds(h.store.state)).toEqual([]); + }); +}); diff --git a/src/tui/providers/providers-model-options.ts b/src/tui/providers/providers-model-options.ts index b25cc273..ccee9d8c 100644 --- a/src/tui/providers/providers-model-options.ts +++ b/src/tui/providers/providers-model-options.ts @@ -6,7 +6,14 @@ import { import { listOpenRouterChatPicks } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; import { OPENROUTER_MODELS_CATALOG } from "../../llm/provider/openrouter/openrouter-models-catalog.js"; import type { ModelCatalogEntry } from "../../llm/provider/model-resolver.js"; +import type { ModelEntryLookup } from "../../llm/provider/model-search.js"; import { GEMINI_DEFAULT_CHAT_MODEL } from "../../llm/provider/gemini/gemini-provider.js"; +import { + formatCapabilitySummary, + formatContextWindow, + formatEmbeddingTokenPrice, + formatTokenPrice, +} from "../../llm/provider/format-model-details.js"; export type ProviderModelOption = { id: string; @@ -145,6 +152,21 @@ const liveOpenRouterEntryById = createCatalogEntryResolver( listOpenRouterChatPicks, ); +/** + * Catalog lookup for a provider kind, so the Cloud pane can search on + * capabilities and price and not just on the id. Curated kinds resolve + * through the live-or-static resolvers above; `openai-compatible` and + * `gemini` point at operator-chosen endpoints with no bundled catalog, + * so they get `undefined` and search stays id-only for them. + */ +export function catalogEntryLookupForKind( + kind: string, +): ModelEntryLookup | undefined { + if (kind === "openrouter") return resolveOpenRouterCatalogEntry; + if (kind === "aimlapi") return resolveAimlapiCatalogEntry; + return undefined; +} + function resolveAimlapiCatalogEntry(modelId: string): ModelCatalogEntry | undefined { return liveAimlapiEntryById(modelId) ?? AIMLAPI_MODELS_CATALOG.get(modelId); } @@ -152,47 +174,3 @@ function resolveAimlapiCatalogEntry(modelId: string): ModelCatalogEntry | undefi function resolveOpenRouterCatalogEntry(modelId: string): ModelCatalogEntry | undefined { return liveOpenRouterEntryById(modelId) ?? OPENROUTER_MODELS_CATALOG.get(modelId); } - -function formatContextWindow(tokens: number): string { - if (tokens >= 1_000_000) { - const millions = tokens / 1_000_000; - return `${formatCompactNumber(millions)}M`; - } - if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; - return `${tokens}`; -} - -function formatTokenPrice( - modelId: string, - pricing: ModelCatalogEntry["pricing"], -): string { - if (!pricing) return "price unknown"; - if (modelId === "openrouter/auto") return "routed"; - if (pricing.input === 0 && pricing.output === 0) return "free"; - return `$${formatPrice(pricing.input)}/$${formatPrice(pricing.output)}`; -} - -function formatEmbeddingTokenPrice(pricing: ModelCatalogEntry["pricing"]): string { - if (!pricing) return "$?"; - if (pricing.input === 0) return "free"; - return `$${formatPrice(pricing.input)}`; -} - -function formatCapabilitySummary(entry: ModelCatalogEntry): string { - const modality = entry.supportsVision ? "vision" : "text"; - const tools = entry.supportsTools === "none" ? null : "tools"; - const cache = entry.supportsPromptCache ? "cache" : null; - return [modality, tools, cache].filter(Boolean).join(" · "); -} - -function formatCompactNumber(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - -function formatPrice(value: number): string { - if (value === 0) return "0"; - if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); - return Number.isInteger(value) - ? String(value) - : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); -} diff --git a/src/tui/providers/providers-panel-state.ts b/src/tui/providers/providers-panel-state.ts index 50f81a32..1080a038 100644 --- a/src/tui/providers/providers-panel-state.ts +++ b/src/tui/providers/providers-panel-state.ts @@ -1,3 +1,7 @@ +import { + searchModelIds, + type ModelEntryLookup, +} from "../../llm/provider/model-search.js"; import type { ProvidersWizardState } from "./providers-wizard-state.js"; export type ProvidersPanelMode = "list"; @@ -66,17 +70,21 @@ export function filteredPickerModels( } /** - * Case-insensitive substring filter over model ids. Shared by the modal - * picker and the inline Cloud-pane model list so both surfaces match the - * same rows for the same query. + * Ranked model search over ids and, when a catalog lookup is supplied, + * their metadata. Shared by the modal picker and the inline Cloud-pane + * model list so both surfaces match the same rows for the same query. + * + * Was a single case-insensitive `includes` over the id. `searchModelIds` + * keeps that behaviour for a one-word query and adds multi-term AND, + * vendor and capability matching, subsequence fallback, and relevance + * ordering — see `src/llm/provider/model-search.ts`. */ export function filterModelIds( models: readonly string[], query: string, + lookup?: ModelEntryLookup, ): readonly string[] { - const q = query.trim().toLowerCase(); - if (q.length === 0) return models; - return models.filter((id) => id.toLowerCase().includes(q)); + return searchModelIds(models, query, lookup); } /** From 3df0e1f93498429507caa98e6c93356fffbf327b Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 04:36:57 +0300 Subject: [PATCH 13/57] =?UTF-8?q?feat(agent):=20fusion=20routing=20?= =?UTF-8?q?=E2=80=94=20cloud=20orchestrator,=20local=20executor,=20complex?= =?UTF-8?q?ity-gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Fusion run mode: the cloud leg plans, the local leg executes, and a per-step complexity score decides which one serves each inference. Behaviour only — no UI yet, so this is reachable by setting `llm.runMode.mode` in config.json. The loop is one inference per step, so "orchestrate on cloud, execute on local" is defined per step: * step 0 always orchestrates (it forms the plan and picks the first tool batch — exactly one call per turn, so the cost is bounded), * continuation steps are scored, with hysteresis, * the parse-repair retry stays on the leg that produced the malformed call — it already inherits `preferredProviderId` by spreading the original params, which is exactly right: a repair must be judged by the model that made the mistake, against the same transport, * memory sub-runners default to the local leg, * MCP sampling is untouched (still hard-wired local). The final synthesis step is deliberately NOT special-cased: the loop cannot know a step is final until the model returns `reply`. Instead the score's dominant term is context pressure, so a step carrying the whole turn escalates on its own. `cloudShare` sets a cutoff at `100 - cloudShare`. The ±10 hysteresis is load-bearing rather than cosmetic: llama-server reuses its KV cache by longest common prefix, so alternating legs every step forces it to reprocess the tail that grew in between. Hysteresis produces runs of consecutive local steps, which is what makes the local cache pay off. Three things this had to get right: * Slot affinity now follows the ROUTED provider, not the active one. Under fusion the active provider is the cloud leg, which reports no affinity, so every locally-routed step would otherwise have run at slotId -1 with cachePrompt off — a full prompt reprocess per step. * A preferred leg is a starting link, not a policy override: health still wins. It is ignored while that specific provider is in cooldown, never sets or clears the sticky override, and is never a probe. A failure on a preferred start resumes the scan from the chain HEAD, because a preferred leg is commonly the chain's tail and advancing "after" it would strand a recoverable turn with the rest of the chain untried. * Providers pinned by fusion survive an active-provider swap. close() is a no-op on both shipped kinds today, so this is not a live crash, but the interface promises teardown and switching into fusion would otherwise close the leg it is about to route to. Also fixes a real cost-attribution defect this made reachable: `resolveModelPricing` looked pricing up against `activeTextProvider`, so a local completion was priced against the cloud provider's catalog. It now prices against the served link, which `servedProviderId` carries for the same reason `servedTransport` already existed. Verified: npm run lint and npm run build clean; npm test 4147 passed. Failures are the same 6 files / 8 tests that already fail on main @ 667dae1; three further files (parallel-tool-calls wall-clock timing, two git-tool suites) flaked under parallel CPU load and pass in isolation. --- .../routing/compute-step-complexity.test.ts | 91 ++++++++ src/agent/routing/compute-step-complexity.ts | 94 +++++++++ src/agent/routing/decide-routing-role.test.ts | 86 ++++++++ src/agent/routing/decide-routing-role.ts | 62 ++++++ src/agent/routing/index.ts | 11 + src/agent/routing/step-router.test.ts | 143 +++++++++++++ src/agent/routing/step-router.ts | 140 +++++++++++++ src/agent/step-events.ts | 15 ++ src/agent/step-executor-routing.test.ts | 198 ++++++++++++++++++ src/agent/step-executor.ts | 62 +++++- src/llm/fallback/index.ts | 5 +- .../fallback/provider-fallback-chain.test.ts | 98 +++++++++ src/llm/fallback/provider-fallback-chain.ts | 46 +++- src/llm/fallback/run-with-fallback.ts | 24 ++- src/llm/provider/completion-types.ts | 9 + .../registry/provider-registry.test.ts | 65 ++++++ .../provider/registry/provider-registry.ts | 19 +- src/runtime/bootstrap.ts | 88 +++++++- src/runtime/llm-fallback-seam.test.ts | 70 +++++++ src/runtime/llm-fallback-seam.ts | 30 ++- src/tui/agent-event-reducer.ts | 15 ++ src/tui/format-event.ts | 17 ++ 22 files changed, 1365 insertions(+), 23 deletions(-) create mode 100644 src/agent/routing/compute-step-complexity.test.ts create mode 100644 src/agent/routing/compute-step-complexity.ts create mode 100644 src/agent/routing/decide-routing-role.test.ts create mode 100644 src/agent/routing/decide-routing-role.ts create mode 100644 src/agent/routing/index.ts create mode 100644 src/agent/routing/step-router.test.ts create mode 100644 src/agent/routing/step-router.ts create mode 100644 src/agent/step-executor-routing.test.ts diff --git a/src/agent/routing/compute-step-complexity.test.ts b/src/agent/routing/compute-step-complexity.test.ts new file mode 100644 index 00000000..104fa6cf --- /dev/null +++ b/src/agent/routing/compute-step-complexity.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + computeStepComplexity, + type StepComplexitySignals, +} from "./compute-step-complexity.js"; + +const base: StepComplexitySignals = { + promptTokens: 0, + stablePrefixTokens: 0, + stepIndex: 0, + maxSteps: 25, + conversationMaxTokens: 32_000, + hasTransientNotice: false, +}; + +const at = (over: Partial): number => + computeStepComplexity({ ...base, ...over }); + +describe("computeStepComplexity", () => { + it("scores a fresh, empty step at zero", () => { + expect(at({})).toBe(0); + }); + + it("saturates at 100 when every signal is maxed", () => { + expect( + at({ + promptTokens: 64_000, + stablePrefixTokens: 0, + stepIndex: 25, + hasTransientNotice: true, + }), + ).toBe(100); + }); + + it("always returns an integer inside 0-100", () => { + const samples = [ + at({ promptTokens: 7_777, stablePrefixTokens: 1_234, stepIndex: 3 }), + at({ promptTokens: 31_999, stablePrefixTokens: 12_001, stepIndex: 7 }), + at({ promptTokens: 1, stablePrefixTokens: 0, stepIndex: 1 }), + ]; + for (const score of samples) { + expect(Number.isInteger(score)).toBe(true); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(100); + } + }); + + it("is monotonic in context pressure", () => { + const low = at({ promptTokens: 4_000, stablePrefixTokens: 4_000 }); + const high = at({ promptTokens: 16_000, stablePrefixTokens: 16_000 }); + expect(high).toBeGreaterThan(low); + }); + + it("is monotonic in turn depth", () => { + expect(at({ stepIndex: 12 })).toBeGreaterThan(at({ stepIndex: 2 })); + }); + + it("is monotonic in tail growth at a fixed prompt size", () => { + const mostlyStable = at({ promptTokens: 20_000, stablePrefixTokens: 19_000 }); + const mostlyTail = at({ promptTokens: 20_000, stablePrefixTokens: 1_000 }); + expect(mostlyTail).toBeGreaterThan(mostlyStable); + }); + + it("adds exactly the transient-notice weight", () => { + const quiet = at({ promptTokens: 8_000, stablePrefixTokens: 6_000 }); + const noisy = at({ + promptTokens: 8_000, + stablePrefixTokens: 6_000, + hasTransientNotice: true, + }); + expect(noisy - quiet).toBe(20); + }); + + it("treats a tail larger than the prompt as zero, never negative", () => { + expect(at({ promptTokens: 100, stablePrefixTokens: 5_000 })).toBe(0); + }); + + it("survives zero and non-finite budgets without producing NaN", () => { + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const score = at({ + promptTokens: 10_000, + conversationMaxTokens: bad, + maxSteps: bad, + stepIndex: 5, + }); + expect(Number.isInteger(score)).toBe(true); + expect(score).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/src/agent/routing/compute-step-complexity.ts b/src/agent/routing/compute-step-complexity.ts new file mode 100644 index 00000000..c88b0072 --- /dev/null +++ b/src/agent/routing/compute-step-complexity.ts @@ -0,0 +1,94 @@ +/** + * Signals available at routing time — i.e. after `buildPrompt` but + * BEFORE `slotManager.acquire`, because the slot depends on which + * provider we route to. + * + * That ordering is why `cacheReused` is deliberately absent: it is + * produced by `slotManager.acquire`, so feeding it back into the + * routing decision would be circular. Do not add it. + */ +export interface StepComplexitySignals { + /** `prompt.tokens.total` for the step about to run. */ + promptTokens: number; + /** `prompt.tokens.stablePrefix` — the KV-stable head of the prompt. */ + stablePrefixTokens: number; + /** 0-based index of this step inside the current turn. */ + stepIndex: number; + /** `config.agent.maxSteps` — the turn's step budget. */ + maxSteps: number; + /** `config.agent.conversationMaxTokens` — the conversation budget. */ + conversationMaxTokens: number; + /** + * Whether a one-shot notice is being rendered into this step's prompt + * (loop detector fired, or a tool batch was trimmed). The model just + * did something wrong, so the step deserves the stronger model. + */ + hasTransientNotice: boolean; +} + +/** + * Weights sum to 100 so the score is directly comparable to the + * operator's `cloudShare` dial without any rescaling. + */ +const WEIGHT_CONTEXT_PRESSURE = 40; +const WEIGHT_TURN_DEPTH = 25; +const WEIGHT_TRANSIENT_NOTICE = 20; +const WEIGHT_TAIL_GROWTH = 15; + +/** + * The tail is judged against half the conversation budget: a turn whose + * accumulated tool output has eaten that much is already synthesis-shaped, + * and waiting for the full budget would only escalate on the very last + * step or two. + */ +const TAIL_BUDGET_FRACTION = 2; + +function clamp01(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + return value >= 1 ? 1 : value; +} + +function ratio(numerator: number, denominator: number): number { + if (!Number.isFinite(denominator) || denominator <= 0) return 0; + return clamp01(numerator / denominator); +} + +/** + * Score one step's difficulty on a bounded 0-100 scale. + * + * Deliberately a *heuristic over cheap signals*, not a model call: it + * runs before every inference in fusion mode, so it has to be free and + * deterministic. The four terms, in weight order: + * + * 1. **Context pressure** (40) — how full the context is. This is the + * dominant term on purpose. It is also how a final synthesis step + * ends up on the cloud without the loop being able to know a step is + * final: by the time the model is ready to answer, it is carrying the + * whole turn's context. + * 2. **Turn depth** (25) — later steps in a long turn are the ones that + * have to hold more state together. + * 3. **Transient notice** (20) — a binary "the model just misbehaved" + * signal from the loop detector / batch trimmer. + * 4. **Tail growth** (15) — how much of the prompt is accumulated tool + * output rather than the stable prefix, i.e. how much raw material + * this step has to reconcile. + */ +export function computeStepComplexity( + signals: StepComplexitySignals, +): number { + const tailTokens = Math.max( + 0, + signals.promptTokens - signals.stablePrefixTokens, + ); + const score = + WEIGHT_CONTEXT_PRESSURE * + ratio(signals.promptTokens, signals.conversationMaxTokens) + + WEIGHT_TURN_DEPTH * ratio(signals.stepIndex, signals.maxSteps) + + WEIGHT_TRANSIENT_NOTICE * (signals.hasTransientNotice ? 1 : 0) + + WEIGHT_TAIL_GROWTH * + ratio( + tailTokens, + signals.conversationMaxTokens / TAIL_BUDGET_FRACTION, + ); + return Math.round(score); +} diff --git a/src/agent/routing/decide-routing-role.test.ts b/src/agent/routing/decide-routing-role.test.ts new file mode 100644 index 00000000..a170ff50 --- /dev/null +++ b/src/agent/routing/decide-routing-role.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + decideRoutingRole, + ROUTING_HYSTERESIS, +} from "./decide-routing-role.js"; + +describe("decideRoutingRole", () => { + it("keeps everything local at cloudShare 0, even a maximal score", () => { + expect( + decideRoutingRole({ score: 100, cloudShare: 0, stepIndex: 0 }), + ).toBe("executor"); + }); + + it("sends everything to the cloud at cloudShare 100, even a zero score", () => { + expect( + decideRoutingRole({ score: 0, cloudShare: 100, stepIndex: 9 }), + ).toBe("orchestrator"); + }); + + it("always orchestrates step 0 when the cloud leg is in play", () => { + expect( + decideRoutingRole({ score: 0, cloudShare: 1, stepIndex: 0 }), + ).toBe("orchestrator"); + }); + + it("routes on the cutoff at 100 - cloudShare with no prior role", () => { + // cloudShare 40 ⇒ cutoff 60. + expect( + decideRoutingRole({ score: 60, cloudShare: 40, stepIndex: 1 }), + ).toBe("orchestrator"); + expect( + decideRoutingRole({ score: 59, cloudShare: 40, stepIndex: 1 }), + ).toBe("executor"); + }); + + it("makes it harder to leave the local leg", () => { + // cutoff 60, previously executor ⇒ effective bar 70. + const args = { cloudShare: 40, stepIndex: 1, previousRole: "executor" } as const; + expect(decideRoutingRole({ ...args, score: 69 })).toBe("executor"); + expect(decideRoutingRole({ ...args, score: 70 })).toBe("orchestrator"); + }); + + it("makes it harder to leave the cloud leg", () => { + // cutoff 60, previously orchestrator ⇒ effective bar 50. + const args = { + cloudShare: 40, + stepIndex: 1, + previousRole: "orchestrator", + } as const; + expect(decideRoutingRole({ ...args, score: 50 })).toBe("orchestrator"); + expect(decideRoutingRole({ ...args, score: 49 })).toBe("executor"); + }); + + it("applies the hysteresis symmetrically", () => { + expect(ROUTING_HYSTERESIS).toBe(10); + const score = 55; + expect( + decideRoutingRole({ + score, + cloudShare: 40, + stepIndex: 1, + previousRole: "executor", + }), + ).toBe("executor"); + expect( + decideRoutingRole({ + score, + cloudShare: 40, + stepIndex: 1, + previousRole: "orchestrator", + }), + ).toBe("orchestrator"); + }); + + it("treats a null previous role like no prior state", () => { + expect( + decideRoutingRole({ + score: 60, + cloudShare: 40, + stepIndex: 1, + previousRole: null, + }), + ).toBe("orchestrator"); + }); +}); diff --git a/src/agent/routing/decide-routing-role.ts b/src/agent/routing/decide-routing-role.ts new file mode 100644 index 00000000..74e5fce7 --- /dev/null +++ b/src/agent/routing/decide-routing-role.ts @@ -0,0 +1,62 @@ +/** + * Which leg of a fusion pair serves one inference. + * + * `orchestrator` is the cloud provider (plans, reconciles, synthesises); + * `executor` is the local provider (mechanical continuation steps). + */ +export type RoutingRole = "orchestrator" | "executor"; + +/** + * Score margin applied against the direction of travel so a step near + * the cutoff does not flip the provider back and forth. + * + * This is load-bearing, not cosmetic. llama-server reuses its KV cache + * by longest common prefix, so every return to the local leg after a + * cloud step has to reprocess the tail that grew in between. Hysteresis + * produces RUNS of consecutive local steps, which is what makes the + * local cache pay for itself. + */ +export const ROUTING_HYSTERESIS = 10; + +export interface RoutingDecisionArgs { + /** 0-100 from `computeStepComplexity`. */ + score: number; + /** 0-100 operator dial from `llm.runMode.fusion.cloudShare`. */ + cloudShare: number; + /** 0-based step index inside the turn. */ + stepIndex: number; + /** Role the previous step of this session resolved to, if any. */ + previousRole?: RoutingRole | null; +} + +/** + * Map a complexity score onto a fusion leg. + * + * The dial sets a cutoff at `100 - cloudShare`: a bigger share means a + * lower bar for reaching the cloud. It is a DIAL, NOT A QUOTA — it does + * not promise that N% of steps go to the cloud, and it must not be + * turned into a running-counter scheduler, which would necessarily send + * some trivial steps to the cloud and keep some hard ones local. + * + * Two rules override the score: + * - `cloudShare` 0 / 100 short-circuit to pure local / pure cloud, so + * the extremes are exact rather than merely very likely. + * - Step 0 always orchestrates (when the cloud leg is in play at all): + * it forms the turn's plan and picks the first tool batch, which + * determines everything downstream. It is exactly one call per turn, + * so the cost is bounded and predictable. + */ +export function decideRoutingRole(args: RoutingDecisionArgs): RoutingRole { + if (args.cloudShare <= 0) return "executor"; + if (args.cloudShare >= 100) return "orchestrator"; + if (args.stepIndex === 0) return "orchestrator"; + + const cutoff = 100 - args.cloudShare; + const margin = + args.previousRole === "executor" + ? ROUTING_HYSTERESIS + : args.previousRole === "orchestrator" + ? -ROUTING_HYSTERESIS + : 0; + return args.score >= cutoff + margin ? "orchestrator" : "executor"; +} diff --git a/src/agent/routing/index.ts b/src/agent/routing/index.ts new file mode 100644 index 00000000..b5775b8e --- /dev/null +++ b/src/agent/routing/index.ts @@ -0,0 +1,11 @@ +export { computeStepComplexity } from "./compute-step-complexity.js"; +export type { StepComplexitySignals } from "./compute-step-complexity.js"; +export { decideRoutingRole, ROUTING_HYSTERESIS } from "./decide-routing-role.js"; +export type { RoutingDecisionArgs, RoutingRole } from "./decide-routing-role.js"; +export { StepRouter } from "./step-router.js"; +export type { + FusionRoutingSnapshot, + RouteStepArgs, + StepRouterDeps, + StepRouting, +} from "./step-router.js"; diff --git a/src/agent/routing/step-router.test.ts b/src/agent/routing/step-router.test.ts new file mode 100644 index 00000000..8bda5e10 --- /dev/null +++ b/src/agent/routing/step-router.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import { StepRouter, type FusionRoutingSnapshot } from "./step-router.js"; + +const FUSION: FusionRoutingSnapshot = { + cloudProviderId: "openrouter", + localProviderId: "local-llama", + cloudShare: 40, + subRunners: "local", + maxSteps: 25, + conversationMaxTokens: 32_000, +}; + +function router(snapshot: FusionRoutingSnapshot | null = FUSION): StepRouter { + return new StepRouter({ resolveFusion: () => snapshot }); +} + +/** + * Scores 55 with the FUSION snapshot: above the cutoff a prior cloud + * step lowers it to (50), below the bare cutoff (60). That band is + * exactly where hysteresis is observable. + */ +const MEDIUM = { promptTokens: 24_000, stablePrefixTokens: 4_000, stepIndex: 10 }; +/** Scores 68: above the bare cutoff, below the "came from local" bar (70). */ +const HEAVY = { promptTokens: 30_000, stablePrefixTokens: 4_000, stepIndex: 15 }; + +const step = (over: Partial[0]> = {}) => ({ + sessionId: "s1", + stepIndex: 1, + promptTokens: 1_000, + stablePrefixTokens: 900, + hasTransientNotice: false, + ...over, +}); + +describe("StepRouter", () => { + it("returns null when fusion is not the effective mode", () => { + expect(router(null).routeStep(step())).toBeNull(); + }); + + it("routes step 0 to the cloud orchestrator", () => { + const routing = router().routeStep(step({ stepIndex: 0 })); + expect(routing).toMatchObject({ + role: "orchestrator", + providerId: "openrouter", + cloudShare: 40, + }); + }); + + it("routes a cheap continuation step to the local executor", () => { + const routing = router().routeStep(step()); + expect(routing).toMatchObject({ + role: "executor", + providerId: "local-llama", + }); + }); + + it("escalates a heavy continuation step to the cloud", () => { + const routing = router().routeStep( + step({ + stepIndex: 20, + promptTokens: 30_000, + stablePrefixTokens: 4_000, + hasTransientNotice: true, + }), + ); + expect(routing?.role).toBe("orchestrator"); + expect(routing?.complexity).toBeGreaterThanOrEqual(60); + }); + + it("re-reads the live snapshot on every step", () => { + let snapshot: FusionRoutingSnapshot = { ...FUSION, cloudShare: 0 }; + const r = new StepRouter({ resolveFusion: () => snapshot }); + expect(r.routeStep(step({ stepIndex: 0 }))?.role).toBe("executor"); + snapshot = { ...FUSION, cloudShare: 100 }; + expect(r.routeStep(step({ stepIndex: 1 }))?.role).toBe("orchestrator"); + }); + + it("keeps hysteresis state per session", () => { + const r = router(); + // Drive session A to the cloud (step 0 always orchestrates) and + // session B to local (a cheap continuation step). + expect(r.routeStep(step({ sessionId: "a", stepIndex: 0 }))?.role).toBe( + "orchestrator", + ); + expect(r.routeStep(step({ sessionId: "b", stepIndex: 1 }))?.role).toBe( + "executor", + ); + // Identical score, opposite prior roles ⇒ opposite decisions. + const a = r.routeStep(step({ sessionId: "a", ...HEAVY })); + const b = r.routeStep(step({ sessionId: "b", ...HEAVY })); + expect(a?.complexity).toBe(b?.complexity); + expect(a?.role).toBe("orchestrator"); + expect(b?.role).toBe("executor"); + }); + + it("forgets a session on request", () => { + const r = router(); + // Same MEDIUM step decided twice: sticky to the cloud while the + // prior role survives, back to the bare cutoff once it is dropped. + r.routeStep(step({ sessionId: "a", stepIndex: 0 })); + expect(r.routeStep(step({ sessionId: "a", ...MEDIUM }))?.role).toBe( + "orchestrator", + ); + r.forgetSession("a"); + expect(r.routeStep(step({ sessionId: "a", ...MEDIUM }))?.role).toBe( + "executor", + ); + }); + + it("drops hysteresis state when fusion is switched off", () => { + let snapshot: FusionRoutingSnapshot | null = FUSION; + const r = new StepRouter({ resolveFusion: () => snapshot }); + r.routeStep(step({ stepIndex: 0 })); + snapshot = null; + expect(r.routeStep(step())).toBeNull(); + snapshot = FUSION; + // The prior cloud role is gone, so the bare cutoff applies again. + expect(r.routeStep(step({ ...MEDIUM }))?.role).toBe("executor"); + }); + + it("sends sub-runners to the local leg by default", () => { + expect(router().subRunnerProviderId("s1")).toBe("local-llama"); + }); + + it("sends sub-runners to the cloud when configured", () => { + expect( + router({ ...FUSION, subRunners: "cloud" }).subRunnerProviderId("s1"), + ).toBe("openrouter"); + }); + + it("follows the session's last main-loop leg when configured", () => { + const r = router({ ...FUSION, subRunners: "follow" }); + r.routeStep(step({ sessionId: "s1", stepIndex: 0 })); + expect(r.subRunnerProviderId("s1")).toBe("openrouter"); + r.forgetSession("s1"); + expect(r.subRunnerProviderId("s1")).toBe("local-llama"); + }); + + it("has no sub-runner opinion outside fusion", () => { + expect(router(null).subRunnerProviderId("s1")).toBeNull(); + }); +}); diff --git a/src/agent/routing/step-router.ts b/src/agent/routing/step-router.ts new file mode 100644 index 00000000..a2fb4d77 --- /dev/null +++ b/src/agent/routing/step-router.ts @@ -0,0 +1,140 @@ +import type { RunModeSubRunners } from "../../config/llm-run-mode-config.js"; +import { computeStepComplexity } from "./compute-step-complexity.js"; +import { decideRoutingRole, type RoutingRole } from "./decide-routing-role.js"; + +/** + * Live fusion parameters. Re-read before every step so a mode switch or + * a dial change made in the TUI takes effect on the next inference + * rather than at the next process start — the same late-binding + * discipline `bootstrap` uses for `toolTransport` and slot affinity. + */ +export interface FusionRoutingSnapshot { + cloudProviderId: string; + localProviderId: string; + cloudShare: number; + subRunners: RunModeSubRunners; + maxSteps: number; + conversationMaxTokens: number; +} + +export interface StepRouterDeps { + /** Returns `null` whenever the effective run mode is not fusion. */ + resolveFusion: () => FusionRoutingSnapshot | null; +} + +export interface RouteStepArgs { + sessionId: string; + stepIndex: number; + promptTokens: number; + stablePrefixTokens: number; + hasTransientNotice: boolean; +} + +export interface StepRouting { + role: RoutingRole; + providerId: string; + complexity: number; + cloudShare: number; +} + +/** + * Bound on the per-session role memory. Only exists so a long-lived + * `serve` process cannot accumulate one entry per session forever; + * eviction is oldest-first, and losing an entry costs nothing but one + * step of hysteresis. + */ +const MAX_TRACKED_SESSIONS = 256; + +/** + * Chooses the fusion leg for each inference. + * + * Deliberately thin: scoring and the cutoff rule live in the two pure + * modules beside it, so the only thing here is the per-session memory + * that hysteresis needs. + * + * Note what is NOT here: repair-retry stickiness. The parse-repair call + * in `step-executor` spreads the original `LlmStreamParams`, so it + * inherits `preferredProviderId` from the attempt it is repairing for + * free — which is exactly the required behaviour, since a repair must + * be judged by the model that made the mistake and against the same + * transport. + */ +export class StepRouter { + private readonly resolveFusion: StepRouterDeps["resolveFusion"]; + private readonly lastRole = new Map(); + + constructor(deps: StepRouterDeps) { + this.resolveFusion = deps.resolveFusion; + } + + /** `null` ⇒ not in fusion; the caller leaves provider selection alone. */ + routeStep(args: RouteStepArgs): StepRouting | null { + const fusion = this.resolveFusion(); + if (!fusion) { + this.lastRole.delete(args.sessionId); + return null; + } + const complexity = computeStepComplexity({ + promptTokens: args.promptTokens, + stablePrefixTokens: args.stablePrefixTokens, + stepIndex: args.stepIndex, + hasTransientNotice: args.hasTransientNotice, + maxSteps: fusion.maxSteps, + conversationMaxTokens: fusion.conversationMaxTokens, + }); + const role = decideRoutingRole({ + score: complexity, + cloudShare: fusion.cloudShare, + stepIndex: args.stepIndex, + previousRole: this.lastRole.get(args.sessionId) ?? null, + }); + this.remember(args.sessionId, role); + return { + role, + providerId: + role === "orchestrator" + ? fusion.cloudProviderId + : fusion.localProviderId, + complexity, + cloudShare: fusion.cloudShare, + }; + } + + /** + * Provider for a memory sub-runner (reflection, link generation, + * curation votes, query rewriting, distillation). + * + * `local` by default: these are cold-path structured-JSON jobs that + * ride the reserved reflection slot and are already KV-warm on the + * local server, so routing them to the cloud multiplies per-turn cost + * with no user-visible latency win. `follow` reuses the leg the last + * main-loop step of that session used. + */ + subRunnerProviderId(sessionId?: string): string | null { + const fusion = this.resolveFusion(); + if (!fusion) return null; + const target: RunModeSubRunners = fusion.subRunners; + if (target === "cloud") return fusion.cloudProviderId; + if (target === "local") return fusion.localProviderId; + const last = sessionId ? this.lastRole.get(sessionId) : undefined; + return last === "orchestrator" + ? fusion.cloudProviderId + : fusion.localProviderId; + } + + /** Drop a finished session's hysteresis memory. */ + forgetSession(sessionId: string): void { + this.lastRole.delete(sessionId); + } + + private remember(sessionId: string, role: RoutingRole): void { + // Re-insert so the Map's insertion order doubles as recency. + this.lastRole.delete(sessionId); + this.lastRole.set(sessionId, role); + while (this.lastRole.size > MAX_TRACKED_SESSIONS) { + const oldest = this.lastRole.keys().next(); + if (oldest.done === true) break; + this.lastRole.delete(oldest.value); + } + } +} diff --git a/src/agent/step-events.ts b/src/agent/step-events.ts index 9cbbcad0..98f43880 100644 --- a/src/agent/step-events.ts +++ b/src/agent/step-events.ts @@ -32,6 +32,21 @@ export interface PromptCapturedTokens { */ export type StepEvent = | { type: "prompt_built"; prompt: BuiltPrompt; slotId: number } + /** + * Fusion routing picked a leg for this step. Emitted before the slot + * is acquired and only while fusion is the effective run mode, so its + * absence is the normal single-provider case rather than a gap. + */ + | { + type: "step_routed"; + stepIndex: number; + role: "orchestrator" | "executor"; + providerId: string; + /** 0-100 score from `computeStepComplexity`. */ + complexity: number; + /** The operator dial in force for this decision. */ + cloudShare: number; + } /** * Trace-oriented sibling of `prompt_built`: carries the salted hash of * the stable prefix (so per-step records stay small) together with the diff --git a/src/agent/step-executor-routing.test.ts b/src/agent/step-executor-routing.test.ts new file mode 100644 index 00000000..f888331b --- /dev/null +++ b/src/agent/step-executor-routing.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; + +import { executeStep } from "./step-executor.js"; +import type { LlmStreamParams, StepDependencies } from "./step-executor.js"; +import type { StepEvent } from "./step-events.js"; +import { StepRouter, type FusionRoutingSnapshot } from "./routing/index.js"; +import { ToolRegistry } from "../tools/tool-registry.js"; +import { compressToolResult } from "../compressor/result-compressor.js"; +import { SlotManager } from "../llm/slot-manager.js"; +import { PLAIN_INSTRUCT_PROFILE } from "../llm/model-profile.js"; +import { buildGrammar } from "../llm/grammar/build-grammar.js"; +import { createEmptySessionState } from "../session/session-state.js"; +import { DEFAULT_TOOL_DESCRIPTORS } from "../prompt/tool-descriptors.js"; +import type { + CapabilitiesSummary, + SkillCatalogEntry, +} from "../prompt/stable-prefix.js"; + +const CAPS: CapabilitiesSummary = { + platform: "darwin", + arch: "arm64", + browserChannel: "chrome", + workingDir: "/work", + hasClipboard: true, + hasWmctrl: false, + hasNotifications: true, +}; +const SKILLS: SkillCatalogEntry[] = []; + +const FUSION: FusionRoutingSnapshot = { + cloudProviderId: "cloud", + localProviderId: "local", + cloudShare: 40, + subRunners: "local", + maxSteps: 25, + conversationMaxTokens: 32_000, +}; + +function replyRegistry(): ToolRegistry { + const registry = new ToolRegistry(); + registry.register({ + name: "reply", + description: "reply", + readonly: true, + async run(args: Record) { + return compressToolResult({ + tool: "reply", + status: "ok", + output: String(args.text ?? ""), + }); + }, + }); + return registry; +} + +function completion(content: string) { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 20, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock", + }; +} + +const REPLY_BODY = JSON.stringify({ tool: "reply", args: { text: "done" } }); + +/** + * Run one step and report what the LLM seam actually received, plus the + * events the step emitted. + */ +async function runStep(opts: { + router?: StepRouter; + resolveSlotAffinity?: (providerId: string) => boolean; + supportsSlotAffinity?: boolean; + bodies?: string[]; +}): Promise<{ seen: LlmStreamParams[]; events: StepEvent[] }> { + const grammar = await buildGrammar( + PLAIN_INSTRUCT_PROFILE, + join(process.cwd(), "grammars"), + ); + const seen: LlmStreamParams[] = []; + const events: StepEvent[] = []; + const bodies = opts.bodies ?? [REPLY_BODY]; + let call = 0; + + const deps = { + registry: replyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async (params: LlmStreamParams) => { + seen.push(params); + const body = bodies[Math.min(call, bodies.length - 1)]!; + call += 1; + return completion(body); + }, + grammar, + profile: PLAIN_INSTRUCT_PROFILE, + supportsSlotAffinity: opts.supportsSlotAffinity ?? false, + onEvent: (event: StepEvent) => events.push(event), + ...(opts.router ? { stepRouter: opts.router } : {}), + ...(opts.resolveSlotAffinity + ? { resolveSlotAffinity: opts.resolveSlotAffinity } + : {}), + } as unknown as StepDependencies; + + await executeStep( + { + session: createEmptySessionState({ id: "s-route", workingDir: "/w" }), + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "x", + }, + deps, + ); + return { seen, events }; +} + +const routerWith = (over: Partial = {}): StepRouter => + new StepRouter({ resolveFusion: () => ({ ...FUSION, ...over }) }); + +describe("executeStep fusion routing", () => { + it("sets no preferredProviderId when no router is wired", async () => { + const { seen, events } = await runStep({}); + expect(seen).toHaveLength(1); + expect(seen[0]).not.toHaveProperty("preferredProviderId"); + expect(events.some((e) => e.type === "step_routed")).toBe(false); + }); + + it("sets no preferredProviderId when the router declines (not fusion)", async () => { + const router = new StepRouter({ resolveFusion: () => null }); + const { seen, events } = await runStep({ router }); + expect(seen[0]).not.toHaveProperty("preferredProviderId"); + expect(events.some((e) => e.type === "step_routed")).toBe(false); + }); + + it("forwards the routed provider to the LLM seam", async () => { + // Step 0 always orchestrates ⇒ the cloud leg. + const { seen } = await runStep({ router: routerWith() }); + expect(seen[0]?.preferredProviderId).toBe("cloud"); + }); + + it("forwards the local leg when the dial is fully local", async () => { + const { seen } = await runStep({ router: routerWith({ cloudShare: 0 }) }); + expect(seen[0]?.preferredProviderId).toBe("local"); + }); + + it("emits step_routed describing the decision", async () => { + const { events } = await runStep({ router: routerWith() }); + const routed = events.find((e) => e.type === "step_routed"); + expect(routed).toMatchObject({ + type: "step_routed", + stepIndex: 0, + role: "orchestrator", + providerId: "cloud", + cloudShare: 40, + }); + }); + + it("acquires a real slot when the ROUTED provider has slot affinity", async () => { + // The active provider reports no affinity (cloud), but the step is + // routed to the local leg, which does. Without `resolveSlotAffinity` + // this would run at slotId -1 and reprocess the whole prompt. + const { seen } = await runStep({ + router: routerWith({ cloudShare: 0 }), + supportsSlotAffinity: false, + resolveSlotAffinity: (id) => id === "local", + }); + expect(seen[0]?.slotId).toBeGreaterThanOrEqual(0); + }); + + it("drops to slotId -1 when the routed provider has no slot affinity", async () => { + const { seen } = await runStep({ + router: routerWith({ cloudShare: 100 }), + supportsSlotAffinity: true, + resolveSlotAffinity: (id) => id === "local", + }); + expect(seen[0]?.preferredProviderId).toBe("cloud"); + expect(seen[0]?.slotId).toBe(-1); + }); + + it("repairs on the same leg that produced the malformed call", async () => { + const { seen } = await runStep({ + router: routerWith({ cloudShare: 0 }), + bodies: ["not json at all", REPLY_BODY], + }); + expect(seen.length).toBeGreaterThanOrEqual(2); + // A repair judged by the OTHER leg would parse a different model's + // mistake against a different transport. + expect(seen[1]?.preferredProviderId).toBe("local"); + }); +}); diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 321f6066..0854d4b1 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -84,6 +84,7 @@ import type { ProfileFact } from "../memory/profile-store.js"; import type { AgentMetrics } from "../tracing/agent-metrics.js"; import type { StructuredLogger } from "../tracing/structured-logger.js"; import type { StepEvent } from "./step-events.js"; +import type { StepRouter } from "./routing/index.js"; export type { PromptCapturedTokens, StepEvent } from "./step-events.js"; export interface LlmStreamParams { @@ -119,6 +120,17 @@ export interface LlmStreamParams { * instead of waiting for the current step to finish on its own. */ signal?: AbortSignal; + /** + * Fusion routing: the provider id this call should START at. Only the + * starting link changes — the fallback chain still owns health, so a + * preferred provider in cooldown is ignored and a failure still + * advances through the chain. + * + * Deliberately a provider id rather than a role: the seam stays + * ignorant of run modes, and the policy that picked the leg lives in + * `src/agent/routing/`. Absent ⇒ today's behaviour, unchanged. + */ + preferredProviderId?: string; } export type LlmCompleteStream = ( @@ -145,6 +157,23 @@ export interface StepDependencies { toolCallAdapter: ToolCallAdapter | null; /** When false, completions use slotId -1 (cloud providers). */ supportsSlotAffinity: boolean; + /** + * Fusion step router. When present and fusion is the effective run + * mode, it picks the leg for each step; absent (or returning null) ⇒ + * provider selection is left entirely to the fallback chain, i.e. + * today's behaviour. + */ + stepRouter?: StepRouter; + /** + * Slot affinity for a SPECIFIC provider, used when `stepRouter` routes + * a step away from the active provider. + * + * Without this, fusion would read `supportsSlotAffinity` off the + * active (cloud) provider and run every locally-routed step with + * `slotId: -1` and `cachePrompt: false` — forcing llama-server to + * reprocess the whole prompt on each one. + */ + resolveSlotAffinity?: (providerId: string) => boolean; /** * Invoked after every LLM completion (initial call and one-shot parse * retry alike). Used by the agent loop to feed the served `modelId` @@ -302,7 +331,33 @@ async function executeStepInner( ? { userMessage: ctx.userMessage } : {}), }); - const slot = deps.supportsSlotAffinity + // Route BEFORE acquiring a slot: which provider serves this step + // decides whether a slot is worth acquiring at all. That ordering is + // also why the complexity score cannot use `cacheReused` — it does + // not exist yet, and making it an input would be circular. + const routing = + deps.stepRouter?.routeStep({ + sessionId: ctx.session.id, + stepIndex: ctx.stepIndex, + promptTokens: prompt.tokens.total, + stablePrefixTokens: prompt.tokens.stablePrefix, + hasTransientNotice: ctx.transientNotice !== undefined, + }) ?? null; + if (routing) { + deps.onEvent?.({ + type: "step_routed", + stepIndex: ctx.stepIndex, + role: routing.role, + providerId: routing.providerId, + complexity: routing.complexity, + cloudShare: routing.cloudShare, + }); + } + const slotAffinity = routing + ? (deps.resolveSlotAffinity?.(routing.providerId) ?? + deps.supportsSlotAffinity) + : deps.supportsSlotAffinity; + const slot = slotAffinity ? deps.slotManager.acquire(ctx.session.id, prompt.stablePrefix) : { slotId: -1, @@ -348,6 +403,7 @@ async function executeStepInner( sessionId: ctx.session.id, toolDescriptors: ctx.toolDescriptors, signal: ctx.signal, + ...(routing ? { preferredProviderId: routing.providerId } : {}), }); const firstAttempt = await runInitialCompletion({ @@ -1118,6 +1174,7 @@ function buildLlmStreamParams(args: { sessionId: string; toolDescriptors: readonly ToolDescriptor[]; signal?: AbortSignal; + preferredProviderId?: string; }): LlmStreamParams { const base: LlmStreamParams = { prompt: args.promptText, @@ -1125,6 +1182,9 @@ function buildLlmStreamParams(args: { slotId: args.slotId, sessionId: args.sessionId, ...(args.signal ? { signal: args.signal } : {}), + ...(args.preferredProviderId + ? { preferredProviderId: args.preferredProviderId } + : {}), }; if (args.deps.toolTransport !== "native_tools") { return base; diff --git a/src/llm/fallback/index.ts b/src/llm/fallback/index.ts index a4dc1f11..cfd7ec33 100644 --- a/src/llm/fallback/index.ts +++ b/src/llm/fallback/index.ts @@ -11,7 +11,10 @@ export { type ResolvedFallbackChain, } from "./fallback-config.js"; export { shouldAdvance, type AdvanceDecision } from "./should-advance.js"; -export { runWithFallback } from "./run-with-fallback.js"; +export { + runWithFallback, + type RunWithFallbackOptions, +} from "./run-with-fallback.js"; export { primeStream, replayPrimedStream, diff --git a/src/llm/fallback/provider-fallback-chain.test.ts b/src/llm/fallback/provider-fallback-chain.test.ts index d93b9581..59fe5a50 100644 --- a/src/llm/fallback/provider-fallback-chain.test.ts +++ b/src/llm/fallback/provider-fallback-chain.test.ts @@ -409,3 +409,101 @@ describe("ProviderFallbackChain", () => { }); }); }); + +describe("ProviderFallbackChain — fusion preferred start", () => { + it("starts at the preferred leg instead of the chain primary", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + expect(chain.pickProvider("s1", "local")).toEqual({ + providerId: "local", + isProbe: false, + }); + }); + + it("never marks a preferred pick as a probe", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + expect(chain.pickProvider("s1", "local").isProbe).toBe(false); + }); + + it("never sets the sticky override", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + chain.pickProvider("s1", "local"); + expect(chain.activeOverrideFor("s1")).toBeNull(); + }); + + it("never clears an override that a real fallover established", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + chain.advanceFrom("cloud", http(429), "s1"); + expect(chain.activeOverrideFor("s1")).toBe("local"); + chain.pickProvider("s1", "local"); + expect(chain.activeOverrideFor("s1")).toBe("local"); + }); + + it("accepts a preferred id that is not a chain member", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud"]), + }); + expect(chain.pickProvider("s1", "local").providerId).toBe("local"); + }); + + it("ignores the preference while THAT leg is in cooldown", () => { + const clock = makeClock(); + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + now: clock.now, + }); + // A 429 on the local leg trips its breaker immediately. + chain.advanceFrom("local", http(429), "s1"); + expect(chain.pickProvider("s1", "local").providerId).not.toBe("local"); + // Once the cooldown elapses the preference is honoured again. + clock.advance(DEFAULT_FALLBACK_TIMING.cooldownMs[0]! + 1); + expect(chain.pickProvider("s1", "local").providerId).toBe("local"); + }); + + it("honours the preference while a DIFFERENT leg is in cooldown", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + chain.advanceFrom("cloud", http(429), "s1"); + expect(chain.pickProvider("s1", "local").providerId).toBe("local"); + }); + + it("resumes from the chain head when a preferred TAIL start fails", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + // Without `restartFromHead` this returns null (nothing after the + // tail) and the turn dies even though the cloud leg is healthy. + expect( + chain.advanceFrom("local", http(503), "s1", { restartFromHead: true }), + ).toBe("cloud"); + }); + + it("keeps the default advance behaviour when the flag is absent", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + expect(chain.advanceFrom("local", http(503), "s1")).toBeNull(); + }); + + it("leaves an unpreferred pick byte-identical to today", () => { + const chain = new ProviderFallbackChain({ + resolve: () => chainOf(["cloud", "local"]), + }); + expect(chain.pickProvider("s1")).toEqual({ + providerId: "cloud", + isProbe: false, + }); + expect(chain.pickProvider("s1", undefined)).toEqual({ + providerId: "cloud", + isProbe: false, + }); + }); +}); diff --git a/src/llm/fallback/provider-fallback-chain.ts b/src/llm/fallback/provider-fallback-chain.ts index 05658ab0..43dbe6ca 100644 --- a/src/llm/fallback/provider-fallback-chain.ts +++ b/src/llm/fallback/provider-fallback-chain.ts @@ -36,6 +36,17 @@ export interface ProviderSwitchNotice { reason: string; } +/** Extra switching policy for one `advanceFrom` call. */ +export interface AdvanceOptions { + /** + * Resume the scan from the chain head rather than from just after + * `fromId`. Set when the failed attempt was a fusion-preferred start, + * whose position in the chain carries no "already tried everything + * above me" meaning. + */ + restartFromHead?: boolean; +} + /** What `pickProvider` decided for the turn about to run. */ export interface ProviderPick { /** Provider id to route this turn through. */ @@ -117,8 +128,20 @@ export class ProviderFallbackChain { * chain, drops a stale override that no longer names a chain member, * and — when the primary's cooldown has elapsed and the probe throttle * allows — routes this one turn back to the primary as a probe. + * + * `preferredId` is the fusion router's chosen leg for THIS call. It + * changes only the starting link, and health still wins: the + * preference is ignored while that specific provider is in cooldown, + * and a failure still advances through the chain as usual. It never + * sets or clears `overrideId` and is never reported as a probe — + * both of those are primary-recovery concepts, and a fusion pick is + * not a fallover. The id need not be a chain member; `advanceFrom` + * already restarts from the chain head for a non-member. */ - pickProvider(partitionKey: string = DEFAULT_PARTITION): ProviderPick { + pickProvider( + partitionKey: string = DEFAULT_PARTITION, + preferredId?: string, + ): ProviderPick { const { chain } = this.resolve(); const primary = chain[0]; if (!primary) { @@ -135,6 +158,16 @@ export class ProviderFallbackChain { this.clearOverride(p); } + // Fusion routing preference, checked before the override/probe + // logic so a healthy preferred leg is honoured — but only while + // that leg itself is healthy, so a tripped breaker still wins. + if (preferredId !== undefined && preferredId.length > 0) { + const preferred = this.breaker(p, preferredId); + if (this.now() >= preferred.cooldownUntil) { + return { providerId: preferredId, isProbe: false }; + } + } + if (!p.overrideId) { return { providerId: primary, isProbe: false }; } @@ -161,6 +194,7 @@ export class ProviderFallbackChain { fromId: string, err: unknown, partitionKey: string = DEFAULT_PARTITION, + options?: AdvanceOptions, ): string | null { const decision = shouldAdvance(err); if (!decision.advance) return null; @@ -171,8 +205,14 @@ export class ProviderFallbackChain { const idx = chain.indexOf(fromId); // Next healthy link after `fromId`. When `fromId` is not in the chain - // (raced config edit) start from the top. - const startFrom = idx < 0 ? 0 : idx + 1; + // (raced config edit) start from the top — and likewise when the + // failure came from a fusion-preferred start, which can sit anywhere + // in the chain and is commonly its TAIL. Advancing "after" the tail + // would strand a recoverable turn with the rest of the chain untried. + // The `candidate === fromId` guard below keeps the failed link out + // of the scan either way. + const startFrom = + idx < 0 || options?.restartFromHead === true ? 0 : idx + 1; for (let i = startFrom; i < chain.length; i += 1) { const candidate = chain[i]!; if (candidate === fromId) continue; diff --git a/src/llm/fallback/run-with-fallback.ts b/src/llm/fallback/run-with-fallback.ts index 0ae01894..a4cca257 100644 --- a/src/llm/fallback/run-with-fallback.ts +++ b/src/llm/fallback/run-with-fallback.ts @@ -17,14 +17,31 @@ import type { ProviderFallbackChain } from "./provider-fallback-chain.js"; * chunks is never restarted (mirrors the openai-http "stream is live" * contract), so failures after the first chunk propagate as-is. */ +export interface RunWithFallbackOptions { + /** + * Provider to START at for this unit of work (fusion routing). Only + * the starting link changes: the switching policy below is untouched, + * so a failure still advances through the chain and a preferred + * provider in cooldown is ignored. + */ + preferredProviderId?: string; +} + export async function runWithFallback( chain: ProviderFallbackChain, attempt: (providerId: string) => Promise, partitionKey?: string, + options?: RunWithFallbackOptions, ): Promise { - const pick = chain.pickProvider(partitionKey); + const pick = chain.pickProvider(partitionKey, options?.preferredProviderId); let currentId = pick.providerId; let wasProbe = pick.isProbe; + // True only while we are still sitting on the fusion-preferred start. + // A failure there resumes the chain from its head, because a preferred + // leg's position in the chain says nothing about what has been tried. + let onPreferredStart = + options?.preferredProviderId !== undefined && + currentId === options.preferredProviderId; // Guard against a pathological empty chain: no provider to try. if (!currentId) { @@ -37,12 +54,15 @@ export async function runWithFallback( chain.recordSuccess(currentId, wasProbe, partitionKey); return result; } catch (err) { - const nextId = chain.advanceFrom(currentId, err, partitionKey); + const nextId = chain.advanceFrom(currentId, err, partitionKey, { + restartFromHead: onPreferredStart, + }); if (nextId === null) throw err; currentId = nextId; // Only the very first pick can be a probe; every advance is a real // fallover on the working path. wasProbe = false; + onPreferredStart = false; } } } diff --git a/src/llm/provider/completion-types.ts b/src/llm/provider/completion-types.ts index d6cd5644..41109097 100644 --- a/src/llm/provider/completion-types.ts +++ b/src/llm/provider/completion-types.ts @@ -98,6 +98,15 @@ export interface CompletionResult { * authoritative. */ servedTransport?: ToolCallTransport; + /** + * Id of the provider that actually served this completion. Stamped by + * the same wrapper, and for the same reason as `servedTransport`: + * under fusion routing (and after any fallover) the link that answered + * is not necessarily `llm.activeTextProvider`, so anything that + * attributes the result — cost/pricing lookup above all — has to ask + * who served it rather than who was active. + */ + servedProviderId?: string; } export interface OpenAiToolCall { diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts index 2fb5eef5..33bb6136 100644 --- a/src/llm/provider/registry/provider-registry.test.ts +++ b/src/llm/provider/registry/provider-registry.test.ts @@ -87,3 +87,68 @@ describe("ProviderRegistry", () => { ).rejects.toThrow(/unknown llm provider kind/); }); }); + +describe("ProviderRegistry pinned providers", () => { + /** Minimal stand-in that records whether it was torn down. */ + function fake(id: string) { + const state = { closed: false }; + const provider = { + id, + name: id, + capabilities: {}, + toolCallAdapter: null, + streamConsumer: null, + async complete() { + throw new Error("unused"); + }, + async *completeStream() { + throw new Error("unused"); + }, + async describeImage() { + throw new Error("unused"); + }, + async health() { + return { reachable: true, status: 200, error: null, latencyMs: 1 }; + }, + async close() { + state.closed = true; + }, + }; + return { provider, state }; + } + + function registryOf(ids: string[]) { + const fakes = ids.map((id) => fake(id)); + const map = new Map( + fakes.map((f) => [f.provider.id, f.provider as never] as const), + ); + // `new ProviderRegistry(...)` is private to the module's factory, so + // reach it the same way `fromConfig` does. + const registry = Reflect.construct(ProviderRegistry, [ids[0], map]) as + ProviderRegistry; + return { registry, fakes }; + } + + it("closes the previous provider on a plain swap", async () => { + const { registry, fakes } = registryOf(["cloud", "local"]); + await registry.swapActive("local"); + expect(fakes[0]!.state.closed).toBe(true); + }); + + it("keeps a pinned provider open across a swap", async () => { + // Fusion keeps both legs live; closing the one it is about to route + // to would break the executor leg on the very next step. + const { registry, fakes } = registryOf(["cloud", "local"]); + registry.setPinnedProviderIds(() => new Set(["cloud", "local"])); + await registry.swapActive("local"); + expect(fakes[0]!.state.closed).toBe(false); + expect(registry.activeText.id).toBe("local"); + }); + + it("resumes closing once nothing is pinned", async () => { + const { registry, fakes } = registryOf(["cloud", "local"]); + registry.setPinnedProviderIds(() => new Set()); + await registry.swapActive("local"); + expect(fakes[0]!.state.closed).toBe(true); + }); +}); diff --git a/src/llm/provider/registry/provider-registry.ts b/src/llm/provider/registry/provider-registry.ts index a8ac4243..cb3dda62 100644 --- a/src/llm/provider/registry/provider-registry.ts +++ b/src/llm/provider/registry/provider-registry.ts @@ -28,6 +28,8 @@ export class ProviderRegistry { this.providers = providers; } + private pinnedProviderIds?: () => ReadonlySet; + static async fromConfig( config: AtomicAgentConfig, ctx: Omit & { @@ -76,6 +78,21 @@ export class ProviderRegistry { return [...this.providers.keys()]; } + /** + * Providers that must stay open even when they stop being active. + * + * Fusion keeps two legs live at once and only one of them can be the + * active provider, so switching INTO fusion would otherwise close the + * very provider it is about to route to. `close()` is a no-op on both + * shipped provider kinds today, which is why this is not currently a + * visible crash — but the interface promises teardown, and the first + * provider kind that honours it (pooled sockets, a WS transport) + * would break fusion silently without this. + */ + setPinnedProviderIds(pinned: () => ReadonlySet): void { + this.pinnedProviderIds = pinned; + } + async swapActive(id: string): Promise { const next = this.providers.get(id); if (!next) { @@ -83,7 +100,7 @@ export class ProviderRegistry { } const prev = this.providers.get(this.activeTextId); this.activeTextId = id; - if (prev && prev.id !== id) { + if (prev && prev.id !== id && !this.pinnedProviderIds?.().has(prev.id)) { await prev.close().catch(() => undefined); } return next; diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 68000664..143f4a2c 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -10,6 +10,8 @@ import { } from "../config/index.js"; import type { LlmStreamParams } from "../agent/step-executor.js"; +import { StepRouter } from "../agent/routing/index.js"; +import { resolveRunMode } from "../llm/run-mode/index.js"; import { TurnController } from "./turn-controller.js"; import type { TurnEventHook, TurnOrigin } from "./turn-controller.js"; import type { ChannelStatus } from "./channel-status.js"; @@ -1188,11 +1190,16 @@ export async function createAgentRuntime( */ const resolveModelPricing = ( modelId: string | null, + servedProviderId?: string, ): ResolvedModel | undefined => { if (!modelId) return undefined; const resolved = resolveLlmConfig(getConfig()); + // Price against the provider that actually SERVED the completion, + // not the active one. They differ after any fallover, and routinely + // under fusion — pricing a local completion against the cloud + // provider's catalog reports a cost that was never incurred. const entry = resolved.providers.find( - (p) => p.id === resolved.activeTextProvider, + (p) => p.id === (servedProviderId ?? resolved.activeTextProvider), ); if (!entry) return undefined; return resolveModel(entry, modelId, catalogForProvider(entry)); @@ -1343,9 +1350,10 @@ export async function createAgentRuntime( const recordUnaryUsage = ( params: LlmStreamParams, result: CompletionResult, + servedProviderId?: string, ): void => { if (!result.usage) return; - const model = resolveModelPricing(result.modelId); + const model = resolveModelPricing(result.modelId, servedProviderId); if (costAccumulator) { costAccumulator.recordTurn({ modelId: result.modelId, @@ -1367,7 +1375,7 @@ export async function createAgentRuntime( result: CompletionResult, ): void => { if (!result.usage || !sessionId) return; - const model = resolveModelPricing(result.modelId); + const model = resolveModelPricing(result.modelId, result.servedProviderId); turnUsageMeter.record({ sessionId, usage: result.usage, @@ -1375,6 +1383,40 @@ export async function createAgentRuntime( }); }; + // Keep both fusion legs open across an active-provider swap. Without + // this, switching into fusion closes the provider it routes to. + providerRegistry.setPinnedProviderIds(() => { + const runMode = resolveRunMode(resolveLlmConfig(getConfig())); + if (runMode.effective !== "fusion") return new Set(); + return new Set( + [runMode.cloudProviderId, runMode.localProviderId].filter( + (id): id is string => id !== null, + ), + ); + }); + + /** + * Fusion step router. Always constructed; it resolves the live config + * on every step and returns `null` unless fusion is the effective run + * mode, so a non-fusion install pays one config read and nothing else. + */ + const stepRouter = new StepRouter({ + resolveFusion: () => { + const live = getConfig(); + const runMode = resolveRunMode(resolveLlmConfig(live)); + if (runMode.effective !== "fusion") return null; + if (!runMode.cloudProviderId || !runMode.localProviderId) return null; + return { + cloudProviderId: runMode.cloudProviderId, + localProviderId: runMode.localProviderId, + cloudShare: runMode.fusion.cloudShare, + subRunners: runMode.fusion.subRunners, + maxSteps: live.agent.maxSteps, + conversationMaxTokens: live.agent.conversationMaxTokens, + }; + }, + }); + const fallbackSeamDeps: FallbackSeamDeps = { fallbackChain, resolveSlice: (providerId) => { @@ -1396,6 +1438,26 @@ export async function createAgentRuntime( ? undefined : createFallbackStreamer(fallbackSeamDeps)); + /** + * Completion seam for the memory sub-runners (reflection, link + * generation, curation votes, query rewriting, distillation). + * + * Under fusion these default to the LOCAL leg: they are cold-path, + * fire-and-forget structured-JSON jobs that ride the reserved + * reflection slot and are already KV-warm on the local server, so + * sending them to the cloud multiplies per-turn cost with no + * user-visible latency win. `llm.runMode.fusion.subRunners` overrides + * it. Outside fusion this is `llmComplete` with no added behaviour. + */ + const subRunnerLlmComplete = ( + params: LlmStreamParams, + ): Promise => { + const providerId = stepRouter.subRunnerProviderId(params.sessionId); + return llmComplete( + providerId ? { ...params, preferredProviderId: providerId } : params, + ); + }; + const taskStore = new TaskStore({ dbFile: config.paths.tasksDbFile }); const webhookSessionStore = new WebhookSessionStore( resolve(config.paths.stateDir, "webhook-sessions.json"), @@ -1426,7 +1488,7 @@ export async function createAgentRuntime( const baseReflectionRunner = buildReflectionRunner({ config, slotManager, - llmComplete, + llmComplete: subRunnerLlmComplete, toolTransport: bootstrapLlmSlice.transport, profileStore, notesStore, @@ -1483,7 +1545,7 @@ export async function createAgentRuntime( { once: true }, ); }); - const completionPromise = llmComplete({ + const completionPromise = subRunnerLlmComplete({ prompt: params.prompt, grammar: params.grammar, slotId: params.slotId, @@ -1552,7 +1614,7 @@ export async function createAgentRuntime( { once: true }, ); }); - const completionPromise = llmComplete({ + const completionPromise = subRunnerLlmComplete({ prompt: params.prompt, grammar: params.grammar, slotId: params.slotId, @@ -1688,7 +1750,7 @@ export async function createAgentRuntime( { once: true }, ); }); - const completionPromise = llmComplete({ + const completionPromise = subRunnerLlmComplete({ prompt: params.prompt, grammar: params.grammar, slotId: params.slotId, @@ -1752,6 +1814,7 @@ export async function createAgentRuntime( registry: toolRegistry, slotManager, grammar, + stepRouter, llmComplete, ...(llmCompleteStream ? { llmCompleteStream } : {}), toolDescriptors: effectiveToolDescriptors, @@ -1833,6 +1896,15 @@ export async function createAgentRuntime( enumerable: true, get: () => resolveActiveLlmSlice().slotAffinity, }); + // Slot affinity for a specific routed provider. Without this, fusion + // would read affinity off the active (cloud) provider and run every + // locally-routed step with slotId -1 — no prompt cache at all on the + // local leg. + Object.defineProperty(loopDeps, "resolveSlotAffinity", { + enumerable: true, + get: () => (providerId: string) => + resolveActiveLlmSlice(providerId).slotAffinity, + }); const loop = new AgentLoop( loopDeps as typeof loopDeps & { skillCatalog: readonly SkillCatalogEntry[]; @@ -2224,7 +2296,7 @@ export async function createAgentRuntime( { once: true }, ); }); - const completionPromise = llmComplete({ + const completionPromise = subRunnerLlmComplete({ prompt: params.prompt, grammar: params.grammar, slotId: params.slotId, diff --git a/src/runtime/llm-fallback-seam.test.ts b/src/runtime/llm-fallback-seam.test.ts index a2a8197a..32ac7719 100644 --- a/src/runtime/llm-fallback-seam.test.ts +++ b/src/runtime/llm-fallback-seam.test.ts @@ -184,3 +184,73 @@ describe("createFallbackStreamer (real bootstrap seam)", () => { expect(result.servedTransport).toBe("native_tools"); }); }); + +describe("fusion routing through the real seam", () => { + const providers = () => + new Map([ + ["cloud", fakeProvider("cloud", "native_tools", async () => answer("cloud"))], + ["local", fakeProvider("local", "grammar", async () => answer("local"))], + ]); + + it("stamps servedProviderId with the link that answered", async () => { + const complete = createFallbackCompleter(seamDeps(providers())); + const result = await complete(baseParams); + expect(result.servedProviderId).toBe("cloud"); + }); + + it("starts at preferredProviderId instead of the chain primary", async () => { + const complete = createFallbackCompleter(seamDeps(providers())); + const result = await complete({ + ...baseParams, + preferredProviderId: "local", + }); + // Load-bearing: "local" is the chain TAIL, so without the + // preference plumbing this would answer from "cloud". + expect(result.servedProviderId).toBe("local"); + expect(result.modelId).toBe("local-model"); + // And the transport stamp must follow the routed leg, not the primary. + expect(result.servedTransport).toBe("grammar"); + }); + + it("still falls over on health when the preferred leg fails", async () => { + const map = new Map([ + ["cloud", fakeProvider("cloud", "native_tools", async () => answer("cloud"))], + [ + "local", + fakeProvider("local", "grammar", async () => { + throw new OpenAiHttpError("boom", 503, "http://local", false, null, "local"); + }), + ], + ]); + const complete = createFallbackCompleter(seamDeps(map)); + const result = await complete({ + ...baseParams, + preferredProviderId: "local", + }); + expect(result.servedProviderId).toBe("cloud"); + }); + + it("prices against the served leg, not the active one", async () => { + // Guards the fusion cost-attribution fix in bootstrap: the recorder + // is handed the id of the link that answered. + const seen: string[] = []; + const deps = seamDeps(providers()); + const complete = createFallbackCompleter({ + ...deps, + recordUnaryUsage: (_params, _result, servedProviderId) => { + seen.push(servedProviderId); + }, + }); + await complete({ ...baseParams, preferredProviderId: "local" }); + expect(seen).toEqual(["local"]); + }); + + it("routes the stream seam by preference and stamps the served id", async () => { + const stream = createFallbackStreamer(seamDeps(providers())); + const gen = stream({ ...baseParams, preferredProviderId: "local" }); + let next = await gen.next(); + while (next.done !== true) next = await gen.next(); + expect(next.value.servedProviderId).toBe("local"); + expect(next.value.servedTransport).toBe("grammar"); + }); +}); diff --git a/src/runtime/llm-fallback-seam.ts b/src/runtime/llm-fallback-seam.ts index d77f7e87..ed54348d 100644 --- a/src/runtime/llm-fallback-seam.ts +++ b/src/runtime/llm-fallback-seam.ts @@ -34,8 +34,17 @@ export interface FallbackSeamDeps { fallbackChain: ProviderFallbackChain; /** Resolve the served link's provider + transport for `providerId`. */ resolveSlice: (providerId: string) => ResolvedLinkSlice; - /** Fold a unary completion's usage into cost + meter (no-op when absent). */ - recordUnaryUsage: (params: LlmStreamParams, result: CompletionResult) => void; + /** + * Fold a unary completion's usage into cost + meter (no-op when + * absent). `servedProviderId` names the link that answered, which is + * what pricing must be looked up against — under fusion it routinely + * differs from `llm.activeTextProvider`. + */ + recordUnaryUsage: ( + params: LlmStreamParams, + result: CompletionResult, + servedProviderId: string, + ) => void; /** Fold a streamed completion's usage into the meter. */ recordStreamUsage: ( sessionId: string | undefined, @@ -92,10 +101,13 @@ export function createFallbackCompleter( slotId: params.slotId, cachePrompt: params.slotId >= 0, }); - deps.recordUnaryUsage(params, result); - return { ...result, servedTransport: transport }; + deps.recordUnaryUsage(params, result, providerId); + return { ...result, servedTransport: transport, servedProviderId: providerId }; }, params.sessionId, + { ...(params.preferredProviderId + ? { preferredProviderId: params.preferredProviderId } + : {}) }, ); } @@ -117,6 +129,7 @@ export function createFallbackStreamer( ): Promise<{ primed: PrimedStream; transport: ToolCallTransport; + providerId: string; }> => { const { provider, transport } = deps.resolveSlice(providerId); const base = { @@ -142,18 +155,21 @@ export function createFallbackStreamer( slotId: params.slotId, cachePrompt: params.slotId >= 0, }); - return { primed: await primeStream(stream), transport }; + return { primed: await primeStream(stream), transport, providerId }; }; return (params) => { async function* run(): AsyncGenerator { - const { primed, transport } = await runWithFallback( + const { primed, transport, providerId } = await runWithFallback( deps.fallbackChain, (id) => openStreamPrimed(id, params), params.sessionId, + { ...(params.preferredProviderId + ? { preferredProviderId: params.preferredProviderId } + : {}) }, ); const result = yield* replayPrimedStream(primed); - return { ...result, servedTransport: transport }; + return { ...result, servedTransport: transport, servedProviderId: providerId }; } return meterStream(deps, params.sessionId, run()); }; diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index b72d64b9..7aabe437 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -375,6 +375,21 @@ function reduceStepEvent( }, }; } + case "step_routed": { + return appendFeed(state, { + kind: "runtime_info", + stepIndex: event.stepIndex, + line: formatFeedLine({ + type: "step_routed", + stepIndex: event.stepIndex, + role: event.role, + providerId: event.providerId, + complexity: event.complexity, + cloudShare: event.cloudShare, + }), + color: "blue", + }); + } case "parse_retry": { const withFeed = appendFeed(state, { kind: "runtime_info", diff --git a/src/tui/format-event.ts b/src/tui/format-event.ts index 696eb816..8e33dd80 100644 --- a/src/tui/format-event.ts +++ b/src/tui/format-event.ts @@ -40,6 +40,14 @@ export type FeedLineInput = | { type: "rare_tool_autoloaded"; tool: string; + } + | { + type: "step_routed"; + stepIndex: number; + role: "orchestrator" | "executor"; + providerId: string; + complexity: number; + cloudShare: number; }; const ARGS_PREVIEW_LIMIT = 160; @@ -66,6 +74,15 @@ export function formatFeedLine(input: FeedLineInput): string { case "rare_tool_autoloaded": { return ` ↻ loaded schema for ${input.tool} after tool error`; } + case "step_routed": { + // Show the cutoff alongside the score so the line explains the + // decision rather than just announcing it. + const cutoff = 100 - input.cloudShare; + const leg = + input.role === "orchestrator" ? "cloud orchestrator" : "local executor"; + const comparison = input.role === "orchestrator" ? "≥" : "<"; + return `[step ${input.stepIndex}] → ${leg} ${input.providerId} (complexity ${input.complexity} ${comparison} ${cutoff})`; + } default: return ""; } From 7c862776c07732409dff95fd37397b139d0adbea Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:38:11 +0300 Subject: [PATCH 14/57] feat(tui): run the key check from the wizard and first-run onboarding --- .../components/cloud-provider-onboarding.tsx | 50 ++++++++++++++++--- .../components/local-models-config-wizard.tsx | 7 +-- src/tui/components/providers-wizard.tsx | 12 ++++- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/tui/components/cloud-provider-onboarding.tsx b/src/tui/components/cloud-provider-onboarding.tsx index fbb57fb3..dec58a9c 100644 --- a/src/tui/components/cloud-provider-onboarding.tsx +++ b/src/tui/components/cloud-provider-onboarding.tsx @@ -1,34 +1,60 @@ import { Box, Text, useInput } from "ink"; -import { useCallback, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; import { handleProvidersWizardKey } from "../providers/providers-wizard-key-bindings.js"; import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import type { ProvidersWizardState } from "../providers/providers-wizard-state.js"; import { saveProviderWizardToConfig } from "../providers/save-provider-wizard.js"; +import { verifyWizardBeforeSave } from "../providers/verify-wizard-before-save.js"; import { theme } from "../theme/theme.js"; import { ProvidersWizard } from "./providers-wizard.js"; export type CloudProviderOnboardingOutcome = "saved_cloud" | "aborted"; export function CloudProviderOnboarding(props: { - onFinished(outcome: CloudProviderOnboardingOutcome): void; + /** `notice` carries a key that was saved without a completed check. */ + onFinished(outcome: CloudProviderOnboardingOutcome, notice?: string): void; onBack(): void; }): ReactElement { const [wizard, setWizard] = useState(() => createProvidersWizardState("add"), ); const [submitting, setSubmitting] = useState(false); + const verifyAbort = useRef(null); + const alive = useRef(true); + useEffect(() => { + return () => { + alive.current = false; + verifyAbort.current?.abort(); + }; + }, []); const submit = useCallback( - (nextWizard: ProvidersWizardState) => { + async (nextWizard: ProvidersWizardState) => { if (submitting) return; setSubmitting(true); + const abort = new AbortController(); + verifyAbort.current = abort; try { + // First run goes through the same gate as the Providers tab, so + // a dead key cannot be the one the agent starts life with. + const gate = await verifyWizardBeforeSave(nextWizard, { + signal: abort.signal, + }); + if (!alive.current) return; + if (!gate.proceed) { + setWizard({ ...nextWizard, error: gate.error, submitting: false }); + setSubmitting(false); + return; + } saveProviderWizardToConfig(nextWizard); - props.onFinished("saved_cloud"); + props.onFinished("saved_cloud", gate.warning ?? undefined); } catch (err) { + if (!alive.current) return; const message = err instanceof Error ? err.message : String(err); setWizard({ ...nextWizard, error: message, submitting: false }); setSubmitting(false); + } finally { + if (verifyAbort.current === abort) verifyAbort.current = null; } }, [props, submitting], @@ -36,6 +62,7 @@ export function CloudProviderOnboarding(props: { useInput((input, key) => { if (key.ctrl && input === "c") { + verifyAbort.current?.abort(); props.onFinished("aborted"); return; } @@ -46,8 +73,19 @@ export function CloudProviderOnboarding(props: { props.onBack(); return; } - if (result.submit) { - submit(result.wizard); + if ("cancelSubmit" in result && result.cancelSubmit) { + verifyAbort.current?.abort(); + verifyAbort.current = null; + setSubmitting(false); + setWizard({ + ...wizard, + submitting: false, + error: "Key check cancelled — press Enter to try again.", + }); + return; + } + if ("submit" in result && result.submit) { + void submit(result.wizard); return; } setWizard(result.wizard); diff --git a/src/tui/components/local-models-config-wizard.tsx b/src/tui/components/local-models-config-wizard.tsx index 3c8678cc..161fc140 100644 --- a/src/tui/components/local-models-config-wizard.tsx +++ b/src/tui/components/local-models-config-wizard.tsx @@ -29,7 +29,8 @@ export interface LocalModelsConfigWizardProps { * configured server really did stop answering. */ hadConfiguredBackend?: boolean; - onFinished(outcome: LocalModelsWizardOutcome): void; + /** `notice` carries a warning the caller should print after teardown. */ + onFinished(outcome: LocalModelsWizardOutcome, notice?: string): void; } type WizardPhase = "pick" | "remote-chat-url" | "remote-embedding-url" | "cloud"; @@ -70,8 +71,8 @@ export function LocalModelsConfigWizard({ const [hint, setHint] = useState(null); const finish = useCallback( - (outcome: LocalModelsWizardOutcome) => { - onFinished(outcome); + (outcome: LocalModelsWizardOutcome, notice?: string) => { + onFinished(outcome, notice); app.exit(); }, [app, onFinished], diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index 69851a3a..b494458b 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -84,6 +84,12 @@ function explainModelListError(error: string, w: ProvidersWizardState): string { return `could not list models from ${service} (${error})`; } +/** + * What `submitting` means now that a save starts with a live key check: + * the wait is the provider answering, and Esc gets out of it. + */ +const CHECKING_KEY_HINT = " · checking the key with the provider… (Esc cancels)"; + function maskedKey(buffer: string): string { const masked = "•".repeat(Math.min(buffer.length, 48)); const extra = buffer.length > 48 ? `+${buffer.length - 48}` : ""; @@ -183,7 +189,9 @@ function CompatChatModelStep(props: { }); } - const hint = !canList + const hint = w.submitting + ? CHECKING_KEY_HINT.trimStart() + : !canList ? "Enter to save · Esc back" : status.loading ? isGemini @@ -314,7 +322,7 @@ export function ProvidersWizard(props: { ) : null} Enter to continue · Esc back · Backspace edit - {w.submitting ? " · saving…" : ""} + {w.submitting ? CHECKING_KEY_HINT : ""} ); From 966413ed865fe2f2067e688ffe968e31c8702a0b Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:40:18 +0300 Subject: [PATCH 15/57] feat(tui): route the wizard cancel through the LLM panel modal too --- src/tui/llm-panel/llm-panel-modal-key-bindings.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts index 695c40e2..16e27617 100644 --- a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts +++ b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts @@ -25,6 +25,10 @@ export function handleLlmModalKey( return true; } if ("wizard" in result) { + if ("cancelSubmit" in result && result.cancelSubmit) { + callbacks.onProvidersWizardSubmitCancel?.(); + return true; + } if ("submit" in result && result.submit) { void callbacks.onProvidersWizardSubmit?.(result.wizard); return true; From 3bc6f6918c8dc6fcf2036ac3dc119fdd5ff03e13 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:40:26 +0300 Subject: [PATCH 16/57] feat(tui): choose whether Enter steers the running turn or queues behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the editor live for the whole turn, Enter needs a meaning while the agent is working. `tui.whileBusySubmit` decides it — default `steer`, because someone who types *while* the agent is working is usually reacting to what they see it doing. - Ctrl+T flips the mode in-app and persists it; the prompt meta-row shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is running, and the hint strip offers the flip. - `/steer ` and `/queue ` land one message in the other mode without changing the default; bare `/steer` / `/queue` switch it. - `/queue` also still lists what is parked, and `/queue clear` drops it. Alt+Enter was the obvious gesture and turns out to be unavailable: `multi-line-editor.tsx` treats Return with ANY modifier (`key.meta || key.shift || key.ctrl`) as "insert newline", so binding it would cost multi-line input. An explicit, visible toggle is the honest alternative — Ctrl+T also had to join `isGlobalHotkey` so the editor does not swallow it as text. Two things that keep the message from ever being shown twice or lost: - `ChatOrchestrator.steerMessage` falls back to the queue when `runtime.steer` returns false (the turn can end between the keypress and the dispatch) and re-queues anything handed back on `RunTurnResult.undelivered`. - The user bubble is rendered on the `steer_applied` event, not optimistically at submit time, so a steer that misses the turn and falls back to the queue renders exactly once — when it actually reaches the model. The hint strip was re-tightened while doing this: with the new chips it no longer fit one terminal row at 80 columns, and a wrapped strip pushes the prompt down. Labels are terse now and an armed Ctrl+C takes the row for itself. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 8 +- src/config/config-schema.test.ts | 29 +++++ src/config/config-schema.ts | 34 ++++++ src/config/index.ts | 2 + src/config/load-config.ts | 1 + src/tui/agent-event-reducer.ts | 7 ++ src/tui/app-key-bindings.test.ts | 64 +++++++++++ src/tui/app-key-bindings.ts | 20 ++++ src/tui/chat-orchestrator.ts | 32 ++++++ src/tui/commands/slash-command-handler.ts | 50 ++++++++- src/tui/commands/slash-commands.ts | 7 +- src/tui/components/hotkey-hint.test.tsx | 42 ++++++- src/tui/components/hotkey-hint.tsx | 24 ++-- src/tui/components/multi-line-editor.tsx | 2 +- src/tui/persist-user-tui-config.ts | 16 +++ src/tui/reduce-ui-actions.test.ts | 41 +++++++ src/tui/reduce-ui-actions.ts | 14 +++ src/tui/submit-handler.test.ts | 130 ++++++++++++++++++++++ src/tui/submit-handler.ts | 42 ++++++- src/tui/tui-action.ts | 14 +++ src/tui/tui-app.tsx | 21 +++- src/tui/tui-command.ts | 41 ++++++- src/tui/tui-state.ts | 12 ++ 23 files changed, 624 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 944a2a4a..b64c97a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1003,7 +1003,13 @@ Per-session FIFO is correct for *starting* turns and wrong for *correcting* one. - **Nothing is silently lost.** A message pushed after the loop's final drain — during the last inference, or into a turn that was cancelled before it stepped — comes back on `RunTurnResult.undelivered`. Callers MUST re-route it (the TUI pushes it onto its pending-message queue). `shutdown()` calls `clearAll()` so a stale steer cannot resurface in a later process. - **Bounded.** `MAX_PENDING_STEERS` (16) per session; `push` refuses past the cap rather than evicting the oldest, so the caller learns the message did not land. -Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) and the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts). +**TUI surface.** The editor stays live for the whole turn, so Enter has to mean something while the agent is working. `tui.whileBusySubmit` (`"steer" | "queue"`, default `"steer"`) decides which, `Ctrl+T` flips it in-app and persists the flip, and the prompt meta-row shows the live mode (`⏎ steer` / `⏎ queue`) whenever a turn is running. `/steer ` and `/queue ` land one message in the other mode without changing the default; bare `/steer` / `/queue` switch it. + +**Why a toggle and not a modifier key.** `Alt+Enter`, `Shift+Enter` and `Ctrl+Enter` are all already "insert a newline" (`key.meta || key.shift || key.ctrl` in [src/tui/components/multi-line-editor.tsx](src/tui/components/multi-line-editor.tsx)), so the second gesture cannot live on a Return modifier without taking multi-line input away. `Ctrl+T` is added to `isGlobalHotkey` so the editor does not swallow it as literal text. + +`ChatOrchestrator.steerMessage` falls back to `sendMessage` (the queue) whenever `runtime.steer` returns false — the turn can end between the keypress and the dispatch — and pushes anything that comes back on `RunTurnResult.undelivered` onto the same queue. The user bubble is rendered on the `steer_applied` event rather than optimistically at submit time, so a steer that misses the turn and falls back to the queue appears exactly once. + +Pinned by [src/runtime/steering-inbox.test.ts](src/runtime/steering-inbox.test.ts), [src/agent/steer-notice.test.ts](src/agent/steer-notice.test.ts), [src/agent/agent-loop-steering.test.ts](src/agent/agent-loop-steering.test.ts) (injection at the next step, one-shot notice, transcript turn, undelivered on reply / on cancel, no-op without the dep) the steering cases in [src/runtime/bootstrap.test.ts](src/runtime/bootstrap.test.ts), and on the TUI side [src/tui/submit-handler.test.ts](src/tui/submit-handler.test.ts) (mode routing, slash overrides, fallback when no steer callback is wired), [src/tui/app-key-bindings.test.ts](src/tui/app-key-bindings.test.ts) (Ctrl+T) and [src/config/config-schema.test.ts](src/config/config-schema.test.ts) (default + validation). ### Extension points diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..ffe1b062 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -993,3 +993,32 @@ describe("parseUserConfigFile", () => { ).toThrow(/timeoutMs/); }); }); + +describe("tui.whileBusySubmit", () => { + it("defaults to steer for a config file that predates the key", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto" }, + }); + expect(parsed.tui.whileBusySubmit).toBe("steer"); + }); + + it("round-trips an explicit queue preference", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "nord", whileBusySubmit: "queue" }, + }); + expect(parsed.tui.whileBusySubmit).toBe("queue"); + expect(parsed.tui.theme).toBe("nord"); + }); + + it("rejects an unknown mode instead of silently defaulting", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto", whileBusySubmit: "interrupt" }, + }), + ).toThrow(/whileBusySubmit/); + }); +}); + diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e1102371..e0f4b77e 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -668,6 +668,7 @@ export interface AtomicAgentConfig { */ tui: { theme: string; + whileBusySubmit: WhileBusySubmitMode; }; /** * Anonymous product analytics (PostHog). Mirrors @@ -1353,6 +1354,7 @@ export interface UserConfigFile { */ tui: { theme: string; + whileBusySubmit: WhileBusySubmitMode; }; /** * Anonymous product analytics (PostHog). Added in config v33. Older @@ -1767,6 +1769,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { }, tui: { theme: "auto", + whileBusySubmit: "steer", }, analytics: { enabled: true, @@ -3421,6 +3424,10 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { tui.theme ?? USER_CONFIG_DEFAULTS.tui.theme, "tui.theme", ), + whileBusySubmit: parseWhileBusySubmit( + tui.whileBusySubmit ?? USER_CONFIG_DEFAULTS.tui.whileBusySubmit, + "tui.whileBusySubmit", + ), }, analytics: { enabled: parseBool( @@ -3461,6 +3468,33 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { * only enforces the string shape — an unknown name falls back to the * autodetect path at startup, never crashes. Anything non-string throws. */ +/** + * What Enter does in the TUI while a turn is already running. + * + * `steer` folds the message into the turn in flight (it reaches the + * model at the next step boundary); `queue` parks it and runs it as its + * own turn once the current one closes. Default is `steer` — an + * operator who types *while* the agent is working is usually reacting + * to what they see it doing. + */ +export type WhileBusySubmitMode = "steer" | "queue"; + +/** + * Parse `tui.whileBusySubmit`. Older config files predate the key and + * are transparently upgraded to the `steer` default by the `??` at the + * call site, so there is no migration step. + */ +export function parseWhileBusySubmit( + raw: unknown, + field: string, +): WhileBusySubmitMode { + if (raw === "steer" || raw === "queue") return raw; + throw new ConfigValidationError( + field, + `expected "steer" or "queue", got ${JSON.stringify(raw)}`, + ); +} + export function parseThemeName(raw: unknown, field: string): string { if (typeof raw !== "string") { throw new ConfigValidationError( diff --git a/src/config/index.ts b/src/config/index.ts index 312d7bcb..046e6bf7 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,12 +11,14 @@ export type { WebSearchConfig, WebSearchProviderName, WebhookConfig, + WhileBusySubmitMode, } from "./config-schema.js"; export { ConfigValidationError, USER_CONFIG_DEFAULTS, USER_CONFIG_VERSION, parseUserConfigFile, + parseWhileBusySubmit, } from "./config-schema.js"; export { ensureUserConfigFileSync, diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 49f9a958..a5279bf0 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -468,6 +468,7 @@ export function loadConfig(): AtomicAgentConfig { }, tui: { theme: user.tui.theme, + whileBusySubmit: user.tui.whileBusySubmit, }, analytics: { enabled: user.analytics.enabled, diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index b72d64b9..aa026eaa 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -209,6 +209,13 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { switch (event.type) { case "user_message": return appendUserMessage(state, event.text); + case "steer_applied": + // Same bubble shape as any other user message: it *is* one, and + // the agent loop recorded it as a real `user` turn. Rendering it + // here (rather than optimistically at submit time) means a steer + // that missed the turn and fell back to the queue appears exactly + // once, when it actually reaches the model. + return appendUserMessage(state, event.text); case "turn_started": return { ...state, diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index 3152f3f3..017d50f4 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -596,3 +596,67 @@ describe("handlePanelEscape", () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +describe("Ctrl+T — Enter-while-busy mode", () => { + function ctx(state: ReturnType, extra = {}) { + return { + state, + dispatch: vi.fn(), + callbacks: { + onApprovalDecision: vi.fn(), + onAbort: vi.fn(), + onQuit: vi.fn(), + onWhileBusyModePersistRequested: vi.fn(), + }, + ctrlCArmed: false, + setCtrlCArmed: vi.fn(), + sidebarVisible: false, + ...extra, + }; + } + + it("toggles the mode and asks for it to be persisted", () => { + const state = createInitialTuiState(stubSession()); + expect(state.whileBusyMode).toBe("steer"); + const c = ctx(state); + const handled = handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(handled).toBe(true); + expect(c.dispatch).toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + }); + expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith( + "queue", + ); + }); + + it("persists the opposite direction from queue mode", () => { + const state = { ...createInitialTuiState(stubSession()), whileBusyMode: "queue" as const }; + const c = ctx(state); + handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(c.callbacks.onWhileBusyModePersistRequested).toHaveBeenCalledWith( + "steer", + ); + }); + + it("leaves a pending approval alone — y/n/esc own the keyboard there", () => { + const state = { + ...createInitialTuiState(stubSession()), + pendingApproval: pendingRequest(), + }; + const c = ctx(state); + const handled = handleAppKey("t", emptyKey({ ctrl: true }), c); + expect(handled).toBe(false); + expect(c.dispatch).not.toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + }); + }); + + it("ignores a plain t", () => { + const c = ctx(createInitialTuiState(stubSession())); + handleAppKey("t", emptyKey(), c); + expect(c.dispatch).not.toHaveBeenCalledWith({ + type: "while_busy_mode_changed", + }); + }); +}); + diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..0edda5d5 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -6,6 +6,7 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; @@ -34,6 +35,8 @@ export interface AppKeyCallbacks { grant?: ApprovalGrantScope, ): void; onAbort(): void; + /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ + onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void; onQuit(): void; /** Optional — called when Enter is pressed on the focused sidebar row. */ onSessionSwitchRequested?(sessionId: string): void; @@ -109,6 +112,23 @@ export function handleAppKey( ) { if (handleSidebarKey(input, key, ctx)) return true; } + // Ctrl+T flips what Enter does while a turn is running (steer <-> queue). + // Alt/Shift/Ctrl+Enter are all already "insert newline" in + // `multi-line-editor.tsx`, so the mode cannot live on a Return + // modifier; an explicit, visible toggle is the honest alternative. + if ( + key.ctrl && + !key.shift && + !key.meta && + input === "t" && + !state.pendingApproval + ) { + dispatch({ type: "while_busy_mode_changed" }); + callbacks.onWhileBusyModePersistRequested?.( + state.whileBusyMode === "steer" ? "queue" : "steer", + ); + return true; + } if (key.ctrl && input === "c") { if (ctrlCArmed) { callbacks.onAbort(); diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 69216a5b..d50f6fba 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -379,6 +379,27 @@ export class ChatOrchestrator { void this.runOneTurn(text); } + /** + * Fold a message into the turn already running on this session. + * + * Falls back to the normal queue whenever the runtime refuses: the + * turn may have finished between the operator's keypress and this + * call, or the steering inbox may be full. Either way the message + * goes somewhere — the one outcome this must never have is silence. + */ + steerMessage(text: string): void { + if (this.quitting) return; + const session = this.ensureSession(); + if (this.runtime.steer(session.id, text)) { + this.bus.emit({ + type: "runtime_info", + line: "steering: will reach the model at the next step", + }); + return; + } + this.sendMessage(text); + } + /** * Drop every parked message without touching the running turn * (`/queue clear`). No-op on an empty queue so the TUI is not spammed @@ -411,6 +432,17 @@ export class ChatOrchestrator { origin: "tui", }); this.session = result.session; + // A steer that landed too late to be drained (final inference, or + // a cancelled turn) comes back here. Park it so it runs as its own + // turn rather than evaporating. + for (const undelivered of result.undelivered ?? []) { + this.queue.push(undelivered); + this.bus.emit({ + type: "runtime_info", + line: `steering: turn ended first — queued "${undelivered}"`, + }); + } + if ((result.undelivered ?? []).length > 0) this.emitQueue(); if (isFailedSessionStatus(this.session.status)) this.exitCode = 1; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 5476c644..45cc02d9 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -1,3 +1,4 @@ +import type { WhileBusySubmitMode } from "../../config/index.js"; import type { TuiAction } from "../tui-action.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; import { isThemeName, THEME_NAMES } from "../theme/theme.js"; @@ -89,6 +90,12 @@ export interface SlashDispatchResult { * orchestrator to drop its own copy of the queue. */ readonly queueVerb?: "list" | "clear"; + /** + * `/steer ` or `/queue `: land this one message in the given + * mode without touching the persisted default. Ignored when no turn is + * running (the caller submits it normally instead). + */ + readonly submitWhileBusy?: { mode: WhileBusySubmitMode; text: string }; /** * `/privacy level <1..5>` side-effect (with `/privacy approve on|off` * kept as aliases for 5 and 1): move the approval ladder to an @@ -157,6 +164,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { }); case "queue": return dispatchQueueSub(parsed.args); + case "steer": + return dispatchSteerSub(parsed.args); case "abort": return pureActions([{ type: "abort_requested" }], { triggerAbort: true, @@ -265,19 +274,47 @@ function formatSlashCommandHelp(): string { } /** - * `/queue` — bare lists what is parked, `clear` (alias `drop`) empties - * it. The reducer action is dispatched optimistically so the strip above - * the prompt disappears immediately; `ChatOrchestrator.clearQueue` then - * re-publishes the authoritative empty queue. + * `/queue` — bare switches the Enter-while-busy mode to `queue` and + * lists what is currently parked; `clear` (alias `drop`) empties it; + * anything else is a one-off message to park without changing the mode. + * The `queue_changed` action is dispatched optimistically so the strip + * above the prompt disappears immediately; `ChatOrchestrator.clearQueue` + * then re-publishes the authoritative empty queue. */ function dispatchQueueSub(args: string): SlashDispatchResult { - const verb = args.trim().toLowerCase(); + const raw = args.trim(); + const verb = raw.toLowerCase(); if (verb === "clear" || verb === "drop") { return pureActions([{ type: "queue_changed", queued: [] }], { queueVerb: "clear", }); } - return pureActions([], { queueVerb: "list" }); + if (raw.length > 0) { + return pureActions([], { + submitWhileBusy: { mode: "queue", text: raw }, + }); + } + return pureActions([{ type: "while_busy_mode_changed", mode: "queue" }], { + queueVerb: "list", + }); +} + +/** + * `/steer` — bare switches the Enter-while-busy mode to `steer`; + * `/steer ` lands one message in the running turn without + * changing the persisted default. + */ +function dispatchSteerSub(args: string): SlashDispatchResult { + const raw = args.trim(); + if (raw.length > 0) { + return pureActions([], { + submitWhileBusy: { mode: "steer", text: raw }, + }); + } + return pureActions([{ type: "while_busy_mode_changed", mode: "steer" }], { + systemMessage: + "Enter now steers the running turn (Ctrl+T or /queue switches back)", + }); } function pureActions( @@ -309,6 +346,7 @@ function pureActions( telegramVerb: undefined, analyticsVerb: undefined, queueVerb: undefined, + submitWhileBusy: undefined, approvalLevelSet: undefined, ...overrides, }; diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 79013021..5b7384a0 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -37,7 +37,12 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ { name: "queue", description: - "messages parked behind the running turn: `/queue` (list) | `/queue clear`", + "Enter parks messages behind the running turn: `/queue` (switch + list) | `/queue clear` | `/queue `", + }, + { + name: "steer", + description: + "Enter folds messages into the running turn: `/steer` (switch) | `/steer `", }, { name: "quit", description: "exit atomic-agent", aliases: ["exit"] }, { name: "debug", description: "toggle debug pane (feed / logs / world …)" }, diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 50386173..961e03fd 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -119,19 +119,53 @@ describe("HotkeyHint scroll key spelling per platform", () => { describe("HotkeyHint queue affordances", () => { it("advertises what Enter does now that the editor stays live mid-run", () => { - const out = renderHint(chatState({ status: "running" })); - expect(out).toContain("queue message"); + const steering = renderHint(chatState({ status: "running" })); + expect(steering).toContain("⏎"); + expect(steering).toContain("steer"); + const queueing = renderHint( + chatState({ status: "running", whileBusyMode: "queue" }), + ); + expect(queueing).toContain("queue"); + }); + + it("offers ctrl+t as the way to flip to the other mode", () => { + const steering = renderHint(chatState({ status: "running" })); + expect(steering).toMatch(/ctrl\+t\]\s*queue/); + const queueing = renderHint( + chatState({ status: "running", whileBusyMode: "queue" }), + ); + expect(queueing).toMatch(/ctrl\+t\]\s*steer/); }); it("shows how many messages are parked behind the turn", () => { const out = renderHint( chatState({ status: "running", queuedMessages: ["a", "b"] }), ); - expect(out).toContain("2 parked"); + expect(out).toContain("queued"); + expect(out).toContain("2"); }); it("hides the parked chip when the queue is empty", () => { const out = renderHint(chatState({ status: "running" })); - expect(out).not.toContain("parked"); + expect(out).not.toContain("queued"); + }); + + it("stays on one row at 80 columns while running with a full queue", () => { + // The strip is a single-row affordance; wrapping pushes the prompt + // down and reads as a layout bug. + const out = renderHint( + chatState({ status: "running", queuedMessages: ["a", "b", "c"] }), + ); + expect(out.split("\n").filter((l) => l.trim().length > 0)).toHaveLength(1); + }); + + it("gives an armed ctrl+c the whole row", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("press again to quit"); + expect(out).not.toContain("ctrl+t"); }); }); diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index da763cf3..19b97e43 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -68,19 +68,29 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { if (state.status === "running") { // A long streaming answer is exactly when the operator wants to // scroll back, so the hint rides along with abort. The editor stays - // live during a run, so advertise what Enter does now — and how many - // messages are already parked behind the turn. + // live during a run, so this row also has to say what Enter will do + // to whatever is being typed, and how to flip it. + // + // Labels are terse on purpose: the strip must stay on ONE terminal + // row at 80 columns. An armed Ctrl+C takes the whole row for itself + // — at that moment nothing else matters. + if (ctrlCArmed) { + return [ + { key: "ctrl+c", label: "press again to quit" }, + { key: "esc", label: "abort" }, + ]; + } const chips: HotkeyChip[] = [ { key: SCROLL_KEY, label: "scroll" }, - { key: "\u23ce", label: "queue message" }, - { key: "esc", label: "abort" }, + { key: "\u23ce", label: state.whileBusyMode }, { - key: "ctrl+c", - label: ctrlCArmed ? "press again to quit" : "abort", + key: "ctrl+t", + label: state.whileBusyMode === "steer" ? "queue" : "steer", }, + { key: "esc", label: "abort" }, ]; if (state.queuedMessages.length > 0) { - chips.push({ key: "/queue", label: `${state.queuedMessages.length} parked` }); + chips.push({ key: "queued", label: `${state.queuedMessages.length}` }); } return chips; } diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index 79c3d695..0659ae79 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -302,7 +302,7 @@ function handleKey(ctx: KeyContext): void { } function isGlobalHotkey(input: string, key: Key): boolean { - if (key.ctrl && (input === "c" || input === "o")) return true; + if (key.ctrl && (input === "c" || input === "o" || input === "t")) return true; // F-keys and other multi-byte escape sequences we don't handle locally. if (input.startsWith("\u001b") && input.length > 1) return true; return false; diff --git a/src/tui/persist-user-tui-config.ts b/src/tui/persist-user-tui-config.ts index fac398ba..bb182530 100644 --- a/src/tui/persist-user-tui-config.ts +++ b/src/tui/persist-user-tui-config.ts @@ -4,6 +4,7 @@ import { parseUserConfigFile, resetConfigCache, writeUserConfigFileSync, + type WhileBusySubmitMode, } from "../config/index.js"; /** @@ -23,3 +24,18 @@ export function persistUserTuiTheme(theme: string): void { writeUserConfigFileSync(path, validated); resetConfigCache(); } + +/** + * Persist what Enter does while a turn is running (`steer` / `queue`) + * into `tui.whileBusySubmit`. Same read → merge → validate → write → + * reset shape as {@link persistUserTuiTheme}; the live `TuiState` flip + * is the caller's job, this only makes it survive a restart. + */ +export function persistUserWhileBusySubmit(mode: WhileBusySubmitMode): void { + const path = getConfig().paths.userConfigFile; + const prev = ensureUserConfigFileSync(path); + const draft = { ...prev, tui: { ...prev.tui, whileBusySubmit: mode } }; + const validated = parseUserConfigFile(draft); + writeUserConfigFileSync(path, validated); + resetConfigCache(); +} diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index 099ad2fa..2bdf2946 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -141,3 +141,44 @@ describe("reduceUiAction message_queued", () => { expect(next?.queuedMessages).toEqual([]); }); }); + +describe("reduceUiAction while_busy_mode_changed", () => { + it("toggles when no explicit mode is given", () => { + const state = createInitialTuiState(SESSION); + expect(state.whileBusyMode).toBe("steer"); + const toQueue = reduceUiAction(state, { type: "while_busy_mode_changed" }); + expect(toQueue?.whileBusyMode).toBe("queue"); + const backToSteer = reduceUiAction(toQueue!, { + type: "while_busy_mode_changed", + }); + expect(backToSteer?.whileBusyMode).toBe("steer"); + }); + + it("sets an explicit mode idempotently", () => { + const state = createInitialTuiState(SESSION); + const once = reduceUiAction(state, { + type: "while_busy_mode_changed", + mode: "queue", + }); + const twice = reduceUiAction(once!, { + type: "while_busy_mode_changed", + mode: "queue", + }); + expect(twice?.whileBusyMode).toBe("queue"); + }); + + it("message_steered clears the editor without parking the message", () => { + const state = { ...createInitialTuiState(SESSION), inputValue: "draft" }; + const next = reduceUiAction(state, { + type: "message_steered", + text: "draft", + }); + expect(next?.inputValue).toBe(""); + // The bubble arrives with `steer_applied`, so nothing is queued and + // nothing is rendered yet — a steer that misses the turn must not + // show up twice when it falls back to the queue. + expect(next?.queuedMessages).toEqual([]); + expect(next?.messages).toEqual([]); + }); +}); + diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 2c2e60d0..230e020a 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -106,6 +106,20 @@ export function reduceUiAction( }; case "queue_changed": return { ...state, queuedMessages: [...action.queued] }; + case "while_busy_mode_changed": { + const next = + action.mode ?? (state.whileBusyMode === "steer" ? "queue" : "steer"); + return { ...state, whileBusyMode: next }; + } + case "message_steered": + return { + ...state, + inputValue: "", + inputHistoryCursor: null, + slashPaletteOpen: false, + slashQuery: "", + slashPaletteCursor: 0, + }; case "chat_cleared": return { ...state, diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 964e3e38..753e647c 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -243,3 +243,133 @@ describe("/queue", () => { expect(messages.join("\n")).toContain("queue: (empty)"); }); }); + +describe("steer vs queue while a turn is running", () => { + function busy(mode: "steer" | "queue"): TuiState { + return { + ...createInitialTuiState(fakeSession()), + status: "running", + whileBusyMode: mode, + }; + } + + it("steers when the mode says steer", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "no, use the staging db", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered, onMessageSubmitted }), + ); + expect(onMessageSteered).toHaveBeenCalledWith("no, use the staging db"); + expect(onMessageSubmitted).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_steered")).toBe(true); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(false); + }); + + it("queues when the mode says queue", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "afterwards, run the tests", + busy("queue"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered, onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("afterwards, run the tests"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(true); + }); + + it("falls back to queueing when the host wired no steer callback", () => { + // Steering is optional on the callback surface; a host that does not + // implement it must still not drop the message. + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + handleEditorSubmit( + "still needs to land", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("still needs to land"); + expect(dispatched.some((a) => a.type === "message_queued")).toBe(true); + }); + + it("/steer lands one message without changing the mode", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSteered = vi.fn(); + handleEditorSubmit( + "/steer drop what you are doing", + busy("queue"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSteered }), + ); + expect(onMessageSteered).toHaveBeenCalledWith("drop what you are doing"); + expect( + dispatched.some((a) => a.type === "while_busy_mode_changed"), + ).toBe(false); + }); + + it("/queue parks one message without changing the mode", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + const onMessageSteered = vi.fn(); + handleEditorSubmit( + "/queue and then deploy", + busy("steer"), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted, onMessageSteered }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("and then deploy"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect( + dispatched.some((a) => a.type === "while_busy_mode_changed"), + ).toBe(false); + }); + + it("bare /steer and bare /queue flip the persisted mode", () => { + const steerDispatched: Array<{ type: string; mode?: string }> = []; + handleEditorSubmit( + "/steer", + busy("queue"), + ((a: { type: string; mode?: string }) => steerDispatched.push(a)) as never, + stubCallbacks(), + ); + expect(steerDispatched).toContainEqual({ + type: "while_busy_mode_changed", + mode: "steer", + }); + + const queueDispatched: Array<{ type: string; mode?: string }> = []; + handleEditorSubmit( + "/queue", + busy("steer"), + ((a: { type: string; mode?: string }) => queueDispatched.push(a)) as never, + stubCallbacks(), + ); + expect(queueDispatched).toContainEqual({ + type: "while_busy_mode_changed", + mode: "queue", + }); + }); + + it("/steer on an idle session just sends it", () => { + const dispatched: Array<{ type: string }> = []; + const onMessageSubmitted = vi.fn(); + const onMessageSteered = vi.fn(); + handleEditorSubmit( + "/steer go", + createInitialTuiState(fakeSession()), + ((a: { type: string }) => dispatched.push(a)) as never, + stubCallbacks({ onMessageSubmitted, onMessageSteered }), + ); + expect(onMessageSubmitted).toHaveBeenCalledWith("go"); + expect(onMessageSteered).not.toHaveBeenCalled(); + expect(dispatched.some((a) => a.type === "message_submitted")).toBe(true); + }); +}); + diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index b77aecba..a562a2fd 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -11,6 +11,7 @@ import type { TuiAction } from "./tui-action.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; import { isThemeName, setActiveTheme, THEME_NAMES, THEMES } from "./theme/theme.js"; import type { TuiAppCallbacks } from "./tui-app.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import { canAcceptMessage, canTypeMessage, @@ -84,13 +85,14 @@ export function handleEditorSubmit( return; } - // A turn is already in flight: park the message instead of dropping - // it. `ChatOrchestrator.sendMessage` has always buffered submissions - // made while busy — this is the path that finally reaches that queue. + // A turn is already in flight. Two ways to land the message, chosen + // by `whileBusyMode` (Ctrl+T, or `/steer` / `/queue` for one message): + // steer — fold it into the running turn at its next step boundary + // queue — park it and run it as its own turn afterwards + // Either way it is never dropped, which is the whole point. if (!canAcceptMessage(state)) { if (!canTypeMessage(state)) return; - dispatch({ type: "message_queued", text: trimmed }); - callbacks.onMessageSubmitted(trimmed); + submitWhileBusy(trimmed, state.whileBusyMode, dispatch, callbacks); return; } dispatch({ type: "message_submitted" }); @@ -174,6 +176,16 @@ export function runSlashCommand( } if (result.clearBuffer) dispatch({ type: "input_changed", value: "" }); dispatch({ type: "slash_palette_closed" }); + if (result.submitWhileBusy) { + const { mode, text } = result.submitWhileBusy; + // `/steer foo` / `/queue foo` on an idle session is just "send foo". + if (canAcceptMessage(state)) { + dispatch({ type: "message_submitted" }); + callbacks.onMessageSubmitted(text); + } else if (canTypeMessage(state)) { + submitWhileBusy(text, mode, dispatch, callbacks); + } + } if (result.queueVerb) runQueueVerb(result.queueVerb, state, dispatch, callbacks); if (result.triggerAbort) callbacks.onAbort(); if (result.triggerQuit) { @@ -230,6 +242,26 @@ export function runSlashCommand( } } +/** + * Land a message that was submitted while a turn was running, in the + * requested mode. Split out so `/steer` and `/queue` can reuse it for a + * one-off override without flipping the persisted default. + */ +export function submitWhileBusy( + text: string, + mode: WhileBusySubmitMode, + dispatch: Dispatch, + callbacks: TuiAppCallbacks, +): void { + if (mode === "steer" && callbacks.onMessageSteered) { + dispatch({ type: "message_steered", text }); + callbacks.onMessageSteered(text); + return; + } + dispatch({ type: "message_queued", text }); + callbacks.onMessageSubmitted(text); +} + /** * `/queue` needs the live queue to render, which `dispatchSlashCommand` * (a pure buffer -> result function) cannot see. The listing is built diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index 2a5a9a3f..a93cc08d 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -13,6 +13,7 @@ import type { PrivacyAction } from "./privacy/privacy-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"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { ChatMessage, SessionPickerEntry, TuiTab, TuiUiMode } from "./tui-state.js"; /** @@ -69,6 +70,19 @@ export type TuiAction = | { type: "message_queued"; text: string } /** Orchestrator re-published its pending-message queue (push/drain/clear). */ | { type: "queue_changed"; queued: readonly string[] } + /** + * Flip (or set) what Enter does while a turn is running. `mode` + * omitted toggles; the persist side-effect lives in the caller, like + * `theme_set`. + */ + | { type: "while_busy_mode_changed"; mode?: WhileBusySubmitMode } + /** + * The operator submitted a message in `steer` mode. Clears the editor + * only — the user bubble is appended when the agent loop confirms + * delivery (`steer_applied`), so a steer that arrives too late and + * falls back to the queue is not rendered twice. + */ + | { type: "message_steered"; text: string } | { type: "quit_requested" } | { type: "loaded_skill"; diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index f3309450..9db0df46 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; import { ApprovalModal } from "./approval-modal.js"; @@ -83,6 +84,14 @@ export interface TuiAppCallbacks { onMessageSubmitted(message: string): void; /** Drop every message parked behind the running turn (`/queue clear`). */ onQueueClearRequested?(): void; + /** + * Fold a message into the turn already running (`steer` mode). The + * orchestrator falls back to the queue when the runtime refuses — + * the turn may have ended between the keypress and the dispatch. + */ + onMessageSteered?(message: string): void; + /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ + onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): 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. */ @@ -711,8 +720,18 @@ export function TuiApp({ {promptLlm.cloudLabel ?? "cloud"} ); + // While a turn is running the meta-row's job changes: the operator + // needs to know what Enter will do to the message they are typing far + // more than they need the context-window size. const promptRightSlot = - state.llmHealth.contextWindow !== null ? ( + state.status === "running" || state.status === "awaiting_approval" ? ( + + + {"\u23ce"} {state.whileBusyMode} + + (ctrl+t) + + ) : state.llmHealth.contextWindow !== null ? ( ctx {state.llmHealth.contextWindow} diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index de552352..f21763ab 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -3,7 +3,11 @@ import { isSea } from "node:sea"; import { render } from "ink"; import React from "react"; import { resolveBootApprovalLevel } from "../approval/approval-level.js"; -import { formatDotenvReadWarning, getConfig } from "../config/index.js"; +import { + formatDotenvReadWarning, + getConfig, + type WhileBusySubmitMode, +} from "../config/index.js"; import { checkLlamaServer } from "../llm/llama-server-health.js"; import { createAgentRuntime } from "../runtime/bootstrap.js"; import type { LogRecord, LogSink } from "../tracing/structured-logger.js"; @@ -19,7 +23,10 @@ import { persistUserLocalLlmUrl, pointsAtManagedDaemon, } from "./persist-user-local-models-config.js"; -import { persistUserTuiTheme } from "./persist-user-tui-config.js"; +import { + persistUserTuiTheme, + persistUserWhileBusySubmit, +} from "./persist-user-tui-config.js"; import { isLocalBackendConfigured, isManagedModeReadyOnDisk, @@ -89,11 +96,17 @@ export async function tuiCommand(args: string[]): Promise { // mode is selected but nothing is ready on disk yet — they still // need to pick + pull a model before chat is useful. Fully-ready // managed setups and external-URL setups land in chat as usual. - const initialLayout: InitialTuiLayoutOptions | undefined = + const layoutBase: InitialTuiLayoutOptions | undefined = startupGate === "saved_managed" && !isManagedModeReadyOnDisk() ? { uiMode: "debug", activeTab: "llm" } : undefined; const config = getConfig(); + // Seed what Enter does while a turn is running from the persisted + // preference, so a Ctrl+T flip survives a restart. + const initialLayout: InitialTuiLayoutOptions = { + ...(layoutBase ?? {}), + whileBusyMode: config.tui.whileBusySubmit, + }; const approvalLevel = resolveBootApprovalLevel( parsed.noApproval, config.agent.approvalLevel, @@ -211,6 +224,9 @@ export async function tuiCommand(args: string[]): Promise { }, onMessageSubmitted: (text) => orchestrator.sendMessage(text), onQueueClearRequested: () => orchestrator.clearQueue(), + onMessageSteered: (text) => orchestrator.steerMessage(text), + onWhileBusyModePersistRequested: (mode) => + persistWhileBusyMode(mode, bus), onSessionPickerRequested: () => orchestrator.openSessionPicker(), onSessionSwitchRequested: (id) => orchestrator.switchSession(id), onSessionNewRequested: () => orchestrator.newSession(), @@ -483,6 +499,25 @@ function persistThemeChoice( } } +function persistWhileBusyMode( + mode: WhileBusySubmitMode, + bus: ReturnType, +): void { + try { + persistUserWhileBusySubmit(mode); + bus.emit({ + type: "runtime_info", + line: + mode === "steer" + ? "Enter now steers the running turn" + : "Enter now queues behind the running turn", + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + bus.emit({ type: "runtime_info", line: `mode not saved: ${msg}` }); + } +} + function persistLlamaUrl( nextUrl: string, bus: ReturnType, diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 5940c31e..a5a29c9c 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -1,4 +1,5 @@ import type { ApprovalRequest } from "../approval/approval-gate.js"; +import type { WhileBusySubmitMode } from "../config/index.js"; import type { LatestResult, LoadedSkillBody, @@ -424,6 +425,14 @@ export interface TuiState { * can show what is parked without reaching into the orchestrator. */ queuedMessages: readonly string[]; + /** + * What Enter does while a turn is running: `steer` folds the message + * into the turn in flight, `queue` parks it for the next one. Seeded + * from `config.tui.whileBusySubmit` at mount and flipped in-app with + * Ctrl+T (persisted). Irrelevant when idle — Enter always starts a + * turn then. + */ + whileBusyMode: WhileBusySubmitMode; } /** @@ -454,6 +463,8 @@ export const DEFAULT_RING_BUFFER_SIZE = 500; export interface InitialTuiLayoutOptions { uiMode?: TuiUiMode; activeTab?: TuiTab; + /** Seeds {@link TuiState.whileBusyMode} from the persisted user config. */ + whileBusyMode?: WhileBusySubmitMode; } export function createInitialTuiState( @@ -546,5 +557,6 @@ export function createInitialTuiState( sidebarTasksCursor: 0, chatScrollOffset: 0, queuedMessages: [], + whileBusyMode: layout?.whileBusyMode ?? "steer", }; } From ef5aa75edd7c55a1d7b2016de1a7708cd29ae731 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:41:59 +0300 Subject: [PATCH 17/57] feat(tui): wire the cancel callback and print an unverified-key notice --- src/tui/run-local-models-config-wizard.ts | 25 ++++++++--------------- src/tui/tui-app.tsx | 2 ++ src/tui/tui-command.ts | 2 ++ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/tui/run-local-models-config-wizard.ts b/src/tui/run-local-models-config-wizard.ts index 47d5b671..2219e4d6 100644 --- a/src/tui/run-local-models-config-wizard.ts +++ b/src/tui/run-local-models-config-wizard.ts @@ -14,6 +14,7 @@ import { LocalModelsConfigWizard, type LocalModelsWizardOutcome, } from "./components/local-models-config-wizard.js"; +import { isLocalProviderUrl } from "./providers/is-local-provider-url.js"; import { presetForEntryId } from "./providers/provider-presets.js"; export type LocalModelsStartupGateResult = @@ -55,19 +56,25 @@ export async function runLocalModelsStartupGateIfNeeded(options: { ); const outcome = { value: "skipped" as LocalModelsWizardOutcome }; + // A cloud key that saved without a completed check has something to + // say; it is printed after the Ink tree is torn down so the message + // survives the redraw. + let notice: string | undefined; const ink = render( React.createElement(LocalModelsConfigWizard, { initialUrl: getConfig().localModels.url, probeError: probe.error, hadConfiguredBackend: isLocalBackendConfigured(), - onFinished: (o) => { + onFinished: (o, n) => { outcome.value = o; + notice = n; }, }), { stdout: process.stdout, stderr: process.stderr, exitOnCtrlC: false }, ); await ink.waitUntilExit(); ink.clear(); + if (notice) process.stderr.write(`[atomic-agent] ${notice}\n`); if (outcome.value === "aborted") return "aborted"; if (outcome.value === "saved_managed") return "saved_managed"; @@ -95,21 +102,7 @@ export function isCloudTextProviderReady(): boolean { */ function isKeylessLocalProviderEntry(entry: UserLlmProviderEntry): boolean { if (presetForEntryId(entry.id)?.local) return true; - if (!entry.baseUrl) return false; - let host: string; - try { - host = new URL(entry.baseUrl).hostname; - } catch { - return false; - } - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "0.0.0.0" || - host === "::1" || - host === "[::1]" || - host.endsWith(".localhost") - ); + return isLocalProviderUrl(entry.baseUrl); } /** diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..3676c471 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -261,6 +261,8 @@ export interface TuiAppCallbacks { onMcpRemoveServer?(name: string): void; /** Providers tab: finish the add/configure wizard. */ onProvidersWizardSubmit?(wizard: import("./providers/providers-wizard-state.js").ProvidersWizardState): void; + /** Providers tab: abandon a running pre-save key check. */ + onProvidersWizardSubmitCancel?(): void; /** Providers tab: remove a provider by id from config + registry. */ onProvidersRemove?(id: string): void; /** Slash-command surface: enable a skill explicitly (`/skill enable `). */ diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 132a37c2..99067710 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -301,6 +301,8 @@ export async function tuiCommand(args: string[]): Promise { void orchestrator.providers.selectEmbeddingModel(providerId, modelId), onProvidersWizardSubmit: (wizard) => void orchestrator.providers.completeWizard(wizard), + onProvidersWizardSubmitCancel: () => + orchestrator.providers.cancelWizardVerification(), onProvidersRemove: (id) => void orchestrator.providers.removeProviderById(id), onImportPreview: (form) => orchestrator.import.preview(form), From 36c0cf16fff7de44f288eaeb45a6963aab8bf101 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:43:22 +0300 Subject: [PATCH 18/57] docs: document the pre-save cloud credential check --- AGENTS.md | 8 ++++++++ README.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..2b2ad874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,14 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm `ProviderRegistry.setActive(id)` / `swapActive(id)` closes the previous provider and switches the active text backend without process restart. TUI **Providers** tab ([src/tui/providers/](src/tui/providers/)) is the only surface that calls this seam. +### Credential check before save + +A cloud provider is verified before anything reaches disk. [src/llm/provider/verify/](src/llm/provider/verify/) is UI-free: `verifyProviderKey(target)` posts one `max_tokens: 1` completion through `openAiFetch` (deliberately not `openAiPostJson` — a key check must not spend the retry budget), and `classifyVerifyResponse` maps the answer onto `ok | invalid_key | no_balance | model_unavailable | rate_limited | unreachable | timeout | provider_error | cancelled`. Status codes alone do not settle it: prepaid services answer 401/403 once credit runs out, OpenAI sends `429 insufficient_quota`, and Gemini answers 400 for a bad key, so the body is consulted for billing/key wording first. `pickProbeModels` picks the cheapest **paid** OpenRouter model — a free model answers 200 on a key with no balance, which would make the check meaningless. + +`verifyWizardBeforeSave` ([src/tui/providers/verify-wizard-before-save.ts](src/tui/providers/verify-wizard-before-save.ts)) is the single seam; both the wizard (`ProvidersOrchestrator.completeWizard`) and first-run onboarding (`CloudProviderOnboarding`) go through it. Only `invalid_key` and `no_balance` block a save (`isBlockingVerifyStatus`); everything else saves and reports, so an offline machine stays configurable. Esc cancels a check in flight (`cancelSubmit` → `providers_wizard_verify_cancelled`), and a verdict arriving after a cancel is dropped. + +Pinned by [src/llm/provider/verify/classify-verify-response.test.ts](src/llm/provider/verify/classify-verify-response.test.ts), [verify-provider-key.test.ts](src/llm/provider/verify/verify-provider-key.test.ts), [pick-probe-models.test.ts](src/llm/provider/verify/pick-probe-models.test.ts), [src/tui/providers/verify-wizard-before-save.test.ts](src/tui/providers/verify-wizard-before-save.test.ts), [providers-wizard-target.test.ts](src/tui/providers/providers-wizard-target.test.ts) and the `completeWizard` cases in [providers-orchestrator.test.ts](src/tui/providers/providers-orchestrator.test.ts). + ### Locked invariants 1. **Local llama-server path unchanged when no cloud provider is active.** Grammar, slots, and GBNF tests remain the reference behaviour. diff --git a/README.md b/README.md index 1b8ff1ee..f3ccc248 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,8 @@ Handy slash commands: `/help` lists every command, `/tools` lists the built-in t 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. +A cloud key is checked before it is saved. The key screen refuses an empty key, and finishing the wizard asks the provider for a one-token completion from its cheapest model: a key that is rejected, or attached to an account with no balance, never reaches `.env` and never becomes the active provider. A provider that cannot be reached at all still saves, with a line saying the key went unverified — an offline or proxied machine stays configurable. Local servers have no account to check and are left alone. +
From 0b2a2b155d52f1290b7304a2dec1817b6b35c277 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 05:25:47 +0300 Subject: [PATCH 19/57] docs(serve): list the steer route in --help The endpoint list in `serve --help` is the discovery surface for this API; a route that is not in it does not exist as far as an operator is concerned. Co-Authored-By: Claude Opus 5 --- src/cli/serve-command.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/serve-command.ts b/src/cli/serve-command.ts index 5ea3ca66..da31dd8a 100644 --- a/src/cli/serve-command.ts +++ b/src/cli/serve-command.ts @@ -47,6 +47,7 @@ const HELP = " GET /api/skills, GET /api/skills/{name} List or inspect installed skills", " POST /api/skills/install, /uninstall Manage installed skills", " GET /api/sessions, GET /api/sessions/{id}, DELETE /api/sessions/{id}", + " POST /api/sessions/{id}/steer Fold a message into the turn already running", " POST /api/approval/resolve Resolve a pending approval", " GET /api/events SSE stream of pending approval requests", ].join("\n") + "\n"; From 47e4dac0dcb780a8343acedf06be76f137541c6f Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 05:27:50 +0300 Subject: [PATCH 20/57] =?UTF-8?q?feat(tui):=20Run=20submenu=20=E2=80=94=20?= =?UTF-8?q?Local=20=C2=B7=20Cloud=20=C2=B7=20Fusion=20with=20a=20fusion=20?= =?UTF-8?q?share=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 84 ++++++++++ README.md | 45 +++++- src/tui/agent-event-reducer.ts | 3 + src/tui/app-key-bindings.ts | 28 ++++ src/tui/chat-orchestrator.ts | 4 + src/tui/commands/dispatch-run-mode.ts | 64 ++++++++ .../commands/slash-command-handler.test.ts | 41 ++++- src/tui/commands/slash-command-handler.ts | 24 +++ src/tui/commands/slash-commands.ts | 7 +- src/tui/components/hotkey-hint.tsx | 14 +- src/tui/components/run-mode-bar.test.tsx | 60 ++++++++ src/tui/components/run-mode-bar.tsx | 56 +++++++ src/tui/components/run-mode-picker.test.tsx | 81 ++++++++++ src/tui/components/run-mode-picker.tsx | 79 ++++++++++ src/tui/index.ts | 11 ++ src/tui/persist-run-mode.test.ts | 129 ++++++++++++++++ src/tui/persist-run-mode.ts | 67 ++++++++ src/tui/run-mode/index.ts | 26 ++++ src/tui/run-mode/run-mode-actions.ts | 39 +++++ .../run-mode/run-mode-key-bindings.test.ts | 127 +++++++++++++++ src/tui/run-mode/run-mode-key-bindings.ts | 90 +++++++++++ src/tui/run-mode/run-mode-nav.test.ts | 38 +++++ src/tui/run-mode/run-mode-nav.ts | 39 +++++ src/tui/run-mode/run-mode-orchestrator.ts | 136 +++++++++++++++++ src/tui/run-mode/run-mode-panel-state.ts | 64 ++++++++ src/tui/run-mode/run-mode-reducer.test.ts | 144 ++++++++++++++++++ src/tui/run-mode/run-mode-reducer.ts | 115 ++++++++++++++ src/tui/run-mode/run-mode-selectors.ts | 48 ++++++ src/tui/submit-handler.ts | 6 + src/tui/tui-action.ts | 2 + src/tui/tui-app.tsx | 15 ++ src/tui/tui-command.ts | 2 + src/tui/tui-state.ts | 7 + 33 files changed, 1691 insertions(+), 4 deletions(-) create mode 100644 src/tui/commands/dispatch-run-mode.ts create mode 100644 src/tui/components/run-mode-bar.test.tsx create mode 100644 src/tui/components/run-mode-bar.tsx create mode 100644 src/tui/components/run-mode-picker.test.tsx create mode 100644 src/tui/components/run-mode-picker.tsx create mode 100644 src/tui/persist-run-mode.test.ts create mode 100644 src/tui/persist-run-mode.ts create mode 100644 src/tui/run-mode/index.ts create mode 100644 src/tui/run-mode/run-mode-actions.ts create mode 100644 src/tui/run-mode/run-mode-key-bindings.test.ts create mode 100644 src/tui/run-mode/run-mode-key-bindings.ts create mode 100644 src/tui/run-mode/run-mode-nav.test.ts create mode 100644 src/tui/run-mode/run-mode-nav.ts create mode 100644 src/tui/run-mode/run-mode-orchestrator.ts create mode 100644 src/tui/run-mode/run-mode-panel-state.ts create mode 100644 src/tui/run-mode/run-mode-reducer.test.ts create mode 100644 src/tui/run-mode/run-mode-reducer.ts create mode 100644 src/tui/run-mode/run-mode-selectors.ts 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..ed5b37c5 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,11 @@ 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 })) return true; if (state.pendingApproval) { return handleApprovalKey(input, key, state.pendingApproval, ctx); } @@ -232,6 +242,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..dfd24718 --- /dev/null +++ b/src/tui/components/run-mode-picker.test.tsx @@ -0,0 +1,81 @@ +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"); + }); +}); 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..eca55462 --- /dev/null +++ b/src/tui/run-mode/run-mode-actions.ts @@ -0,0 +1,39 @@ +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. + * + * `run_mode_change_requested` is intentionally a REQUEST, not a state + * change: only the orchestrator may write config or move the active + * provider, and the mirror updates when it 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; + cloudProviderMissing: boolean; + localProviderMissing: boolean; + degradedMessage: string | null; + } + | { type: "run_mode_change_requested"; mode: RunModeName; cloudShare?: number } + | { 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..9e4bf099 --- /dev/null +++ b/src/tui/run-mode/run-mode-key-bindings.test.ts @@ -0,0 +1,127 @@ +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 handled = handleRunModePickerKey(input, { ...KEY, ...key }, { + state, + dispatch, + }); + return { handled, dispatch }; +} + +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 } = press(withPicker(), "", { escape: true }); + expect(handled).toBe(true); + expect(dispatch).toHaveBeenCalledWith({ type: "run_mode_picker_closed" }); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "run_mode_change_requested" }), + ); + }); + + it("applies the draft on Enter and closes", () => { + const { dispatch } = press(withPicker(), "", { return: true }); + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: "run_mode_change_requested", + mode: "local", + cloudShare: 40, + }); + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: "run_mode_picker_closed", + }); + }); + + 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..def1435d --- /dev/null +++ b/src/tui/run-mode/run-mode-key-bindings.ts @@ -0,0 +1,90 @@ +import type { Key } from "ink"; +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; +} + +/** + * 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) { + dispatch({ + type: "run_mode_change_requested", + mode: picker.draftMode, + cloudShare: 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..93fbe2b0 --- /dev/null +++ b/src/tui/run-mode/run-mode-orchestrator.ts @@ -0,0 +1,136 @@ +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), + 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..75874d99 --- /dev/null +++ b/src/tui/run-mode/run-mode-panel-state.ts @@ -0,0 +1,64 @@ +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; + /** + * 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, + 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..b4e75c82 --- /dev/null +++ b/src/tui/run-mode/run-mode-reducer.test.ts @@ -0,0 +1,144 @@ +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("does not move the mirror on a change REQUEST", () => { + // Only the orchestrator may change the mode; the mirror follows the + // `run_mode_synced` it emits afterwards. + const state = apply(base(), [ + synced, + { type: "run_mode_change_requested", mode: "local" }, + ]); + expect(state.runModePanel.effective).toBe("fusion"); + }); + + 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..aadad01e --- /dev/null +++ b/src/tui/run-mode/run-mode-reducer.ts @@ -0,0 +1,115 @@ +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, + 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: "" } }; + } + // A request is handled by the orchestrator; the mirror only moves + // on the `run_mode_synced` that follows. + case "run_mode_change_requested": + return panel; + } +} 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(), From 78a8bc0659226bde920f2f6bf5164c0a26005b47 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 05:29:30 +0300 Subject: [PATCH 21/57] =?UTF-8?q?feat(tui):=20mouse=20support=20=E2=80=94?= =?UTF-8?q?=20click=20the=20nav=20bar,=20panels,=20selectors=20and=20the?= =?UTF-8?q?=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI had no mouse layer at all: Ink parses stdin as keystrokes only, and `tui-command.ts` deliberately left SGR mouse tracking off because capture takes the terminal's own drag-to-select away (Apple Terminal has no Shift-bypass). That trade-off is real, but it is a *setting*, not a reason to have no mouse at all — so mouse reporting is now on by default with three ways to turn it off, and everything the keyboard can reach is clickable. New `src/tui/mouse/`: - `mouse-tracking.ts` — enables 1000 + 1006 (button events + SGR coordinates). Motion tracking (1002/1003) is deliberately not requested: nothing hovers or drags. Paired with a `process.on("exit")` restore so a crash never leaves the terminal reporting clicks into the user's shell. - `parse-mouse-events.ts` — pure decoder for SGR and legacy X10 reports. Buffers a report split across two reads; passes a lone trailing ESC straight through, since holding it would delay the Escape key by one keystroke. - `mouse-stdin.ts` — hands Ink a stream with the mouse bytes removed (they would otherwise be typed into the chat buffer as mojibake) while proxying isTTY / setRawMode / ref / unref to the real stdin. - `mouse-registry.ts` — hit testing. Ink exposes no absolute positions, but every node keeps its Yoga node, so `absoluteRect` sums getComputedLeft/Top up the parent chain — the same walk the renderer does when painting. Ancestors with `overflow: hidden` clip the result. - `mouse-context.tsx` / `mouse-list-row.tsx` — React glue plus the shared row: first click selects, a second click on the selected row activates. Both activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow, so the mouse cannot drift from the keyboard. Wired up: Run / Observe / Manage pills and the sub-tab strip, sidebar sessions and tasks, skills, skills hub, tasks, memory, MCP, LLM, providers and local-model rows, the session / theme / slash pickers, approval decision buttons, tool cards (the per-card toggle had no key binding at all until now), clickable hotkey chips, click-to-place-caret in the prompt, and wheel scrolling. Delta-only cursors gained absolute `*_cursor_set` actions (sidebar, session picker, theme picker, providers, local models, skills hub). `isPanelModalOpen` and `decideApproval` were extracted from `app-key-bindings.ts` so the mouse gates and resolves on exactly the same predicates as the keyboard. Off switch: `tui.mouse` (config v38, default true), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime. With it off, behaviour is byte-for-byte what it was before — alternate-scroll turns the wheel into cursor keys. --- AGENTS.md | 17 + README.md | 4 + src/config/config-schema.test.ts | 31 ++ src/config/config-schema.ts | 20 +- src/config/load-config.ts | 1 + src/tui/app-key-bindings.ts | 214 +++++++------ src/tui/approval-modal.tsx | 95 ++++-- src/tui/commands/slash-command-handler.ts | 28 ++ src/tui/commands/slash-commands.ts | 4 + src/tui/components/debug-pane.tsx | 73 +++-- src/tui/components/hotkey-hint.tsx | 101 +++++- src/tui/components/llm-mode-rows.tsx | 28 +- src/tui/components/local-models-panel.tsx | 27 +- src/tui/components/mcp-list.tsx | 15 +- src/tui/components/memory-list.tsx | 13 +- src/tui/components/multi-line-editor-body.tsx | 24 +- src/tui/components/multi-line-editor.tsx | 23 +- src/tui/components/providers-panel.tsx | 53 +++- src/tui/components/session-picker.tsx | 30 +- src/tui/components/sidebar.tsx | 99 +++++- src/tui/components/skills-hub-list.tsx | 16 +- src/tui/components/skills-list.tsx | 13 +- src/tui/components/slash-palette.tsx | 26 +- src/tui/components/status-bar.tsx | 86 +++-- src/tui/components/tasks-list.tsx | 18 +- src/tui/components/theme-picker.tsx | 41 ++- src/tui/components/tool-card.tsx | 37 ++- src/tui/local-models/local-models-actions.ts | 2 + src/tui/local-models/local-models-reducer.ts | 8 + src/tui/mouse/index.ts | 41 +++ src/tui/mouse/mouse-app.test.tsx | 297 ++++++++++++++++++ src/tui/mouse/mouse-context.tsx | 130 ++++++++ src/tui/mouse/mouse-event.ts | 45 +++ src/tui/mouse/mouse-list-row.tsx | 95 ++++++ src/tui/mouse/mouse-registry.test.ts | 224 +++++++++++++ src/tui/mouse/mouse-registry.ts | 185 +++++++++++ src/tui/mouse/mouse-source.ts | 29 ++ src/tui/mouse/mouse-stdin.test.ts | 96 ++++++ src/tui/mouse/mouse-stdin.ts | 82 +++++ src/tui/mouse/mouse-tracking.test.ts | 87 +++++ src/tui/mouse/mouse-tracking.ts | 75 +++++ src/tui/mouse/parse-mouse-events.test.ts | 107 +++++++ src/tui/mouse/parse-mouse-events.ts | 148 +++++++++ src/tui/mouse/synthetic-key.ts | 47 +++ src/tui/persist-user-tui-config.ts | 14 + src/tui/providers/providers-actions.ts | 2 + src/tui/providers/providers-reducer.ts | 9 + src/tui/reduce-ui-actions.ts | 29 ++ src/tui/skills/skills-actions.ts | 2 + src/tui/skills/skills-reducer.ts | 5 + src/tui/submit-handler.ts | 5 + src/tui/tui-action.ts | 8 + src/tui/tui-app.tsx | 159 ++++++++-- src/tui/tui-args.test.ts | 22 +- src/tui/tui-args.ts | 17 + src/tui/tui-command.ts | 85 ++++- 56 files changed, 2912 insertions(+), 280 deletions(-) create mode 100644 src/tui/mouse/index.ts create mode 100644 src/tui/mouse/mouse-app.test.tsx create mode 100644 src/tui/mouse/mouse-context.tsx create mode 100644 src/tui/mouse/mouse-event.ts create mode 100644 src/tui/mouse/mouse-list-row.tsx create mode 100644 src/tui/mouse/mouse-registry.test.ts create mode 100644 src/tui/mouse/mouse-registry.ts create mode 100644 src/tui/mouse/mouse-source.ts create mode 100644 src/tui/mouse/mouse-stdin.test.ts create mode 100644 src/tui/mouse/mouse-stdin.ts create mode 100644 src/tui/mouse/mouse-tracking.test.ts create mode 100644 src/tui/mouse/mouse-tracking.ts create mode 100644 src/tui/mouse/parse-mouse-events.test.ts create mode 100644 src/tui/mouse/parse-mouse-events.ts create mode 100644 src/tui/mouse/synthetic-key.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..358b18ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,22 @@ Speculative batching (the runtime guessing that the model "should" have batched - Tests are colocated with source: `build-prompt.test.ts` next to `build-prompt.ts`. - Config lives in `src/config/` — read it before touching env vars. +## Mouse support + +The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse/`: + +1. **Reporting** — `enableMouseTracking` writes `\x1b[?1000h\x1b[?1006h` (button events + SGR coordinates). 1002/1003 motion tracking is deliberately **not** requested: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream. Paired with a `process.on("exit")` restore, like `alt-screen.ts`. +2. **Decoding** — `decodeMouseEvents` is a pure function over a stdin chunk returning `{ events, text, rest }`. It understands SGR and legacy X10, buffers a report split across two reads, and passes a lone trailing `ESC` straight through (buffering it would delay the Escape key by one keystroke). +3. **Stream split** — `createMouseStdin` reads the real TTY, hands Ink a `PassThrough` carrying only the keyboard bytes, and proxies `isTTY` / `setRawMode` / `ref` / `unref` to the real stdin. Without this the reports reach Ink's key parser and get typed into the chat buffer. +4. **Hit testing** — `MouseTargetRegistry` resolves a cell to a component. Ink exposes no absolute positions, but every node keeps its Yoga node, and `absoluteRect` sums `getComputedLeft/Top` up the parent chain — the same walk `render-node-to-output.ts` does when painting, so the rectangle is exactly where the node was drawn. Ancestors with `overflow: hidden` clip the result. Ties resolve innermost-first (higher layer, then smaller box, then later mount). +5. **Layers** — `MOUSE_LAYER_BASE` / `_PANEL` / `_MODAL`. `TuiApp` raises the registry floor to `_MODAL` whenever a modal, confirm or picker owns the keyboard (`isPanelModalOpen`, shared with `handleAppKey`), so a click cannot reach the list rendered behind a modal. + +**Interaction contract.** First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow key (`synthetic-key.ts`), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (`rowColToCursor`, clamped to the line length). + +**The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v38, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys. + +**Testing.** Escape sequences, decoder and stream split are unit-tested; `mouse-app.test.tsx` drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target. + ## Module map | Folder | Responsibility | @@ -177,6 +193,7 @@ Speculative batching (the runtime guessing that the model "should" have batched | `src/channels/telegram/` | `TelegramChannel` (lifecycle + live-control), `inbound-handler` (slash commands + dispatch into `runTurn`), `outbound-sender` (chunked replies + 429 retry), `approval-bridge` (inline-keyboard approvals with 8-min auto-deny), `pairing-mode` (60s window for first-DM owner claim), `telegram-settings` (`config.json` + `.env` persistence), `telegram-bot-factory` (grammy adapter). The **only** module that imports `grammy`. See §"Telegram remote-control channel". | | `src/tui/telegram/` | TUI "Telegram" tab: `telegram-panel-state` + `telegram-actions` + `telegram-panel-reducer` (pure UI state slice), `tui-telegram-orchestrator` (the only TUI module that touches `runtime.telegramChannel`), `telegram-key-bindings`, and the `telegram-panel` / `telegram-token-prompt` / `telegram-pairing-modal` components. See §"Telegram remote-control channel". | | `src/mcp/` | MCP (Model Context Protocol) **client** subsystem. `McpManager` (lifecycle for N `McpClient` instances), `mcp-client` (the **only** file that imports `@modelcontextprotocol/sdk` — together with `mcp-sampling-handler` for SDK type shapes), `mcp-tool-adapter` (`McpToolMeta` → `ToolDefinition`), `mcp-resource-class` (per-server trust → `ResourceClass` resolver), `mcp-descriptor-builder` (rare-tier descriptors), `mcp-grammar-builder` (dynamic `mcp-server-tool` GBNF fragment), `mcp-sampling-handler` (forwards `sampling/createMessage` to `LlamaServerClient` with `slotId: -1`), `mcp-resource-tools` + `mcp-prompt-tools` (aggregate read-only `mcp.{resource,prompt}.*` tools dispatching by `server` arg). See §"MCP client". | +| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler). See §"Mouse support". | ## Secrets and process environment diff --git a/README.md b/README.md index 1b8ff1ee..0f9eb518 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,10 @@ 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). +**Mouse.** The TUI is clickable: the Run / Observe / Manage bar and its sub-tabs, sidebar sessions and tasks, every list row (skills, tasks, memory, MCP, models, providers), the session / theme / slash pickers, approval buttons, tool cards, and the prompt itself — clicking in the input places the caret. A click selects a row, a second click on the selected row opens it, and the wheel scrolls the chat or walks the focused panel. + +While mouse reporting is on the terminal hands clicks to the app, which means its own drag-to-select is unavailable (iTerm2, GNOME Terminal and Windows Terminal let you hold Shift to bypass; Apple Terminal does not). Turn it off whenever you want to select text: `/mouse off` in the app, `atomic-agent tui --no-mouse` for one run, or `"tui": { "mouse": false }` in `/config.json`. With mouse off, wheel scrolling still works through the terminal's alternate-scroll mode, exactly as before. + 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.
diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..3427dac0 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -175,6 +175,37 @@ describe("parseUserConfigFile", () => { expect(parsed.tui.theme).toBe("auto"); }); + it("enables tui.mouse by default when migrating from v37", () => { + const parsed = parseUserConfigFile({ version: 37 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.tui.mouse).toBe(true); + }); + + it("preserves tui.mouse: false so an operator's opt-out survives", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { theme: "auto", mouse: false }, + }); + expect(parsed.tui.mouse).toBe(false); + }); + + it("accepts the string forms parseBool understands for tui.mouse", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { mouse: "off" }, + }); + expect(parsed.tui.mouse).toBe(false); + }); + + it("rejects a non-boolean tui.mouse", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + tui: { mouse: 42 }, + }), + ).toThrow(/tui.mouse/); + }); + it("rejects a non-string tui.theme", () => { expect(() => parseUserConfigFile({ diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e1102371..ba17f8aa 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -662,12 +662,14 @@ export interface AtomicAgentConfig { maxImagesPerCall: number; }; /** - * TUI appearance. Mirrors `UserConfigFile.tui`. `theme` is `"auto"` - * (OSC 11 autodetect) or a registered theme name. Consumed by the TUI - * startup path; the rest of the runtime ignores it. + * TUI appearance and input. Mirrors `UserConfigFile.tui`. `theme` is + * `"auto"` (OSC 11 autodetect) or a registered theme name; `mouse` + * toggles terminal mouse reporting. Consumed by the TUI startup path; + * the rest of the runtime ignores it. */ tui: { theme: string; + mouse: boolean; }; /** * Anonymous product analytics (PostHog). Mirrors @@ -1350,9 +1352,16 @@ export interface UserConfigFile { * the matching GitHub theme) or a registered theme name (e.g. `dracula`, * `nord`). Persisted from the in-app `/theme` picker. Older files are * transparently upgraded with `tui: { theme: "auto" }`. + * + * `mouse` (config v38, default `true`) turns terminal mouse reporting + * on: clicking panels, list rows, the nav bar and the prompt, plus + * wheel scrolling. Turning it off restores the terminal's own + * drag-to-select, which mouse reporting takes over — see `/mouse` and + * `--no-mouse`. Older files are upgraded with `mouse: true`. */ tui: { theme: string; + mouse: boolean; }; /** * Anonymous product analytics (PostHog). Added in config v33. Older @@ -1412,7 +1421,7 @@ export interface UserConfigFile { // is absent, a legacy `approvalRequired: false` maps to level 5 and // `true`/absent maps to level 1 — both preserve the old behaviour // exactly. The legacy key is never written back. -export const USER_CONFIG_VERSION = 37 as const; +export const USER_CONFIG_VERSION = 38 as const; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1529,6 +1538,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 34, 35, 36, + 37, USER_CONFIG_VERSION, ]; @@ -1767,6 +1777,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { }, tui: { theme: "auto", + mouse: true, }, analytics: { enabled: true, @@ -3421,6 +3432,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { tui.theme ?? USER_CONFIG_DEFAULTS.tui.theme, "tui.theme", ), + mouse: parseBool(tui.mouse ?? USER_CONFIG_DEFAULTS.tui.mouse, "tui.mouse"), }, analytics: { enabled: parseBool( diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 49f9a958..46138949 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -468,6 +468,7 @@ export function loadConfig(): AtomicAgentConfig { }, tui: { theme: user.tui.theme, + mouse: user.tui.mouse, }, analytics: { enabled: user.analytics.enabled, diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..24c45cec 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -145,6 +145,74 @@ export function handleAppKey( return true; } } + const debugTabBusy = isPanelModalOpen(state); + // Ctrl+B is the dedicated nav-cycle escape valve: it always advances + // one nav slot forward regardless of where focus currently is. This + // is the key power users press when they want to reach Observe / + // Manage without first clearing sidebar focus or re-pressing Tab to + // walk through both sidebar panes. + if ( + !debugTabBusy && + !state.slashPaletteOpen && + !state.pendingApproval && + key.ctrl && + !key.shift && + !key.meta && + input === "b" + ) { + const next = cycleNavSlot(state, 1); + applyNavSlot(dispatch, next); + return true; + } + // Tab / Shift+Tab routing: + // - In chat mode with the sidebar visible, plain Tab cycles + // editor → sidebar(sessions) → sidebar(tasks) → editor so the + // operator can reach the rail with a single key. The + // in-sidebar transition (sessions ↔ tasks) is handled in + // `handleSidebarKey`; the path here covers the "land into the + // sidebar from the editor" leg. + // - Shift+Tab always cycles nav slots backward — same key surface + // as before so muscle memory survives. + // - Outside chat (debug mode) or with sidebar collapsed, plain + // Tab cycles nav slots forward as a fallback so power users on + // narrow terminals are not stranded. + if ( + !debugTabBusy && + !state.slashPaletteOpen && + key.tab && + !state.pendingApproval + ) { + if (key.shift) { + const prev = cycleNavSlot(state, -1); + applyNavSlot(dispatch, prev); + return true; + } + if ( + ctx.sidebarVisible && + state.uiMode === "chat" && + state.chatFocus === "editor" + ) { + // Land in the sidebar at the section the operator left last. + dispatch({ type: "chat_focus_set", focus: "sidebar" }); + return true; + } + const next = cycleNavSlot(state, 1); + applyNavSlot(dispatch, next); + return true; + } + return false; +} + +/** + * True while a debug panel has a modal, confirm or text-entry surface + * open — the state in which Tab / Ctrl+B / letter keys belong to that + * surface instead of the global nav cycler. + * + * Extracted from `handleAppKey` so the mouse layer can gate clicks on + * exactly the same condition the keyboard gates on: one predicate, no + * chance of the two drifting apart. + */ +export function isPanelModalOpen(state: TuiState): boolean { const tasksTabBusy = state.uiMode === "debug" && state.activeTab === "tasks" && @@ -205,7 +273,7 @@ export function handleAppKey( // must not cycle the nav away mid-typing. (state.llmPanel.mode === "cloud" && state.llmPanel.cloudModelFilterFocused)); - const debugTabBusy = + return ( tasksTabBusy || skillsTabBusy || memoryTabBusy || @@ -213,62 +281,8 @@ export function handleAppKey( telegramTabBusy || mcpTabBusy || providersTabBusy || - llmTabBusy; - // Ctrl+B is the dedicated nav-cycle escape valve: it always advances - // one nav slot forward regardless of where focus currently is. This - // is the key power users press when they want to reach Observe / - // Manage without first clearing sidebar focus or re-pressing Tab to - // walk through both sidebar panes. - if ( - !debugTabBusy && - !state.slashPaletteOpen && - !state.pendingApproval && - key.ctrl && - !key.shift && - !key.meta && - input === "b" - ) { - const next = cycleNavSlot(state, 1); - applyNavSlot(dispatch, next); - return true; - } - // Tab / Shift+Tab routing: - // - In chat mode with the sidebar visible, plain Tab cycles - // editor → sidebar(sessions) → sidebar(tasks) → editor so the - // operator can reach the rail with a single key. The - // in-sidebar transition (sessions ↔ tasks) is handled in - // `handleSidebarKey`; the path here covers the "land into the - // sidebar from the editor" leg. - // - Shift+Tab always cycles nav slots backward — same key surface - // as before so muscle memory survives. - // - Outside chat (debug mode) or with sidebar collapsed, plain - // Tab cycles nav slots forward as a fallback so power users on - // narrow terminals are not stranded. - if ( - !debugTabBusy && - !state.slashPaletteOpen && - key.tab && - !state.pendingApproval - ) { - if (key.shift) { - const prev = cycleNavSlot(state, -1); - applyNavSlot(dispatch, prev); - return true; - } - if ( - ctx.sidebarVisible && - state.uiMode === "chat" && - state.chatFocus === "editor" - ) { - // Land in the sidebar at the section the operator left last. - dispatch({ type: "chat_focus_set", focus: "sidebar" }); - return true; - } - const next = cycleNavSlot(state, 1); - applyNavSlot(dispatch, next); - return true; - } - return false; + llmTabBusy + ); } /** @@ -389,7 +403,12 @@ function handleSidebarKey( return false; } -function applyNavSlot( +/** + * Apply a nav slot — the one place that knows "run" means chat mode and + * every other slot is a debug tab. Exported so a click on a status-bar + * pill lands the operator in exactly the same state Tab would. + */ +export function applyNavSlot( dispatch: (action: TuiAction) => void, slot: NavSlot, ): void { @@ -435,6 +454,42 @@ function grantConfirmation( return `granted: ${formatApprovalCategory(request.category)} for this session`; } +/** + * Resolve a pending approval: tell the runtime, then fold the decision + * into the reducer (and, for a grant, print the confirmation line). + * Shared by the key handler and the approval modal's clickable + * buttons — one implementation, so the two can never disagree about + * what "approve" means. + */ +export function decideApproval( + request: ApprovalRequest, + approved: boolean, + ctx: { + dispatch: (action: TuiAction) => void; + callbacks: Pick; + }, + grant?: ApprovalGrantScope, +): void { + // Call through without a trailing `undefined`: the callback's arity + // is observable (tests spy on it, hosts may inspect `arguments`). + if (grant) { + ctx.callbacks.onApprovalDecision(request.approvalId, approved, grant); + } else { + ctx.callbacks.onApprovalDecision(request.approvalId, approved); + } + ctx.dispatch({ + type: "approval_resolved", + approvalId: request.approvalId, + approved, + }); + if (approved && grant) { + ctx.dispatch({ + type: "system_message", + text: grantConfirmation(request, grant), + }); + } +} + function handleApprovalKey( input: string, key: Key, @@ -443,57 +498,24 @@ function handleApprovalKey( ): boolean { const lower = input.toLowerCase(); if (lower === "y") { - ctx.callbacks.onApprovalDecision(request.approvalId, true); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); + decideApproval(request, true, ctx); return true; } if (lower === "s" && canGrantCategory(request)) { - ctx.callbacks.onApprovalDecision(request.approvalId, true, "category"); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); - ctx.dispatch({ - type: "system_message", - text: grantConfirmation(request, "category"), - }); + decideApproval(request, true, ctx, "category"); return true; } if (lower === "a" && canGrantShape(request)) { - ctx.callbacks.onApprovalDecision(request.approvalId, true, "shape"); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: true, - }); - ctx.dispatch({ - type: "system_message", - text: grantConfirmation(request, "shape"), - }); + decideApproval(request, true, ctx, "shape"); return true; } if (lower === "n") { - ctx.callbacks.onApprovalDecision(request.approvalId, false); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: false, - }); + decideApproval(request, false, ctx); return true; } if (key.escape || (key.ctrl && input === "c")) { - ctx.callbacks.onApprovalDecision(request.approvalId, false); + decideApproval(request, false, ctx); ctx.callbacks.onAbort(); - ctx.dispatch({ - type: "approval_resolved", - approvalId: request.approvalId, - approved: false, - }); ctx.dispatch({ type: "abort_requested" }); return true; } diff --git a/src/tui/approval-modal.tsx b/src/tui/approval-modal.tsx index f12896fe..7899650e 100644 --- a/src/tui/approval-modal.tsx +++ b/src/tui/approval-modal.tsx @@ -1,11 +1,16 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { canGrantCategory, canGrantShape, + type ApprovalGrantScope, type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import { decideApproval } from "./app-key-bindings.js"; +import { MouseTarget, useMouseCommands } from "./mouse/mouse-context.js"; +import { isPrimaryPress } from "./mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "./mouse/mouse-registry.js"; interface ApprovalModalProps { request: ApprovalRequest; @@ -14,7 +19,9 @@ interface ApprovalModalProps { /** * Displayed as an in-place banner rather than a floating window to keep * rendering predictable across terminals. Hotkey handling lives at the - * app root (`tui-app.tsx`) via ink's `useInput`. + * app root (`tui-app.tsx`) via ink's `useInput`; the `[y]` / `[s]` / + * `[a]` / `[n]` markers are also click targets, routed through the same + * `decideApproval` the keys use. */ export function ApprovalModal({ request }: ApprovalModalProps): ReactElement { const categoryLabel = formatApprovalCategory(request.category); @@ -59,22 +66,39 @@ export function ApprovalModal({ request }: ApprovalModalProps): ReactElement {
) : null}
- - - [y] approve{" "} - {grantCategory ? ( - <> - [s] allow {categoryLabel} this session{" "} - - ) : null} - {grantShape ? ( - <> - [a] allow all {request.commandShape}{" "} - commands this session{" "} - - ) : null} - [n] deny [esc] abort run - + + + + [y] + + approve + + {grantCategory ? ( + + + [s] + + allow {categoryLabel} this session + + ) : null} + {grantShape ? ( + + + [a] + + allow all {request.commandShape} commands this session + + ) : null} + + + [n] + + deny + + + [esc] + abort run + {footerHint(grantCategory)} @@ -92,3 +116,38 @@ function clip(value: string, limit: number): string { if (value.length <= limit) return value; return `${value.slice(0, limit - 1)}…`; } + +interface ApprovalButtonProps { + request: ApprovalRequest; + approved: boolean; + grant?: ApprovalGrantScope; + children: ReactNode; +} + +/** + * A clickable decision marker. Renders as plain text when the mouse + * layer is absent, so the modal looks identical with `--no-mouse` and + * under the test renderer. + */ +function ApprovalButton({ + request, + approved, + grant, + children, +}: ApprovalButtonProps): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + decideApproval(request, approved, mouse, grant); + return true; + }} + > + {children} + + ); +} diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 7fa51b55..30379d0c 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -89,6 +89,13 @@ export interface SlashDispatchResult { * `PrivacyOrchestrator.setApprovalLevel`. */ readonly approvalLevelSet?: number; + /** + * `/mouse [on|off]` — flip terminal mouse reporting at runtime, or + * report the current state with no argument. The caller owns the + * escape sequences and the config write, because both live outside + * React (see `tui-command.ts`). + */ + readonly mouseVerb?: "on" | "off" | "status"; } /** @@ -142,6 +149,8 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return pureActions([], { systemMessage: formatSlashCommandHelp(), }); + case "mouse": + return dispatchMouseSub(parsed.args); case "theme": return dispatchThemeSub(parsed.args); case "clear": @@ -295,6 +304,25 @@ function pureActions( * the registry and, on success, asks the caller to swap + persist + re-render. * Unknown names surface a usage hint instead of switching. */ +/** + * `/mouse` with no argument reports state; `on` / `off` set it. Any + * other word is rejected rather than guessed at — a typo'd `/mouse ff` + * silently disabling clicks would be a maddening bug to chase. + */ +function dispatchMouseSub(rawArgs: string): SlashDispatchResult { + const verb = rawArgs.trim().toLowerCase(); + if (verb.length === 0) return pureActions([], { mouseVerb: "status" }); + if (verb === "on" || verb === "enable") { + return pureActions([], { mouseVerb: "on" }); + } + if (verb === "off" || verb === "disable") { + return pureActions([], { mouseVerb: "off" }); + } + return pureActions([], { + systemMessage: `usage: /mouse [on|off] (got "${rawArgs.trim()}")`, + }); +} + function dispatchThemeSub(rawArgs: string): SlashDispatchResult { const arg = rawArgs.trim().toLowerCase(); if (arg.length === 0) { diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..9ca5b17a 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -27,6 +27,10 @@ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ description: "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", }, + { + name: "mouse", + description: "mouse support on/off/status (off restores drag-to-select)", + }, { name: "theme", description: diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0946055c..822e662f 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -1,6 +1,9 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "../mouse/mouse-registry.js"; import { EventFeed } from "../event-feed.js"; import { LogsTab } from "../logs-tab.js"; import { ReasoningTab } from "../reasoning-tab.js"; @@ -76,32 +79,60 @@ function SubTabBar({ state, section }: SubTabBarProps): ReactElement | null { const tabs = section === "manage" ? buildManageTabs(state) : buildObserveTabs(state); return ( - - {tabs.map((tab, idx) => { - const active = tab.id === state.activeTab; - return ( - - - {active ? `${theme.glyphs.chevronRight} ` : " "} - {tab.label} + + {tabs.map((tab, idx) => ( + + + {idx < tabs.length - 1 ? ( + + {" "} + {theme.glyphs.pipeSeparator} + {" "} - {idx < tabs.length - 1 ? ( - - {" "} - {theme.glyphs.pipeSeparator} - {" "} - - ) : null} - - ); - })} + ) : null} + + ))}
); } +/** + * One sub-tab. Split out of the strip so each label owns a measurable + * box the mouse layer can hit — clicking a tab performs the same + * dispatch Tab-cycling does. + */ +function SubTabLabel({ + tab, + active, +}: { + tab: SubTab; + active: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {tab.label} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (!active) mouse.dispatch({ type: "tab_changed", tab: tab.id }); + return true; + }} + > + {label} + + ); +} + interface SubTab { id: TuiTab; label: string; diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..27e81d7a 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -1,5 +1,13 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { applyNavSlot, decideApproval } from "../app-key-bindings.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { cycleNavSlot } from "../section.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; @@ -12,6 +20,13 @@ interface HotkeyHintProps { interface HotkeyChip { readonly key: string; readonly label: string; + /** + * What a click on this chip does. Only chips with one unambiguous + * meaning get one — "alt+enter newline" or "↑↓ select" describe a + * gesture, not a command, so they stay plain text rather than + * pretending to be buttons. + */ + readonly onClick?: (mouse: MouseContextValue) => void; } /** @@ -30,13 +45,10 @@ const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgd export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement { const chips = resolveChips(state, ctrlCArmed ?? false); return ( - + {chips.map((chip, idx) => ( - - - [{chip.key}] - - {chip.label} + + {idx < chips.length - 1 ? ( {" "} @@ -44,17 +56,57 @@ export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement {" "} ) : null} - + ))} ); } +function Chip({ chip }: { chip: HotkeyChip }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + + [{chip.key}] + + {chip.label} + + ); + if (!mouse || !chip.onClick) return label; + const onClick = chip.onClick; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onClick(mouse); + return true; + }} + > + {label} + + ); +} + +/** Opens the slash palette exactly the way typing `/` does. */ +function openSlashPalette(mouse: MouseContextValue): void { + mouse.dispatch({ type: "input_changed", value: "/" }); + mouse.dispatch({ type: "slash_palette_opened", query: "" }); +} + function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { if (state.pendingApproval) { + const approval = state.pendingApproval; return [ - { key: "y", label: "approve" }, - { key: "n", label: "deny" }, + { + key: "y", + label: "approve", + onClick: (mouse) => decideApproval(approval, true, mouse), + }, + { + key: "n", + label: "deny", + onClick: (mouse) => decideApproval(approval, false, mouse), + }, { key: "esc", label: "abort run" }, ]; } @@ -82,10 +134,24 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { // Tab chip word-for-word, and the freed slot pays for the one hint // panels actually lacked — the way back to Run. return [ - { key: "tab", label: "next panel" }, - { key: "shift+tab", label: "prev panel" }, - { key: "esc", label: "back to Run" }, - { key: "/", label: "commands" }, + { + key: "tab", + label: "next panel", + onClick: (mouse) => + applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), 1)), + }, + { + key: "shift+tab", + label: "prev panel", + onClick: (mouse) => + applyNavSlot(mouse.dispatch, cycleNavSlot(mouse.getState(), -1)), + }, + { + key: "esc", + label: "back to Run", + onClick: (mouse) => mouse.dispatch({ type: "ui_mode_set", mode: "chat" }), + }, + { key: "/", label: "commands", onClick: openSlashPalette }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -110,9 +176,14 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, - { key: "tab", label: "sidebar" }, + { + key: "tab", + label: "sidebar", + onClick: (mouse) => + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }), + }, { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, + { key: "/", label: "commands", onClick: openSlashPalette }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index d58699a5..15a8735f 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -4,6 +4,8 @@ import { selectCloudModelSection } from "../llm-panel/llm-panel-row-builders.js" import { activeCursor, selectLlmPanelRows, type LlmPanelRow } from "../llm-panel/llm-panel-selectors.js"; import { classifyRamFit, classifyVramFit } from "../local-models/local-models-panel-state.js"; import { computeRowWindow } from "../row-window.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { FallbackRows } from "./llm-fallback-rows.js"; @@ -350,15 +352,23 @@ function Row({ row, state }: { row: LlmPanelRow; state: TuiState }): ReactElemen // see `LlmModeRows` — but never guarded the horizontal axis), which is // what garbles adjacent rows and drags rendering on a narrow window. return ( - - {mark} {renderRowText(row, state)} - {insufficient ? ( - Not enough VRAM - ) : ramFit === "tight" ? ( - RAM tight - ) : null} - · {row.enterEffect} - + + mouse.dispatch({ type: "llm_cursor_set", cursor: idx }) + } + onActivate={pressEnter(handleLlmPanelKey)} + > + + {mark} {renderRowText(row, state)} + {insufficient ? ( + Not enough VRAM + ) : ramFit === "tight" ? ( + RAM tight + ) : null} + · {row.enterEffect} + + ); } diff --git a/src/tui/components/local-models-panel.tsx b/src/tui/components/local-models-panel.tsx index 607b15f9..358237ba 100644 --- a/src/tui/components/local-models-panel.tsx +++ b/src/tui/components/local-models-panel.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleLocalModelsTabKey } from "../local-models/local-models-key-bindings.js"; import { theme } from "../theme/theme.js"; import { computeRowWindow } from "../row-window.js"; import { @@ -595,7 +597,15 @@ function renderChatRow( // their individual colors; the badges that fall off the edge are // informational and reappear once the window is widened. return ( - + + mouse.dispatch({ type: "local_models_cursor_set", row: index }) + } + onActivate={pressEnter(handleLocalModelsTabKey)} + > + ) : null} + ); } @@ -677,7 +688,18 @@ function renderEmbeddingRow( // See renderChatRow: nowrap + per-fragment truncate-end so a narrow // window clips the row instead of wrapping and overlapping the next. return ( - + + mouse.dispatch({ + type: "local_models_cursor_set", + row: embOffset + index, + }) + } + onActivate={pressEnter(handleLocalModelsTabKey)} + > + {isCursor ? "> " : " "} {r.active ? "* " : ""} @@ -698,6 +720,7 @@ function renderEmbeddingRow( ) : null} + ); } diff --git a/src/tui/components/mcp-list.tsx b/src/tui/components/mcp-list.tsx index e8fb3e05..2c09dbb0 100644 --- a/src/tui/components/mcp-list.tsx +++ b/src/tui/components/mcp-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleMcpTabKey } from "../mcp/mcp-key-bindings.js"; import type { McpPanelState, McpServerRow, @@ -30,11 +32,16 @@ export function McpList(props: McpListProps): ReactElement { return ( {slice.map((row, idx) => ( - + selected={start + idx === panel.cursor} + onSelect={(mouse) => + mouse.dispatch({ type: "mcp_cursor_set", row: start + idx }) + } + onActivate={pressEnter(handleMcpTabKey)} + > + + ))} ); diff --git a/src/tui/components/memory-list.tsx b/src/tui/components/memory-list.tsx index 90d07f6f..3ac2380d 100644 --- a/src/tui/components/memory-list.tsx +++ b/src/tui/components/memory-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleMemoryTabKey } from "../memory/memory-key-bindings.js"; import type { MemoryPanelState } from "../memory/memory-panel-state.js"; import type { MemorySummaryRow } from "../memory/memory-panel-state.js"; @@ -44,11 +46,16 @@ export function MemoryList(props: MemoryListProps): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "memory_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleMemoryTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/multi-line-editor-body.tsx b/src/tui/components/multi-line-editor-body.tsx index d7d5f5a6..e1ed64f8 100644 --- a/src/tui/components/multi-line-editor-body.tsx +++ b/src/tui/components/multi-line-editor-body.tsx @@ -1,13 +1,24 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { useMouseTarget } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { theme } from "../theme/theme.js"; import type { Cursor } from "./multi-line-editor-cursor.js"; +/** Width of the `❯ ` / ` ` gutter in front of every editor line. */ +const GUTTER_COLUMNS = 2; + export interface EditorBodyProps { value: string; cursor: Cursor; placeholder: string; focus: boolean; + /** + * Move the caret to a clicked cell. `row`/`col` are already relative + * to the text, gutter excluded; the owner clamps and converts them to + * a buffer offset. + */ + onClickCursor?: (row: number, col: number) => void; } /** @@ -21,10 +32,19 @@ export function EditorBody({ cursor, placeholder, focus, + onClickCursor, }: EditorBodyProps): ReactElement { + // One target for the whole buffer: the click's local row is the line, + // its local column minus the gutter is the character. Lines are not + // soft-wrapped here, so the mapping is exact. + const bodyRef = useMouseTarget((hit) => { + if (!isPrimaryPress(hit.event) || !onClickCursor) return false; + onClickCursor(hit.localY, hit.localX - GUTTER_COLUMNS); + return true; + }); if (value.length === 0) { return ( - + {theme.glyphs.promptCaret} {focus ? : null} {placeholder} @@ -33,7 +53,7 @@ export function EditorBody({ } const lines = value.split("\n"); return ( - + {lines.map((line, idx) => ( diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index 79c3d695..55c22f3a 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -142,6 +142,20 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { ); const cursor = cursorToRowCol(value, cursorPos); + /** + * Place the caret where the operator clicked. `rowColToCursor` does + * not clamp, so a click past the end of a short line would otherwise + * run the offset into the following line; clamping here keeps a click + * in the empty space to the right of a line meaning "end of this + * line", which is what every editor does. + */ + const placeCursorAt = (row: number, col: number): void => { + if (disabled) return; + const lines = value.split("\n"); + const safeRow = Math.max(0, Math.min(row, lines.length - 1)); + const safeCol = Math.max(0, Math.min(col, (lines[safeRow] ?? "").length)); + setCursorPos(rowColToCursor(lines, safeRow, safeCol)); + }; if (bare) { return ( ); } @@ -159,7 +174,13 @@ export function MultiLineEditor(props: MultiLineEditorProps): ReactElement { paddingX={1} flexDirection="column" > - + ); } diff --git a/src/tui/components/providers-panel.tsx b/src/tui/components/providers-panel.tsx index ebea65e2..09d6c802 100644 --- a/src/tui/components/providers-panel.tsx +++ b/src/tui/components/providers-panel.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; import { theme } from "../theme/theme.js"; import type { ProvidersPanelState } from "../providers/providers-panel-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; @@ -30,12 +31,20 @@ export function ProvidersPanel(props: { ); } - const lines: string[] = ["Providers (text LLM + embeddings)", ""]; + // Each line is its own element rather than one joined string: the + // provider rows have to be individually measurable for the mouse + // layer, and a column of one-line Texts renders identically. + const lines: PanelLine[] = [ + { text: "Providers (text LLM + embeddings)" }, + { text: "" }, + ]; if (props.panel.statusLine) { - lines.push(props.panel.statusLine, ""); + lines.push({ text: props.panel.statusLine }, { text: "" }); } if (props.panel.rows.length === 0) { - lines.push("(no providers — press n to add OpenRouter or OpenAI-compatible)"); + lines.push({ + text: "(no providers — press n to add OpenRouter or OpenAI-compatible)", + }); } else { props.panel.rows.forEach((row, i) => { const mark = i === props.panel.cursor ? ">" : " "; @@ -52,20 +61,44 @@ export function ProvidersPanel(props: { ] .filter(Boolean) .join(" "); - lines.push( - `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`, - ); + lines.push({ + text: `${mark} ${row.id} [${row.kind}] ${flags}${models ? ` · ${models}` : ""}`, + rowIndex: i, + }); }); } lines.push( - "", - "j/k move · n add · c configure cloud · d remove", - "t active text · e active embedding · r refresh", + { text: "" }, + { text: "j/k move · n add · c configure cloud · d remove" }, + { text: "t active text · e active embedding · r refresh" }, ); return ( - {lines.join("\n")} + {lines.map((line, idx) => + line.rowIndex === undefined ? ( + {line.text} + ) : ( + + mouse.dispatch({ + type: "providers_cursor_set", + row: line.rowIndex as number, + }) + } + > + {line.text} + + ), + )} ); } + +/** One rendered line; `rowIndex` marks the clickable provider rows. */ +interface PanelLine { + text: string; + rowIndex?: number; +} diff --git a/src/tui/components/session-picker.tsx b/src/tui/components/session-picker.tsx index dd85cc8a..126c913d 100644 --- a/src/tui/components/session-picker.tsx +++ b/src/tui/components/session-picker.tsx @@ -2,6 +2,9 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import type { SessionPickerEntry } from "../tui-state.js"; import { theme } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; export interface SessionPickerProps { sessions: readonly SessionPickerEntry[]; @@ -45,12 +48,31 @@ export function SessionPicker(props: SessionPickerProps): ReactElement { ↑ {hiddenBefore} above ) : null} {visible.map((entry, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "session_picker_cursor_set", + row: windowStart + idx, + }) + } + onActivate={(mouse) => + handleEditorSubmit( + "", + mouse.getState(), + mouse.dispatch, + mouse.callbacks, + ) + } + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index f7ce46b0..ecd00d1f 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -1,5 +1,11 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { + MouseTarget, + useMouseCommands, + useMouseTarget, +} from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; @@ -48,8 +54,22 @@ export function Sidebar(props: SidebarProps): ReactElement { } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + const mouse = useMouseCommands(); + // Wheel over the rail walks the pane that owns the cursor, so the + // gesture matches what ↑/↓ do once the rail has focus. + const wheelRef = useMouseTarget((hit) => { + if (hit.event.kind !== "wheel" || !mouse) return false; + const delta = hit.event.wheel === "up" ? -1 : 1; + mouse.dispatch( + activeSection === "tasks" + ? { type: "sidebar_tasks_cursor_moved", delta } + : { type: "sidebar_cursor_moved", delta }, + ); + return true; + }); return ( {visible.map((entry, idx) => ( - + onActivate={(mouse) => + mouse.callbacks.onSessionSwitchRequested?.(entry.sessionId) + } + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} more @@ -170,11 +199,17 @@ function TasksList({ tasks, cursor, focused }: TasksListProps): ReactElement { return ( {visible.map((row, idx) => ( - + onActivate={(mouse) => + mouse.callbacks.onSidebarTaskActivated?.(row.id) + } + > + + ))} ); @@ -224,3 +259,51 @@ function computeWindowStart(cursor: number, total: number, size: number): number if (cursor < size) return 0; return Math.min(cursor - size + 1, total - size); } + +interface SidebarRowProps { + section: SidebarSection; + /** Absolute index into the pane's data, not the visible window. */ + row: number; + selected: boolean; + onActivate: (mouse: NonNullable>) => void; + children: ReactNode; +} + +/** + * Click behaviour shared by both rails: the first click focuses the + * rail and moves the cursor, a click on the row that is already + * selected activates it. Two deliberate clicks instead of a + * double-click — no timing window to guess, and it matches what the + * keyboard does (arrow to the row, then Enter). + */ +function SidebarRow({ + section, + row, + selected, + onActivate, + children, +}: SidebarRowProps): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (selected) { + onActivate(mouse); + return true; + } + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }); + mouse.dispatch({ type: "sidebar_section_focused", section }); + mouse.dispatch( + section === "tasks" + ? { type: "sidebar_tasks_cursor_set", row } + : { type: "sidebar_cursor_set", row }, + ); + return true; + }} + > + {children} + + ); +} diff --git a/src/tui/components/skills-hub-list.tsx b/src/tui/components/skills-hub-list.tsx index 76908701..71005232 100644 --- a/src/tui/components/skills-hub-list.tsx +++ b/src/tui/components/skills-hub-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleSkillsTabKey } from "../skills/skills-key-bindings.js"; import { formatDownloads } from "../skills/format-downloads.js"; import type { HubSkillRow, @@ -84,11 +86,19 @@ function renderBody(panel: SkillsPanelState, maxRows: number): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "skills_hub_cursor_set", + row: idx + windowStart, + }) + } + onActivate={pressEnter(handleSkillsTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/skills-list.tsx b/src/tui/components/skills-list.tsx index 81978f0d..ea61e1f8 100644 --- a/src/tui/components/skills-list.tsx +++ b/src/tui/components/skills-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleSkillsTabKey } from "../skills/skills-key-bindings.js"; import type { SkillSummaryRow, SkillsPanelState, @@ -45,11 +47,16 @@ export function SkillsList(props: SkillsListProps): ReactElement { ↑ {hiddenBefore} above ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "skills_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleSkillsTabKey)} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/slash-palette.tsx b/src/tui/components/slash-palette.tsx index 08c567e8..ac26f61a 100644 --- a/src/tui/components/slash-palette.tsx +++ b/src/tui/components/slash-palette.tsx @@ -3,6 +3,9 @@ import type { ReactElement } from "react"; import { filterSlashCommands } from "../commands/slash-commands.js"; import type { SlashCommandDef } from "../commands/slash-commands.js"; import { theme } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; interface SlashPaletteProps { query: string; @@ -51,11 +54,28 @@ export function SlashPalette(props: SlashPaletteProps): ReactElement | null { ↑ {hiddenBefore} above ) : null} {visible.map((cmd, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ + type: "slash_palette_cursor_set", + row: windowStart + idx, + }) + } + onActivate={(mouse) => { + const state = mouse.getState(); + handleEditorSubmit( + state.inputValue, + state, + mouse.dispatch, + mouse.callbacks, + ); + }} + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 33750987..2a0db968 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,8 +1,13 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { applyNavSlot } from "../app-key-bindings.js"; +import { useMouseCommands } from "../mouse/mouse-context.js"; +import { MouseTarget } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { getCurrentSection, + getDefaultTabForSection, SECTION_ORDER, type TuiSection, } from "../section.js"; @@ -49,32 +54,71 @@ const SECTION_LABELS: Record = { manage: "Manage", }; +/** + * The Run / Observe / Manage switcher. Each pill is its own `` + * rather than one flat `` run so the mouse layer can measure it: + * a click lands on the pill the operator actually pointed at instead of + * being reverse-engineered from label widths, which would break the + * next time a label changes. + */ function SectionPills({ active }: { active: TuiSection }): ReactElement { return ( - - {SECTION_ORDER.map((id, idx) => { - const isActive = id === active; - return ( - - - {isActive ? `${theme.glyphs.chevronRight} ` : " "} - {SECTION_LABELS[id]} + + {SECTION_ORDER.map((id, idx) => ( + + + {idx < SECTION_ORDER.length - 1 ? ( + + {" "} + {theme.glyphs.dotSeparator} + {" "} - {idx < SECTION_ORDER.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + ) : null} + + ))} + + ); +} + +function SectionPill({ + id, + active, +}: { + id: TuiSection; + active: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {SECTION_LABELS[id]} ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // Clicking the section you are already in is a no-op rather + // than a reset — it would otherwise throw away the sub-tab the + // operator navigated to. + if (!active) { + applyNavSlot( + mouse.dispatch, + id === "run" + ? { kind: "run" } + : { kind: "debug-tab", tab: getDefaultTabForSection(id) }, + ); + } + return true; + }} + > + {label} + + ); } interface SessionTagProps { diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx index abed9ca4..ec1140b4 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-list.tsx @@ -1,6 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; import type { TaskSummaryRow, TasksPanelState, @@ -46,12 +48,20 @@ export function TasksList(props: TasksListProps): ReactElement { ) : null} {pageRows.map((row, idx) => ( - + onSelect={(mouse) => + mouse.dispatch({ type: "tasks_cursor_set", row: idx + windowStart }) + } + onActivate={pressEnter(handleTasksTabKey)} + > + + ))} {hiddenAfter > 0 ? ( diff --git a/src/tui/components/theme-picker.tsx b/src/tui/components/theme-picker.tsx index 3ad7c744..effe5f80 100644 --- a/src/tui/components/theme-picker.tsx +++ b/src/tui/components/theme-picker.tsx @@ -1,6 +1,15 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { THEME_NAMES, THEMES, theme, type ThemeName } from "../theme/theme.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { handleEditorSubmit } from "../submit-handler.js"; +import { + setActiveTheme, + THEME_NAMES, + THEMES, + theme, + type ThemeName, +} from "../theme/theme.js"; export interface ThemePickerProps { /** Highlighted row index into {@link THEME_NAMES}. */ @@ -52,12 +61,34 @@ export function ThemePicker(props: ThemePickerProps): ReactElement { ↑ {hiddenBefore} above ) : null} {visible.map((name, idx) => ( - + onSelect={(mouse) => { + // Same live preview the arrow keys give: the palette swaps + // under the cursor, Enter (or a second click) commits it. + setActiveTheme(THEMES[name]); + mouse.dispatch({ + type: "theme_picker_cursor_set", + row: windowStart + idx, + }); + }} + onActivate={(mouse) => + handleEditorSubmit( + "", + mouse.getState(), + mouse.dispatch, + mouse.callbacks, + ) + } + > + + ))} {hiddenAfter > 0 ? ( ↓ {hiddenAfter} below diff --git a/src/tui/components/tool-card.tsx b/src/tui/components/tool-card.tsx index b27bcb7f..d3a9068e 100644 --- a/src/tui/components/tool-card.tsx +++ b/src/tui/components/tool-card.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { formatToolArgsBlock, previewToolArgs, @@ -18,6 +20,11 @@ interface ToolCardProps { * reveals the full args block and the full summary/details text. Pending * (in-flight) calls render with a spinner-less hourglass glyph and no * duration yet. + * + * Clicking the header line toggles the card. Until now the per-card + * toggle existed in the reducer but had no key binding at all — only + * `/expand` and `/collapse`, which act on every card at once — so the + * mouse is the first way to open one specific card. */ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { const isFinalised = "status" in card; @@ -29,6 +36,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { ? `${card.finishedAt - card.startedAt}ms` : "…"; const header = ( + {theme.glyphs.toolBoxTopLeft} @@ -51,6 +59,7 @@ export function ToolCard({ card, expanded }: ToolCardProps): ReactElement { ) : null} + ); if (!expanded) { return ( @@ -135,3 +144,29 @@ function toGlyph(status: "pending" | "ok" | "error"): string { function splitLines(text: string): string[] { return text.replace(/\r\n/g, "\n").split("\n"); } + +/** + * Wraps the card header so a click folds / unfolds that one card. + * Transparent when the mouse layer is absent. + */ +function ExpandToggle({ + cardId, + children, +}: { + cardId: string; + children: ReactNode; +}): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "tool_expand_toggled", toolCardId: cardId }); + return true; + }} + > + {children} + + ); +} diff --git a/src/tui/local-models/local-models-actions.ts b/src/tui/local-models/local-models-actions.ts index b08b8652..76faab72 100644 --- a/src/tui/local-models/local-models-actions.ts +++ b/src/tui/local-models/local-models-actions.ts @@ -32,6 +32,8 @@ export type LocalModelsAction = embeddingDaemon: EmbeddingDaemonInfo; } | { type: "local_models_cursor_up" } + /** Put the model-list cursor on an absolute row (mouse click). */ + | { type: "local_models_cursor_set"; row: number } | { type: "local_models_cursor_down" } | { type: "local_models_embedding_remove_confirm_opened"; diff --git a/src/tui/local-models/local-models-reducer.ts b/src/tui/local-models/local-models-reducer.ts index e61cb7d2..c20d8c5a 100644 --- a/src/tui/local-models/local-models-reducer.ts +++ b/src/tui/local-models/local-models-reducer.ts @@ -48,6 +48,14 @@ export function reduceLocalModelsAction(state: TuiState, action: TuiAction): Tui }, }; } + case "local_models_cursor_set": + return { + ...state, + localModelsPanel: { + ...p, + cursor: clampCursor(action.row, totalRowCount(p)), + }, + }; case "local_models_cursor_up": return { ...state, diff --git a/src/tui/mouse/index.ts b/src/tui/mouse/index.ts new file mode 100644 index 00000000..76a879fb --- /dev/null +++ b/src/tui/mouse/index.ts @@ -0,0 +1,41 @@ +export { + isPrimaryPress, + type MouseButton, + type MouseEventKind, + type TuiMouseEvent, + type WheelDirection, +} from "./mouse-event.js"; +export { + decodeMouseEvents, + type DecodedMouseChunk, +} from "./parse-mouse-events.js"; +export { + enableMouseTracking, + type MouseTrackingController, + type MouseTrackingOptions, +} from "./mouse-tracking.js"; +export { createMouseStdin, type MouseStdin } from "./mouse-stdin.js"; +export { + makeMouseSource, + type MouseSource, + type MouseSourceEmitter, +} from "./mouse-source.js"; +export { + absoluteRect, + MOUSE_LAYER_BASE, + MOUSE_LAYER_MODAL, + MOUSE_LAYER_PANEL, + MouseTargetRegistry, + type MouseHit, + type MouseRect, + type MouseTargetHandler, +} from "./mouse-registry.js"; +export { + MouseProvider, + MouseTarget, + useMouseCommands, + useMouseTarget, + type MouseContextValue, +} from "./mouse-context.js"; +export { MouseListRow, pressEnter } from "./mouse-list-row.js"; +export { arrowKey, returnKey } from "./synthetic-key.js"; diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx new file mode 100644 index 00000000..5103cc3b --- /dev/null +++ b/src/tui/mouse/mouse-app.test.tsx @@ -0,0 +1,297 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import type { TuiSessionInfo } from "../tui-state.js"; +import { makeMouseSource, type MouseSourceEmitter } from "./mouse-source.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/mouse", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function noopCallbacks(): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; +} + +function strip(value: string): string { + return value + .replace(/\u001B\[[0-9;]*m/g, "") + .replace(/\u001B\]8;;[^]*/g, ""); +} + +/** + * Screen position of `needle` in the rendered frame. Stripping SGR + * codes leaves the visual grid intact, so the returned column/row are + * the same cells the terminal would report for a click. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + const lines = strip(frame).split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +function wheel(direction: "up" | "down", x: number, y: number): TuiMouseEvent { + return { + kind: "wheel", + button: "none", + wheel: direction, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Ink commits frames on its own throttle (`maxFps` 30) and React + * flushes the effects that register click targets after that commit, so + * a freshly rendered target is not clickable for a frame or two. Under a + * loaded test runner that window stretches, which is why nothing here + * waits a fixed number of milliseconds: `waitUntil` polls the rendered + * frame, and `clickUntil` re-sends the click until it takes effect — + * the terminal equivalent of a user who clicks again when the first one + * lands mid-repaint. + */ +async function waitUntil( + condition: () => boolean, + describe: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${describe}`); +} + +async function clickUntil( + mouse: MouseSourceEmitter, + point: () => { x: number; y: number }, + settled: () => boolean, + describe: string, +): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + const { x, y } = point(); + mouse.emit(click(x, y)); + await delay(50); + if (settled()) return; + } + throw new Error(`click never took effect: ${describe}`); +} + +function mountApp(): { + frame: () => string; + mouse: MouseSourceEmitter; + stdin: { write: (data: string) => void }; + openSkillsPanel: () => void; + unmount: () => void; +} { + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const { lastFrame, stdin, unmount } = render( + , + ); + return { + frame: () => strip(lastFrame() ?? ""), + mouse, + stdin, + openSkillsPanel: () => { + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "skills" }); + bus.emit({ + type: "skills_refreshed", + at: 0, + rows: [ + { + name: "alpha-skill", + description: "first", + version: "1.0.0", + source: "builtin", + disabled: false, + }, + { + name: "beta-skill", + description: "second", + version: "1.0.0", + source: "builtin", + disabled: false, + }, + ], + }); + }, + unmount, + }; +} + +describe("TuiApp mouse", () => { + it("switches section when the nav bar is clicked", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Observe"), + () => app.frame().includes("▸ Observe"), + "click on the Observe pill", + ); + expect(app.frame()).toContain("▸ Observe"); + app.unmount(); + }); + + it("switches sub-tab when a tab label is clicked", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("Observe"), "the nav bar"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Observe"), + () => app.frame().includes("▸ Observe"), + "click on the Observe pill", + ); + await waitUntil(() => app.frame().includes("Logs"), "the Observe sub-tabs"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Logs"), + () => app.frame().includes("▸ Logs"), + "click on the Logs sub-tab", + ); + expect(app.frame()).toContain("▸ Logs"); + app.unmount(); + }); + + it("ignores a click that lands on no target", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + const before = app.frame(); + app.mouse.emit(click(0, 0)); + await delay(150); + expect(app.frame()).toBe(before); + app.unmount(); + }); + + it("places the editor caret where the prompt is clicked", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + app.stdin.write("hello"); + await waitUntil(() => app.frame().includes("hello"), "the typed buffer"); + // Click the second "l" (index 3) then type: the character has to land + // at the caret, not at the end of the buffer. + await clickUntil( + app.mouse, + () => { + const at = locate(app.frame(), "hello"); + return { x: at.x + 3, y: at.y }; + }, + () => true, + "click inside the prompt", + ); + app.stdin.write("X"); + await waitUntil( + () => app.frame().includes("helXlo"), + "the character inserted at the clicked caret", + ); + expect(app.frame()).toContain("helXlo"); + app.unmount(); + }); + + it("clamps a click past the end of a line to the line end", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + app.stdin.write("hi"); + await waitUntil(() => app.frame().includes("hi"), "the typed buffer"); + await clickUntil( + app.mouse, + () => { + const at = locate(app.frame(), "hi"); + return { x: at.x + 30, y: at.y }; + }, + () => true, + "click past the end of the line", + ); + app.stdin.write("!"); + await waitUntil( + () => app.frame().includes("hi!"), + "the character appended at the clamped caret", + ); + expect(app.frame()).toContain("hi!"); + app.unmount(); + }); + + it("moves a panel cursor with the wheel", async () => { + const app = mountApp(); + app.openSkillsPanel(); + const marker = (name: string): string => { + const line = app + .frame() + .split("\n") + .find((candidate) => candidate.includes(name)); + return line?.trimStart().slice(0, 1) ?? ""; + }; + await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows"); + for (let attempt = 0; attempt < 40; attempt += 1) { + app.mouse.emit(wheel("down", 10, 6)); + await delay(50); + if (marker("beta-skill") === "▸") break; + } + expect(marker("beta-skill")).toBe("▸"); + app.unmount(); + }); + + it("routes a click to a list row and moves the cursor there", async () => { + const app = mountApp(); + app.openSkillsPanel(); + const marker = (name: string): string => { + const line = app + .frame() + .split("\n") + .find((candidate) => candidate.includes(name)); + return line?.trimStart().slice(0, 1) ?? ""; + }; + await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "beta-skill"), + () => marker("beta-skill") === "▸", + "click on the beta-skill row", + ); + expect(marker("beta-skill")).toBe("▸"); + expect(marker("alpha-skill")).not.toBe("▸"); + app.unmount(); + }); +}); diff --git a/src/tui/mouse/mouse-context.tsx b/src/tui/mouse/mouse-context.tsx new file mode 100644 index 00000000..c0502243 --- /dev/null +++ b/src/tui/mouse/mouse-context.tsx @@ -0,0 +1,130 @@ +/** + * React glue for the mouse layer. + * + * The TUI's panels are presentational: `DebugPane` hands each panel the + * state slice it renders and nothing else, so wiring clicks by + * prop-drilling `dispatch` and the orchestrator callbacks through ten + * panels would be a far larger change than the feature warrants. A + * context instead gives any component the three things a click handler + * needs — `dispatch`, `callbacks`, and a *fresh* read of state — while + * leaving every existing prop signature untouched. + * + * Outside the app (component tests, the wizard's separate Ink tree) the + * context is absent and `useMouseTarget` degrades to a no-op ref, so a + * clickable component still renders exactly as before. + */ +import { Box, type DOMElement } from "ink"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { + MOUSE_LAYER_BASE, + MouseTargetRegistry, + type MouseTargetHandler, +} from "./mouse-registry.js"; + +export interface MouseContextValue { + readonly registry: MouseTargetRegistry; + readonly dispatch: (action: TuiAction) => void; + readonly callbacks: TuiAppCallbacks; + /** Reads the live state — handlers fire outside React's render pass. */ + readonly getState: () => TuiState; +} + +const MouseContext = createContext(null); + +export interface MouseProviderProps extends MouseContextValue { + readonly children: ReactNode; +} + +export function MouseProvider({ + children, + ...value +}: MouseProviderProps): ReactElement { + const memo = useMemo( + () => value, + [value.registry, value.dispatch, value.callbacks, value.getState], + ); + return ( + {children} + ); +} + +/** + * Access to `dispatch` / `callbacks` / `getState` for click handlers. + * `null` when rendered outside `MouseProvider` (unit tests). + */ +export function useMouseCommands(): MouseContextValue | null { + return useContext(MouseContext); +} + +export interface UseMouseTargetOptions { + readonly layer?: number; + /** Set false to keep the element inert without changing the tree. */ + readonly enabled?: boolean; +} + +/** + * Registers the returned ref as a click target. Attach it to a ``; + * the handler receives the click position relative to that box. + */ +export function useMouseTarget( + handler: MouseTargetHandler, + options: UseMouseTargetOptions = {}, +): RefObject { + const { layer = MOUSE_LAYER_BASE, enabled = true } = options; + const ref = useRef(null); + const context = useContext(MouseContext); + // The handler is re-created on every render; keeping it in a ref means + // registration survives without re-subscribing on each keystroke. + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => { + if (!context || !enabled) return; + return context.registry.register({ + ref, + layer, + handler: (hit) => handlerRef.current(hit), + }); + }, [context, enabled, layer]); + return ref; +} + +export interface MouseTargetProps extends UseMouseTargetOptions { + readonly onMouse: MouseTargetHandler; + /** + * Pass `0` for inline markers on a text row: without it Yoga squeezes + * the wrapper when the row overflows and the label loses characters. + */ + readonly flexShrink?: number; + readonly children: ReactNode; +} + +/** + * Layout-neutral clickable wrapper: an unstyled `` around + * `children`. In a column it occupies the same single row its content + * did; in a row it hugs its content. + */ +export function MouseTarget({ + onMouse, + children, + flexShrink, + ...options +}: MouseTargetProps): ReactElement { + const ref = useMouseTarget(onMouse, options); + return ( + + {children} + + ); +} diff --git a/src/tui/mouse/mouse-event.ts b/src/tui/mouse/mouse-event.ts new file mode 100644 index 00000000..5b204c51 --- /dev/null +++ b/src/tui/mouse/mouse-event.ts @@ -0,0 +1,45 @@ +/** + * Terminal mouse event model. + * + * The TUI decodes xterm mouse reports itself (see + * `parse-mouse-events.ts`) instead of leaning on a library, because Ink + * has no mouse layer at all: it parses stdin as keystrokes only. Keeping + * the event shape terminal-agnostic here means the hit-testing and the + * per-component handlers never touch escape sequences. + * + * Coordinates are **0-based** and measured in terminal cells from the + * top-left of the screen — the same space Yoga computes the Ink layout + * in, so a hit test is a plain rectangle containment check. + */ + +export type MouseButton = "left" | "middle" | "right" | "none"; + +export type MouseEventKind = "press" | "release" | "wheel"; + +export type WheelDirection = "up" | "down"; + +export interface TuiMouseEvent { + readonly kind: MouseEventKind; + /** Which button changed state. `"none"` for wheel and for release-without-button reports. */ + readonly button: MouseButton; + /** Set only when `kind === "wheel"`. */ + readonly wheel: WheelDirection | null; + /** 0-based terminal column. */ + readonly x: number; + /** 0-based terminal row. */ + readonly y: number; + readonly shift: boolean; + readonly alt: boolean; + readonly ctrl: boolean; +} + +/** True for a plain (unmodified) left-button press — the "click" gesture. */ +export function isPrimaryPress(event: TuiMouseEvent): boolean { + return ( + event.kind === "press" && + event.button === "left" && + !event.shift && + !event.alt && + !event.ctrl + ); +} diff --git a/src/tui/mouse/mouse-list-row.tsx b/src/tui/mouse/mouse-list-row.tsx new file mode 100644 index 00000000..0acc5c6f --- /dev/null +++ b/src/tui/mouse/mouse-list-row.tsx @@ -0,0 +1,95 @@ +import type { Key } from "ink"; +import type { ReactElement, ReactNode } from "react"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { returnKey } from "./synthetic-key.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "./mouse-context.js"; +import { isPrimaryPress } from "./mouse-event.js"; +import { MOUSE_LAYER_PANEL } from "./mouse-registry.js"; + +export interface MouseListRowProps { + /** Whether this row currently holds the panel's cursor. */ + readonly selected: boolean; + /** Move the cursor here. Called on a click on an unselected row. */ + readonly onSelect: (mouse: MouseContextValue) => void; + /** + * Open / run the row. Called on a click on the row that is already + * selected. Omit for lists where selection is the whole interaction. + */ + readonly onActivate?: (mouse: MouseContextValue) => void; + readonly layer?: number; + readonly children: ReactNode; +} + +/** + * Click behaviour for every cursor-driven list in the TUI: **the first + * click selects, a second click on the selected row activates.** + * + * The two-step is deliberate. A double-click needs a timing window that + * is unreliable over SSH and invisible to the user, and select-and-open + * on a single click makes a mis-click destructive in lists where Enter + * starts a download or opens a session. Two plain clicks mirror what + * the keyboard already does — arrow to the row, then Enter — and reuse + * each panel's own activation path rather than duplicating it. + * + * Without the mouse context (component tests, `--no-mouse`) this is a + * transparent pass-through and the row renders exactly as before. + */ +export function MouseListRow({ + selected, + onSelect, + onActivate, + layer = MOUSE_LAYER_PANEL, + children, +}: MouseListRowProps): ReactElement { + const mouse = useMouseCommands(); + if (!mouse) return <>{children}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (selected) { + onActivate?.(mouse); + return true; + } + onSelect(mouse); + return true; + }} + > + {children} + + ); +} + +/** + * Adapter that turns "the operator clicked the selected row" into the + * Enter keypress the owning panel already knows how to handle. Reusing + * `*-key-bindings.ts` means the mouse cannot drift from the keyboard: + * whatever Enter opens, downloads or confirms today, a second click + * does too. + */ +export function pressEnter( + handler: ( + input: string, + key: Key, + ctx: { + state: TuiState; + dispatch: (action: TuiAction) => void; + callbacks: TuiAppCallbacks; + }, + ) => boolean | null, +): (mouse: MouseContextValue) => void { + return (mouse) => { + handler("", returnKey(), { + state: mouse.getState(), + dispatch: mouse.dispatch, + callbacks: mouse.callbacks, + }); + }; +} diff --git a/src/tui/mouse/mouse-registry.test.ts b/src/tui/mouse/mouse-registry.test.ts new file mode 100644 index 00000000..323921fd --- /dev/null +++ b/src/tui/mouse/mouse-registry.test.ts @@ -0,0 +1,224 @@ +import type { DOMElement } from "ink"; +import { describe, expect, it } from "vitest"; +import { + absoluteRect, + MOUSE_LAYER_BASE, + MOUSE_LAYER_MODAL, + MouseTargetRegistry, +} from "./mouse-registry.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +interface FakeLayout { + left: number; + top: number; + width: number; + height: number; +} + +/** + * Minimal stand-in for an Ink node: the registry only ever reads + * `yogaNode.getComputedLayout()`, `parentNode` and `style`. + */ +function node( + layout: FakeLayout, + parent?: DOMElement, + style: Record = {}, +): DOMElement { + return { + nodeName: "ink-box", + attributes: {}, + childNodes: [], + style, + parentNode: parent, + yogaNode: { + getComputedLayout: () => layout, + }, + } as unknown as DOMElement; +} + +function press(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +describe("absoluteRect", () => { + it("sums the offsets of every ancestor", () => { + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + const column = node({ left: 2, top: 1, width: 78, height: 20 }, root); + const row = node({ left: 0, top: 3, width: 78, height: 1 }, column); + expect(absoluteRect(row)).toEqual({ + left: 2, + top: 4, + width: 78, + height: 1, + }); + }); + + it("returns null for an unmounted ref", () => { + expect(absoluteRect(null)).toBeNull(); + }); + + it("clips against an ancestor that hides overflow", () => { + const viewport = node({ left: 0, top: 0, width: 40, height: 5 }, undefined, { + overflowY: "hidden", + }); + const scrolled = node({ left: 0, top: 3, width: 40, height: 4 }, viewport); + expect(absoluteRect(scrolled)).toEqual({ + left: 0, + top: 3, + width: 40, + height: 2, + }); + }); + + it("drops a row scrolled fully out of a clipping viewport", () => { + const viewport = node({ left: 0, top: 0, width: 40, height: 5 }, undefined, { + overflowY: "hidden", + }); + const offscreen = node({ left: 0, top: -4, width: 40, height: 1 }, viewport); + expect(absoluteRect(offscreen)).toBeNull(); + }); +}); + +describe("MouseTargetRegistry", () => { + it("routes a click to the target under the pointer", () => { + const registry = new MouseTargetRegistry(); + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + const hits: string[] = []; + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 10, height: 1 }, root) }, + handler: () => { + hits.push("first"); + return true; + }, + }); + registry.register({ + ref: { current: node({ left: 0, top: 2, width: 10, height: 1 }, root) }, + handler: () => { + hits.push("second"); + return true; + }, + }); + expect(registry.dispatch(press(3, 2))).toBe(true); + expect(hits).toEqual(["second"]); + }); + + it("reports the click position relative to the target", () => { + const registry = new MouseTargetRegistry(); + const root = node({ left: 0, top: 0, width: 80, height: 24 }); + let seen = { x: -1, y: -1 }; + registry.register({ + ref: { current: node({ left: 5, top: 4, width: 20, height: 3 }, root) }, + handler: (hit) => { + seen = { x: hit.localX, y: hit.localY }; + return true; + }, + }); + registry.dispatch(press(9, 6)); + expect(seen).toEqual({ x: 4, y: 2 }); + }); + + it("prefers the innermost target when boxes nest", () => { + const registry = new MouseTargetRegistry(); + const container = node({ left: 0, top: 0, width: 40, height: 10 }); + const row = node({ left: 0, top: 2, width: 40, height: 1 }, container); + const claimed: string[] = []; + registry.register({ + ref: { current: container }, + handler: () => { + claimed.push("container"); + return true; + }, + }); + registry.register({ + ref: { current: row }, + handler: () => { + claimed.push("row"); + return true; + }, + }); + registry.dispatch(press(1, 2)); + expect(claimed).toEqual(["row"]); + }); + + it("falls through to the next candidate when a handler declines", () => { + const registry = new MouseTargetRegistry(); + const container = node({ left: 0, top: 0, width: 40, height: 10 }); + const row = node({ left: 0, top: 0, width: 40, height: 1 }, container); + const claimed: string[] = []; + registry.register({ + ref: { current: container }, + handler: () => { + claimed.push("container"); + return true; + }, + }); + registry.register({ + ref: { current: row }, + handler: () => { + claimed.push("row"); + return false; + }, + }); + registry.dispatch(press(1, 0)); + expect(claimed).toEqual(["row", "container"]); + }); + + it("ignores clicks outside every target", () => { + const registry = new MouseTargetRegistry(); + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 4, height: 1 }) }, + handler: () => true, + }); + expect(registry.dispatch(press(9, 9))).toBe(false); + }); + + it("lets a modal layer lock out the surfaces behind it", () => { + const registry = new MouseTargetRegistry(); + const claimed: string[] = []; + registry.register({ + ref: { current: node({ left: 0, top: 0, width: 40, height: 10 }) }, + layer: MOUSE_LAYER_BASE, + handler: () => { + claimed.push("background"); + return true; + }, + }); + registry.register({ + ref: { current: node({ left: 0, top: 5, width: 40, height: 2 }) }, + layer: MOUSE_LAYER_MODAL, + handler: () => { + claimed.push("modal"); + return true; + }, + }); + registry.setMinLayer(MOUSE_LAYER_MODAL); + expect(registry.dispatch(press(1, 1))).toBe(false); + registry.dispatch(press(1, 5)); + expect(claimed).toEqual(["modal"]); + }); + + it("stops routing to an unregistered target", () => { + const registry = new MouseTargetRegistry(); + let calls = 0; + const unregister = registry.register({ + ref: { current: node({ left: 0, top: 0, width: 4, height: 1 }) }, + handler: () => { + calls += 1; + return true; + }, + }); + registry.dispatch(press(0, 0)); + unregister(); + registry.dispatch(press(0, 0)); + expect(calls).toBe(1); + }); +}); diff --git a/src/tui/mouse/mouse-registry.ts b/src/tui/mouse/mouse-registry.ts new file mode 100644 index 00000000..56e7bf9d --- /dev/null +++ b/src/tui/mouse/mouse-registry.ts @@ -0,0 +1,185 @@ +/** + * Hit-testing registry: turns a screen coordinate into the component + * that owns that cell. + * + * Ink exposes no absolute positions — `measureElement` returns a size + * only — but every rendered node keeps its Yoga node, and Yoga computes + * each box's offset relative to its parent's border box. Ink's own + * renderer walks the tree the same way (`render-node-to-output.ts` + * accumulates `offsetX/offsetY` from `getComputedLeft/Top`), so summing + * the chain up to the root reproduces exactly the cell the renderer + * painted the node into. That equivalence is what makes clicking + * reliable instead of a table of hardcoded row numbers that rots the + * next time a panel gains a header line. + * + * Targets register a ref plus a handler; a click is offered to the + * candidates whose rectangle contains the point, innermost first, until + * one claims it. `minLayer` is the modal gate: while a modal owns the + * keyboard, only targets registered at the modal layer are eligible, so + * a click cannot reach the list rendered behind it. + */ +import type { DOMElement } from "ink"; +import type { RefObject } from "react"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** Chat log, status bar, sidebar, prompt — the resting UI. */ +export const MOUSE_LAYER_BASE = 0; +/** Observe / Manage panel bodies. */ +export const MOUSE_LAYER_PANEL = 1; +/** Modals, confirms and pickers — claim clicks exclusively while open. */ +export const MOUSE_LAYER_MODAL = 2; + +export interface MouseRect { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +export interface MouseHit { + readonly event: TuiMouseEvent; + /** Click column relative to the target's left edge. */ + readonly localX: number; + /** Click row relative to the target's top edge. */ + readonly localY: number; + readonly rect: MouseRect; +} + +/** Return `true` to claim the event; `false` lets it fall through. */ +export type MouseTargetHandler = (hit: MouseHit) => boolean; + +export interface MouseTargetOptions { + readonly ref: RefObject; + readonly handler: MouseTargetHandler; + readonly layer?: number; +} + +interface RegisteredTarget extends MouseTargetOptions { + readonly id: number; + readonly layer: number; +} + +export class MouseTargetRegistry { + private readonly targets = new Map(); + private nextId = 1; + private minLayer = MOUSE_LAYER_BASE; + + /** Registers a target and returns its unregister function. */ + register(options: MouseTargetOptions): () => void { + const id = this.nextId++; + this.targets.set(id, { + ...options, + id, + layer: options.layer ?? MOUSE_LAYER_BASE, + }); + return () => { + this.targets.delete(id); + }; + } + + /** + * Raises the floor for eligible targets. Set to `MOUSE_LAYER_MODAL` + * while a modal is open so background surfaces stop responding. + */ + setMinLayer(layer: number): void { + this.minLayer = layer; + } + + /** Offers `event` to the matching targets; `true` when one claimed it. */ + dispatch(event: TuiMouseEvent): boolean { + const hits: Array<{ target: RegisteredTarget; rect: MouseRect }> = []; + for (const target of this.targets.values()) { + if (target.layer < this.minLayer) continue; + const rect = absoluteRect(target.ref.current); + if (!rect || !containsPoint(rect, event.x, event.y)) continue; + hits.push({ target, rect }); + } + // Innermost wins: higher layer first, then the smaller box, then the + // more recently mounted node (later siblings paint over earlier ones). + hits.sort( + (a, b) => + b.target.layer - a.target.layer || + area(a.rect) - area(b.rect) || + b.target.id - a.target.id, + ); + for (const hit of hits) { + const claimed = hit.target.handler({ + event, + localX: event.x - hit.rect.left, + localY: event.y - hit.rect.top, + rect: hit.rect, + }); + if (claimed) return true; + } + return false; + } +} + +/** + * Absolute screen rectangle of `node`, in terminal cells, or `null` + * when the node is unmounted or has not been through a layout pass. + * Ancestors that clip (`overflow: hidden`) trim the result, so a chat + * row scrolled out of the viewport is not clickable where it would + * have been painted. + */ +export function absoluteRect(node: DOMElement | null): MouseRect | null { + const yoga = node?.yogaNode; + if (!node || !yoga) return null; + const own = yoga.getComputedLayout(); + // `rect` is always expressed in the coordinate space of the ancestor + // currently being visited, then translated one level up per step. + let rect: MouseRect = { + left: own.left, + top: own.top, + width: own.width, + height: own.height, + }; + let parent = node.parentNode; + while (parent?.yogaNode) { + const layout = parent.yogaNode.getComputedLayout(); + if (clipsOverflow(parent)) { + const clipped = intersect(rect, { + left: 0, + top: 0, + width: layout.width, + height: layout.height, + }); + if (!clipped) return null; + rect = clipped; + } + rect = { + ...rect, + left: rect.left + layout.left, + top: rect.top + layout.top, + }; + parent = parent.parentNode; + } + return rect; +} + +function clipsOverflow(node: DOMElement): boolean { + const style = node.style as { overflowX?: string; overflowY?: string }; + return style.overflowX === "hidden" || style.overflowY === "hidden"; +} + +function intersect(a: MouseRect, b: MouseRect): MouseRect | null { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.left + a.width, b.left + b.width); + const bottom = Math.min(a.top + a.height, b.top + b.height); + if (right <= left || bottom <= top) return null; + return { left, top, width: right - left, height: bottom - top }; +} + +function containsPoint(rect: MouseRect, x: number, y: number): boolean { + return ( + x >= rect.left && + x < rect.left + rect.width && + y >= rect.top && + y < rect.top + rect.height + ); +} + +function area(rect: MouseRect): number { + return rect.width * rect.height; +} diff --git a/src/tui/mouse/mouse-source.ts b/src/tui/mouse/mouse-source.ts new file mode 100644 index 00000000..84f5f99a --- /dev/null +++ b/src/tui/mouse/mouse-source.ts @@ -0,0 +1,29 @@ +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** Read side of the mouse pipe, handed to `TuiApp` as a prop. */ +export interface MouseSource { + subscribe(listener: (event: TuiMouseEvent) => void): () => void; +} + +export interface MouseSourceEmitter extends MouseSource { + emit(event: TuiMouseEvent): void; +} + +/** + * Tiny pub/sub bridging the stdin decoder (plain Node, outside React) + * to the Ink tree — the same shape as `makeTuiEventBus`, for the same + * reason: the app shell subscribes, the process-level plumbing emits, + * and tests can drive clicks without a terminal. + */ +export function makeMouseSource(): MouseSourceEmitter { + const listeners = new Set<(event: TuiMouseEvent) => void>(); + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit(event) { + for (const listener of listeners) listener(event); + }, + }; +} diff --git a/src/tui/mouse/mouse-stdin.test.ts b/src/tui/mouse/mouse-stdin.test.ts new file mode 100644 index 00000000..ddf22ef4 --- /dev/null +++ b/src/tui/mouse/mouse-stdin.test.ts @@ -0,0 +1,96 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { createMouseStdin } from "./mouse-stdin.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +const ESC = "\u001B"; + +interface FakeTty extends PassThrough { + isTTY?: boolean; + rawModeCalls?: boolean[]; +} + +function makeSource(): FakeTty { + const stream = new PassThrough() as FakeTty; + stream.isTTY = true; + stream.rawModeCalls = []; + (stream as unknown as { setRawMode: (mode: boolean) => void }).setRawMode = ( + mode: boolean, + ) => { + stream.rawModeCalls?.push(mode); + }; + return stream; +} + +async function collect(stream: NodeJS.ReadStream): Promise { + await new Promise((resolve) => setImmediate(resolve)); + const chunks: string[] = []; + let chunk: unknown; + while ((chunk = stream.read()) !== null) { + chunks.push(String(chunk)); + } + return chunks.join(""); +} + +describe("createMouseStdin", () => { + it("keeps mouse reports away from the keyboard stream", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + source.write(`a${ESC}[<0;5;2Mb`); + expect(await collect(stdin)).toBe("ab"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "press", x: 4, y: 1 }); + }); + + it("reassembles a report split across two reads", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + source.write(`${ESC}[<64;3`); + source.write(";9M"); + expect(await collect(stdin)).toBe(""); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "wheel", wheel: "up", y: 8 }); + }); + + it("forwards ordinary keystrokes untouched", async () => { + const source = makeSource(); + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + ); + source.write(`hi${ESC}[A${ESC}`); + expect(await collect(stdin)).toBe(`hi${ESC}[A${ESC}`); + }); + + it("proxies TTY-ness and raw mode to the real stdin", () => { + const source = makeSource(); + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + ); + expect(stdin.isTTY).toBe(true); + stdin.setRawMode(true); + expect(source.rawModeCalls).toEqual([true]); + }); + + it("stops listening after dispose", async () => { + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin, dispose } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + dispose(); + source.write(`x${ESC}[<0;1;1M`); + expect(await collect(stdin)).toBe(""); + expect(events).toEqual([]); + }); +}); diff --git a/src/tui/mouse/mouse-stdin.ts b/src/tui/mouse/mouse-stdin.ts new file mode 100644 index 00000000..f331e04e --- /dev/null +++ b/src/tui/mouse/mouse-stdin.ts @@ -0,0 +1,82 @@ +/** + * Keyboard/mouse demultiplexer for the TUI's stdin. + * + * Ink parses stdin as keystrokes and has no mouse layer, so a raw mouse + * report reaching it is decoded as a stray Escape plus a handful of + * literal characters typed into the chat buffer. Rather than fight that + * downstream, we hand Ink a *different* stream: this module reads the + * real TTY, pulls the mouse reports out, and forwards everything else + * to a `PassThrough` that Ink treats as its stdin. + * + * The wrapper has to look enough like `process.stdin` for Ink's raw + * mode plumbing — `isTTY`, `setRawMode`, `ref`/`unref` — so those are + * delegated to the real stream. Ink also `unshift()`s bytes back during + * its kitty-keyboard probe; a `PassThrough` supports that natively. + */ +import { PassThrough } from "node:stream"; +import { decodeMouseEvents } from "./parse-mouse-events.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +export interface MouseStdin { + /** Stream to hand to Ink's `render({ stdin })` — mouse bytes removed. */ + readonly stdin: NodeJS.ReadStream; + /** Detaches from the real stdin. Call during TUI teardown. */ + dispose(): void; +} + +/** + * Wraps `source` so mouse reports are delivered to `onMouseEvent` and + * every other byte flows through to the returned stream. + */ +export function createMouseStdin( + source: NodeJS.ReadStream, + onMouseEvent: (event: TuiMouseEvent) => void, +): MouseStdin { + const passthrough = new PassThrough(); + // Ink asks its stdin for raw mode and for TTY-ness; both questions + // are really about the underlying terminal, so proxy them. + const proxy = passthrough as unknown as NodeJS.ReadStream; + Object.defineProperty(proxy, "isTTY", { + configurable: true, + get: () => source.isTTY, + }); + Object.defineProperty(proxy, "isRaw", { + configurable: true, + get: () => source.isRaw, + }); + proxy.setRawMode = (mode: boolean): NodeJS.ReadStream => { + source.setRawMode?.(mode); + return proxy; + }; + // `ref`/`unref` are TTY/socket concerns — a PassThrough has neither, + // so they belong to the real stdin and only to it. + proxy.ref = (): NodeJS.ReadStream => { + source.ref?.(); + return proxy; + }; + proxy.unref = (): NodeJS.ReadStream => { + source.unref?.(); + return proxy; + }; + + // A report can straddle two reads; `pending` holds the head of a + // truncated sequence until the rest of it arrives. + let pending = ""; + const onData = (chunk: Buffer | string): void => { + const decoded = decodeMouseEvents( + pending + (typeof chunk === "string" ? chunk : chunk.toString("utf8")), + ); + pending = decoded.rest; + for (const event of decoded.events) onMouseEvent(event); + if (decoded.text.length > 0) passthrough.write(decoded.text); + }; + source.on("data", onData); + + return { + stdin: proxy, + dispose: () => { + source.off("data", onData); + pending = ""; + }, + }; +} diff --git a/src/tui/mouse/mouse-tracking.test.ts b/src/tui/mouse/mouse-tracking.test.ts new file mode 100644 index 00000000..760864fc --- /dev/null +++ b/src/tui/mouse/mouse-tracking.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { enableMouseTracking } from "./mouse-tracking.js"; + +interface FakeStdout { + isTTY: boolean; + writes: string[]; + write(chunk: string): boolean; +} + +function makeStdout(isTty: boolean): FakeStdout { + const writes: string[] = []; + return { + isTTY: isTty, + writes, + write(chunk: string): boolean { + writes.push(chunk); + return true; + }, + }; +} + +const ENABLE_TRACKING = "\u001B[?1000h"; +const DISABLE_TRACKING = "\u001B[?1000l"; +const ENABLE_SGR = "\u001B[?1006h"; +const DISABLE_SGR = "\u001B[?1006l"; + +describe("enableMouseTracking", () => { + it("requests button tracking with SGR reports on a TTY", () => { + const stdout = makeStdout(true); + enableMouseTracking({ stdout: stdout as unknown as NodeJS.WriteStream }); + expect(stdout.writes).toEqual([ENABLE_TRACKING, ENABLE_SGR]); + }); + + it("never asks for motion tracking", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes.join("")).not.toContain("1002"); + expect(stdout.writes.join("")).not.toContain("1003"); + }); + + it("hands selection back to the terminal on disable", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes).toEqual([ + ENABLE_TRACKING, + ENABLE_SGR, + DISABLE_SGR, + DISABLE_TRACKING, + ]); + }); + + it("is idempotent — a second disable writes nothing", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + controller.disable(); + expect(stdout.writes).toHaveLength(4); + }); + + it("is a no-op when stdout is not a TTY", () => { + const stdout = makeStdout(false); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + expect(stdout.writes).toEqual([]); + }); + + it("detaches its exit hook when disabled explicitly", () => { + const before = process.listenerCount("exit"); + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + expect(process.listenerCount("exit")).toBe(before + 1); + controller.disable(); + expect(process.listenerCount("exit")).toBe(before); + }); +}); diff --git a/src/tui/mouse/mouse-tracking.ts b/src/tui/mouse/mouse-tracking.ts new file mode 100644 index 00000000..e98a56ca --- /dev/null +++ b/src/tui/mouse/mouse-tracking.ts @@ -0,0 +1,75 @@ +/** + * Mouse reporting mode manager — the terminal-side half of TUI mouse + * support. Deliberately shaped like `alt-screen.ts`: a single + * `enable → controller.disable()` pair, silent on non-TTY streams, and + * a `process.on("exit")` safety net so a crash never leaves the host + * terminal in reporting mode (where every click would print garbage + * into the user's shell). + * + * We request **1000 (normal tracking)** plus **1006 (SGR encoding)** + * only. 1002/1003 (drag / any-motion) are intentionally left off: the + * app has no hover or drag affordance, and motion reports are a + * constant stream of wakeups for a UI that does not use them. + * + * The trade-off this mode forces — the terminal stops doing its own + * drag-to-select while reporting is on — is why mouse support is a + * toggle (`tui.mouse`, `--no-mouse`, `/mouse`) rather than a + * hard-wired behaviour. `disable()` restores native selection + * instantly, without restarting the TUI. + */ +import type { Writable } from "node:stream"; + +/** Normal tracking: press + release, no motion. */ +const ENABLE_BUTTON_TRACKING = "\u001B[?1000h"; +const DISABLE_BUTTON_TRACKING = "\u001B[?1000l"; +/** SGR extended reports — required past column 223. */ +const ENABLE_SGR_REPORTS = "\u001B[?1006h"; +const DISABLE_SGR_REPORTS = "\u001B[?1006l"; + +export interface MouseTrackingController { + /** Stops mouse reporting and hands selection back to the terminal. Safe to call twice. */ + disable(): void; +} + +export interface MouseTrackingOptions { + readonly stdout?: NodeJS.WriteStream; +} + +/** + * Turns on mouse reporting for `stdout` and returns a controller whose + * `disable()` turns it back off. On a non-TTY stream (pipes, CI, the + * test harness) both halves are no-ops, exactly like `enterAltScreen`. + */ +export function enableMouseTracking( + options: MouseTrackingOptions = {}, +): MouseTrackingController { + const stdout = options.stdout ?? process.stdout; + if (!streamIsTty(stdout)) { + return { disable: () => {} }; + } + stdout.write(ENABLE_BUTTON_TRACKING); + stdout.write(ENABLE_SGR_REPORTS); + let disabled = false; + const disable = (): void => { + if (disabled) return; + disabled = true; + // Reverse order: stop the extended encoding first so a terminal + // that only understood 1000 still sees a clean disable. + stdout.write(DISABLE_SGR_REPORTS); + stdout.write(DISABLE_BUTTON_TRACKING); + }; + // Last-chance cleanup. Without it an uncaught exception leaves the + // terminal reporting clicks as escape sequences into the shell. + const onExit = (): void => disable(); + process.once("exit", onExit); + return { + disable: () => { + process.off("exit", onExit); + disable(); + }, + }; +} + +function streamIsTty(stream: Writable): boolean { + return (stream as NodeJS.WriteStream).isTTY === true; +} diff --git a/src/tui/mouse/parse-mouse-events.test.ts b/src/tui/mouse/parse-mouse-events.test.ts new file mode 100644 index 00000000..ec982a5f --- /dev/null +++ b/src/tui/mouse/parse-mouse-events.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { decodeMouseEvents } from "./parse-mouse-events.js"; + +const ESC = "\u001B"; + +/** SGR press/release report for the given button code at 1-based col/row. */ +function sgr(code: number, column: number, row: number, press = true): string { + return `${ESC}[<${code};${column};${row}${press ? "M" : "m"}`; +} + +describe("decodeMouseEvents", () => { + it("decodes a left-button press into 0-based coordinates", () => { + const { events, text, rest } = decodeMouseEvents(sgr(0, 12, 3)); + expect(text).toBe(""); + expect(rest).toBe(""); + expect(events).toEqual([ + { + kind: "press", + button: "left", + wheel: null, + x: 11, + y: 2, + shift: false, + alt: false, + ctrl: false, + }, + ]); + }); + + it("distinguishes release reports from presses", () => { + const { events } = decodeMouseEvents(sgr(0, 1, 1, false)); + expect(events[0]?.kind).toBe("release"); + expect(events[0]?.button).toBe("none"); + }); + + it("decodes middle and right buttons", () => { + const { events } = decodeMouseEvents(sgr(1, 2, 2) + sgr(2, 2, 2)); + expect(events.map((event) => event.button)).toEqual(["middle", "right"]); + }); + + it("decodes wheel up and wheel down", () => { + const { events } = decodeMouseEvents(sgr(64, 5, 5) + sgr(65, 5, 5)); + expect(events.map((event) => event.kind)).toEqual(["wheel", "wheel"]); + expect(events.map((event) => event.wheel)).toEqual(["up", "down"]); + }); + + it("decodes modifier bits", () => { + const { events } = decodeMouseEvents(sgr(0 + 4 + 8 + 16, 1, 1)); + expect(events[0]).toMatchObject({ shift: true, alt: true, ctrl: true }); + }); + + it("handles coordinates past the 223-column legacy ceiling", () => { + const { events } = decodeMouseEvents(sgr(0, 400, 260)); + expect(events[0]).toMatchObject({ x: 399, y: 259 }); + }); + + it("keeps the keyboard bytes around a report intact", () => { + const { events, text } = decodeMouseEvents(`a${sgr(0, 2, 2)}b`); + expect(text).toBe("ab"); + expect(events).toHaveLength(1); + }); + + it("reassembles a report split across two chunks", () => { + const whole = sgr(0, 30, 7); + const first = decodeMouseEvents(whole.slice(0, 6)); + expect(first.events).toEqual([]); + expect(first.text).toBe(""); + expect(first.rest).toBe(whole.slice(0, 6)); + const second = decodeMouseEvents(first.rest + whole.slice(6)); + expect(second.events).toHaveLength(1); + expect(second.events[0]).toMatchObject({ x: 29, y: 6 }); + expect(second.rest).toBe(""); + }); + + it("passes a lone Escape through instead of buffering it", () => { + const { events, text, rest } = decodeMouseEvents(ESC); + expect(events).toEqual([]); + expect(text).toBe(ESC); + expect(rest).toBe(""); + }); + + it("leaves non-mouse CSI sequences untouched", () => { + const arrows = `${ESC}[A${ESC}[B${ESC}[Z`; + const { events, text } = decodeMouseEvents(arrows); + expect(events).toEqual([]); + expect(text).toBe(arrows); + }); + + it("decodes the legacy X10 encoding so it is never typed as text", () => { + const x10 = `${ESC}[M${String.fromCharCode(32, 32 + 10, 32 + 4)}`; + const { events, text } = decodeMouseEvents(x10); + expect(text).toBe(""); + expect(events[0]).toMatchObject({ + kind: "press", + button: "left", + x: 9, + y: 3, + }); + }); + + it("buffers a truncated X10 report", () => { + const partial = `${ESC}[M${String.fromCharCode(32)}`; + const { events, rest } = decodeMouseEvents(partial); + expect(events).toEqual([]); + expect(rest).toBe(partial); + }); +}); diff --git a/src/tui/mouse/parse-mouse-events.ts b/src/tui/mouse/parse-mouse-events.ts new file mode 100644 index 00000000..4ef1a16a --- /dev/null +++ b/src/tui/mouse/parse-mouse-events.ts @@ -0,0 +1,148 @@ +import type { MouseButton, TuiMouseEvent } from "./mouse-event.js"; + +/** + * Incremental decoder for xterm mouse reports. + * + * Two encodings are understood: + * + * - **SGR / 1006** — `ESC [ < b ; col ; row (M|m)`. What we ask for + * (`\u001B[?1006h`) and what every modern terminal answers with. + * `M` is a press, `m` a release; columns/rows are 1-based and + * unbounded, which is why 1006 exists at all (the legacy encoding + * tops out at column 223). + * - **X10 / legacy** — `ESC [ M b col row` with each field a single + * byte offset by 32. Terminals that ignore the 1006 request fall + * back to this; decoding it costs ten lines and stops the raw bytes + * from being typed into the chat buffer as mojibake. + * + * The decoder is a pure function so the interesting part — a chunk + * boundary splitting a report in half — is unit-testable without a + * terminal. Everything that is not a mouse report comes back verbatim + * in `text` and must reach Ink's key parser untouched. + */ + +const ESC = "\u001B"; +/** Bit 2 of the button byte. */ +const SHIFT_BIT = 4; +/** Bit 3 ("meta" in the spec, Alt/Option in practice). */ +const ALT_BIT = 8; +/** Bit 4. */ +const CTRL_BIT = 16; +/** Bit 6 — wheel reports arrive as buttons 64 (up) / 65 (down). */ +const WHEEL_BIT = 64; + +const SGR_MOUSE = /^\u001B\[<(\d{1,6});(\d{1,6});(\d{1,6})([Mm])/; +const TRUNCATED_SGR = /^\u001B\[<\d{0,6}(;\d{0,6}){0,2}$/; +const TRUNCATED_X10 = /^\u001B\[M[\s\S]{0,2}$/; + +export interface DecodedMouseChunk { + /** Mouse reports found in this chunk, in arrival order. */ + readonly events: TuiMouseEvent[]; + /** Everything that was not a mouse report — forward this to Ink. */ + readonly text: string; + /** + * Trailing bytes that *might* be the head of a mouse report split + * across a read boundary. Prepend to the next chunk. + */ + readonly rest: string; +} + +/** + * Split `buffer` into mouse events plus the keyboard bytes around them. + * + * A lone trailing `ESC` is deliberately **not** buffered: that is how + * the Escape key itself arrives, and holding it back would make Esc + * respond only after the next keystroke. An incomplete `ESC [` (or a + * truncated report) is buffered — neither is a complete key sequence + * Ink could act on anyway. + */ +export function decodeMouseEvents(buffer: string): DecodedMouseChunk { + const events: TuiMouseEvent[] = []; + let text = ""; + let index = 0; + while (index < buffer.length) { + const esc = buffer.indexOf(ESC, index); + if (esc === -1) { + text += buffer.slice(index); + return { events, text, rest: "" }; + } + text += buffer.slice(index, esc); + const tail = buffer.slice(esc); + // Lone ESC at the very end, or an ESC followed by something that is + // not a CSI introducer: not ours, pass it through. + if (tail.length === 1 || tail[1] !== "[") { + text += ESC; + index = esc + 1; + continue; + } + const sgr = SGR_MOUSE.exec(tail); + if (sgr) { + events.push(decodeSgr(sgr)); + index = esc + sgr[0].length; + continue; + } + if (tail.startsWith(`${ESC}[M`)) { + if (tail.length < 6) return { events, text, rest: tail }; + events.push(decodeX10(tail)); + index = esc + 6; + continue; + } + if ( + tail === `${ESC}[` || + TRUNCATED_SGR.test(tail) || + TRUNCATED_X10.test(tail) + ) { + return { events, text, rest: tail }; + } + // A CSI that is not a mouse report (arrows, Shift+Tab, kitty + // protocol replies…). Emit the introducer and resume scanning after + // it so the rest of the sequence flows to Ink unchanged. + text += `${ESC}[`; + index = esc + 2; + } + return { events, text, rest: "" }; +} + +function decodeSgr(match: RegExpExecArray): TuiMouseEvent { + const code = Number.parseInt(match[1] ?? "0", 10); + const column = Number.parseInt(match[2] ?? "1", 10); + const row = Number.parseInt(match[3] ?? "1", 10); + return buildEvent(code, column, row, match[4] === "m"); +} + +function decodeX10(tail: string): TuiMouseEvent { + const code = (tail.codePointAt(3) ?? 32) - 32; + const column = (tail.codePointAt(4) ?? 33) - 32; + const row = (tail.codePointAt(5) ?? 33) - 32; + // The legacy encoding has no dedicated release code: low bits `3` + // mean "some button came up" and never say which one. + return buildEvent(code, column, row, (code & 3) === 3); +} + +function buildEvent( + code: number, + column: number, + row: number, + released: boolean, +): TuiMouseEvent { + const wheeling = (code & WHEEL_BIT) !== 0; + const low = code & 3; + return { + kind: wheeling ? "wheel" : released ? "release" : "press", + button: wheeling || released ? "none" : buttonFromLowBits(low), + wheel: wheeling ? (low === 0 ? "up" : "down") : null, + // Terminals report 1-based cells; the layout engine is 0-based. + x: Math.max(0, column - 1), + y: Math.max(0, row - 1), + shift: (code & SHIFT_BIT) !== 0, + alt: (code & ALT_BIT) !== 0, + ctrl: (code & CTRL_BIT) !== 0, + }; +} + +function buttonFromLowBits(low: number): MouseButton { + if (low === 0) return "left"; + if (low === 1) return "middle"; + if (low === 2) return "right"; + return "none"; +} diff --git a/src/tui/mouse/synthetic-key.ts b/src/tui/mouse/synthetic-key.ts new file mode 100644 index 00000000..298d806d --- /dev/null +++ b/src/tui/mouse/synthetic-key.ts @@ -0,0 +1,47 @@ +import type { Key } from "ink"; + +/** + * Ink `Key` objects synthesised from mouse gestures. + * + * The wheel and "click the already-selected row" gestures mean exactly + * what ↑/↓/Enter mean, and every panel already owns a key handler with + * its own clamping, windowing and activation rules + * (`*-key-bindings.ts`). Feeding those handlers a synthetic key reuses + * that logic wholesale instead of duplicating a second, drifting copy + * of "what does moving the cursor mean in the Skills panel". + */ + +const NO_KEY: Key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, +}; + +/** A bare ↑ or ↓ press. */ +export function arrowKey(direction: "up" | "down"): Key { + return direction === "up" + ? { ...NO_KEY, upArrow: true } + : { ...NO_KEY, downArrow: true }; +} + +/** A bare Enter press — the "activate what is selected" gesture. */ +export function returnKey(): Key { + return { ...NO_KEY, return: true }; +} diff --git a/src/tui/persist-user-tui-config.ts b/src/tui/persist-user-tui-config.ts index fac398ba..4cee87e0 100644 --- a/src/tui/persist-user-tui-config.ts +++ b/src/tui/persist-user-tui-config.ts @@ -23,3 +23,17 @@ export function persistUserTuiTheme(theme: string): void { writeUserConfigFileSync(path, validated); resetConfigCache(); } + +/** + * Persist the mouse-support toggle into `tui.mouse`. Same read → merge → + * validate → write → reset cycle as the theme; the caller owns turning + * the terminal's reporting mode on or off for the running session. + */ +export function persistUserTuiMouse(mouse: boolean): void { + const path = getConfig().paths.userConfigFile; + const prev = ensureUserConfigFileSync(path); + const draft = { ...prev, tui: { ...prev.tui, mouse } }; + const validated = parseUserConfigFile(draft); + writeUserConfigFileSync(path, validated); + resetConfigCache(); +} diff --git a/src/tui/providers/providers-actions.ts b/src/tui/providers/providers-actions.ts index c4497784..ba1615b1 100644 --- a/src/tui/providers/providers-actions.ts +++ b/src/tui/providers/providers-actions.ts @@ -14,6 +14,8 @@ export type ProvidersAction = | { type: "providers_set_active_embedding"; id: string } | { type: "providers_cursor_down" } | { type: "providers_cursor_up" } + /** Put the provider-list cursor on an absolute row (mouse click). */ + | { type: "providers_cursor_set"; row: number } | { type: "providers_status"; line: string | null } | { type: "providers_busy"; busy: boolean } | { type: "providers_wizard_opened"; wizard: ProvidersWizardState } diff --git a/src/tui/providers/providers-reducer.ts b/src/tui/providers/providers-reducer.ts index 39acf506..5a56c05e 100644 --- a/src/tui/providers/providers-reducer.ts +++ b/src/tui/providers/providers-reducer.ts @@ -46,6 +46,15 @@ export function reduceProvidersPanel( cursor: (panel.cursor + 1) % panel.rows.length, }, }; + case "providers_cursor_set": + if (panel.rows.length === 0) return state; + return { + ...state, + providersPanel: { + ...panel, + cursor: Math.min(panel.rows.length - 1, Math.max(0, action.row)), + }, + }; case "providers_cursor_up": if (panel.rows.length === 0) return state; return { diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..b68cd2c5 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -44,6 +44,14 @@ export function reduceUiAction( ); return { ...state, themePickerCursor: next }; } + case "theme_picker_cursor_set": { + if (!state.themePickerOpen) return state; + const max = THEME_NAMES.length - 1; + return { + ...state, + themePickerCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "tool_expand_toggled": { const current = state.toolsExpandedById[action.toolCardId] ?? false; return { @@ -123,6 +131,13 @@ export function reduceUiAction( ); return { ...state, sessionPickerCursor: next }; } + case "session_picker_cursor_set": { + const max = Math.max(0, state.sessionPickerList.length - 1); + return { + ...state, + sessionPickerCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "llama_url_changed": return { ...state, @@ -153,6 +168,13 @@ export function reduceUiAction( ); return { ...state, sidebarCursor: next }; } + case "sidebar_cursor_set": { + const max = Math.max(0, state.recentSessions.length - 1); + return { + ...state, + sidebarCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "sidebar_tasks_cursor_moved": { // Upper bound here is the **rendered** sidebar tasks list size, // capped by SIDEBAR_TASKS_LIMIT and the number of active/recurring @@ -167,6 +189,13 @@ export function reduceUiAction( ); return { ...state, sidebarTasksCursor: next }; } + case "sidebar_tasks_cursor_set": { + const max = Math.max(0, selectSidebarTasks(state.tasksPanel.rows).length - 1); + return { + ...state, + sidebarTasksCursor: Math.min(max, Math.max(0, action.row)), + }; + } case "chat_scrolled": { // `chatScrollOffset` is in **lines** since the line-by-line // scroll refactor — the unit changed but the field name is diff --git a/src/tui/skills/skills-actions.ts b/src/tui/skills/skills-actions.ts index 0101a6f1..34046189 100644 --- a/src/tui/skills/skills-actions.ts +++ b/src/tui/skills/skills-actions.ts @@ -40,6 +40,8 @@ export type SkillsAction = error: string | null; } | { type: "skills_hub_cursor_moved"; delta: 1 | -1 | number } + /** Put the Skills Hub cursor on an absolute row (mouse click). */ + | { type: "skills_hub_cursor_set"; row: number } | { type: "skills_hub_search_focus"; editing: boolean } | { type: "skills_hub_query_changed"; query: string } | { type: "skills_install_loading"; loading: boolean } diff --git a/src/tui/skills/skills-reducer.ts b/src/tui/skills/skills-reducer.ts index e5447677..6027b654 100644 --- a/src/tui/skills/skills-reducer.ts +++ b/src/tui/skills/skills-reducer.ts @@ -121,6 +121,11 @@ function reducePanel( hubLoading: false, hubCursor: clampCursor(panel.hubCursor, action.rows.length), }; + case "skills_hub_cursor_set": + return { + ...panel, + hubCursor: clampCursor(action.row, panel.hubRows.length), + }; case "skills_hub_cursor_moved": { const total = panel.hubRows.length; const nextCursor = Math.max( diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index e7542555..55f5a1a4 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -212,6 +212,11 @@ export function runSlashCommand( break; } } + if (result.mouseVerb) { + callbacks.onMouseSupportRequested?.( + result.mouseVerb === "status" ? null : result.mouseVerb === "on", + ); + } if (result.approvalLevelSet !== undefined) { void callbacks.onApprovalLevelSetRequested?.(result.approvalLevelSet); } diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..3512f17a 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -109,6 +109,8 @@ export type TuiAction = | { type: "session_picker_closed" } /** Move the highlight in the open session picker by delta rows. */ | { type: "session_picker_cursor_moved"; delta: 1 | -1 } + /** Put the session picker highlight on an absolute row (mouse click). */ + | { type: "session_picker_cursor_set"; row: number } /** * Open the interactive theme picker. The reducer seeds the cursor from the * current `themeName` and records it in `themePickerOriginal` so Esc can @@ -119,6 +121,8 @@ export type TuiAction = | { type: "theme_picker_closed" } /** Move the theme picker highlight by delta rows (clamped). */ | { type: "theme_picker_cursor_moved"; delta: 1 | -1 } + /** Put the theme picker highlight on an absolute row (mouse click). */ + | { type: "theme_picker_cursor_set"; row: number } /** * Hard-switch the TUI transcript to an already-loaded session. The * orchestrator performs the SessionStore load + swap, then dispatches @@ -172,8 +176,12 @@ export type TuiAction = | { type: "sidebar_section_focused"; section: "sessions" | "tasks" } /** Move the sidebar's session-list cursor by N rows (clamped). */ | { type: "sidebar_cursor_moved"; delta: 1 | -1 } + /** Put the sidebar's session-list cursor on an absolute row (mouse click). */ + | { type: "sidebar_cursor_set"; row: number } /** Move the sidebar's tasks-list cursor by N rows (clamped). */ | { type: "sidebar_tasks_cursor_moved"; delta: 1 | -1 } + /** Put the sidebar's tasks-list cursor on an absolute row (mouse click). */ + | { type: "sidebar_tasks_cursor_set"; row: number } /** Scroll the chat history by N messages (positive = older). Clamped to [0, total]. */ | { type: "chat_scrolled"; delta: number } /** Snap the chat scroll back to the bottom (newest message). */ diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..731c7543 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useApp, useInput } from "ink"; +import { Box, Text, useApp, useInput, type DOMElement, type Key } from "ink"; import { useCallback, useEffect, @@ -10,7 +10,11 @@ import { import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; -import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { + handleAppKey, + handlePanelEscape, + isPanelModalOpen, +} from "./app-key-bindings.js"; import { ApprovalModal } from "./approval-modal.js"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; @@ -63,6 +67,15 @@ import type { ImportFormState } from "./import/import-panel-state.js"; import { handleProvidersTabKey } from "./providers/providers-key-bindings.js"; import { handleTelegramTabKey } from "./telegram/telegram-key-bindings.js"; import { handlePrivacyTabKey } from "./privacy/privacy-key-bindings.js"; +import { MouseProvider } from "./mouse/mouse-context.js"; +import { + MOUSE_LAYER_BASE, + MOUSE_LAYER_MODAL, + MouseTargetRegistry, + type MouseHit, +} from "./mouse/mouse-registry.js"; +import type { MouseSource } from "./mouse/mouse-source.js"; +import { arrowKey } from "./mouse/synthetic-key.js"; export { makeTuiEventBus } from "./make-event-bus.js"; @@ -93,6 +106,12 @@ export interface TuiAppCallbacks { onPersistLlamaUrl?(url: string): void; /** Persist the chosen TUI theme name into the user config (`/theme`). */ onThemePersistRequested?(themeName: string): void; + /** + * `/mouse on|off` — flip terminal mouse reporting live. `null` asks + * for the current state to be reported without changing it. The + * handler owns the escape sequences and the config write. + */ + onMouseSupportRequested?(enabled: boolean | null): void; /** Start the Tasks-tab auto-refresh loop (first entry only). */ onTasksAutoRefreshStart?(): void; /** Perform a one-shot refresh of the tasks list. */ @@ -340,6 +359,12 @@ export interface TuiAppProps { maxVisibleRows?: number; /** Optional initial debug tab / mode (e.g. after managed-mode wizard). */ initialLayout?: InitialTuiLayoutOptions; + /** + * Decoded terminal mouse reports. Supplied by `tui-command.ts` when + * mouse support is on; omitted (tests, `--no-mouse`) the app is + * keyboard-only and every clickable surface simply never fires. + */ + mouse?: MouseSource; } const DEFAULT_MAX_VISIBLE_ROWS = 14; @@ -354,6 +379,14 @@ const CTRL_C_WINDOW_MS = 1500; const SIDEBAR_MIN_COLUMNS = 100; const SIDEBAR_WIDTH = 30; +/** + * Rows the chat transcript moves per wheel notch. Three keeps a flick + * of the wheel useful on a long transcript without overshooting the + * reply the operator is reading; the keyboard's own ±2 arrow scroll is + * deliberately finer. + */ +const WHEEL_SCROLL_LINES = 3; + /** * Rotating placeholder pool shown in the prompt's empty state. Phrasing * intentionally nudges the operator toward concrete actions the agent @@ -374,6 +407,7 @@ export function TuiApp({ callbacks, maxVisibleRows = DEFAULT_MAX_VISIBLE_ROWS, initialLayout, + mouse, }: TuiAppProps): ReactElement { const [state, dispatch] = useReducer(reduceTuiState, { session, initialLayout }, (init) => createInitialTuiState(init.session, DEFAULT_RING_BUFFER_SIZE, init.initialLayout), @@ -381,9 +415,24 @@ export function TuiApp({ const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); const ctrlCTimer = useRef(null); + const registryRef = useRef(null); + registryRef.current ??= new MouseTargetRegistry(); + const registry = registryRef.current; + // Click handlers run outside React's render pass, so they read state + // through a ref rather than a closure that may be a frame stale. + const stateRef = useRef(state); + stateRef.current = state; + const getState = useCallback(() => stateRef.current, []); useEffect(() => bus.subscribe(dispatch), [bus]); + useEffect(() => { + if (!mouse) return; + return mouse.subscribe((event) => { + registry.dispatch(event); + }); + }, [mouse, registry]); + useEffect(() => { callbacks.onProvidersTabRefresh?.(); }, [callbacks]); @@ -522,6 +571,77 @@ export function TuiApp({ } }, [sidebarVisible, state.chatFocus]); + /** + * Routes a key to whichever Observe / Manage panel is on screen. + * Returns `null` when no panel owns the surface (chat mode), `true` / + * `false` for handled / declined. Shared by the keyboard hook and the + * mouse wheel, so a wheel notch means exactly what an arrow key means + * on every panel — including the clamping each panel does itself. + */ + const routePanelKey = (input: string, key: Key): boolean | null => { + const ctx = { state, dispatch, callbacks }; + if (tasksTabActive) return handleTasksTabKey(input, key, ctx); + if (skillsTabActive) return handleSkillsTabKey(input, key, ctx); + if (memoryTabActive) return handleMemoryTabKey(input, key, ctx); + if (mcpTabActive) return handleMcpTabKey(input, key, ctx); + if (providersTabActive) return handleProvidersTabKey(input, key, ctx); + if (llmTabActive) return handleLlmPanelKey(input, key, ctx); + if (localModelsTabActive) return handleLocalModelsTabKey(input, key, ctx); + if (telegramTabActive) return handleTelegramTabKey(input, key, ctx); + if (importTabActive) return handleImportTabKey(input, key, ctx); + if (privacyTabActive) return handlePrivacyTabKey(input, key, ctx); + return null; + }; + + // While a modal or confirm owns the keyboard it owns the mouse too: + // raising the floor stops a click from reaching the list rendered + // behind it. Same predicate the key layer gates on. + const modalOwnsInput = + Boolean(state.pendingApproval) || + Boolean(state.updatePrompt) || + state.updateStatus === "done" || + state.sessionPickerOpen || + state.themePickerOpen || + state.slashPaletteOpen || + isPanelModalOpen(state); + useEffect(() => { + registry.setMinLayer(modalOwnsInput ? MOUSE_LAYER_MODAL : MOUSE_LAYER_BASE); + }, [registry, modalOwnsInput]); + + /** + * Whole-viewport wheel target. Scrolling over the chat moves the + * transcript; over a panel it walks that panel's cursor. Registered + * at the base layer and covering everything, so it only ever fires + * for events no smaller target claimed. + */ + const contentMouseRef = useRef(null); + const wheelHandler = (hit: MouseHit): boolean => { + if (hit.event.kind !== "wheel" || !hit.event.wheel) return false; + const direction = hit.event.wheel; + if (state.uiMode === "chat") { + dispatch({ + type: "chat_scrolled", + delta: direction === "up" ? WHEEL_SCROLL_LINES : -WHEEL_SCROLL_LINES, + }); + return true; + } + return routePanelKey("", arrowKey(direction)) === true; + }; + // TuiApp renders the provider, so it cannot consume the context hook + // itself — it registers on the registry it owns. The handler is read + // through a ref so the subscription survives every re-render. + const wheelHandlerRef = useRef(wheelHandler); + wheelHandlerRef.current = wheelHandler; + useEffect( + () => + registry.register({ + ref: contentMouseRef, + layer: MOUSE_LAYER_BASE, + handler: (hit) => wheelHandlerRef.current(hit), + }), + [registry], + ); + useInput((input, key) => { const appHandled = handleAppKey(input, key, { state, @@ -537,32 +657,7 @@ export function TuiApp({ // navigation, tab completion, enter to run, esc to close. Routing to // a debug-tab panel here would re-interpret letters as hotkeys. if (state.slashPaletteOpen) return; - let panelHandled: boolean | null = null; - if (tasksTabActive) { - panelHandled = handleTasksTabKey(input, key, { state, dispatch, callbacks }); - } else if (skillsTabActive) { - panelHandled = handleSkillsTabKey(input, key, { state, dispatch, callbacks }); - } else if (memoryTabActive) { - panelHandled = handleMemoryTabKey(input, key, { state, dispatch, callbacks }); - } else if (mcpTabActive) { - panelHandled = handleMcpTabKey(input, key, { state, dispatch, callbacks }); - } else if (providersTabActive) { - panelHandled = handleProvidersTabKey(input, key, { state, dispatch, callbacks }); - } else if (llmTabActive) { - panelHandled = handleLlmPanelKey(input, key, { state, dispatch, callbacks }); - } else if (localModelsTabActive) { - panelHandled = handleLocalModelsTabKey(input, key, { - state, - dispatch, - callbacks, - }); - } else if (telegramTabActive) { - panelHandled = handleTelegramTabKey(input, key, { state, dispatch, callbacks }); - } else if (importTabActive) { - panelHandled = handleImportTabKey(input, key, { state, dispatch, callbacks }); - } else if (privacyTabActive) { - panelHandled = handlePrivacyTabKey(input, key, { state, dispatch, callbacks }); - } + const panelHandled = routePanelKey(input, key); if (panelHandled !== null) { handlePanelEscape(key, { panelHandled, editorFocus, dispatch }); return; @@ -715,9 +810,16 @@ export function TuiApp({ ) : null; return ( + @@ -829,6 +931,7 @@ export function TuiApp({ ) : null} + ); } diff --git a/src/tui/tui-args.test.ts b/src/tui/tui-args.test.ts index bdde0131..3abbed21 100644 --- a/src/tui/tui-args.test.ts +++ b/src/tui/tui-args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { nonInteractiveStdinError, parseTuiArgs } from "./tui-args.js"; +import { nonInteractiveStdinError, parseTuiArgs, TUI_HELP } from "./tui-args.js"; describe("nonInteractiveStdinError", () => { it("refuses a piped stdin with an actionable sentence", () => { @@ -25,3 +25,23 @@ describe("parseTuiArgs", () => { expect(parseTuiArgs(["--definitely-not-a-flag"])).toHaveProperty("error"); }); }); + +describe("parseTuiArgs mouse flags", () => { + it("defers to the config by default", () => { + const parsed = parseTuiArgs([]); + expect(parsed).toMatchObject({ mouse: null }); + }); + + it("--no-mouse turns reporting off for the run", () => { + expect(parseTuiArgs(["--no-mouse"])).toMatchObject({ mouse: false }); + }); + + it("--mouse forces reporting on even when the config disabled it", () => { + expect(parseTuiArgs(["--mouse"])).toMatchObject({ mouse: true }); + }); + + it("advertises both flags in --help", () => { + expect(TUI_HELP).toContain("--no-mouse"); + expect(TUI_HELP).toContain("--mouse"); + }); +}); diff --git a/src/tui/tui-args.ts b/src/tui/tui-args.ts index d68a9599..fbd862ca 100644 --- a/src/tui/tui-args.ts +++ b/src/tui/tui-args.ts @@ -11,6 +11,12 @@ export interface TuiArgs { noApproval: boolean; /** Skip the first-run llama-server setup wizard when /health fails. */ skipLlamaSetup: boolean; + /** + * Terminal mouse reporting override. `null` defers to `tui.mouse` in + * the user config; `false` (`--no-mouse`) keeps the terminal's own + * text selection for this run. + */ + mouse: boolean | null; } export type TuiArgsResult = TuiArgs | { error: string } | { help: true }; @@ -28,6 +34,8 @@ export const TUI_HELP = " --max-steps Step budget per turn (default: agent.maxSteps from config)", " --no-approval Force approval level 5: auto-approve every dangerous tool call", " --skip-llama-setup Skip the first-run local-model setup gate", + " --mouse Force terminal mouse support on for this run", + " --no-mouse Disable mouse support; restores drag-to-select", "", "Needs an interactive terminal; in scripts use `atomic-agent run`.", ].join("\n") + "\n"; @@ -42,12 +50,14 @@ export const TUI_HELP = * --max-steps override the loop safety cap * --no-approval force approval level 5 (approve everything) for this run * --skip-llama-setup skip the startup llama URL wizard + * --mouse / --no-mouse force mouse reporting on / off */ export function parseTuiArgs(args: string[]): TuiArgsResult { let workingDir: string | null = null; let maxSteps: number | null = null; let noApproval = false; let skipLlamaSetup = false; + let mouse: boolean | null = null; for (let i = 0; i < args.length; i += 1) { const flag = args[i]; switch (flag) { @@ -74,6 +84,12 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { case "--skip-llama-setup": skipLlamaSetup = true; break; + case "--mouse": + mouse = true; + break; + case "--no-mouse": + mouse = false; + break; default: return { error: `unknown flag: ${flag}` }; } @@ -83,6 +99,7 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { maxSteps, noApproval, skipLlamaSetup, + mouse, }; } diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 132a37c2..846e4840 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -19,7 +19,16 @@ import { persistUserLocalLlmUrl, pointsAtManagedDaemon, } from "./persist-user-local-models-config.js"; -import { persistUserTuiTheme } from "./persist-user-tui-config.js"; +import { + persistUserTuiMouse, + persistUserTuiTheme, +} from "./persist-user-tui-config.js"; +import { createMouseStdin } from "./mouse/mouse-stdin.js"; +import { makeMouseSource } from "./mouse/mouse-source.js"; +import { + enableMouseTracking, + type MouseTrackingController, +} from "./mouse/mouse-tracking.js"; import { isLocalBackendConfigured, isManagedModeReadyOnDisk, @@ -180,18 +189,62 @@ export async function tuiCommand(args: string[]): Promise { const altScreen = enterAltScreen({ stdout: process.stdout, hideCursor: false }); - // Mouse-wheel scroll relies on the terminal's alternate-scroll mode - // (`\x1b[?1007h`, enabled by `enterAltScreen`): while the alt screen - // is active the wheel is translated by the terminal into cursor - // up/down keys, which `handleAppKey.shouldTreatArrowAsChatScroll` - // routes into `chat_scrolled`. Crucially we do NOT enable SGR mouse - // tracking (1000 + 1006) — doing so would hand every click/drag to - // the app and disable the terminal's native text selection (broken - // entirely in Apple Terminal, which has no Shift-bypass). Keeping - // capture off means drag-to-copy works natively everywhere, matching - // opencode's default. The wheel only drives chat scroll while the - // editor is focused and empty — an accepted trade-off for native - // selection. + // Mouse support. Enabling SGR tracking (1000 + 1006) is what makes + // clicking panels, rows, tabs and the prompt work at all — the app + // cannot see a click the terminal never reports. The cost is real and + // was the reason this was previously left off: while reporting is on, + // the terminal stops doing its own drag-to-select (Apple Terminal has + // no Shift-bypass at all). So it is a toggle, not a fact of life — + // `tui.mouse` in the config, `--mouse` / `--no-mouse` per run, and + // `/mouse on|off` live. With reporting off, behaviour is exactly what + // it was before: alternate-scroll (`\x1b[?1007h` from + // `enterAltScreen`) turns the wheel into cursor keys, which + // `handleAppKey.shouldTreatArrowAsChatScroll` routes into + // `chat_scrolled`. + // + // The decoded events reach React through `mouseSource`; the bytes + // themselves are stripped from the stream Ink reads, because Ink's key + // parser would otherwise type them into the chat buffer. + const mouseEnabled = parsed.mouse ?? config.tui.mouse; + const mouseSource = makeMouseSource(); + const mouseStdin = createMouseStdin(process.stdin, mouseSource.emit); + let mouseTracking: MouseTrackingController | null = mouseEnabled + ? enableMouseTracking({ stdout: process.stdout }) + : null; + const setMouseEnabled = (next: boolean | null): void => { + if (next === null) { + bus.emit({ + type: "system_message", + text: `mouse support is ${mouseTracking ? "on" : "off"} — /mouse on|off to change`, + }); + return; + } + if (next === Boolean(mouseTracking)) { + bus.emit({ + type: "system_message", + text: `mouse support already ${next ? "on" : "off"}`, + }); + return; + } + if (next) { + mouseTracking = enableMouseTracking({ stdout: process.stdout }); + } else { + mouseTracking?.disable(); + mouseTracking = null; + } + try { + persistUserTuiMouse(next); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + bus.emit({ type: "runtime_info", line: `mouse setting not saved: ${msg}` }); + } + bus.emit({ + type: "system_message", + text: next + ? "mouse support on — click panels, rows and the prompt; wheel scrolls" + : "mouse support off — the terminal's own text selection is back", + }); + }; const ink = render( React.createElement(TuiApp, { @@ -386,10 +439,12 @@ export async function tuiCommand(args: string[]): Promise { onUpdateRestart: () => { restartRequested = true; }, + onMouseSupportRequested: setMouseEnabled, }, + ...(mouseEnabled ? { mouse: mouseSource } : {}), }), { - stdin: process.stdin, + stdin: mouseStdin.stdin, stdout: process.stdout, stderr: process.stderr, exitOnCtrlC: false, @@ -434,6 +489,8 @@ export async function tuiCommand(args: string[]): Promise { process.off("SIGTERM", onSignal); process.off("SIGHUP", onSignal); try { + mouseTracking?.disable(); + mouseStdin.dispose(); altScreen.restore(); ink.clear(); } catch { From b6d3eb4098b4e67b9bd83e0e5798b5d25769185e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Wed, 19 Aug 2026 05:36:23 +0300 Subject: [PATCH 22/57] config: parse userModels, promptCache and providerPreferences on llm providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseLlmProviderEntry rebuilds its result field by field, and three fields documented in the config schema and typed on LlmProviderConfigEntry were never read: a `llm.providers[].userModels` array written into config.json was silently dropped at parse time, as were `promptCache` and `providerPreferences`. userModels is the one with teeth today: resolveModel reads it as its highest-priority source (userModels > bundled catalog > defaults), so a hand-configured model for a provider the catalog does not know silently fell back to the 128k/no-pricing defaults instead of the configured context window, capabilities and prices. promptCache and providerPreferences have no consumer yet — this only makes them survive the round-trip so one can read them. Validation mirrors the surrounding parsers: ConfigValidationError with a `llm.providers[i].userModels[j].x` field path. Duplicate model ids inside one provider are rejected because resolveModel looks a model up with .find, so a duplicate would silently shadow. Co-Authored-By: Claude Opus 5 --- src/config/llm-config.test.ts | 154 +++++++++++++++++++++++++++ src/config/llm-config.ts | 195 +++++++++++++++++++++++++++++++++- 2 files changed, 347 insertions(+), 2 deletions(-) diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts index 544099de..511d5470 100644 --- a/src/config/llm-config.test.ts +++ b/src/config/llm-config.test.ts @@ -231,6 +231,160 @@ describe("llm-config", () => { }); }); + const withProviderField = (extra: Record) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "openrouter", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto" as const, + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "gpt", ...extra }, + ], + }, + }); + + it("round-trips promptCache and providerPreferences on a provider entry", () => { + const parsed = parseUserConfigFile( + withProviderField({ + promptCache: "explicit-markers", + providerPreferences: { order: ["anthropic"], allow_fallbacks: false }, + }), + ); + expect(parsed.llm?.providers[1]).toMatchObject({ + promptCache: "explicit-markers", + providerPreferences: { order: ["anthropic"], allow_fallbacks: false }, + }); + }); + + it("rejects an unknown promptCache mode", () => { + expect(() => + parseUserConfigFile(withProviderField({ promptCache: "always" })), + ).toThrow(/llm\.providers\[1\]\.promptCache/); + }); + + it("rejects a non-object providerPreferences", () => { + expect(() => + parseUserConfigFile(withProviderField({ providerPreferences: ["anthropic"] })), + ).toThrow(/llm\.providers\[1\]\.providerPreferences/); + }); + + const withUserModels = (userModels: unknown) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "model-studio", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto" as const, + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { + id: "model-studio", + kind: "qwen-openai-compatible", + baseUrl: "https://example.invalid/compatible-mode", + defaultChatModel: "qwen3.8-27b", + userModels, + }, + ], + }, + }); + + it("round-trips userModels on a provider entry", () => { + const parsed = parseUserConfigFile( + withUserModels([ + { + id: "qwen3.8-27b", + kind: "chat", + contextWindow: 262144, + supportsVision: true, + supportsTools: "strict", + supportsPromptCache: true, + reasoningFormat: "delta_reasoning_content", + pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 }, + }, + { id: "text-embedding-v4", kind: "embedding", dim: 1024 }, + ]), + ); + + // resolveModel reads userModels as its highest-priority source, so + // the parser dropping these rows is the difference between a + // hand-configured model and the 128k/no-pricing defaults. + expect(parsed.llm?.providers[1]?.userModels).toEqual([ + { + id: "qwen3.8-27b", + kind: "chat", + contextWindow: 262144, + dim: undefined, + supportsVision: true, + supportsTools: "strict", + supportsPromptCache: true, + reasoningFormat: "delta_reasoning_content", + pricing: { input: 0.0004, output: 0.0012, cacheRead: 0 }, + }, + { + id: "text-embedding-v4", + kind: "embedding", + contextWindow: undefined, + dim: 1024, + supportsVision: undefined, + supportsTools: undefined, + supportsPromptCache: undefined, + reasoningFormat: undefined, + pricing: undefined, + }, + ]); + }); + + it("omits userModels when the entry does not configure any", () => { + const parsed = parseUserConfigFile(withUserModels(undefined)); + expect(parsed.llm?.providers[1]?.userModels).toBeUndefined(); + }); + + it("rejects a userModels row with an unknown kind", () => { + expect(() => + parseUserConfigFile( + withUserModels([{ id: "qwen3.8-27b", kind: "completion" }]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.kind/); + }); + + it("rejects a userModels row with a malformed contextWindow", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat" }, + { id: "b", kind: "chat", contextWindow: "262144" }, + ]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[1\]\.contextWindow/); + }); + + it("rejects userModels pricing that is missing a rate", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat", pricing: { input: 0.0004 } }, + ]), + ), + ).toThrow(/llm\.providers\[1\]\.userModels\[0\]\.pricing\.output/); + }); + + it("rejects duplicate model ids within one provider's userModels", () => { + expect(() => + parseUserConfigFile( + withUserModels([ + { id: "a", kind: "chat" }, + { id: "a", kind: "chat" }, + ]), + ), + ).toThrow(/userModels\[1\]\.id/); + }); + + it("rejects a non-array userModels", () => { + expect(() => + parseUserConfigFile(withUserModels({ "qwen3.8-27b": { kind: "chat" } })), + ).toThrow(/userModels/); + }); + it("rejects a non-object extraBody", () => { expect(() => parseUserConfigFile({ diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index ed3f3a6c..9ea09818 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -22,6 +22,18 @@ export type UserLlmProviderEntry = { supportsTools?: boolean; supportsVision?: boolean; requestTimeoutMs?: number; + /** + * Prompt-caching policy for this provider. Declared in the config + * schema and on `LlmProviderConfigEntry`; no provider reads it yet, + * so today it only has to survive the round-trip through config. + */ + promptCache?: "auto" | "off" | "explicit-markers"; + /** + * Vendor routing preferences (e.g. OpenRouter's `provider` block). + * Same status as `promptCache`: carried through config, not yet read + * by any provider. + */ + providerPreferences?: Record; /** * Vendor-specific fields merged into the OpenAI-compatible chat body * for `openai-compatible` / `qwen-openai-compatible` providers. Lets a @@ -36,6 +48,44 @@ export type UserLlmProviderEntry = { * after the merge and cannot be overridden from config. */ extraBody?: Record; + /** + * Hand-written model metadata for this provider. `resolveModel` + * reads it as its highest-priority source (userModels > bundled + * catalog > defaults), so it is the documented way to teach the + * runtime about a model the bundled catalog does not know: context + * window, capabilities and pricing. + */ + userModels?: ReadonlyArray; +}; + +/** + * One hand-configured model on a provider entry. Mirrors + * `UserModelConfigEntry` in the provider registry — the shape + * `resolveModel` merges over the bundled catalog. + * + * Note `supportsTools` here is a support *level*, not the boolean of + * the same name on the provider entry: a model can advertise strict or + * parallel tool calling independently of whether the transport does. + */ +export type UserModelEntry = { + id: string; + kind: "chat" | "embedding"; + contextWindow?: number; + dim?: number; + supportsVision?: boolean; + supportsTools?: "none" | "basic" | "parallel" | "strict"; + supportsPromptCache?: boolean; + reasoningFormat?: + | "none" + | "delta_reasoning" + | "delta_thinking" + | "delta_reasoning_content"; + pricing?: { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + }; }; export type UserLlmFallbackConfig = { @@ -172,11 +222,19 @@ export function parseLlmProviderEntry( "expected positive number", ); })(), - extraBody: parseOptionalExtraBody(obj.extraBody, `${field}.extraBody`), + promptCache: parseOptionalEnum< + NonNullable + >(obj.promptCache, `${field}.promptCache`, PROMPT_CACHE_MODES), + providerPreferences: parseOptionalPlainObject( + obj.providerPreferences, + `${field}.providerPreferences`, + ), + extraBody: parseOptionalPlainObject(obj.extraBody, `${field}.extraBody`), + userModels: parseOptionalUserModels(obj.userModels, `${field}.userModels`), }; } -function parseOptionalExtraBody( +function parseOptionalPlainObject( raw: unknown, field: string, ): Record | undefined { @@ -187,6 +245,139 @@ function parseOptionalExtraBody( return { ...(raw as Record) }; } +const PROMPT_CACHE_MODES = new Set(["auto", "off", "explicit-markers"]); +const TOOLS_SUPPORT_LEVELS = new Set(["none", "basic", "parallel", "strict"]); +const REASONING_FORMATS = new Set([ + "none", + "delta_reasoning", + "delta_thinking", + "delta_reasoning_content", +]); + +function parseOptionalBoolean( + raw: unknown, + field: string, +): boolean | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "boolean") { + throw new ConfigValidationError(field, "expected boolean"); + } + return raw; +} + +function parseOptionalEnum( + raw: unknown, + field: string, + allowed: ReadonlySet, +): T | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "string" || !allowed.has(raw)) { + throw new ConfigValidationError(field, `expected ${[...allowed].join("|")}`); + } + return raw as T; +} + +/** + * Prices are per-token rates, so 0 is legal (free tiers) but negative + * or non-finite is not — a NaN rate would poison every cost estimate + * downstream rather than fail loudly. + */ +function parseRate(raw: unknown, field: string): number { + if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) { + throw new ConfigValidationError(field, "expected a non-negative number"); + } + return raw; +} + +function parseUserModelPricing( + raw: unknown, + field: string, +): UserModelEntry["pricing"] | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const pricing: NonNullable = { + input: parseRate(obj.input, `${field}.input`), + output: parseRate(obj.output, `${field}.output`), + }; + if (obj.cacheRead !== undefined && obj.cacheRead !== null) { + pricing.cacheRead = parseRate(obj.cacheRead, `${field}.cacheRead`); + } + if (obj.cacheWrite !== undefined && obj.cacheWrite !== null) { + pricing.cacheWrite = parseRate(obj.cacheWrite, `${field}.cacheWrite`); + } + return pricing; +} + +function parseUserModelEntry(raw: unknown, field: string): UserModelEntry { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + if (typeof obj.id !== "string" || obj.id.length === 0) { + throw new ConfigValidationError(`${field}.id`, "expected non-empty string"); + } + if (obj.kind !== "chat" && obj.kind !== "embedding") { + throw new ConfigValidationError(`${field}.kind`, "expected chat|embedding"); + } + return { + id: obj.id, + kind: obj.kind, + contextWindow: + obj.contextWindow === undefined || obj.contextWindow === null + ? undefined + : parsePositiveInt(obj.contextWindow, `${field}.contextWindow`), + dim: + obj.dim === undefined || obj.dim === null + ? undefined + : parsePositiveInt(obj.dim, `${field}.dim`), + supportsVision: parseOptionalBoolean( + obj.supportsVision, + `${field}.supportsVision`, + ), + supportsTools: parseOptionalEnum< + NonNullable + >(obj.supportsTools, `${field}.supportsTools`, TOOLS_SUPPORT_LEVELS), + supportsPromptCache: parseOptionalBoolean( + obj.supportsPromptCache, + `${field}.supportsPromptCache`, + ), + reasoningFormat: parseOptionalEnum< + NonNullable + >(obj.reasoningFormat, `${field}.reasoningFormat`, REASONING_FORMATS), + pricing: parseUserModelPricing(obj.pricing, `${field}.pricing`), + }; +} + +function parseOptionalUserModels( + raw: unknown, + field: string, +): UserModelEntry[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected array"); + } + // `resolveModel` looks a model up by id with `.find`, so a duplicate + // id would silently shadow the later row. Reject it at parse time + // instead of serving whichever copy happens to come first. + const seen = new Set(); + const out: UserModelEntry[] = []; + for (let i = 0; i < raw.length; i++) { + const entry = parseUserModelEntry(raw[i], `${field}[${i}]`); + if (seen.has(entry.id)) { + throw new ConfigValidationError( + `${field}[${i}].id`, + `duplicate model id ${JSON.stringify(entry.id)}`, + ); + } + seen.add(entry.id); + out.push(entry); + } + return out; +} + export function parseLlmProviders( raw: unknown, field: string, From c571d379479f448f5b4dde855a7b247f1f318887 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 03:58:28 +0300 Subject: [PATCH 23/57] fix(tui): Esc returns to Run from Observe tabs instead of quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Esc on any Observe tab (Feed / World / Reasoning / Logs / LLM logs) terminated the agent. The bottom hint strip promises "[esc] back to Run" on every debug tab, so this was a one-keypress kill on a surface that advertised the opposite. Cause: the TUI runs two independent Ink `useInput` subscriptions — the global one in `TuiApp` and the chat editor's own. The Observe tabs have no panel key layer, so `panelHandled` stays `null`, `handlePanelEscape` is never reached, and the keypress falls through to the editor. The editor keeps focus on those tabs by design (you can keep typing while watching the feed), and its `onEscape` ends in `onQuit()` whenever the agent is idle. Esc in the editor now resolves debug mode to "back to Run" before it can reach the quit branch, reusing the same `ui_mode_set` action that `handlePanelEscape` and `applyNavSlot` already dispatch. Manage tabs are unaffected: they unfocus the editor, so their panel layer still owns Esc. Reachable in one keypress from the model picker: `/model` then `L` opens Local LLM logs, where Esc killed the agent. --- src/tui/escape-observe-tabs.test.tsx | 91 ++++++++++++++++++++++++++++ src/tui/tui-app.tsx | 10 +++ 2 files changed, 101 insertions(+) create mode 100644 src/tui/escape-observe-tabs.test.tsx diff --git a/src/tui/escape-observe-tabs.test.tsx b/src/tui/escape-observe-tabs.test.tsx new file mode 100644 index 00000000..ec7582a3 --- /dev/null +++ b/src/tui/escape-observe-tabs.test.tsx @@ -0,0 +1,91 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import { OBSERVE_TABS } from "./section.js"; +import type { TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** + * A lone Esc byte is held back by Ink's input parser for + * `pendingInputFlushDelayMilliseconds` (20ms) so it can be disambiguated + * from the start of a longer escape sequence. Every assertion therefore + * has to wait past that flush window before reading the frame. + */ +const ESC = String.fromCharCode(27); +const FLUSH_MS = 60; + +const strip = (value: string): string => + value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, FLUSH_MS)); + +function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => { + counts.abort++; + }, + onQuit: () => { + counts.quit++; + }, + onMessageSubmitted: () => {}, + }; +} + +describe("Esc on the Observe tabs", () => { + for (const tab of OBSERVE_TABS) { + it(`returns to Run from "${tab}" instead of quitting the agent`, async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab }); + await settle(); + expect(strip(lastFrame() ?? "")).toContain("▸ Observe"); + + stdin.write(ESC); + await settle(); + + // The hint strip promises "[esc] back to Run" on every debug tab. + // These five have no panel key layer, so before the fix the keypress + // reached the still-focused chat editor and quit the process. + expect(counts.quit).toBe(0); + expect(counts.abort).toBe(0); + expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + unmount(); + }); + } + + it("does not quit when Esc is pressed twice from an Observe tab", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "logs" }); + await settle(); + + stdin.write(ESC); + await settle(); + expect(counts.quit).toBe(0); + unmount(); + }); +}); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..37bb1952 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -613,6 +613,16 @@ export function TuiApp({ dispatch({ type: "chat_scroll_reset" }); return; } + // A debug panel is open: Esc is the way back to Run, exactly as the + // hint strip advertises. The Observe tabs (Feed / World / Reasoning / + // Logs / LLM logs) have no key layer of their own, so `handlePanelEscape` + // never sees the keypress and the editor — which stays focused there so + // the operator can keep typing while watching the feed — used to fall + // through to the quit branch below and kill the agent instead. + if (state.uiMode === "debug") { + dispatch({ type: "ui_mode_set", mode: "chat" }); + return; + } if (canAcceptMessage(state)) { callbacks.onQuit(); dispatch({ type: "quit_requested" }); From 6d0fed4d77ef3b2cef916a6e632d39f6de5a2329 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:00:14 +0300 Subject: [PATCH 24/57] fix(tui): Esc leaves the Import tab instead of being swallowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esc did nothing at all on the Import tab — pressing it repeatedly left the operator parked on the form with no way back to Run, while the hint strip advertised "[esc] back to Run". `handleConfigureKey` ends in a catch-all `return true` so stray letters cannot leak out of the form into the global hotkey layer. That catch-all also claimed Esc, which made `TuiApp` see `panelHandled === true` and skip `handlePanelEscape` — the layer that turns a declined Esc into "back to Run". Configure mode now declines Esc explicitly, the same way the local models panel already does, so the panel-escape layer can act on it. The letter swallowing is untouched, and preview / done modes keep their own Esc meaning (reset the form one level). --- src/tui/escape-import-tab.test.tsx | 64 +++++++++++++++++++++++++++ src/tui/import/import-key-bindings.ts | 6 +++ 2 files changed, 70 insertions(+) create mode 100644 src/tui/escape-import-tab.test.tsx diff --git a/src/tui/escape-import-tab.test.tsx b/src/tui/escape-import-tab.test.tsx new file mode 100644 index 00000000..2c6d0ef7 --- /dev/null +++ b/src/tui/escape-import-tab.test.tsx @@ -0,0 +1,64 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import type { TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** + * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds` + * (20ms) to disambiguate it from a longer escape sequence, so every + * assertion waits past that flush window before reading the frame. + */ +const ESC = String.fromCharCode(27); +const FLUSH_MS = 60; + +const strip = (value: string): string => + value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, FLUSH_MS)); + +describe("Esc on the Import tab", () => { + it("returns to Run instead of being swallowed by the form", async () => { + let quit = 0; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => { + quit++; + }, + onMessageSubmitted: () => {}, + }; + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "import" }); + await settle(); + expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + + stdin.write(ESC); + await settle(); + + // The configure-mode handler ends in a catch-all `return true` that + // swallows stray letters; before the fix it swallowed Esc too, so the + // operator was stuck on the tab with no "back" gesture at all. + expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + expect(quit).toBe(0); + unmount(); + }); +}); diff --git a/src/tui/import/import-key-bindings.ts b/src/tui/import/import-key-bindings.ts index f40d2252..69195e1a 100644 --- a/src/tui/import/import-key-bindings.ts +++ b/src/tui/import/import-key-bindings.ts @@ -48,6 +48,12 @@ function handleConfigureKey( form: ImportFormState, ): boolean { const { dispatch, callbacks } = ctx; + // Decline Esc so `handlePanelEscape` can turn it into "back to Run" — + // the gesture the hint strip advertises on every debug tab. The + // catch-all `return true` at the bottom of this handler (which swallows + // stray letters so they cannot leak into the form) would otherwise eat + // it and leave the operator with no way off the Import tab. + if (key.escape) return false; if (key.ctrl && key.return) { callbacks.onImportPreview?.(form); return true; From 21b77ff0debe3ef3e77265361dbe7f57c026fa27 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:07:01 +0300 Subject: [PATCH 25/57] fix(tui): Esc aborts a running turn again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint strip advertises "[esc] abort" for the whole time a turn is in flight, but the keypress did nothing — Ctrl+C was the only way to stop a run. The abort lived in the chat editor's `onEscape`, and the editor is handed `disabled={!canAcceptMessage(state)}` — true for every status except idle. `disabled` switches its `useInput` off, so while a turn runs the editor is deaf and the abort branch is unreachable. Esc-to-abort now lives in `handleAppKey`, which is subscribed independently of editor focus and disabled state. Overlays that own Esc themselves (slash palette, theme picker, session picker) keep it, and a pending approval still returns earlier with its own abort semantics. Precedence matches the hint strip, which resolves `running` before `uiMode === "debug"`: a turn in flight aborts rather than navigating. --- src/tui/app-key-bindings.ts | 17 +++++ src/tui/escape-abort-running.test.tsx | 102 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/tui/escape-abort-running.test.tsx diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..bb1d0420 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -124,6 +124,23 @@ export function handleAppKey( return true; } setCtrlCArmed(false); + // Esc aborts a turn in flight — the binding the hint strip advertises + // for the whole time `status === "running"`. It has to be claimed here + // rather than in the editor's own Esc handler because the editor is + // `disabled` while a turn runs, which switches its `useInput` off and + // makes the abort branch over there unreachable. Overlays that own Esc + // themselves keep it; a pending approval already returned above. + if ( + key.escape && + state.status === "running" && + !state.slashPaletteOpen && + !state.themePickerOpen && + !state.sessionPickerOpen + ) { + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + return true; + } if ( state.uiMode === "chat" && !state.slashPaletteOpen && diff --git a/src/tui/escape-abort-running.test.tsx b/src/tui/escape-abort-running.test.tsx new file mode 100644 index 00000000..df850eb9 --- /dev/null +++ b/src/tui/escape-abort-running.test.tsx @@ -0,0 +1,102 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import type { TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** + * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds` + * (20ms) to disambiguate it from a longer escape sequence, so every + * assertion waits past that flush window before reading the frame. + */ +const ESC = String.fromCharCode(27); +const FLUSH_MS = 60; + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, FLUSH_MS)); + +function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => { + counts.abort++; + }, + onQuit: () => { + counts.quit++; + }, + onMessageSubmitted: () => {}, + }; +} + +describe("Esc while a turn is running", () => { + it("aborts the run from the chat surface", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "message_submitted" }); + await settle(); + + stdin.write(ESC); + await settle(); + + // The editor is `disabled` for the whole run, which switches its + // `useInput` off — so this has to be claimed by the global key layer + // or the advertised "[esc] abort" does nothing at all. + expect(counts.abort).toBeGreaterThan(0); + expect(counts.quit).toBe(0); + unmount(); + }); + + it("aborts the run from a debug tab too", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "logs" }); + bus.emit({ type: "message_submitted" }); + await settle(); + + stdin.write(ESC); + await settle(); + + // The hint strip checks `running` before `uiMode === "debug"`, so a + // run in flight aborts rather than navigating back to Run. + expect(counts.abort).toBeGreaterThan(0); + expect(counts.quit).toBe(0); + unmount(); + }); + + it("leaves an idle session alone", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "tasks" }); + await settle(); + + stdin.write(ESC); + await settle(); + + expect(counts.abort).toBe(0); + unmount(); + }); +}); From 2eebb34a04d0566a8cadc4fcc5bd7ba37f2e52ed Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 04:09:20 +0300 Subject: [PATCH 26/57] fix(tui): Esc in the chat editor clears the draft instead of quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Esc on the Run screen terminated the agent the moment the session was idle. Nothing advertised it — the chat hint strip only ever offered "[ctrl+c] quit", and Ctrl+C deliberately asks twice before it kills anything. Esc means cancel / back one level on every other surface of this TUI, so the one place it meant "exit" was a trap, and it took any half-typed message down with it. It is reachable straight out of normal navigation: Esc walks back from a Manage panel to Run, and the next press — the natural "and out of here too" — used to end the session. Esc on an idle Run screen now clears the draft (and no-ops on an empty buffer), reusing the same `input_changed` reset the submit handler dispatches, which also clears the input-history cursor. Quitting stays on Ctrl+C twice and `/quit`. The abort path for a non-idle session is unchanged. Stacks on the Observe-tab Esc fix — both touch `onEscape`. Merge that one first. --- src/tui/escape-chat-editor.test.tsx | 110 ++++++++++++++++++++++++++++ src/tui/tui-app.tsx | 16 ++-- 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/tui/escape-chat-editor.test.tsx diff --git a/src/tui/escape-chat-editor.test.tsx b/src/tui/escape-chat-editor.test.tsx new file mode 100644 index 00000000..e4c68578 --- /dev/null +++ b/src/tui/escape-chat-editor.test.tsx @@ -0,0 +1,110 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import type { TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** + * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds` + * (20ms) to disambiguate it from a longer escape sequence, so every + * assertion waits past that flush window before reading the frame. + */ +const ESC = String.fromCharCode(27); +const FLUSH_MS = 60; + +const strip = (value: string): string => + value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); + +const settle = (): Promise => + new Promise((resolve) => setTimeout(resolve, FLUSH_MS)); + +function trackingCallbacks(counts: { quit: number; abort: number }): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => { + counts.abort++; + }, + onQuit: () => { + counts.quit++; + }, + onMessageSubmitted: () => {}, + }; +} + +describe("Esc in the chat editor", () => { + it("does not quit an idle agent", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { stdin, unmount } = render( + , + ); + await settle(); + + stdin.write(ESC); + await settle(); + + expect(counts.quit).toBe(0); + expect(counts.abort).toBe(0); + unmount(); + }); + + it("clears a half-typed draft instead of killing the agent", async () => { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await settle(); + stdin.write("draft message"); + await settle(); + expect(strip(lastFrame() ?? "")).toContain("draft message"); + + stdin.write(ESC); + await settle(); + + expect(strip(lastFrame() ?? "")).not.toContain("draft message"); + expect(counts.quit).toBe(0); + unmount(); + }); + + it("survives leaving a Manage panel and pressing Esc again", async () => { + // The reported trap: Esc walks back from the panel to Run, and the + // next Esc — the natural "and back out of here too" press — used to + // terminate the agent. + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await settle(); + bus.emit({ type: "ui_mode_set", mode: "debug" }); + bus.emit({ type: "tab_changed", tab: "skills" }); + await settle(); + + stdin.write(ESC); + await settle(); + expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + expect(counts.quit).toBe(0); + + stdin.write(ESC); + await settle(); + expect(counts.quit).toBe(0); + + stdin.write(ESC); + await settle(); + expect(counts.quit).toBe(0); + unmount(); + }); +}); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 37bb1952..cba1560c 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -623,13 +623,19 @@ export function TuiApp({ dispatch({ type: "ui_mode_set", mode: "chat" }); return; } + // Esc never quits. Everywhere else in the TUI it means cancel / back + // one level, so a single unannounced press killing the agent — and + // the half-typed message with it — was a trap: no hint strip ever + // advertised it, while Ctrl+C deliberately asks twice. Quitting stays + // on Ctrl+C twice and `/quit`; Esc just clears the draft. if (canAcceptMessage(state)) { - callbacks.onQuit(); - dispatch({ type: "quit_requested" }); - } else { - callbacks.onAbort(); - dispatch({ type: "abort_requested" }); + if (state.inputValue.length > 0) { + dispatch({ type: "input_changed", value: "" }); + } + return; } + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); }, [state, callbacks]); // Tab in the editor is reserved for slash-palette completion. Section From e6e77d0963b8e9c67ac10c346bfc83e579949c36 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 05:46:53 +0300 Subject: [PATCH 27/57] feat(llm): drive a Claude Code subscription through its own CLI (no API key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cloud provider kind so far needs a paid per-token API key. Anyone already paying for a Claude Code subscription had no way to point the agent at it. Adds the `subscription-cli` provider kind, which runs the vendor CLI you are already signed into as an inference backend. The CLI authenticates from its own session — we never read, copy, or replay OAuth tokens or keychain entries, and never pass `--bare` (whose docs say OAuth and keychain are never read, which would defeat the feature). One kind, parameterised by `subscriptionCli.cli`, with every CLI-specific byte behind a `CliAdapterDescriptor` — so a second vendor CLI is a new descriptor, not a new provider kind. Three decisions worth knowing: - The prompt travels on stdin, never argv. A full two-zone prompt exceeds the 128 KiB single-argument limit, so argv delivery would E2BIG on exactly the long sessions that matter most. - `--tools ""` and `--strict-mcp-config` are safety-critical, not cosmetic: without them Claude Code's own Bash/Edit/Write would act on the machine outside the approval ladder, and the operator's MCP servers would leak into what should be a stateless completion. - The transport is `native_tools` even though this provider never returns `tool_calls`. On `grammar`, any format drift throws out of `parseToolCalls` and buys a second full CLI invocation on the repair path; on `native_tools` an empty `toolCalls` sends step-executor down its guarded recovery ladder instead. Same result when the model complies, no extra process when it does not. Verified end to end against claude 2.1.220: a real turn drives `os.fs.read` -> `reply`, and a multi-step turn drives `os.shell.run` -> `os.fs.write` -> `reply`, with zero parse retries. Server-side prompt caching survives across separate invocations, so the KV-stable prompt is not wasted; the cost is ~0.8s of process spawn per completion. Not supported here, and dropped rather than silently approximated: vision, embeddings (they stay on the local daemon), and the sampling knobs temperature/top_p/top_k/seed/stop/maxTokens — the CLI exposes no flag for any of them. --- AGENTS.md | 5 +- README.md | 46 ++- src/config/config-schema.ts | 13 + src/config/index.ts | 7 + src/config/llm-config.test.ts | 94 ++++++ src/config/llm-config.ts | 104 ++++++ src/config/provider-auth-mode.test.ts | 45 +++ src/config/provider-auth-mode.ts | 33 ++ src/llm/provider/index.ts | 6 + .../registry/provider-registry.test.ts | 1 + src/llm/provider/registry/provider-types.ts | 6 + .../registry/register-built-in-providers.ts | 39 +++ .../claude-cli-adapter.test.ts | 311 ++++++++++++++++++ .../subscription-cli/claude-cli-adapter.ts | 269 +++++++++++++++ .../subscription-cli/claude-cli-models.ts | 41 +++ .../cli-adapter-descriptor.ts | 64 ++++ src/llm/provider/subscription-cli/index.ts | 37 +++ .../subscription-cli/register-cli-adapters.ts | 15 + .../resolve-cli-binary.test.ts | 34 ++ .../subscription-cli/resolve-cli-binary.ts | 38 +++ .../subscription-cli/run-cli-completion.ts | 87 +++++ .../stream-cli-completion.test.ts | 127 +++++++ .../subscription-cli/stream-cli-completion.ts | 140 ++++++++ .../subscription-cli-errors.test.ts | 106 ++++++ .../subscription-cli-errors.ts | 115 +++++++ .../subscription-cli-provider.test.ts | 283 ++++++++++++++++ .../subscription-cli-provider.ts | 244 ++++++++++++++ src/tui/components/llm-mode-rows.tsx | 9 +- src/tui/components/providers-panel.tsx | 7 +- src/tui/components/providers-wizard.tsx | 6 + src/tui/llm-panel/llm-panel-row-builders.ts | 10 + src/tui/providers/providers-orchestrator.ts | 6 +- src/tui/providers/providers-panel-state.ts | 4 + .../providers-wizard-build-entry.test.ts | 34 ++ .../providers/providers-wizard-build-entry.ts | 27 +- .../providers-wizard-key-bindings.test.ts | 31 +- src/tui/providers/providers-wizard-phases.ts | 17 +- src/tui/providers/providers-wizard-state.ts | 21 ++ .../providers/save-provider-wizard.test.ts | 26 +- src/tui/providers/save-provider-wizard.ts | 15 +- .../run-local-models-config-wizard.test.ts | 53 +++ src/tui/run-local-models-config-wizard.ts | 8 +- 42 files changed, 2562 insertions(+), 22 deletions(-) create mode 100644 src/config/provider-auth-mode.test.ts create mode 100644 src/config/provider-auth-mode.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-adapter.test.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-adapter.ts create mode 100644 src/llm/provider/subscription-cli/claude-cli-models.ts create mode 100644 src/llm/provider/subscription-cli/cli-adapter-descriptor.ts create mode 100644 src/llm/provider/subscription-cli/index.ts create mode 100644 src/llm/provider/subscription-cli/register-cli-adapters.ts create mode 100644 src/llm/provider/subscription-cli/resolve-cli-binary.test.ts create mode 100644 src/llm/provider/subscription-cli/resolve-cli-binary.ts create mode 100644 src/llm/provider/subscription-cli/run-cli-completion.ts create mode 100644 src/llm/provider/subscription-cli/stream-cli-completion.test.ts create mode 100644 src/llm/provider/subscription-cli/stream-cli-completion.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-errors.test.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-errors.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-provider.test.ts create mode 100644 src/llm/provider/subscription-cli/subscription-cli-provider.ts diff --git a/AGENTS.md b/AGENTS.md index 09b2ad11..9d255e94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,8 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register ### Registry and transport -- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `openrouter`. +- **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `qwen-openai-compatible`, `openrouter`, `aimlapi`, `gemini`, `subscription-cli`. +- **`subscription-cli`** ([src/llm/provider/subscription-cli/](src/llm/provider/subscription-cli/)) — drives an already-signed-in vendor CLI (`claude`) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised by `entry.subscriptionCli.cli`; every CLI-specific byte (argv builders, output parsers, hints) lives behind a `CliAdapterDescriptor`, so a new vendor CLI is a descriptor plus a `SUBSCRIPTION_CLIS` entry and never a new provider kind. It declares `native_tools` while never returning `tool_calls`: an empty `toolCalls` sends step-executor down its guarded recovery ladder, whereas `grammar` would throw out of `parseToolCalls` on any drift and pay for a second CLI invocation on the repair path. - **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`. - **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider). - **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools. @@ -228,6 +229,8 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm 6. **Every default tool ships a structured `argsJsonSchema`.** `ToolDescriptor.argsJsonSchema` is consumed exclusively by `descriptorsToOpenAiTools` ([openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts)) to populate `function.parameters` on the OpenAI `tools` payload. Without it, cloud providers fall back to `{ type: "object", additionalProperties: true }` — which is what we shipped originally and what enabled the `os.shell.run` silent-arg-drop bug (model double-serialised `args` into a JSON string, the provider accepted it, the tool coerced the non-array to `[]` without warning, the model never learned). The canonical map lives in [src/prompt/default-tool-args-schemas.ts](src/prompt/default-tool-args-schemas.ts); it is merged into `DEFAULT_TOOL_DESCRIPTORS` via `attachDefaultArgsJsonSchema`. MCP descriptors carry the server's `inputSchema` verbatim through the same field. Adding a new tool **requires** an entry in `DEFAULT_TOOL_ARGS_SCHEMAS` (pinned by [src/prompt/default-tool-args-schemas.test.ts](src/prompt/default-tool-args-schemas.test.ts) "attaches a schema to every default descriptor that has one registered"). Local llama-server with GBNF does **not** consume this field — the grammar already constrains the shape. 7. **`os.shell.run` rejects non-array `args` structurally.** A non-array, non-JSON-array-string `args` value now returns `{ status: "error" }` instead of silently dropping the operator's intent. JSON-stringified arrays (the cloud `native_tools` double-serialise pattern) are auto-coerced back to `string[]`. Pinned by [src/tools/os/os-tools.test.ts](src/tools/os/os-tools.test.ts) ("returns a structured error when `args` is an object" / "is a scalar string" / "recovers a JSON-stringified array `args`"). +8. **Subscription CLIs are driven, never impersonated.** `subscription-cli` shells out to the vendor CLI's documented headless mode (`--print`) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes `--bare` (whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clears `ANTHROPIC_API_KEY`. It always passes `--tools ""` and `--strict-mcp-config` so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which would `E2BIG` on a full two-zone prompt. Pinned by [claude-cli-adapter.test.ts](src/llm/provider/subscription-cli/claude-cli-adapter.test.ts) ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv"). + ### Embeddings Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-provider-registry.ts](src/memory/embeddings/embedding-provider-registry.ts)) with `OpenAiEmbeddingProvider` / `OpenRouterEmbeddingProvider` for `POST /v1/embeddings`. Hybrid recall degradation contract unchanged. diff --git a/README.md b/README.md index 1b8ff1ee..495c2ca2 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ Atomic Agent drives a full desktop tool surface. Dangerous actions are routed th | **Skills** | View and run Markdown skill playbooks (scripts are approval-gated), install more from ClawHub. Ships with 17 starter skills (Docker, GitHub, Notion, Obsidian, PDF, and more), auto-installed on first run. | | **Vision** | Optional `vision.describe` for multimodal models with `mmproj`, kept outside the text transcript. | | **MCP** | Connect external MCP servers; their tools, resources, and prompts join the same registry. | -| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, and AI/ML API providers when configured, with live model catalogs and mid-session switching. Reasoning-only completions from reasoning models are recovered instead of failing the turn. | +| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, AI/ML API, and Gemini providers when configured, with live model catalogs and mid-session switching. Your existing **Claude Code subscription** works too, driven through its own signed-in CLI with no API key. Reasoning-only completions from reasoning models are recovered instead of failing the turn. | | **Telegram** | Single-user remote control with owner pairing, inline approval buttons, and opt-in result reports from scheduled tasks. | ### Memory That Grows Outside the Prompt @@ -423,6 +423,7 @@ Local-first bounds where control lives, not where packets go. Network egress hap - an HTTP tool calls a requested endpoint; - a web search provider answers a query; - a configured cloud LLM or embedding provider receives its request; +- a `subscription-cli` provider is active and the vendor CLI (`claude`) receives your prompt on its stdin, then sends it on under its own account; - an MCP server receives a tool call you routed to it; - the Telegram channel is enabled and the bot exchanges messages with your paired chat, including opt-in scheduled task reports; - you install a skill from ClawHub; @@ -481,6 +482,49 @@ Shell-exported variables win over `.env`. The built-in parser intentionally supp +
+Claude Code subscription (no API key) + +Drives the `claude` CLI you are already signed into, so a flat-rate Claude subscription can power the agent with no API key and no per-token billing. + +**Prerequisite:** [Claude Code](https://claude.com/claude-code) installed and signed in — run `claude` once and complete `/login`. Atomic only spawns that binary; it never reads, copies, or replays its OAuth tokens or keychain entries. + +In the TUI: **Providers → `n` → Claude Code subscription**, then type a model (`sonnet`, `opus`, `haiku`, `fable`, or a pinned id like `claude-sonnet-5`). There is no API-key screen, because there is no key. Equivalent `config.json`: + +```json +{ + "llm": { + "activeTextProvider": "claude-cli", + "providers": [ + { + "id": "claude-cli", + "kind": "subscription-cli", + "defaultChatModel": "sonnet", + "subscriptionCli": { "cli": "claude" } + } + ] + } +} +``` + +Optional keys inside `subscriptionCli`: `binPath` (absolute path when the CLI is not on `PATH`), `extraArgs` (appended verbatim — e.g. `["--effort", "high"]`), `streaming` (set `false` to buffer), `maxBudgetUsd`. + +Each completion runs `claude --print` with the prompt on **stdin** (a two-zone prompt exceeds the 128 KiB argv limit) and these flags, which are load-bearing rather than cosmetic: + +- **`--tools ""`** — disables Claude Code's own Bash/Edit/Write. Without it a second agent would act on your machine outside Atomic's approval ladder. +- **`--strict-mcp-config`** with no config — keeps your MCP servers out of what should be a stateless completion. +- **`--system-prompt`** — replaces Claude Code's coding-agent prompt, which would otherwise compete with the prompt Atomic already built. +- **`--no-session-persistence`** — Atomic owns session state; CLI-side history would double-count context. +- **`--bare` is never passed.** Its own docs say OAuth and keychain are never read under it, which would defeat the whole feature. + +Not supported on this provider: vision, embeddings (they stay on the local daemon), and the sampling knobs `temperature` / `top_p` / `top_k` / `seed` / `stop` / `maxTokens` — the CLI exposes no flag for them, so they are dropped rather than silently approximated. Reconfiguring `binPath` or `extraArgs` means editing `config.json`; the model is changeable from the LLM tab. + +Two things worth knowing before you switch a long-running agent onto it: each completion pays roughly 0.8 s of process startup, and subscription plans have session and weekly caps that an autonomous multi-step agent reaches much faster than interactive use. When a cap is hit, the CLI's own message is surfaced verbatim. + +> [!NOTE] +> Whether driving a subscription CLI from another agent is acceptable use is the vendor's call, not this project's. Atomic uses the officially documented headless mode and nothing else; the decision to use it is yours. +
+
Qwen / Tinker tagged tool calls (opt-in compatibility provider) diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e1102371..1f2c1f50 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -725,6 +725,19 @@ export interface AtomicAgentConfig { * are re-applied after the merge and cannot be overridden. */ extraBody?: Record; + /** + * Settings for a `subscription-cli` provider: which already + * signed-in vendor CLI to drive (`claude`, `codex`) and how to + * invoke it. There is no API key on these entries — the CLI + * authenticates from its own session. + */ + subscriptionCli?: { + cli: "claude" | "codex"; + binPath?: string; + extraArgs?: string[]; + streaming?: boolean; + maxBudgetUsd?: number; + }; userModels?: ReadonlyArray<{ id: string; kind: "chat" | "embedding"; diff --git a/src/config/index.ts b/src/config/index.ts index 312d7bcb..5f482c0a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -40,7 +40,14 @@ export { type UserLlmFileConfig, type UserLlmFallbackConfig, type UserLlmProviderEntry, + type UserSubscriptionCliOptions, + type SubscriptionCliName, + SUBSCRIPTION_CLIS, } from "./llm-config.js"; +export { + SUBSCRIPTION_CLI_KIND, + usesExternalCliAuth, +} from "./provider-auth-mode.js"; export type { DotenvLoadResult, DotenvReadFailure, diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts index 544099de..5ff33263 100644 --- a/src/config/llm-config.test.ts +++ b/src/config/llm-config.test.ts @@ -257,4 +257,98 @@ describe("llm-config", () => { }), ).toThrow(/extraBody/); }); + it("accepts subscription-cli entries and round-trips subscriptionCli", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:19091", + }, + { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "sonnet", + subscriptionCli: { + cli: "claude", + binPath: "/opt/homebrew/bin/claude", + extraArgs: ["--effort", "high"], + streaming: false, + maxBudgetUsd: 5, + }, + }, + ], + }, + }); + const entry = parsed.llm?.providers.find((p) => p.id === "claude-cli"); + // parseLlmProviderEntry is a whitelist that rebuilds the entry from + // known keys, so an unparsed field would be silently dropped on the + // next config rewrite. Pin the whole block, not just `cli`. + expect(entry?.subscriptionCli).toEqual({ + cli: "claude", + binPath: "/opt/homebrew/bin/claude", + extraArgs: ["--effort", "high"], + streaming: false, + maxBudgetUsd: 5, + }); + }); + + it("rejects a subscription-cli entry with no subscriptionCli block", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "claude-cli", + toolTransport: "auto", + providers: [{ id: "claude-cli", kind: "subscription-cli" }], + }, + }), + ).toThrow(/subscriptionCli/); + }); + + it("rejects an unknown cli name", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "gemini-cli", + activeEmbeddingProvider: "gemini-cli", + toolTransport: "auto", + providers: [ + { + id: "gemini-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "gemini" }, + }, + ], + }, + }), + ).toThrow(/subscriptionCli\.cli/); + }); + + it("rejects non-string extraArgs", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "claude-cli", + toolTransport: "auto", + providers: [ + { + id: "claude-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "claude", extraArgs: ["--effort", 3] }, + }, + ], + }, + }), + ).toThrow(/extraArgs\[1\]/); + }); }); diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index ed3f3a6c..1bc406dc 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -1,7 +1,34 @@ import { ConfigValidationError } from "./config-validation-error.js"; +import { SUBSCRIPTION_CLI_KIND } from "./provider-auth-mode.js"; export type UserLlmToolTransport = "auto" | "grammar" | "native_tools"; +/** Vendor CLIs a `subscription-cli` provider knows how to drive. */ +export const SUBSCRIPTION_CLIS = ["claude", "codex"] as const; +export type SubscriptionCliName = (typeof SUBSCRIPTION_CLIS)[number]; + +/** + * Settings for a provider backed by an already-signed-in vendor CLI. + * The CLI authenticates itself from its own session, so there is no + * `apiKey` / `apiKeyEnvVar` anywhere in this block. + */ +export type UserSubscriptionCliOptions = { + /** Which CLI to drive. Required when `kind` is `subscription-cli`. */ + cli: SubscriptionCliName; + /** Absolute path to the binary. Omit to resolve it from `PATH`. */ + binPath?: string; + /** + * Extra argv appended verbatim to every invocation. The escape hatch + * for flags we do not model (`--effort high`) and for correcting a + * vendor CLI whose interface moved, without waiting for a release. + */ + extraArgs?: string[]; + /** Opt out of the streaming path and always buffer. */ + streaming?: boolean; + /** Passed through as the CLI's own spend ceiling where it has one. */ + maxBudgetUsd?: number; +}; + export type UserLlmProviderEntry = { id: string; kind: string; @@ -36,6 +63,8 @@ export type UserLlmProviderEntry = { * after the merge and cannot be overridden from config. */ extraBody?: Record; + /** Present only on `subscription-cli` entries; see the type above. */ + subscriptionCli?: UserSubscriptionCliOptions; }; export type UserLlmFallbackConfig = { @@ -63,6 +92,7 @@ const PROVIDER_KINDS = new Set([ "openrouter", "aimlapi", "gemini", + SUBSCRIPTION_CLI_KIND, ]); function parseProviderId(raw: unknown, field: string): string { @@ -120,6 +150,18 @@ export function parseLlmProviderEntry( `expected one of ${[...PROVIDER_KINDS].join(", ")}`, ); } + const subscriptionCli = parseSubscriptionCliOptions( + obj.subscriptionCli, + `${field}.subscriptionCli`, + ); + // A `subscription-cli` entry without a `cli` has no binary to drive, so + // fail at load rather than at the first inference an hour into a run. + if (kind === SUBSCRIPTION_CLI_KIND && !subscriptionCli) { + throw new ConfigValidationError( + `${field}.subscriptionCli`, + `required when kind is ${SUBSCRIPTION_CLI_KIND}`, + ); + } return { id, kind, @@ -173,9 +215,71 @@ export function parseLlmProviderEntry( ); })(), extraBody: parseOptionalExtraBody(obj.extraBody, `${field}.extraBody`), + subscriptionCli, }; } +function parseSubscriptionCliOptions( + raw: unknown, + field: string, +): UserSubscriptionCliOptions | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const cli = obj.cli; + if ( + typeof cli !== "string" || + !(SUBSCRIPTION_CLIS as readonly string[]).includes(cli) + ) { + throw new ConfigValidationError( + `${field}.cli`, + `expected one of ${SUBSCRIPTION_CLIS.join(", ")}`, + ); + } + const out: UserSubscriptionCliOptions = { cli: cli as SubscriptionCliName }; + const binPath = parseOptionalString(obj.binPath, `${field}.binPath`); + if (binPath !== undefined) out.binPath = binPath; + if (obj.extraArgs !== undefined && obj.extraArgs !== null) { + if (!Array.isArray(obj.extraArgs)) { + throw new ConfigValidationError( + `${field}.extraArgs`, + "expected array of strings", + ); + } + out.extraArgs = obj.extraArgs.map((value, i) => { + if (typeof value !== "string") { + throw new ConfigValidationError( + `${field}.extraArgs[${i}]`, + "expected string", + ); + } + return value; + }); + } + if (obj.streaming !== undefined && obj.streaming !== null) { + if (typeof obj.streaming !== "boolean") { + throw new ConfigValidationError(`${field}.streaming`, "expected boolean"); + } + out.streaming = obj.streaming; + } + if (obj.maxBudgetUsd !== undefined && obj.maxBudgetUsd !== null) { + if ( + typeof obj.maxBudgetUsd !== "number" || + !Number.isFinite(obj.maxBudgetUsd) || + obj.maxBudgetUsd <= 0 + ) { + throw new ConfigValidationError( + `${field}.maxBudgetUsd`, + "expected positive number", + ); + } + out.maxBudgetUsd = obj.maxBudgetUsd; + } + return out; +} + function parseOptionalExtraBody( raw: unknown, field: string, diff --git a/src/config/provider-auth-mode.test.ts b/src/config/provider-auth-mode.test.ts new file mode 100644 index 00000000..f3c26b02 --- /dev/null +++ b/src/config/provider-auth-mode.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + SUBSCRIPTION_CLI_KIND, + usesExternalCliAuth, +} from "./provider-auth-mode.js"; + +describe("usesExternalCliAuth", () => { + it("is true for a subscription-cli entry that names a cli", () => { + expect( + usesExternalCliAuth({ + kind: SUBSCRIPTION_CLI_KIND, + subscriptionCli: { cli: "claude" }, + }), + ).toBe(true); + expect( + usesExternalCliAuth({ + kind: SUBSCRIPTION_CLI_KIND, + subscriptionCli: { cli: "codex" }, + }), + ).toBe(true); + }); + + it("is false for the kind without a cli block", () => { + expect(usesExternalCliAuth({ kind: SUBSCRIPTION_CLI_KIND })).toBe(false); + }); + + it("is false for every key-carrying kind", () => { + for (const kind of [ + "llama-server", + "openai-compatible", + "qwen-openai-compatible", + "openrouter", + "aimlapi", + "gemini", + ]) { + expect(usesExternalCliAuth({ kind })).toBe(false); + // Even a hand-edited config that bolts the block onto another kind + // must not be treated as CLI-authenticated. + expect(usesExternalCliAuth({ kind, subscriptionCli: { cli: "claude" } })).toBe( + false, + ); + } + }); +}); diff --git a/src/config/provider-auth-mode.ts b/src/config/provider-auth-mode.ts new file mode 100644 index 00000000..3cd1fce1 --- /dev/null +++ b/src/config/provider-auth-mode.ts @@ -0,0 +1,33 @@ +import type { UserLlmProviderEntry } from "./llm-config.js"; + +/** + * Provider kind that authenticates by delegating to an already-signed-in + * vendor CLI (`claude`, `codex`) instead of carrying an API key. Lives + * here rather than in the provider folder so the config and TUI layers + * can classify an entry without importing the provider implementation. + */ +export const SUBSCRIPTION_CLI_KIND = "subscription-cli"; + +/** + * Whether this entry gets its credentials from an external CLI's own + * session rather than from an API key we resolve. + * + * Callers use it wherever "has no API key" would otherwise be read as + * "not configured": the TUI startup gate and the providers panel both + * treat a keyless entry as unusable, which is right for every kind that + * existed before subscription CLIs and wrong for this one. + * + * Deliberately does NOT probe the binary. Both call sites are on + * synchronous hot paths (startup, panel refresh), spawning the CLI there + * would add ~800ms per TUI launch, and a transient PATH problem would + * bounce the user into the local-model setup wizard. A missing binary + * surfaces on the first completion and through `health()` instead. + */ +export function usesExternalCliAuth( + entry: Pick, +): boolean { + return ( + entry.kind === SUBSCRIPTION_CLI_KIND && + Boolean(entry.subscriptionCli?.cli) + ); +} diff --git a/src/llm/provider/index.ts b/src/llm/provider/index.ts index dd57217b..2b7c1b7b 100644 --- a/src/llm/provider/index.ts +++ b/src/llm/provider/index.ts @@ -29,6 +29,12 @@ export { resolveModel, type ResolvedModel } from "./model-resolver.js"; export { CostAccumulator, type CostAccumulatorSnapshot } from "./cost-accumulator.js"; export { OpenAiProvider, type OpenAiProviderOptions } from "./openai/index.js"; export { OpenRouterProvider } from "./openrouter/index.js"; +export { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, + SubscriptionCliProvider, + type SubscriptionCliProviderOptions, +} from "./subscription-cli/index.js"; export { GeminiProvider, type GeminiProviderOptions, diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts index 2fb5eef5..e7a67281 100644 --- a/src/llm/provider/registry/provider-registry.test.ts +++ b/src/llm/provider/registry/provider-registry.test.ts @@ -19,6 +19,7 @@ describe("ProviderRegistry", () => { expect(kinds).toContain("qwen-openai-compatible"); expect(kinds).toContain("openrouter"); expect(kinds).toContain("gemini"); + expect(kinds).toContain("subscription-cli"); }); it("resolveLlmConfig synthesizes local-llama when llm block absent", () => { diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index 9aa7de62..44b5f0a8 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -1,4 +1,5 @@ import type { AtomicAgentConfig } from "../../../config/index.js"; +import type { UserSubscriptionCliOptions } from "../../../config/llm-config.js"; import type { LlamaServerClient } from "../../llama-server-client.js"; import type { ModelProfile } from "../../model-profile.js"; import type { StructuredLogger } from "../../../tracing/index.js"; @@ -43,6 +44,11 @@ export type LlmProviderConfigEntry = { * the request from the resolved model or drop the tool contract. */ extraBody?: Record; + /** + * Settings for a `subscription-cli` provider — which vendor CLI to + * drive and how to invoke it. Absent on every other kind. + */ + subscriptionCli?: UserSubscriptionCliOptions; userModels?: ReadonlyArray; }; diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index e3e61f1b..09d17ffe 100644 --- a/src/llm/provider/registry/register-built-in-providers.ts +++ b/src/llm/provider/registry/register-built-in-providers.ts @@ -14,6 +14,12 @@ import { OPENROUTER_APP_REFERER, OPENROUTER_APP_TITLE, } from "../openrouter/openrouter-provider.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../../config/provider-auth-mode.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, + SubscriptionCliProvider, +} from "../subscription-cli/index.js"; import { registerProviderKind } from "./provider-types.js"; let registered = false; @@ -126,4 +132,37 @@ export function registerBuiltInProviderKinds(): void { requestTimeoutMs: entry.requestTimeoutMs, }); }); + + registerProviderKind(SUBSCRIPTION_CLI_KIND, (ctx) => { + const entry = ctx.entry; + const options = entry.subscriptionCli; + if (!options) { + throw new Error( + `${SUBSCRIPTION_CLI_KIND} provider "${entry.id}" requires a subscriptionCli block naming the cli to drive`, + ); + } + registerBuiltInCliAdapters(); + const descriptor = resolveCliAdapter(options.cli); + return new SubscriptionCliProvider({ + id: entry.id, + descriptor, + // The state dir, not the agent's working directory: with tools + // disabled there is nothing to read there anyway, and it keeps a + // project-level CLAUDE.md out of the completion. + cwd: ctx.config.paths.stateDir, + ...(entry.defaultChatModel ? { model: entry.defaultChatModel } : {}), + ...(options.binPath ? { binPath: options.binPath } : {}), + ...(options.extraArgs ? { extraArgs: options.extraArgs } : {}), + ...(options.streaming === undefined + ? {} + : { streaming: options.streaming }), + ...(options.maxBudgetUsd === undefined + ? {} + : { maxBudgetUsd: options.maxBudgetUsd }), + ...(entry.requestTimeoutMs + ? { requestTimeoutMs: entry.requestTimeoutMs } + : {}), + onNotice: (message) => ctx.logger.warn("llm.subscription_cli", { message }), + }); + }); } diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts new file mode 100644 index 00000000..3393e5fd --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-adapter.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +const input = { + model: "sonnet", + systemPrompt: "SYSTEM", + extraArgs: [] as readonly string[], +}; + +/** Captured verbatim from `claude -p --output-format json` v2.1.220. */ +const REAL_ENVELOPE = JSON.stringify({ + is_error: false, + duration_api_ms: 4630, + num_turns: 1, + stop_reason: "end_turn", + session_id: "32c150e6-44a2-422d-a03d-76b18b607b71", + total_cost_usd: 0.0363007, + usage: { + input_tokens: 2, + cache_creation_input_tokens: 5777, + cache_read_input_tokens: 3289, + output_tokens: 4, + }, + modelUsage: { + "claude-sonnet-5": { contextWindow: 1_000_000, maxOutputTokens: 64_000 }, + }, + permission_denials: [], + subtype: "success", + api_error_status: null, + result: "OK", + type: "result", +}); + +describe("claudeCliAdapter argv", () => { + it("passes the headless, tool-free, stateless flag set", () => { + const args = claudeCliAdapter.completeArgs(input); + expect(args).toContain("--print"); + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--no-session-persistence"); + expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual([ + "--tools", + "", + ]); + expect(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2)).toEqual([ + "--model", + "sonnet", + ]); + expect( + args.slice( + args.indexOf("--output-format"), + args.indexOf("--output-format") + 2, + ), + ).toEqual(["--output-format", "json"]); + expect( + args.slice( + args.indexOf("--system-prompt"), + args.indexOf("--system-prompt") + 2, + ), + ).toEqual(["--system-prompt", "SYSTEM"]); + }); + + it("never passes flags that would defeat subscription auth or the approval ladder", () => { + for (const build of [ + claudeCliAdapter.completeArgs, + claudeCliAdapter.streamArgs, + ]) { + const args = build({ ...input, responseSchema: { type: "object" } }); + // --bare makes the CLI read ANTHROPIC_API_KEY only, never OAuth. + expect(args).not.toContain("--bare"); + expect(args).not.toContain("--dangerously-skip-permissions"); + expect(args).not.toContain("--allow-dangerously-skip-permissions"); + expect(args).not.toContain("--add-dir"); + expect(args).not.toContain("--permission-mode"); + } + }); + + it("never places the prompt on argv", () => { + // Regression guard for E2BIG: a two-zone prompt exceeds the 128 KiB + // single-argument limit, so it must travel on stdin. + const prompt = "P".repeat(200_000); + const args = claudeCliAdapter.completeArgs(input); + expect(args.some((arg) => arg.includes(prompt))).toBe(false); + expect(args.join(" ").length).toBeLessThan(4096); + }); + + it("adds --verbose only on the streaming path", () => { + expect(claudeCliAdapter.completeArgs(input)).not.toContain("--verbose"); + const streamArgs = claudeCliAdapter.streamArgs(input); + // Verified: `--print` + `--output-format stream-json` errors without it. + expect(streamArgs).toContain("--verbose"); + expect(streamArgs).toContain("--include-partial-messages"); + expect( + streamArgs.slice( + streamArgs.indexOf("--output-format"), + streamArgs.indexOf("--output-format") + 2, + ), + ).toEqual(["--output-format", "stream-json"]); + }); + + it("passes --json-schema only when a schema is set and small enough", () => { + expect(claudeCliAdapter.completeArgs(input)).not.toContain("--json-schema"); + + const schema = { type: "object", properties: { name: { type: "string" } } }; + const withSchema = claudeCliAdapter.completeArgs({ + ...input, + responseSchema: schema, + }); + expect( + withSchema[withSchema.indexOf("--json-schema") + 1], + ).toBe(JSON.stringify(schema)); + + const huge = { type: "object", description: "x".repeat(40_000) }; + expect( + claudeCliAdapter.completeArgs({ ...input, responseSchema: huge }), + ).not.toContain("--json-schema"); + }); + + it("appends extraArgs verbatim, last", () => { + const args = claudeCliAdapter.completeArgs({ + ...input, + extraArgs: ["--effort", "high"], + maxBudgetUsd: 5, + }); + expect(args.slice(-2)).toEqual(["--effort", "high"]); + expect(args).toContain("--max-budget-usd"); + expect(args[args.indexOf("--max-budget-usd") + 1]).toBe("5"); + }); + + it("health uses --version, not a real turn", () => { + expect(claudeCliAdapter.healthArgs()).toEqual(["--version"]); + }); +}); + +describe("claudeCliAdapter parseResult", () => { + it("maps the real success envelope", () => { + const result = claudeCliAdapter.parseResult(REAL_ENVELOPE, "sonnet"); + expect(result.content).toBe("OK"); + expect(result.finishReason).toBe("stop"); + expect(result.truncated).toBe(false); + expect(result.stop).toBe(true); + expect(result.slotId).toBe(-1); + expect(result.modelId).toBe("claude-sonnet-5"); + expect(result.cacheHitTokens).toBe(3289); + // prompt tokens = fresh + cache-write + cache-read, matching the + // OpenAI `prompt_tokens` semantics the usage meter expects. + expect(result.usage).toEqual({ + promptTokens: 2 + 5777 + 3289, + completionTokens: 4, + totalTokens: 2 + 5777 + 3289 + 4, + }); + expect(result.timing.predictedMs).toBe(4630); + }); + + it("treats a tool_use stop as a normal stop", () => { + // --json-schema is implemented as a forced tool call, so a perfectly + // successful structured completion reports stop_reason tool_use. + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + is_error: false, + result: '{"name":"Ada"}', + stop_reason: "tool_use", + }), + "sonnet", + ); + expect(result.finishReason).toBe("stop"); + expect(result.content).toBe('{"name":"Ada"}'); + }); + + it("reports truncation on max_tokens", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + is_error: false, + result: "half", + stop_reason: "max_tokens", + }), + "sonnet", + ); + expect(result.truncated).toBe(true); + expect(result.stop).toBe(false); + expect(result.finishReason).toBe("length"); + }); + + it("ignores the internal helper model in modelUsage", () => { + // Observed live: a `sonnet` turn also bills a haiku helper turn for + // Claude Code's own post-turn summary. Reporting haiku as the model + // that served the completion would corrupt cost and model analytics. + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + result: "hi", + modelUsage: { + "claude-haiku-4-5-20251001": { outputTokens: 13 }, + "claude-sonnet-5": { outputTokens: 4 }, + }, + }), + "sonnet", + ); + expect(result.modelId).toBe("claude-sonnet-5"); + }); + + it("keeps the requested model when only a helper model was billed", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "success", + result: "hi", + modelUsage: { "claude-haiku-4-5-20251001": { outputTokens: 13 } }, + }), + "sonnet", + ); + expect(result.modelId).toBe("sonnet"); + }); + + it("falls back to the configured model when modelUsage is absent", () => { + const result = claudeCliAdapter.parseResult( + JSON.stringify({ subtype: "success", result: "hi" }), + "opus", + ); + expect(result.modelId).toBe("opus"); + }); + + it("throws on an error envelope and keeps the message", () => { + expect(() => + claudeCliAdapter.parseResult( + JSON.stringify({ + subtype: "error_during_execution", + is_error: true, + result: "5-hour limit reached; resets at 14:00", + }), + "sonnet", + ), + ).toThrow(/5-hour limit reached/); + }); + + it("maps a 401 to an auth error", () => { + expect(() => + claudeCliAdapter.parseResult( + JSON.stringify({ subtype: "success", api_error_status: 401 }), + "sonnet", + ), + ).toThrow(SubscriptionCliAuthError); + }); + + it("throws rather than silently returning empty on non-JSON output", () => { + expect(() => claudeCliAdapter.parseResult("not json", "sonnet")).toThrow( + SubscriptionCliInvocationError, + ); + }); +}); + +describe("claudeCliAdapter parseStreamEvent", () => { + it("extracts text deltas", () => { + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "1\n2" }, + }, + }), + ), + ).toEqual({ kind: "delta", text: "1\n2" }); + }); + + it("marks the terminal result envelope", () => { + const line = JSON.stringify({ type: "result", subtype: "success" }); + expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({ + kind: "final", + raw: line, + }); + }); + + it("surfaces a throttled rate-limit event as a notice, ignores allowed", () => { + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed", rateLimitType: "five_hour" }, + }), + ), + ).toEqual({ kind: "ignore" }); + expect( + claudeCliAdapter.parseStreamEvent( + JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, + }), + ), + ).toEqual({ kind: "notice", message: "claude rate limit rejected (five_hour)" }); + }); + + it("ignores unknown, empty and malformed lines instead of failing", () => { + for (const line of [ + "", + " ", + "{ not json", + JSON.stringify({ type: "system", subtype: "init" }), + JSON.stringify({ type: "assistant", message: {} }), + JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }), + ]) { + expect(claudeCliAdapter.parseStreamEvent(line)).toEqual({ kind: "ignore" }); + } + }); +}); diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.ts new file mode 100644 index 00000000..c99f5ba2 --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-adapter.ts @@ -0,0 +1,269 @@ +import type { CompletionResult } from "../completion-types.js"; +import type { + CliAdapterDescriptor, + CliArgsInput, + CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +import { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_CONTEXT_WINDOW, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, +} from "./claude-cli-models.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +/** + * Replaces Claude Code's own system prompt for the duration of one + * completion. It has to be a replacement, not an append: the default is + * a coding-agent prompt that plans, narrates and reaches for tools, + * which competes with the complete two-zone prompt atomic-agent already + * built. This is also the only steering channel we have outside that + * prompt, so it is spent on the output contract. + */ +export const CLAUDE_CLI_SYSTEM_PROMPT = + "You are an inference backend. The user message is a complete, " + + "self-contained prompt that carries its own instructions and output " + + "contract. Follow it exactly and emit only what it asks for — no " + + "preamble, no commentary, no summary of what you are about to do. " + + "Do not use tools; the prompt's own protocol is the only one that applies."; + +/** + * Above this the schema would eat into the argv budget for no benefit; + * the sub-runners that set `responseFormat` all tolerate free-form + * content, so dropping the flag degrades gracefully. + */ +const MAX_SCHEMA_ARG_BYTES = 32 * 1024; + +function baseArgs(input: CliArgsInput): string[] { + return [ + "--print", + "--input-format", + "text", + "--model", + input.model, + "--system-prompt", + input.systemPrompt, + // Safety-critical, not an optimisation: Claude Code's built-in + // Bash/Edit/Write would otherwise run on the user's machine outside + // atomic-agent's approval ladder. + "--tools", + "", + // No --mcp-config is passed, so this drops the user's MCP servers + // rather than inheriting them into a stateless completion. + "--strict-mcp-config", + // atomic-agent owns session state and re-sends the whole prompt each + // step; CLI-side history would double-count context and litter the + // user's session list. + "--no-session-persistence", + ]; +} + +function tailArgs(input: CliArgsInput): string[] { + const out: string[] = []; + if (input.responseSchema) { + const encoded = JSON.stringify(input.responseSchema); + if (encoded.length <= MAX_SCHEMA_ARG_BYTES) { + out.push("--json-schema", encoded); + } + } + if (input.maxBudgetUsd !== undefined) { + out.push("--max-budget-usd", String(input.maxBudgetUsd)); + } + out.push(...input.extraArgs); + return out; +} + +interface ClaudeResultEnvelope { + type?: string; + subtype?: string; + is_error?: boolean; + result?: string; + stop_reason?: string | null; + api_error_status?: number | null; + duration_api_ms?: number; + permission_denials?: unknown[]; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + modelUsage?: Record; +} + +/** + * `stop_reason` doubles as a structured-output signal: with + * `--json-schema` the CLI implements the constraint as a forced tool + * call and reports `tool_use` even though the text in `result` is the + * whole answer. Treat it as a normal stop — we never surface tool calls + * from this provider. + */ +function toFinishReason(stopReason: string | null | undefined): string | null { + if (!stopReason) return null; + if (stopReason === "end_turn" || stopReason === "tool_use") return "stop"; + if (stopReason === "max_tokens") return "length"; + return stopReason; +} + +/** + * `modelUsage` is keyed by every model the CLI billed for this turn, + * which includes the small helper model Claude Code uses for its own + * side tasks (post-turn summaries). Taking the first key would report + * `claude-haiku-4-5` as the model that served a `sonnet` request, so the + * requested model stays authoritative and `modelUsage` is used only to + * expand an alias into the concrete id it resolved to. + */ +function resolveModelId( + modelUsage: Record | undefined, + requested: string, +): string { + const keys = Object.keys(modelUsage ?? {}); + if (keys.includes(requested)) return requested; + return keys.find((key) => key.includes(requested)) ?? requested; +} + +function parseResult(stdout: string, fallbackModel: string): CompletionResult { + let envelope: ClaudeResultEnvelope; + try { + envelope = JSON.parse(stdout.trim()) as ClaudeResultEnvelope; + } catch { + throw new SubscriptionCliInvocationError( + `claude returned output that is not JSON: ${stdout.slice(0, 500)}`, + ); + } + const status = envelope.api_error_status ?? null; + if (status === 401 || status === 403) { + throw new SubscriptionCliAuthError( + "claude", + "Run `claude` in a terminal and complete /login, then retry.", + `api_error_status ${status}`, + ); + } + if (envelope.is_error || (envelope.subtype && envelope.subtype !== "success")) { + // The message is the only description of subscription rate limits and + // usage caps, so it is passed through rather than summarised away. + throw new SubscriptionCliInvocationError( + `claude reported ${envelope.subtype ?? "an error"}${ + status ? ` (api status ${status})` : "" + }: ${envelope.result ?? "no detail"}`, + ); + } + + const usage = envelope.usage ?? {}; + const promptTokens = + (usage.input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0); + const completionTokens = usage.output_tokens ?? 0; + const predictedMs = envelope.duration_api_ms ?? 0; + const modelId = resolveModelId(envelope.modelUsage, fallbackModel); + const truncated = envelope.stop_reason === "max_tokens"; + + return { + content: envelope.result ?? "", + reasoningContent: "", + stop: !truncated, + truncated, + timing: { + promptMs: 0, + predictedMs, + promptTokens, + predictedTokens: completionTokens, + }, + cacheHitTokens: usage.cache_read_input_tokens ?? 0, + // No slot affinity: every completion is a fresh process. + slotId: -1, + modelId, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, + finishReason: toFinishReason(envelope.stop_reason), + }; +} + +interface ClaudeStreamLine { + type?: string; + event?: { + type?: string; + delta?: { type?: string; text?: string }; + }; + rate_limit_info?: { status?: string; rateLimitType?: string }; +} + +function parseStreamEvent(line: string): CliStreamEvent { + const trimmed = line.trim(); + if (trimmed.length === 0) return { kind: "ignore" }; + let parsed: ClaudeStreamLine; + try { + parsed = JSON.parse(trimmed) as ClaudeStreamLine; + } catch { + // A partial or unrecognised line is never fatal: the terminal + // `result` envelope carries the authoritative text either way. + return { kind: "ignore" }; + } + if (parsed.type === "result") return { kind: "final", raw: trimmed }; + if (parsed.type === "rate_limit_event") { + const info = parsed.rate_limit_info ?? {}; + return info.status && info.status !== "allowed" + ? { + kind: "notice", + message: `claude rate limit ${info.status}${ + info.rateLimitType ? ` (${info.rateLimitType})` : "" + }`, + } + : { kind: "ignore" }; + } + if (parsed.type === "stream_event") { + const event = parsed.event ?? {}; + if ( + event.type === "content_block_delta" && + event.delta?.type === "text_delta" && + typeof event.delta.text === "string" + ) { + return { kind: "delta", text: event.delta.text }; + } + } + return { kind: "ignore" }; +} + +export const claudeCliAdapter: CliAdapterDescriptor = { + cli: "claude", + displayName: "Claude Code subscription", + defaultBinary: "claude", + defaultChatModel: CLAUDE_CLI_DEFAULT_CHAT_MODEL, + systemPrompt: CLAUDE_CLI_SYSTEM_PROMPT, + staticModels: CLAUDE_CLI_CHAT_MODELS, + contextWindow: CLAUDE_CLI_CONTEXT_WINDOW, + supportsJsonSchema: true, + streamMode: "ndjson", + installHint: + "Install Claude Code (https://claude.com/claude-code) and run `claude` once to sign in, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.", + authHint: "Run `claude` in a terminal and complete /login, then retry.", + completeArgs(input) { + return [...baseArgs(input), "--output-format", "json", ...tailArgs(input)]; + }, + streamArgs(input) { + return [ + ...baseArgs(input), + "--output-format", + "stream-json", + "--include-partial-messages", + // Verified requirement: `--print` with `--output-format=stream-json` + // errors out without it. + "--verbose", + ...tailArgs(input), + ]; + }, + healthArgs() { + // Cheap liveness only. It cannot detect a signed-out CLI — that + // surfaces on the first completion as SubscriptionCliAuthError — + // but a real turn would cost seconds and tokens on every poll. + return ["--version"]; + }, + parseResult, + parseStreamEvent, +}; diff --git a/src/llm/provider/subscription-cli/claude-cli-models.ts b/src/llm/provider/subscription-cli/claude-cli-models.ts new file mode 100644 index 00000000..0a8b8cbe --- /dev/null +++ b/src/llm/provider/subscription-cli/claude-cli-models.ts @@ -0,0 +1,41 @@ +/** + * Models the `claude` CLI accepts for `--model`. The CLI exposes no + * list command, so this is curated: aliases first because they keep + * working across releases, then the pinned ids for reproducibility. + * + * When Anthropic ships a new model, add its id here — nothing else in + * the provider needs to change. + */ +export const CLAUDE_CLI_MODEL_ALIASES = [ + "opus", + "sonnet", + "haiku", + "fable", +] as const; + +export const CLAUDE_CLI_MODEL_IDS = [ + "claude-opus-5", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-fable-5", + "claude-opus-4-8", +] as const; + +export const CLAUDE_CLI_CHAT_MODELS: readonly string[] = [ + ...CLAUDE_CLI_MODEL_ALIASES, + ...CLAUDE_CLI_MODEL_IDS, +]; + +/** + * The alias, not a pinned id: a subscription user wants the current + * model behind the name they already use in Claude Code. + */ +export const CLAUDE_CLI_DEFAULT_CHAT_MODEL = "sonnet"; + +/** + * Conservative floor rather than the 1M ceiling the top models carry. + * This only feeds `capabilities.contextWindow`, which the runtime uses + * to decide when to compact — overstating it for a `haiku` session + * would let the prompt grow past what that model accepts. + */ +export const CLAUDE_CLI_CONTEXT_WINDOW = 200_000; diff --git a/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts new file mode 100644 index 00000000..8683d9a6 --- /dev/null +++ b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts @@ -0,0 +1,64 @@ +import type { SubscriptionCliName } from "../../../config/llm-config.js"; +import type { CompletionResult } from "../completion-types.js"; + +/** Everything the argv builders need from one completion request. */ +export interface CliArgsInput { + model: string; + systemPrompt: string; + /** JSON Schema from `CompletionRequest.responseFormat`, when set. */ + responseSchema?: Record; + maxBudgetUsd?: number; + extraArgs: readonly string[]; +} + +/** One parsed line of a streaming CLI's NDJSON output. */ +export type CliStreamEvent = + | { kind: "delta"; text: string } + /** Terminal envelope — the same payload the buffered path parses. */ + | { kind: "final"; raw: string } + /** Something worth logging but not worth failing on (rate-limit warnings). */ + | { kind: "notice"; message: string } + | { kind: "ignore" }; + +/** + * Everything that differs between one vendor CLI and another. The + * provider class holds no CLI-specific knowledge, so adding a CLI is a + * new descriptor plus a `SUBSCRIPTION_CLIS` entry — and a vendor + * changing its interface is an edit to one file. + */ +export interface CliAdapterDescriptor { + readonly cli: SubscriptionCliName; + readonly displayName: string; + readonly defaultBinary: string; + readonly defaultChatModel: string; + /** Replaces the CLI's own system prompt for the duration of a turn. */ + readonly systemPrompt: string; + readonly staticModels: readonly string[]; + readonly contextWindow: number; + readonly supportsJsonSchema: boolean; + /** `"none"` means `completeStream` must fall back to buffering. */ + readonly streamMode: "ndjson" | "none"; + readonly installHint: string; + readonly authHint: string; + completeArgs(input: CliArgsInput): string[]; + streamArgs(input: CliArgsInput): string[]; + healthArgs(): string[]; + parseResult(stdout: string, fallbackModel: string): CompletionResult; + parseStreamEvent(line: string): CliStreamEvent; +} + +const descriptors = new Map(); + +export function registerCliAdapter(descriptor: CliAdapterDescriptor): void { + descriptors.set(descriptor.cli, descriptor); +} + +export function resolveCliAdapter( + cli: SubscriptionCliName, +): CliAdapterDescriptor { + const descriptor = descriptors.get(cli); + if (!descriptor) { + throw new Error(`unknown subscription cli "${cli}"`); + } + return descriptor; +} diff --git a/src/llm/provider/subscription-cli/index.ts b/src/llm/provider/subscription-cli/index.ts new file mode 100644 index 00000000..83f8a079 --- /dev/null +++ b/src/llm/provider/subscription-cli/index.ts @@ -0,0 +1,37 @@ +export { + claudeCliAdapter, + CLAUDE_CLI_SYSTEM_PROMPT, +} from "./claude-cli-adapter.js"; +export { + CLAUDE_CLI_CHAT_MODELS, + CLAUDE_CLI_CONTEXT_WINDOW, + CLAUDE_CLI_DEFAULT_CHAT_MODEL, +} from "./claude-cli-models.js"; +export { + registerCliAdapter, + resolveCliAdapter, + type CliAdapterDescriptor, + type CliArgsInput, + type CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +export { registerBuiltInCliAdapters } from "./register-cli-adapters.js"; +export { resolveCliBinary } from "./resolve-cli-binary.js"; +export { + runCliCommand, + type CliRunner, + type CliRunOptions, + type CliRunOutcome, +} from "./run-cli-completion.js"; +export { + streamCliCommand, + type CliStreamRunner, +} from "./stream-cli-completion.js"; +export { + SubscriptionCliProvider, + type SubscriptionCliProviderOptions, +} from "./subscription-cli-provider.js"; +export { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; diff --git a/src/llm/provider/subscription-cli/register-cli-adapters.ts b/src/llm/provider/subscription-cli/register-cli-adapters.ts new file mode 100644 index 00000000..73d966e5 --- /dev/null +++ b/src/llm/provider/subscription-cli/register-cli-adapters.ts @@ -0,0 +1,15 @@ +import { registerCliAdapter } from "./cli-adapter-descriptor.js"; +import { claudeCliAdapter } from "./claude-cli-adapter.js"; + +let registered = false; + +/** + * Wire the shipped CLI descriptors into the lookup. Idempotent, and + * called from the provider factory so importing the provider class + * alone never has a registration side effect. + */ +export function registerBuiltInCliAdapters(): void { + if (registered) return; + registered = true; + registerCliAdapter(claudeCliAdapter); +} diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts new file mode 100644 index 00000000..32f7912c --- /dev/null +++ b/src/llm/provider/subscription-cli/resolve-cli-binary.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveCliBinary } from "./resolve-cli-binary.js"; + +describe("resolveCliBinary", () => { + it("prefers a configured binPath on every platform", () => { + expect(resolveCliBinary("claude", "/opt/bin/claude", "darwin")).toBe( + "/opt/bin/claude", + ); + expect(resolveCliBinary("claude", "C:\\bin\\claude.cmd", "win32")).toBe( + "C:\\bin\\claude.cmd", + ); + }); + + it("hands the bare name to spawn on posix", () => { + expect(resolveCliBinary("claude", undefined, "darwin")).toBe("claude"); + expect(resolveCliBinary("codex", undefined, "linux")).toBe("codex"); + }); + + it("finds the .cmd shim on windows, where spawn with shell:false would not", () => { + const present = new Set(["C:\\npm\\claude.cmd"]); + expect( + resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, (p) => + present.has(p), + ), + ).toBe("C:\\npm\\claude.cmd"); + }); + + it("falls back to the bare name on windows so ENOENT still surfaces", () => { + expect( + resolveCliBinary("claude", undefined, "win32", { PATH: "C:\\npm" }, () => false), + ).toBe("claude"); + }); +}); diff --git a/src/llm/provider/subscription-cli/resolve-cli-binary.ts b/src/llm/provider/subscription-cli/resolve-cli-binary.ts new file mode 100644 index 00000000..c89b6454 --- /dev/null +++ b/src/llm/provider/subscription-cli/resolve-cli-binary.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { win32 } from "node:path"; + +/** + * Pick the command to spawn for a vendor CLI. + * + * A configured `binPath` always wins — that is the escape hatch for a + * binary outside `PATH`. Otherwise we hand the bare name to `spawn`, + * which resolves it through `PATH` itself, except on Windows: with + * `shell: false` Node will not try the `PATHEXT` suffixes, so + * `spawn("claude")` misses the `claude.cmd` shim npm installs. There we + * walk `PATH` ourselves and return the first suffixed hit. + */ +export function resolveCliBinary( + defaultBinary: string, + binPath?: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + fileExists: (path: string) => boolean = existsSync, +): string { + if (binPath && binPath.length > 0) return binPath; + if (platform !== "win32") return defaultBinary; + if (win32.isAbsolute(defaultBinary)) return defaultBinary; + + const suffixes = [".cmd", ".exe", ".bat", ""]; + // Windows path semantics regardless of the host we are running on, + // so the branch is testable from macOS/Linux. + for (const dir of (env.PATH ?? "").split(";")) { + if (dir.length === 0) continue; + for (const suffix of suffixes) { + const candidate = win32.join(dir, `${defaultBinary}${suffix}`); + if (fileExists(candidate)) return candidate; + } + } + // Nothing on PATH — hand back the bare name so the ENOENT surfaces + // from spawn with the standard not-installed message. + return defaultBinary; +} diff --git a/src/llm/provider/subscription-cli/run-cli-completion.ts b/src/llm/provider/subscription-cli/run-cli-completion.ts new file mode 100644 index 00000000..32267416 --- /dev/null +++ b/src/llm/provider/subscription-cli/run-cli-completion.ts @@ -0,0 +1,87 @@ +import { runCommand } from "../../../sandbox/command-runner.js"; +import { + isEnoent, + mapCliFailure, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +export interface CliRunOptions { + binary: string; + args: readonly string[]; + /** Prompt text, written to stdin. Never placed on argv — see the provider. */ + input?: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; + installHint: string; + authHint: string; +} + +export interface CliRunOutcome { + stdout: string; + stderr: string; + exitCode: number | null; + durationMs: number; +} + +/** + * Injection seam. Tests substitute their own runner so no test ever + * spawns a real CLI; mirrors `OpenAiProviderOptions.fetchImpl`. + */ +export type CliRunner = (options: CliRunOptions) => Promise; + +/** + * Run a vendor CLI to completion and hand back its stdout, or throw a + * typed error. Builds on `runCommand`, which already provides + * shell-free spawn, stdin injection, timeout, an output cap and Windows + * tree-kill; this adds the failure taxonomy on top, the same way + * `git-runner.ts` wraps it for git. + */ +export const runCliCommand: CliRunner = async (options) => { + let result; + try { + result = await runCommand(options.binary, [...options.args], { + cwd: options.cwd, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + shell: false, + // Inherit the environment untouched. We deliberately neither set + // nor clear ANTHROPIC_API_KEY: setting it would silently move the + // user onto API billing, clearing it would break anyone who wants + // exactly that. + ...(options.input === undefined ? {} : { input: options.input }), + ...(options.signal ? { signal: options.signal } : {}), + }); + } catch (err) { + if (isEnoent(err)) { + throw new SubscriptionCliNotInstalledError( + options.binary, + options.installHint, + ); + } + throw err; + } + + if (result.exitCode !== 0 || result.timedOut || result.truncated) { + throw mapCliFailure({ + binary: options.binary, + installHint: options.installHint, + authHint: options.authHint, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + timedOut: result.timedOut, + truncated: result.truncated, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + }); + } + + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + durationMs: result.durationMs, + }; +}; diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.test.ts b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts new file mode 100644 index 00000000..e49af1a0 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import type { CliRunOptions } from "./run-cli-completion.js"; +import { streamCliCommand } from "./stream-cli-completion.js"; +import { SubscriptionCliNotInstalledError } from "./subscription-cli-errors.js"; + +/** + * These exercise the real spawn/line-splitting path against a scripted + * node child — never against a vendor CLI. Mocking `child_process` + * instead would test the mock, not the buffering behaviour that the + * NDJSON reader actually has to get right. + */ +function options(script: string, extra: Partial = {}): CliRunOptions { + return { + binary: process.execPath, + args: ["-e", script], + cwd: process.cwd(), + timeoutMs: 15_000, + maxOutputBytes: 1024 * 1024, + installHint: "install it", + authHint: "log in", + ...extra, + }; +} + +async function collect(opts: CliRunOptions): Promise { + const lines: string[] = []; + for await (const line of streamCliCommand(opts)) lines.push(line); + return lines; +} + +describe("streamCliCommand", () => { + it("reassembles lines split across chunk boundaries", async () => { + // Deliberately writes half a JSON object, pauses, then the rest. + const script = ` + process.stdout.write('{"type":"a"}\\n{"ty'); + setTimeout(() => { + process.stdout.write('pe":"b"}\\n{"type":"c"}\\n'); + }, 20); + `; + expect(await collect(options(script))).toEqual([ + '{"type":"a"}', + '{"type":"b"}', + '{"type":"c"}', + ]); + }); + + it("yields a final line that has no trailing newline", async () => { + const script = `process.stdout.write('one\\ntwo');`; + expect(await collect(options(script))).toEqual(["one", "two"]); + }); + + it("delivers the prompt on stdin", async () => { + const script = ` + let buf = ""; + process.stdin.on("data", (c) => { buf += c; }); + process.stdin.on("end", () => process.stdout.write(buf.length + "\\n")); + `; + expect(await collect(options(script, { input: "x".repeat(5000) }))).toEqual([ + "5000", + ]); + }); + + it("raises a typed error when the binary does not exist", async () => { + await expect( + collect(options("", { binary: "definitely-not-a-real-binary-xyz" })), + ).rejects.toBeInstanceOf(SubscriptionCliNotInstalledError); + }); + + it("surfaces stderr when the child exits non-zero", async () => { + const script = ` + process.stderr.write("weekly limit reached"); + process.exit(3); + `; + await expect(collect(options(script))).rejects.toThrow( + /exited with code 3[\s\S]*weekly limit reached/, + ); + }); + + it("maps a signed-out message to an auth error", async () => { + const script = ` + process.stderr.write("Please run /login to authenticate"); + process.exit(1); + `; + // Classified as an auth failure (not a generic non-zero exit), and + // the descriptor's own hint is what reaches the user. + await expect(collect(options(script))).rejects.toThrow( + /is not signed in\. log in/, + ); + }); + + it("stops the child when the caller aborts", async () => { + const controller = new AbortController(); + // Emits one line, then would hang for a minute. + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const lines: string[] = []; + const started = Date.now(); + await expect( + (async () => { + for await (const line of streamCliCommand( + options(script, { signal: controller.signal }), + )) { + lines.push(line); + controller.abort(); + } + })(), + ).rejects.toThrow(); + expect(lines).toEqual(['{"type":"a"}']); + // SIGTERM must land well before the child's own 60s timer. + expect(Date.now() - started).toBeLessThan(10_000); + }); + + it("does not leak the child when the consumer abandons the iterator", async () => { + const script = ` + process.stdout.write('{"type":"a"}\\n'); + setTimeout(() => {}, 60000); + `; + const iterator = streamCliCommand(options(script)); + const first = await iterator.next(); + expect(first.value).toBe('{"type":"a"}'); + // The generator's finally block is responsible for the kill. + await iterator.return(); + }); +}); diff --git a/src/llm/provider/subscription-cli/stream-cli-completion.ts b/src/llm/provider/subscription-cli/stream-cli-completion.ts new file mode 100644 index 00000000..0098f502 --- /dev/null +++ b/src/llm/provider/subscription-cli/stream-cli-completion.ts @@ -0,0 +1,140 @@ +import { spawn } from "node:child_process"; +import type { CliRunOptions } from "./run-cli-completion.js"; +import { + isEnoent, + mapCliFailure, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +/** Grace period between asking a child to stop and killing it. */ +const SIGKILL_DELAY_MS = 2_000; +/** A single NDJSON line larger than this means the stream went wrong. */ +const MAX_LINE_BYTES = 4 * 1024 * 1024; + +export type CliStreamRunner = ( + options: CliRunOptions, +) => AsyncGenerator; + +/** + * Spawn a CLI and yield its stdout one line at a time. + * + * Separate from `runCliCommand` because the buffered runner resolves + * only once the process exits, which is exactly what streaming must + * avoid. The generator's `finally` always kills the child, so a consumer + * that abandons the iterator cannot leak a process. + */ +export const streamCliCommand: CliStreamRunner = async function* (options) { + const child = spawn(options.binary, [...options.args], { + cwd: options.cwd, + env: process.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + ...(process.platform === "win32" ? { windowsHide: true } : {}), + }); + + let stderr = ""; + let timedOut = false; + let killTimer: NodeJS.Timeout | null = null; + let settled = false; + + const stop = (reason: "timeout" | "abort" | "done") => { + if (settled) return; + if (reason === "timeout") timedOut = true; + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + // Escalate only if SIGTERM was not enough. + killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + }, SIGKILL_DELAY_MS); + killTimer.unref?.(); + }; + + const timer = + options.timeoutMs > 0 && Number.isFinite(options.timeoutMs) + ? setTimeout(() => stop("timeout"), options.timeoutMs) + : null; + const onAbort = () => stop("abort"); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + if (stderr.length < options.maxOutputBytes) stderr += chunk; + }); + + const exited = new Promise<{ code: number | null }>((resolve, reject) => { + child.on("error", (err) => { + settled = true; + reject( + isEnoent(err) + ? new SubscriptionCliNotInstalledError( + options.binary, + options.installHint, + ) + : err, + ); + }); + child.on("close", (code) => { + settled = true; + resolve({ code }); + }); + }); + + // The exit promise is awaited only after stdout drains, so attach a + // no-op handler now: a spawn error (ENOENT) rejects immediately and + // would otherwise be reported as an unhandled rejection before the + // real await picks it up. Other awaiters still see the rejection. + exited.catch(() => {}); + + if (options.input !== undefined) child.stdin.write(options.input); + child.stdin.end(); + + child.stdout.setEncoding("utf8"); + let buffer = ""; + try { + for await (const chunk of child.stdout as AsyncIterable) { + buffer += chunk; + if (buffer.length > MAX_LINE_BYTES) { + throw new Error( + `${options.binary} emitted a line larger than ${MAX_LINE_BYTES} bytes`, + ); + } + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + yield line; + newline = buffer.indexOf("\n"); + } + } + // A stream that ends without a trailing newline still has a line. + if (buffer.length > 0) yield buffer; + + const { code } = await exited; + if (code !== 0 || timedOut) { + throw mapCliFailure({ + binary: options.binary, + installHint: options.installHint, + authHint: options.authHint, + exitCode: code, + stdout: "", + stderr, + timedOut, + truncated: false, + timeoutMs: options.timeoutMs, + maxOutputBytes: options.maxOutputBytes, + }); + } + } finally { + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + if (!settled) stop("done"); + if (killTimer) clearTimeout(killTimer); + } +}; diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts new file mode 100644 index 00000000..d412794f --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-errors.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + isEnoent, + looksLikeAuthFailure, + mapCliFailure, + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + SubscriptionCliNotInstalledError, +} from "./subscription-cli-errors.js"; + +const base = { + binary: "claude", + installHint: "Install Claude Code.", + authHint: "Run `claude` and complete /login.", + exitCode: 1, + stdout: "", + stderr: "", + timedOut: false, + truncated: false, + timeoutMs: 1000, + maxOutputBytes: 4096, +}; + +describe("isEnoent", () => { + it("detects the spawn error for a missing binary", () => { + expect(isEnoent(Object.assign(new Error("x"), { code: "ENOENT" }))).toBe(true); + expect(isEnoent(new Error("x"))).toBe(false); + expect(isEnoent(null)).toBe(false); + }); +}); + +describe("looksLikeAuthFailure", () => { + it("matches the signed-out phrasings", () => { + for (const text of [ + "Please run /login to authenticate", + "You are not logged in", + "Authentication required", + "Invalid API key", + "401 Unauthorized", + "credentials expired", + ]) { + expect(looksLikeAuthFailure(text)).toBe(true); + } + }); + + it("does not claim an auth problem for ordinary failures", () => { + // A false positive would send the user to /login for a rate limit. + for (const text of [ + "5-hour limit reached; resets at 14:00", + "network error: ECONNRESET", + "model not found", + "Overloaded", + ]) { + expect(looksLikeAuthFailure(text)).toBe(false); + } + }); +}); + +describe("mapCliFailure", () => { + it("reports a timeout with the budget that was exceeded", () => { + const err = mapCliFailure({ ...base, timedOut: true }); + expect(err).toBeInstanceOf(SubscriptionCliInvocationError); + expect(err.message).toMatch(/timed out after 1000ms/); + }); + + it("refuses to parse truncated output rather than failing later", () => { + const err = mapCliFailure({ ...base, truncated: true }); + expect(err.message).toMatch(/refusing to parse a truncated response/); + }); + + it("maps a signed-out CLI to an auth error carrying the hint", () => { + const err = mapCliFailure({ + ...base, + stderr: "Error: not logged in. Please run /login", + }); + expect(err).toBeInstanceOf(SubscriptionCliAuthError); + expect(err.message).toMatch(/complete \/login/); + }); + + it("passes an unexplained failure through verbatim", () => { + // Subscription rate limits have no structured form; swallowing the + // text would leave the user with an exit code and nothing else. + const err = mapCliFailure({ + ...base, + exitCode: 2, + stderr: "weekly limit reached, resets Monday", + }); + expect(err).toBeInstanceOf(SubscriptionCliInvocationError); + expect(err.message).toMatch(/exited with code 2/); + expect(err.message).toMatch(/weekly limit reached, resets Monday/); + }); + + it("truncates a huge stderr instead of pasting megabytes into the message", () => { + const err = mapCliFailure({ ...base, stderr: "e".repeat(10_000) }); + expect(err.message.length).toBeLessThan(3000); + }); +}); + +describe("error messages", () => { + it("tells the user how to fix a missing binary", () => { + const err = new SubscriptionCliNotInstalledError("claude", "Install it."); + expect(err.message).toMatch(/"claude" was not found on PATH/); + expect(err.message).toMatch(/Install it\./); + }); +}); diff --git a/src/llm/provider/subscription-cli/subscription-cli-errors.ts b/src/llm/provider/subscription-cli/subscription-cli-errors.ts new file mode 100644 index 00000000..6ab74805 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-errors.ts @@ -0,0 +1,115 @@ +/** + * Failure taxonomy for CLI-backed providers. The three cases the user + * can actually act on are kept apart from each other: the binary is + * missing, the CLI is signed out, or the invocation itself failed. + */ + +export class SubscriptionCliNotInstalledError extends Error { + constructor(binary: string, installHint: string) { + super(`"${binary}" was not found on PATH. ${installHint}`); + this.name = "SubscriptionCliNotInstalledError"; + } +} + +export class SubscriptionCliAuthError extends Error { + constructor(binary: string, authHint: string, detail?: string) { + super( + `"${binary}" is not signed in. ${authHint}${detail ? ` (${detail})` : ""}`, + ); + this.name = "SubscriptionCliAuthError"; + } +} + +export class SubscriptionCliInvocationError extends Error { + readonly exitCode: number | null; + constructor(message: string, exitCode: number | null = null) { + super(message); + this.name = "SubscriptionCliInvocationError"; + this.exitCode = exitCode; + } +} + +/** + * Signed-out CLIs do not use a stable exit code, so the text is the only + * signal. Kept deliberately narrow: a false positive here would relabel + * a real API error as "run /login" and send the user down a dead end. + */ +const AUTH_PATTERNS = [ + /\bplease run\s+\/login\b/i, + /\brun\s+`?\/login`?\b/i, + /\bnot (?:logged in|authenticated|signed in)\b/i, + /\bauthentication (?:required|failed|error)\b/i, + /\binvalid api key\b/i, + /\bunauthorized\b/i, + /\bcredentials (?:are )?(?:missing|expired|invalid)\b/i, +]; + +export function looksLikeAuthFailure(text: string): boolean { + return AUTH_PATTERNS.some((re) => re.test(text)); +} + +/** `spawn` reports a missing binary as an ENOENT on the error event. */ +export function isEnoent(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: unknown }).code === "ENOENT" + ); +} + +export interface CliFailureInput { + binary: string; + installHint: string; + authHint: string; + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; + truncated: boolean; + timeoutMs: number; + maxOutputBytes: number; +} + +const DETAIL_CHARS = 2048; + +/** + * Turn a finished-but-unhappy CLI run into a typed error. Callers hand + * the raw streams over verbatim: subscription rate-limit messages have + * no documented structured form, so swallowing the text would leave the + * user with an exit code and no explanation. + */ +export function mapCliFailure(input: CliFailureInput): Error { + if (input.timedOut) { + return new SubscriptionCliInvocationError( + `"${input.binary}" timed out after ${input.timeoutMs}ms`, + input.exitCode, + ); + } + if (input.truncated) { + return new SubscriptionCliInvocationError( + `"${input.binary}" produced more than ${input.maxOutputBytes} bytes; refusing to parse a truncated response`, + input.exitCode, + ); + } + const combined = `${input.stderr}\n${input.stdout}`; + if (looksLikeAuthFailure(combined)) { + return new SubscriptionCliAuthError( + input.binary, + input.authHint, + tail(input.stderr || input.stdout), + ); + } + return new SubscriptionCliInvocationError( + `"${input.binary}" exited with code ${input.exitCode ?? "null"}: ${ + tail(input.stderr || input.stdout) || "no output" + }`, + input.exitCode, + ); +} + +function tail(text: string): string { + const trimmed = text.trim(); + return trimmed.length > DETAIL_CHARS + ? `…${trimmed.slice(-DETAIL_CHARS)}` + : trimmed; +} diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts new file mode 100644 index 00000000..571b8137 --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-provider.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; + +import type { AtomicAgentConfig } from "../../../config/index.js"; +import { getConfig } from "../../../config/index.js"; +import { VisionUnsupportedError } from "../llm-provider.js"; +import { + getProviderFactory, + type LlmProviderConfigEntry, +} from "../registry/provider-types.js"; +import { registerBuiltInProviderKinds } from "../registry/register-built-in-providers.js"; +import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import type { CliRunOptions, CliRunOutcome } from "./run-cli-completion.js"; +import { SubscriptionCliProvider } from "./subscription-cli-provider.js"; +import { SubscriptionCliNotInstalledError } from "./subscription-cli-errors.js"; + +const SUCCESS = JSON.stringify({ + subtype: "success", + is_error: false, + result: "hello", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 3 }, +}); + +function stubRunner(stdout: string, calls: CliRunOptions[] = []) { + return async (options: CliRunOptions): Promise => { + calls.push(options); + return { stdout, stderr: "", exitCode: 0, durationMs: 1 }; + }; +} + +function makeProvider(overrides: Partial[0]> = {}) { + return new SubscriptionCliProvider(buildOptions(overrides)); +} + +function buildOptions(overrides: Record = {}) { + return { + id: "claude-cli", + descriptor: claudeCliAdapter, + cwd: "/tmp", + runCliImpl: stubRunner(SUCCESS), + ...overrides, + } as ConstructorParameters[0]; +} + +describe("SubscriptionCliProvider capabilities", () => { + it("declares the native transport with no vision and no slot affinity", () => { + const provider = makeProvider(); + // native_tools, despite never returning tool_calls: it routes + // step-executor down its guarded recovery ladder instead of the + // repair path, which would cost a second CLI invocation. + expect(provider.capabilities.toolTransport).toBe("native_tools"); + expect(provider.toolCallAdapter).not.toBeNull(); + expect(provider.streamConsumer).toBeNull(); + expect(provider.capabilities.vision).toBe(false); + expect(provider.capabilities.supportsSlotAffinity).toBe(false); + expect(provider.capabilities.supportsPromptCache).toBe(true); + expect(provider.capabilities.contextWindow).toBeGreaterThan(0); + }); + + it("rejects vision instead of pretending", async () => { + await expect( + makeProvider().describeImage({ prompt: "x", images: [] }), + ).rejects.toBeInstanceOf(VisionUnsupportedError); + }); + + it("lists models without spawning anything", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + expect(await provider.listModels()).toContain("sonnet"); + expect(calls).toHaveLength(0); + }); + + it("closes without error", async () => { + await expect(makeProvider().close()).resolves.toBeUndefined(); + }); +}); + +describe("SubscriptionCliProvider.complete", () => { + it("sends the prompt on stdin and never on argv", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + const prompt = "P".repeat(200_000); + const result = await provider.complete({ prompt }); + + expect(result.content).toBe("hello"); + expect(calls).toHaveLength(1); + expect(calls[0]?.input).toBe(prompt); + expect(calls[0]?.args.some((arg) => arg.includes("PPPP"))).toBe(false); + }); + + it("uses the configured model and appends extraArgs", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ + model: "opus", + extraArgs: ["--effort", "high"], + runCliImpl: stubRunner(SUCCESS, calls), + }); + await provider.complete({ prompt: "x" }); + const args = calls[0]?.args ?? []; + expect(args[args.indexOf("--model") + 1]).toBe("opus"); + expect(args.slice(-2)).toEqual(["--effort", "high"]); + }); + + it("forwards the abort signal to the child", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + const controller = new AbortController(); + await provider.complete({ prompt: "x", signal: controller.signal }); + expect(calls[0]?.signal).toBe(controller.signal); + }); + + it("passes responseFormat through as --json-schema", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ runCliImpl: stubRunner(SUCCESS, calls) }); + await provider.complete({ + prompt: "x", + responseFormat: { name: "vote", schema: { type: "object" } }, + }); + expect(calls[0]?.args).toContain("--json-schema"); + }); +}); + +describe("SubscriptionCliProvider.completeStream", () => { + it("falls back to one buffered chunk when streaming is disabled", async () => { + const provider = makeProvider({ streaming: false }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["hello"]); + expect(next.value.content).toBe("hello"); + }); + + it("streams deltas and returns the parsed final envelope", async () => { + const lines = [ + JSON.stringify({ type: "system", subtype: "init" }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "he" }, + }, + }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "llo" }, + }, + }), + SUCCESS.replace('"subtype"', '"type":"result","subtype"'), + ]; + const provider = makeProvider({ + streamCliImpl: async function* () { + for (const line of lines) yield line; + }, + }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["he", "llo"]); + expect(next.value.content).toBe("hello"); + }); + + it("emits the final text once when no delta was recognised", async () => { + // Safety net for a stream schema we do not control: a mismatch must + // degrade to buffered behaviour, never to an empty turn. + const provider = makeProvider({ + streamCliImpl: async function* () { + yield JSON.stringify({ type: "stream_event", event: { type: "unknown" } }); + yield SUCCESS.replace('"subtype"', '"type":"result","subtype"'); + }, + }); + const deltas: string[] = []; + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) { + if (next.value.delta) deltas.push(next.value.delta); + next = await iterator.next(); + } + expect(deltas).toEqual(["hello"]); + }); + + it("fails loudly when the stream ends with no result envelope", async () => { + const provider = makeProvider({ + streamCliImpl: async function* () { + yield JSON.stringify({ type: "system" }); + }, + }); + const iterator = provider.completeStream({ prompt: "x" }); + await expect( + (async () => { + let next = await iterator.next(); + while (!next.done) next = await iterator.next(); + })(), + ).rejects.toThrow(/without a result envelope/); + }); + + it("routes rate-limit notices to onNotice instead of failing", async () => { + const notices: string[] = []; + const provider = makeProvider({ + onNotice: (message: string) => notices.push(message), + streamCliImpl: async function* () { + yield JSON.stringify({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, + }); + yield SUCCESS.replace('"subtype"', '"type":"result","subtype"'); + }, + }); + const iterator = provider.completeStream({ prompt: "x" }); + let next = await iterator.next(); + while (!next.done) next = await iterator.next(); + expect(notices).toEqual(["claude rate limit rejected (five_hour)"]); + }); +}); + +describe("SubscriptionCliProvider.health", () => { + it("is reachable when the version probe exits cleanly", async () => { + const calls: CliRunOptions[] = []; + const provider = makeProvider({ + runCliImpl: stubRunner("2.1.220 (Claude Code)", calls), + }); + const health = await provider.health(); + expect(health.reachable).toBe(true); + expect(calls[0]?.args).toEqual(["--version"]); + // A health probe must never send a prompt or cost tokens. + expect(calls[0]?.input).toBeUndefined(); + }); + + it("reports an actionable message when the binary is missing", async () => { + const provider = makeProvider({ + runCliImpl: async () => { + throw new SubscriptionCliNotInstalledError("claude", "Install it."); + }, + }); + const health = await provider.health(); + expect(health.reachable).toBe(false); + expect(health.error).toMatch(/not found on PATH/); + }); +}); + +describe("registry factory", () => { + it("builds the provider from a subscription-cli entry", async () => { + registerBuiltInProviderKinds(); + const factory = getProviderFactory("subscription-cli"); + expect(factory).toBeDefined(); + const entry: LlmProviderConfigEntry = { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "opus", + subscriptionCli: { cli: "claude" }, + }; + const provider = await factory!({ + config: getConfig() as AtomicAgentConfig, + entry, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }); + expect(provider).toBeInstanceOf(SubscriptionCliProvider); + expect(provider.id).toBe("claude-cli"); + }); + + it("refuses an entry with no subscriptionCli block", () => { + registerBuiltInProviderKinds(); + const factory = getProviderFactory("subscription-cli")!; + // Config parsing rejects this first; the factory guard is the + // backstop for an entry built in code rather than loaded from disk. + expect(() => + factory({ + config: getConfig() as AtomicAgentConfig, + entry: { id: "claude-cli", kind: "subscription-cli" }, + logger: { debug() {}, info() {}, warn() {}, error() {} } as never, + }), + ).toThrow(/requires a subscriptionCli block/); + }); +}); diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.ts new file mode 100644 index 00000000..30a123fe --- /dev/null +++ b/src/llm/provider/subscription-cli/subscription-cli-provider.ts @@ -0,0 +1,244 @@ +import type { + CompletionRequest, + CompletionResult, + StreamChunk, +} from "../completion-types.js"; +import type { + LlmProvider, + ProviderCapabilities, + ProviderHealthResult, + VisionRequest, + VisionResult, +} from "../llm-provider.js"; +import { VisionUnsupportedError } from "../llm-provider.js"; +import type { ToolCallAdapter } from "../adapters/tool-call-adapter.js"; +import { openAiToolCallAdapter } from "../openai/openai-tool-call-adapter.js"; +import type { CliAdapterDescriptor } from "./cli-adapter-descriptor.js"; +import { resolveCliBinary } from "./resolve-cli-binary.js"; +import { runCliCommand, type CliRunner } from "./run-cli-completion.js"; +import { + streamCliCommand, + type CliStreamRunner, +} from "./stream-cli-completion.js"; + +/** Matches `OpenAiProvider`'s default; a CLI turn is never quick. */ +const DEFAULT_TIMEOUT_MS = 600_000; +/** + * `runCommand` defaults to 256 KiB, which would silently truncate a long + * completion and hand `JSON.parse` a torn object. + */ +const MAX_COMPLETION_BYTES = 8 * 1024 * 1024; +const HEALTH_TIMEOUT_MS = 5_000; +const MAX_HEALTH_BYTES = 64 * 1024; + +export interface SubscriptionCliProviderOptions { + id: string; + descriptor: CliAdapterDescriptor; + /** Working directory for the child — the state dir, not the agent's cwd. */ + cwd: string; + model?: string; + binPath?: string; + extraArgs?: readonly string[]; + streaming?: boolean; + maxBudgetUsd?: number; + requestTimeoutMs?: number; + onNotice?: (message: string) => void; + /** Test seams; default to the real spawn-backed implementations. */ + runCliImpl?: CliRunner; + streamCliImpl?: CliStreamRunner; +} + +/** + * Drives an already-signed-in vendor CLI (`claude`, `codex`) as an LLM + * backend so a flat-rate subscription can power the agent with no API + * key. The CLI authenticates from its own session — this provider never + * reads, copies or replays OAuth tokens or keychain entries. + * + * Every CLI-specific decision lives in the descriptor; this class only + * knows how to run a process and shape the result. + */ +export class SubscriptionCliProvider implements LlmProvider { + readonly id: string; + readonly name: string; + readonly capabilities: ProviderCapabilities; + /** + * We never return `tool_calls`, yet the transport is `native_tools` + * and the adapter is present on purpose. On the grammar transport a + * format drift throws out of `parseToolCalls` and costs a second full + * CLI invocation on the repair path; on the native transport an empty + * `toolCalls` sends step-executor down its guarded recovery ladder, + * which parses the tool-call JSON out of `content` inside a + * try/catch and otherwise wraps the prose as a `reply`. Same result + * when the model complies, no extra process when it does not. + */ + readonly toolCallAdapter: ToolCallAdapter = openAiToolCallAdapter; + /** Streaming is owned end to end here; that seam consumes SSE bytes. */ + readonly streamConsumer = null; + + private readonly descriptor: CliAdapterDescriptor; + private readonly binary: string; + private readonly cwd: string; + private readonly model: string; + private readonly extraArgs: readonly string[]; + private readonly streamingEnabled: boolean; + private readonly maxBudgetUsd: number | undefined; + private readonly timeoutMs: number; + private readonly onNotice: ((message: string) => void) | undefined; + private readonly runCli: CliRunner; + private readonly streamCli: CliStreamRunner; + + constructor(options: SubscriptionCliProviderOptions) { + const descriptor = options.descriptor; + this.id = options.id; + this.descriptor = descriptor; + this.name = descriptor.displayName; + this.binary = resolveCliBinary(descriptor.defaultBinary, options.binPath); + this.cwd = options.cwd; + this.model = options.model ?? descriptor.defaultChatModel; + this.extraArgs = options.extraArgs ?? []; + this.streamingEnabled = + descriptor.streamMode === "ndjson" && options.streaming !== false; + this.maxBudgetUsd = options.maxBudgetUsd; + this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS; + this.onNotice = options.onNotice; + this.runCli = options.runCliImpl ?? runCliCommand; + this.streamCli = options.streamCliImpl ?? streamCliCommand; + this.capabilities = { + vision: false, + visionSource: "config-disabled", + toolTransport: "native_tools", + contextWindow: descriptor.contextWindow, + supportsParallelTools: false, + // Every completion is a fresh process; there is no slot to pin. + supportsSlotAffinity: false, + // Verified: server-side prompt caching survives across separate + // invocations, so the KV-stable two-zone prompt still pays off. + supportsPromptCache: true, + reasoningFormat: "none", + }; + } + + async complete(request: CompletionRequest): Promise { + const args = this.descriptor.completeArgs(this.argsInput(request)); + const outcome = await this.runCli(this.runOptions(args, request)); + return this.descriptor.parseResult(outcome.stdout, this.model); + } + + async *completeStream( + request: CompletionRequest, + ): AsyncGenerator { + if (!this.streamingEnabled) { + const result = await this.complete(request); + if (result.content.length > 0) { + yield { delta: result.content, reasoningDelta: "", done: false }; + } + yield { delta: "", reasoningDelta: "", done: true }; + return result; + } + + const args = this.descriptor.streamArgs(this.argsInput(request)); + const lines = this.streamCli(this.runOptions(args, request)); + let final: string | null = null; + let sawDelta = false; + + for await (const line of lines) { + const event = this.descriptor.parseStreamEvent(line); + if (event.kind === "delta") { + sawDelta = true; + yield { delta: event.text, reasoningDelta: "", done: false }; + } else if (event.kind === "final") { + final = event.raw; + } else if (event.kind === "notice") { + this.onNotice?.(event.message); + } + } + + if (final === null) { + throw new Error( + `${this.binary} stream ended without a result envelope`, + ); + } + const result = this.descriptor.parseResult(final, this.model); + // Safety net for a stream schema we do not control: if no delta was + // recognised, emit the authoritative text once so a mismatch + // degrades to buffered behaviour instead of an empty turn. + if (!sawDelta && result.content.length > 0) { + yield { delta: result.content, reasoningDelta: "", done: false }; + } + yield { delta: "", reasoningDelta: "", done: true }; + return result; + } + + async describeImage(_request: VisionRequest): Promise { + throw new VisionUnsupportedError(this.id); + } + + async health(): Promise { + const started = Date.now(); + try { + await this.runCli({ + binary: this.binary, + args: this.descriptor.healthArgs(), + cwd: this.cwd, + timeoutMs: HEALTH_TIMEOUT_MS, + maxOutputBytes: MAX_HEALTH_BYTES, + installHint: this.descriptor.installHint, + authHint: this.descriptor.authHint, + }); + return { + reachable: true, + status: null, + error: null, + latencyMs: Date.now() - started, + }; + } catch (err) { + return { + reachable: false, + status: null, + error: err instanceof Error ? err.message : String(err), + latencyMs: Date.now() - started, + }; + } + } + + async listModels(): Promise { + // Curated list, no probe: the CLI exposes no model-list command. + return this.descriptor.staticModels; + } + + async close(): Promise { + // Nothing to release — every invocation is its own short-lived process. + } + + private argsInput(request: CompletionRequest) { + return { + model: this.model, + systemPrompt: this.descriptor.systemPrompt, + ...(request.responseFormat + ? { responseSchema: request.responseFormat.schema } + : {}), + ...(this.maxBudgetUsd === undefined + ? {} + : { maxBudgetUsd: this.maxBudgetUsd }), + extraArgs: this.extraArgs, + }; + } + + private runOptions(args: readonly string[], request: CompletionRequest) { + return { + binary: this.binary, + args, + // The prompt goes on stdin, never argv: a two-zone prompt routinely + // exceeds the 128 KiB single-argument limit once the conversation + // zone fills, and argv delivery would fail with E2BIG on exactly + // the long sessions that matter most. + input: request.prompt, + cwd: this.cwd, + timeoutMs: this.timeoutMs, + maxOutputBytes: MAX_COMPLETION_BYTES, + installHint: this.descriptor.installHint, + authHint: this.descriptor.authHint, + ...(request.signal ? { signal: request.signal } : {}), + }; + } +} diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index d58699a5..50161259 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -7,6 +7,7 @@ import { computeRowWindow } from "../row-window.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { FallbackRows } from "./llm-fallback-rows.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; export function LlmModeRows({ rows, @@ -373,7 +374,13 @@ function renderRowText(row: LlmPanelRow, state: TuiState): string { case "localBackend": return `llama.cpp backend [${state.localModelsPanel.backend.currentTag ?? "not installed"}]`; case "cloudProvider": - return `${row.provider.id} [${row.provider.kind}] ${row.provider.hasApiKey ? "key ok" : "missing key"}`; + return `${row.provider.id} [${row.provider.kind}] ${ + row.provider.kind === SUBSCRIPTION_CLI_KIND + ? "cli auth" + : row.provider.hasApiKey + ? "key ok" + : "missing key" + }`; case "cloudChatModel": return `${row.providerId}/${row.modelId} [text]`; case "cloudEmbeddingModel": diff --git a/src/tui/components/providers-panel.tsx b/src/tui/components/providers-panel.tsx index ebea65e2..d8c6d8eb 100644 --- a/src/tui/components/providers-panel.tsx +++ b/src/tui/components/providers-panel.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; import type { ProvidersPanelState } from "../providers/providers-panel-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; export function ProvidersPanel(props: { panel: ProvidersPanelState; @@ -42,7 +43,11 @@ export function ProvidersPanel(props: { const flags = [ row.isActiveText ? "TEXT*" : "", row.isActiveEmbedding ? "EMB*" : "", - row.hasApiKey ? "key" : "no-key", + row.kind === SUBSCRIPTION_CLI_KIND + ? "cli auth" + : row.hasApiKey + ? "key" + : "no-key", ] .filter(Boolean) .join(" "); diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index c713409d..c5083eba 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -29,6 +29,7 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL, OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "../providers/providers-model-options.js"; +import { subscriptionCliForWizardKind } from "../providers/providers-wizard-state.js"; import type { ProvidersWizardKind, ProvidersWizardState, @@ -36,6 +37,8 @@ import type { import { renderPickList } from "./wizard-pick-list.js"; const KIND_LABELS: Record = { + "claude-cli": + "Claude Code subscription (drives your signed-in `claude` CLI — no API key)", openrouter: "OpenRouter (cloud chat + optional cloud embed)", aimlapi: "AI/ML API (aimlapi.com — 500+ models, OpenAI-compatible)", gemini: "Gemini (Google AI)", @@ -66,6 +69,9 @@ const KIND_OPTIONS = KIND_ROW_ORDER.map((row) => ({ * does not use. */ function envHintForWizard(w: ProvidersWizardState): string { + // CLI-backed providers read no env var; the key screen is skipped + // entirely, so there is no variable to name. + if (w.kind && subscriptionCliForWizardKind(w.kind)) return ""; const preset = w.presetId ? findProviderPreset(w.presetId) : undefined; if (preset) return preset.envVar; if (w.kind === "openrouter") return "OPENROUTER_API_KEY"; diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts index 4b542caa..dfa2936c 100644 --- a/src/tui/llm-panel/llm-panel-row-builders.ts +++ b/src/tui/llm-panel/llm-panel-row-builders.ts @@ -18,6 +18,8 @@ import { OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "../providers/providers-model-options.js"; import type { LocalModelsPullState } from "../local-models/local-models-panel-state.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; +import { CLAUDE_CLI_CHAT_MODELS } from "../../llm/provider/subscription-cli/claude-cli-models.js"; import type { TuiState } from "../tui-state.js"; import type { LlmPanelRow } from "./llm-panel-selectors.js"; @@ -399,6 +401,14 @@ function inlineModelsForProvider( for (const option of listAimlapiChatModels()) out.add(option.id); return { models: [...out], status: "ready", error: null }; } + // subscription-cli: nothing to fetch. Falling through to the + // openai-compatible tail would inject `gpt-5.4-mini` as a fake row and + // spin on "loading" forever, because no request will ever complete for + // a provider that has no HTTP endpoint. + if (provider.kind === SUBSCRIPTION_CLI_KIND) { + for (const id of CLAUDE_CLI_CHAT_MODELS) out.add(id); + return { models: [...out], status: "ready", error: null }; + } // gemini: no baseUrl on the entry, so the openai-compat URL-keyed cache // can never hit. Read the gemini-keyed cache the orchestrator warms, and // fall back to the gemini default — never the openai-compat placeholder diff --git a/src/tui/providers/providers-orchestrator.ts b/src/tui/providers/providers-orchestrator.ts index 9fe38615..23f173a5 100644 --- a/src/tui/providers/providers-orchestrator.ts +++ b/src/tui/providers/providers-orchestrator.ts @@ -1,5 +1,6 @@ import { getConfig } from "../../config/index.js"; import type { UserLlmProviderEntry } from "../../config/index.js"; +import { usesExternalCliAuth } from "../../config/provider-auth-mode.js"; import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; import { resolveLlmConfig } from "../../llm/provider/registry/index.js"; import type { AgentRuntime } from "../../runtime/bootstrap.js"; @@ -210,7 +211,10 @@ export class ProvidersOrchestrator { kind: p.kind, isActiveText: p.id === resolved.activeTextProvider, isActiveEmbedding: p.id === resolved.activeEmbeddingProvider, - hasApiKey: Boolean(resolveLlmProviderApiKey(p)?.length), + // A CLI-backed entry has no key by design. Without this the row + // renders unavailable and Enter is a silent no-op. + hasApiKey: + Boolean(resolveLlmProviderApiKey(p)?.length) || usesExternalCliAuth(p), baseUrl: fileEntry?.baseUrl ?? null, chatModel: fileEntry?.defaultChatModel ?? fileEntry?.model ?? null, chatModelOptions: listChatModelOptionsForEntry(fileEntry), diff --git a/src/tui/providers/providers-panel-state.ts b/src/tui/providers/providers-panel-state.ts index 50f81a32..1954224b 100644 --- a/src/tui/providers/providers-panel-state.ts +++ b/src/tui/providers/providers-panel-state.ts @@ -7,6 +7,10 @@ export type ProviderRow = { kind: string; isActiveText: boolean; isActiveEmbedding: boolean; + /** + * Credentials are resolved for this entry — an API key, or the vendor + * CLI's own session for `subscription-cli` entries. + */ hasApiKey: boolean; /** * Stored base URL for `openai-compatible` entries (`null` for curated diff --git a/src/tui/providers/providers-wizard-build-entry.test.ts b/src/tui/providers/providers-wizard-build-entry.test.ts index 2934b873..0a5170d0 100644 --- a/src/tui/providers/providers-wizard-build-entry.test.ts +++ b/src/tui/providers/providers-wizard-build-entry.test.ts @@ -150,4 +150,38 @@ describe("buildProviderEntryFromWizard", () => { expect(built.entry.id).toBe("my-vllm"); expect(built.entry.apiKeyEnvVar).toBeUndefined(); }); + + it("maps the claude-cli row onto a keyless subscription-cli entry", () => { + const built = buildProviderEntryFromWizard({ + kind: "claude-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: "opus", + }); + expect(built.entry).toEqual({ + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "opus", + subscriptionCli: { cli: "claude" }, + }); + // No endpoint and no env var: the CLI authenticates itself, and a + // stray apiKeyEnvVar would make resolveLlmProviderApiKey look for a + // key that is never meant to exist. + expect(built.entry.baseUrl).toBeUndefined(); + expect(built.entry.apiKeyEnvVar).toBeUndefined(); + // There is no embedding endpoint behind the CLI. + expect(built.useLocalEmbedding).toBe(true); + expect(built.activateEmbeddingProviderId).toBe("local-llama"); + }); + + it("falls back to the default model when nothing was typed", () => { + const built = buildProviderEntryFromWizard({ + kind: "claude-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: " ", + }); + expect(built.entry.defaultChatModel).toBe("sonnet"); + }); + }); diff --git a/src/tui/providers/providers-wizard-build-entry.ts b/src/tui/providers/providers-wizard-build-entry.ts index bc413eea..4ee245ea 100644 --- a/src/tui/providers/providers-wizard-build-entry.ts +++ b/src/tui/providers/providers-wizard-build-entry.ts @@ -10,7 +10,12 @@ import { OPENAI_COMPAT_DEFAULT_BASE_URL, OPENAI_COMPAT_DEFAULT_CHAT_MODEL, } from "./providers-model-options.js"; -import type { ProvidersWizardKind } from "./providers-wizard-state.js"; +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, +} from "./providers-wizard-state.js"; +import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; +import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; export type BuiltWizardProvider = { entry: UserLlmProviderEntry; @@ -22,6 +27,7 @@ export type BuiltWizardProvider = { /** The fixed entry id each wizard kind maps to when no preset is involved. */ function baseIdForKind(kind: ProvidersWizardKind): string { + if (kind === "claude-cli") return "claude-cli"; if (kind === "openrouter") return "openrouter"; if (kind === "aimlapi") return "aimlapi"; if (kind === "gemini") return "gemini"; @@ -75,6 +81,25 @@ export function buildProviderEntryFromWizard(input: { takenProviderIds?: readonly string[]; }): BuiltWizardProvider { const id = providerIdForWizardSave(input); + const subscriptionCli = subscriptionCliForWizardKind(input.kind); + if (subscriptionCli) { + // Several wizard rows, one config kind: the CLI name is the only + // thing that differs, and it rides in the entry rather than in the + // kind so a new vendor CLI never needs a new provider kind. + return { + entry: { + id, + kind: SUBSCRIPTION_CLI_KIND, + defaultChatModel: + input.customChatModel?.trim() || CLAUDE_CLI_DEFAULT_CHAT_MODEL, + subscriptionCli: { cli: subscriptionCli }, + }, + // No embedding endpoint exists behind these CLIs, so notes keep + // being embedded by the local daemon. + useLocalEmbedding: true, + activateEmbeddingProviderId: "local-llama", + }; + } const preset = input.presetId ? findProviderPreset(input.presetId) : undefined; const chatModel = isCuratedCatalogKind(input.kind) ? input.chatModelId diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index b50c4862..a2209e65 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -89,13 +89,16 @@ describe("createProvidersWizardState configure prefill", () => { }); describe("KIND_ROW_ORDER", () => { - it("lists catalogs first, presets alphabetically by label, manual last", () => { - expect(KIND_ROW_ORDER[0]).toBe("openrouter"); - expect(KIND_ROW_ORDER[1]).toBe("aimlapi"); - expect(KIND_ROW_ORDER[2]).toBe("gemini"); + it("lists the subscription CLI first, then catalogs, presets alphabetically by label, manual last", () => { + // A CLI-backed provider needs no key and no endpoint, so it is the + // shortest path from a fresh install to a working agent. + expect(KIND_ROW_ORDER[0]).toBe("claude-cli"); + expect(KIND_ROW_ORDER[1]).toBe("openrouter"); + expect(KIND_ROW_ORDER[2]).toBe("aimlapi"); + expect(KIND_ROW_ORDER[3]).toBe("gemini"); expect(KIND_ROW_ORDER[KIND_ROW_ORDER.length - 1]).toBe("openai-compatible"); - const presetRows = KIND_ROW_ORDER.slice(3, -1); + const presetRows = KIND_ROW_ORDER.slice(4, -1); expect(presetRows).toEqual( PROVIDER_PRESETS.map((preset) => ({ presetId: preset.id })), ); @@ -106,6 +109,19 @@ describe("KIND_ROW_ORDER", () => { }); describe("handleProvidersWizardKey", () => { + it("takes the Claude CLI row straight past the API-key screen", () => { + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("claude-cli") }; + wizard = next(wizard, "", emptyKey({ return: true })); + // There is no key to paste — the CLI authenticates from its own + // session — so stopping on `api_key` would be a dead end. + expect(wizard).toMatchObject({ + kind: "claude-cli", + phase: "chat_model_line", + }); + expect(wizard.presetId).toBeNull(); + }); + it("takes Gemini from API key directly to model selection", () => { let wizard = createProvidersWizardState("add"); wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("gemini") }; @@ -120,7 +136,9 @@ describe("handleProvidersWizardKey", () => { it("walks the aimlapi onboarding flow when the cursor lands on it", () => { let wizard = createProvidersWizardState("add"); - wizard = next(wizard, "", emptyKey({ downArrow: true })); + // Addressed by name so inserting a row above it does not silently + // repoint this flow at a different provider. + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("aimlapi") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("aimlapi"); expect(wizard.phase).toBe("api_key"); @@ -354,6 +372,7 @@ describe("handleProvidersWizardKey", () => { it("walks the OpenRouter onboarding flow through model and embedding picks", () => { let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("openrouter") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard.kind).toBe("openrouter"); expect(wizard.phase).toBe("api_key"); diff --git a/src/tui/providers/providers-wizard-phases.ts b/src/tui/providers/providers-wizard-phases.ts index a3c3ec5b..1f873323 100644 --- a/src/tui/providers/providers-wizard-phases.ts +++ b/src/tui/providers/providers-wizard-phases.ts @@ -5,10 +5,11 @@ import { listOpenRouterChatModels, listOpenRouterEmbeddingModels, } from "./providers-model-options.js"; -import type { - ProvidersWizardKind, - ProvidersWizardPhase, - ProvidersWizardState, +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, + type ProvidersWizardPhase, + type ProvidersWizardState, } from "./providers-wizard-state.js"; /** @@ -28,6 +29,9 @@ export type ProvidersWizardKindRow = * never disagree. */ export const KIND_ROW_ORDER: readonly ProvidersWizardKindRow[] = [ + // Subscription CLIs first: they need no key and no endpoint, so they + // are the shortest path from a fresh install to a working agent. + "claude-cli", "openrouter", "aimlapi", "gemini", @@ -122,6 +126,11 @@ export function advanceWizardPhase( ): ProvidersWizardState { const { phase, kind } = wizard; if (phase === "pick_kind" && kind) { + // A CLI-backed provider has no key to paste — it authenticates from + // the CLI's own session — so the key screen would be a dead end. + if (subscriptionCliForWizardKind(kind)) { + return { ...wizard, phase: "chat_model_line", cursor: 0, error: null }; + } return { ...wizard, phase: "api_key", cursor: 0, error: null }; } if (phase === "api_key" && kind) { diff --git a/src/tui/providers/providers-wizard-state.ts b/src/tui/providers/providers-wizard-state.ts index 19d31ad5..017d750d 100644 --- a/src/tui/providers/providers-wizard-state.ts +++ b/src/tui/providers/providers-wizard-state.ts @@ -1,11 +1,32 @@ +import type { SubscriptionCliName } from "../../config/llm-config.js"; import { presetForEntryId } from "./provider-presets.js"; export type ProvidersWizardKind = + | "claude-cli" | "openrouter" | "aimlapi" | "gemini" | "openai-compatible"; +/** + * Wizard rows that map onto the single `subscription-cli` config kind. + * One row per vendor CLI keeps the choice on the screen the operator is + * already looking at, instead of adding a wizard phase whose only job is + * to ask "which CLI?". + */ +const SUBSCRIPTION_CLI_WIZARD_KINDS: Partial< + Record +> = { + "claude-cli": "claude", +}; + +/** The vendor CLI this row drives, or null for a key-based provider. */ +export function subscriptionCliForWizardKind( + kind: ProvidersWizardKind, +): SubscriptionCliName | null { + return SUBSCRIPTION_CLI_WIZARD_KINDS[kind] ?? null; +} + export type ProvidersWizardPhase = | "pick_kind" | "api_key" diff --git a/src/tui/providers/save-provider-wizard.test.ts b/src/tui/providers/save-provider-wizard.test.ts index ad847c7c..7517ada8 100644 --- a/src/tui/providers/save-provider-wizard.test.ts +++ b/src/tui/providers/save-provider-wizard.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -242,4 +242,28 @@ describe("saveProviderWizardToConfig", () => { // stole the selection. expect(getConfig().llm?.activeTextProvider).toBe("groq"); }); + + it("saves a claude-cli provider with an empty key and writes no .env", () => { + const wizard = { + ...createProvidersWizardState("add"), + kind: "claude-cli" as const, + phase: "chat_model_line" as const, + apiKeyBuffer: "", + chatModelLine: "opus", + selectedEmbeddingChoiceId: LOCAL_EMBEDDING_CHOICE_ID, + }; + + // Would throw "API key is empty" for any key-based kind. + const built = saveProviderWizardToConfig(wizard); + + expect(built.entry.kind).toBe("subscription-cli"); + expect(built.entry.subscriptionCli).toEqual({ cli: "claude" }); + expect(built.entry.defaultChatModel).toBe("opus"); + + const cfg = getConfig(); + expect(cfg.llm?.activeTextProvider).toBe("claude-cli"); + expect(cfg.llm?.activeEmbeddingProvider).toBe("local-llama"); + expect(existsSync(join(stateDir, ".env"))).toBe(false); + }); + }); diff --git a/src/tui/providers/save-provider-wizard.ts b/src/tui/providers/save-provider-wizard.ts index 43156714..6e814100 100644 --- a/src/tui/providers/save-provider-wizard.ts +++ b/src/tui/providers/save-provider-wizard.ts @@ -16,12 +16,15 @@ import { buildProviderEntryFromWizard, type BuiltWizardProvider, } from "./providers-wizard-build-entry.js"; -import type { - ProvidersWizardKind, - ProvidersWizardState, +import { + subscriptionCliForWizardKind, + type ProvidersWizardKind, + type ProvidersWizardState, } from "./providers-wizard-state.js"; +import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; function defaultChatModelForKind(kind: ProvidersWizardKind): string { + if (subscriptionCliForWizardKind(kind)) return CLAUDE_CLI_DEFAULT_CHAT_MODEL; if (kind === "aimlapi") return AIMLAPI_DEFAULT_CHAT_MODEL; if (kind === "gemini") return GEMINI_DEFAULT_CHAT_MODEL; return OPENROUTER_DEFAULT_CHAT_MODEL; @@ -58,7 +61,11 @@ export function saveProviderWizardToConfig( // Local servers have no key at all, and keyless-listing services work // before one is entered, so an empty key is a valid state for both: // nothing is written to .env and requests go out without Authorization. - const keyOptional = Boolean(preset && (preset.local || preset.listsModelsWithoutKey)); + // CLI-backed providers have no key by construction, so an empty key + // buffer is the normal case rather than an unfinished form. + const keyOptional = + Boolean(subscriptionCliForWizardKind(kind)) || + Boolean(preset && (preset.local || preset.listsModelsWithoutKey)); if (wizard.apiKeyBuffer.trim().length > 0) { writeProviderApiKeyToDotenv(kind, wizard.apiKeyBuffer, preset?.envVar); } else if (existing?.apiKey) { diff --git a/src/tui/run-local-models-config-wizard.test.ts b/src/tui/run-local-models-config-wizard.test.ts index 7dccd47c..1aaceefb 100644 --- a/src/tui/run-local-models-config-wizard.test.ts +++ b/src/tui/run-local-models-config-wizard.test.ts @@ -129,6 +129,59 @@ describe("isCloudTextProviderReady", () => { expect(isCloudTextProviderReady()).toBe(true); }); + it("treats a subscription-cli entry as ready with no key and no base URL", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "claude-cli", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "claude-cli", + kind: "subscription-cli", + defaultChatModel: "sonnet", + subscriptionCli: { cli: "claude" }, + }, + ], + }, + }); + resetConfigCache(); + + // Neither existing check can see this entry: there is no API key and + // no loopback base URL. Without the CLI-auth branch the user would + // land in the local-model wizard on every single start. + expect(isCloudTextProviderReady()).toBe(true); + }); + + it("still refuses a keyless remote entry that is not CLI-backed", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "remote-compat", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "remote-compat", + kind: "openai-compatible", + baseUrl: "https://api.example.invalid", + defaultChatModel: "some-model", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(false); + }); + it("treats a manual keyless entry with a loopback base URL as ready", () => { const cfg = getConfig(); const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); diff --git a/src/tui/run-local-models-config-wizard.ts b/src/tui/run-local-models-config-wizard.ts index 47d5b671..d1d64c89 100644 --- a/src/tui/run-local-models-config-wizard.ts +++ b/src/tui/run-local-models-config-wizard.ts @@ -2,6 +2,7 @@ import { render } from "ink"; import React from "react"; import { getConfig, USER_CONFIG_DEFAULTS } from "../config/index.js"; import type { UserLlmProviderEntry } from "../config/llm-config.js"; +import { usesExternalCliAuth } from "../config/provider-auth-mode.js"; import { resolveLlmProviderApiKey } from "../config/resolve-llm-api-key.js"; import { getLocalModelDef, @@ -82,7 +83,12 @@ export function isCloudTextProviderReady(): boolean { const entry = cfg.llm?.providers.find((provider) => provider.id === active); if (!entry) return false; if (resolveLlmProviderApiKey(entry)) return true; - return isKeylessLocalProviderEntry(entry); + if (isKeylessLocalProviderEntry(entry)) return true; + // A subscription CLI carries no key and no base URL, so both checks + // above miss it. Reachable only for a kind that config validation + // rejected outright before this existed, so no pre-existing config + // changes behaviour here. + return usesExternalCliAuth(entry); } /** From 3d358339adf29e5d0bac555f62a7ac67efccb536 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 06:15:43 +0300 Subject: [PATCH 28/57] feat(llm): add the OpenAI Codex subscription to subscription-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second vendor CLI behind the same provider kind. Written against the real `codex exec --json` interface (codex-cli 0.148.0) rather than from its docs, because four things did not match what the Claude adapter assumes. - Structured output takes a *file path* (`--output-schema `), not inline JSON, so the provider now stages a temp file and cleans it up. The argv builders stay pure; the side effect lives with the process. - Codex exits 0 even when the turn fails. A bad model id, an expired login and a rate limit all give a clean exit plus a `turn.failed` event, so the parser treats a missing `turn.completed` as a failure instead of trusting the exit code and returning empty content. - Under a ChatGPT login Codex rejects every explicit model id ("not supported when using Codex with a ChatGPT account") and resolves one server-side. So `-m` is omitted unless the operator sets one, and the descriptor ships no model list rather than a guessed one. - `exec --json` emits the answer in a single `item.completed` with no incremental text events, so `streamMode: "none"` and the provider buffers rather than pretending to stream. The one real gap versus Claude: Codex has no `--tools ""`. `-s read-only` confines its tools but cannot remove them, and left alone Codex tries to *perform* the request with its own tools instead of emitting Atomic's protocol — the first live run answered "I can't find probe.txt" after looking in its own working directory. Since Codex also has no system-prompt flag, the steering is prepended to the prompt instead. That works, but it is a prompt-level guarantee rather than a structural one, and the README says so plainly. Verified end to end: `os.fs.read` -> `reply`, and `os.fs.read` -> `os.fs.write` -> `reply`, both with zero parse retries. --- AGENTS.md | 4 +- README.md | 26 ++- .../subscription-cli/claude-cli-adapter.ts | 10 +- .../cli-adapter-descriptor.ts | 19 +- .../codex-cli-adapter.test.ts | 158 ++++++++++++++ .../subscription-cli/codex-cli-adapter.ts | 202 ++++++++++++++++++ src/llm/provider/subscription-cli/index.ts | 4 + .../subscription-cli/register-cli-adapters.ts | 2 + .../subscription-cli-provider.ts | 55 ++++- src/tui/components/providers-wizard.tsx | 2 + src/tui/llm-panel/llm-panel-row-builders.ts | 6 +- src/tui/providers/providers-orchestrator.ts | 3 + src/tui/providers/providers-panel-state.ts | 6 + .../providers-wizard-build-entry.test.ts | 18 ++ .../providers/providers-wizard-build-entry.ts | 16 +- .../providers-wizard-key-bindings.test.ts | 19 +- src/tui/providers/providers-wizard-phases.ts | 1 + src/tui/providers/providers-wizard-state.ts | 2 + src/tui/providers/save-provider-wizard.ts | 11 +- 19 files changed, 533 insertions(+), 31 deletions(-) create mode 100644 src/llm/provider/subscription-cli/codex-cli-adapter.test.ts create mode 100644 src/llm/provider/subscription-cli/codex-cli-adapter.ts diff --git a/AGENTS.md b/AGENTS.md index 9d255e94..83a30cd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,7 +204,7 @@ Text completion, vision, embeddings, and sub-calls route through plugin-register ### Registry and transport - **`ProviderRegistry`** ([src/llm/provider/registry/provider-registry.ts](src/llm/provider/registry/provider-registry.ts)) — `registerProviderKind(kind, factory)` + `fromConfig(config)`. Built-in kinds self-register in [register-built-in-providers.ts](src/llm/provider/registry/register-built-in-providers.ts): `llama-server`, `openai-compatible`, `qwen-openai-compatible`, `openrouter`, `aimlapi`, `gemini`, `subscription-cli`. -- **`subscription-cli`** ([src/llm/provider/subscription-cli/](src/llm/provider/subscription-cli/)) — drives an already-signed-in vendor CLI (`claude`) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised by `entry.subscriptionCli.cli`; every CLI-specific byte (argv builders, output parsers, hints) lives behind a `CliAdapterDescriptor`, so a new vendor CLI is a descriptor plus a `SUBSCRIPTION_CLIS` entry and never a new provider kind. It declares `native_tools` while never returning `tool_calls`: an empty `toolCalls` sends step-executor down its guarded recovery ladder, whereas `grammar` would throw out of `parseToolCalls` on any drift and pay for a second CLI invocation on the repair path. +- **`subscription-cli`** ([src/llm/provider/subscription-cli/](src/llm/provider/subscription-cli/)) — drives an already-signed-in vendor CLI (`claude`, `codex`) as an inference backend so a flat-rate subscription works with no API key. One kind, parameterised by `entry.subscriptionCli.cli`; every CLI-specific byte (argv builders, output parsers, hints) lives behind a `CliAdapterDescriptor`, so a new vendor CLI is a descriptor plus a `SUBSCRIPTION_CLIS` entry and never a new provider kind. It declares `native_tools` while never returning `tool_calls`: an empty `toolCalls` sends step-executor down its guarded recovery ladder, whereas `grammar` would throw out of `parseToolCalls` on any drift and pay for a second CLI invocation on the repair path. - **`LlmProvider`** ([src/llm/provider/llm-provider.ts](src/llm/provider/llm-provider.ts)) — `complete`, `completeStream`, `describeImage`, `health`, `close`, `capabilities`, optional `toolCallAdapter` + `streamConsumer`. - **`toolTransport`** — `grammar` (GBNF on llama-server) vs `native_tools` (OpenAI `tools` / `tool_calls`). Resolved by `resolveActiveToolTransport` from `config.llm.toolTransport` (`auto` follows the active provider). - **Name escape** — qualified tool names use `__` for dots (`os.fs.read` → `os__fs__read`) in [openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts). `reply` / `finish` are synthetic OpenAI functions alongside registry tools. @@ -229,7 +229,7 @@ Optional `config.llm` (v24) lists `providers[]`, `activeTextProvider`, `activeEm 6. **Every default tool ships a structured `argsJsonSchema`.** `ToolDescriptor.argsJsonSchema` is consumed exclusively by `descriptorsToOpenAiTools` ([openai-tool-call-adapter.ts](src/llm/provider/openai/openai-tool-call-adapter.ts)) to populate `function.parameters` on the OpenAI `tools` payload. Without it, cloud providers fall back to `{ type: "object", additionalProperties: true }` — which is what we shipped originally and what enabled the `os.shell.run` silent-arg-drop bug (model double-serialised `args` into a JSON string, the provider accepted it, the tool coerced the non-array to `[]` without warning, the model never learned). The canonical map lives in [src/prompt/default-tool-args-schemas.ts](src/prompt/default-tool-args-schemas.ts); it is merged into `DEFAULT_TOOL_DESCRIPTORS` via `attachDefaultArgsJsonSchema`. MCP descriptors carry the server's `inputSchema` verbatim through the same field. Adding a new tool **requires** an entry in `DEFAULT_TOOL_ARGS_SCHEMAS` (pinned by [src/prompt/default-tool-args-schemas.test.ts](src/prompt/default-tool-args-schemas.test.ts) "attaches a schema to every default descriptor that has one registered"). Local llama-server with GBNF does **not** consume this field — the grammar already constrains the shape. 7. **`os.shell.run` rejects non-array `args` structurally.** A non-array, non-JSON-array-string `args` value now returns `{ status: "error" }` instead of silently dropping the operator's intent. JSON-stringified arrays (the cloud `native_tools` double-serialise pattern) are auto-coerced back to `string[]`. Pinned by [src/tools/os/os-tools.test.ts](src/tools/os/os-tools.test.ts) ("returns a structured error when `args` is an object" / "is a scalar string" / "recovers a JSON-stringified array `args`"). -8. **Subscription CLIs are driven, never impersonated.** `subscription-cli` shells out to the vendor CLI's documented headless mode (`--print`) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes `--bare` (whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clears `ANTHROPIC_API_KEY`. It always passes `--tools ""` and `--strict-mcp-config` so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which would `E2BIG` on a full two-zone prompt. Pinned by [claude-cli-adapter.test.ts](src/llm/provider/subscription-cli/claude-cli-adapter.test.ts) ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv"). +8. **Subscription CLIs are driven, never impersonated.** `subscription-cli` shells out to the vendor CLI's documented headless mode (`--print`) and inherits that process's own authentication. It never reads, extracts, copies, or replays OAuth tokens or keychain entries; it never passes `--bare` (whose docs state OAuth and keychain are never read, which would defeat the feature); and it neither sets nor clears `ANTHROPIC_API_KEY`. It always disables the child's own tools as far as the CLI allows — `--tools ""` + `--strict-mcp-config` on `claude`, `-s read-only` + `--ignore-user-config` on `codex`, which confines rather than removes them — so the child agent cannot touch the filesystem or the operator's MCP servers outside atomic-agent's approval ladder, and the prompt always travels on stdin — never argv, which would `E2BIG` on a full two-zone prompt. Pinned by [claude-cli-adapter.test.ts](src/llm/provider/subscription-cli/claude-cli-adapter.test.ts) ("never passes flags that would defeat subscription auth or the approval ladder" / "never places the prompt on argv"). ### Embeddings diff --git a/README.md b/README.md index 495c2ca2..b21c4fe8 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ Atomic Agent drives a full desktop tool surface. Dangerous actions are routed th | **Skills** | View and run Markdown skill playbooks (scripts are approval-gated), install more from ClawHub. Ships with 17 starter skills (Docker, GitHub, Notion, Obsidian, PDF, and more), auto-installed on first run. | | **Vision** | Optional `vision.describe` for multimodal models with `mmproj`, kept outside the text transcript. | | **MCP** | Connect external MCP servers; their tools, resources, and prompts join the same registry. | -| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, AI/ML API, and Gemini providers when configured, with live model catalogs and mid-session switching. Your existing **Claude Code subscription** works too, driven through its own signed-in CLI with no API key. Reasoning-only completions from reasoning models are recovered instead of failing the turn. | +| **Providers** | Local `llama-server` by default; OpenAI-compatible, OpenRouter, AI/ML API, and Gemini providers when configured, with live model catalogs and mid-session switching. Your existing **Claude Code and OpenAI Codex subscriptions** work too, driven through their own signed-in CLIs with no API key. Reasoning-only completions from reasoning models are recovered instead of failing the turn. | | **Telegram** | Single-user remote control with owner pairing, inline approval buttons, and opt-in result reports from scheduled tasks. | ### Memory That Grows Outside the Prompt @@ -423,7 +423,7 @@ Local-first bounds where control lives, not where packets go. Network egress hap - an HTTP tool calls a requested endpoint; - a web search provider answers a query; - a configured cloud LLM or embedding provider receives its request; -- a `subscription-cli` provider is active and the vendor CLI (`claude`) receives your prompt on its stdin, then sends it on under its own account; +- a `subscription-cli` provider is active and the vendor CLI (`claude` or `codex`) receives your prompt on its stdin, then sends it on under its own account; - an MCP server receives a tool call you routed to it; - the Telegram channel is enabled and the bot exchanges messages with your paired chat, including opt-in scheduled task reports; - you install a skill from ClawHub; @@ -483,13 +483,13 @@ Shell-exported variables win over `.env`. The built-in parser intentionally supp
-Claude Code subscription (no API key) +Claude Code / OpenAI Codex subscriptions (no API key) -Drives the `claude` CLI you are already signed into, so a flat-rate Claude subscription can power the agent with no API key and no per-token billing. +Drives a vendor CLI you are already signed into, so a flat-rate subscription can power the agent with no API key and no per-token billing. Two are supported: `claude` (Claude Code) and `codex` (OpenAI Codex). -**Prerequisite:** [Claude Code](https://claude.com/claude-code) installed and signed in — run `claude` once and complete `/login`. Atomic only spawns that binary; it never reads, copies, or replays its OAuth tokens or keychain entries. +**Prerequisite:** the CLI installed and signed in — `claude` then `/login`, or `npm i -g @openai/codex` then `codex login`. Atomic only spawns the binary; it never reads, copies, or replays its OAuth tokens or keychain entries. -In the TUI: **Providers → `n` → Claude Code subscription**, then type a model (`sonnet`, `opus`, `haiku`, `fable`, or a pinned id like `claude-sonnet-5`). There is no API-key screen, because there is no key. Equivalent `config.json`: +In the TUI: **Providers → `n` →** pick the subscription row, then type a model. For Claude that is `sonnet`, `opus`, `haiku`, `fable`, or a pinned id like `claude-sonnet-5`; **for Codex leave it blank** — under a ChatGPT login Codex rejects explicit model ids (`not supported when using Codex with a ChatGPT account`) and resolves one itself. There is no API-key screen, because there is no key. Equivalent `config.json`: ```json { @@ -509,7 +509,9 @@ In the TUI: **Providers → `n` → Claude Code subscription**, then type a mode Optional keys inside `subscriptionCli`: `binPath` (absolute path when the CLI is not on `PATH`), `extraArgs` (appended verbatim — e.g. `["--effort", "high"]`), `streaming` (set `false` to buffer), `maxBudgetUsd`. -Each completion runs `claude --print` with the prompt on **stdin** (a two-zone prompt exceeds the 128 KiB argv limit) and these flags, which are load-bearing rather than cosmetic: +Swap `"cli": "claude"` for `"cli": "codex"` to drive Codex instead, and drop `defaultChatModel`. + +Each completion spawns the CLI fresh with the prompt on **stdin** (a two-zone prompt exceeds the 128 KiB argv limit). For `claude` it runs `claude --print` with these flags, which are load-bearing rather than cosmetic: - **`--tools ""`** — disables Claude Code's own Bash/Edit/Write. Without it a second agent would act on your machine outside Atomic's approval ladder. - **`--strict-mcp-config`** with no config — keeps your MCP servers out of what should be a stateless completion. @@ -517,9 +519,15 @@ Each completion runs `claude --print` with the prompt on **stdin** (a two-zone p - **`--no-session-persistence`** — Atomic owns session state; CLI-side history would double-count context. - **`--bare` is never passed.** Its own docs say OAuth and keychain are never read under it, which would defeat the whole feature. -Not supported on this provider: vision, embeddings (they stay on the local daemon), and the sampling knobs `temperature` / `top_p` / `top_k` / `seed` / `stop` / `maxTokens` — the CLI exposes no flag for them, so they are dropped rather than silently approximated. Reconfiguring `binPath` or `extraArgs` means editing `config.json`; the model is changeable from the LLM tab. +For `codex` it runs `codex exec --json` with `--ephemeral`, `--skip-git-repo-check`, `--ignore-user-config` and `-s read-only`. Three differences are worth knowing, because Codex is a more opinionated agent than Claude's headless mode: + +- **There is no `--tools ""` equivalent.** `-s read-only` confines Codex's own tools to reading; it cannot remove them. Left to itself, Codex will try to *perform* the request with its own tools instead of emitting Atomic's tool-call protocol — in testing it answered "I can't find `probe.txt`" after looking in its own working directory. The fix is an explicit completion-engine instruction prepended to the prompt (Codex has no system-prompt flag). It works — verified turns drive `os.fs.read` → `reply` and `os.fs.read` → `os.fs.write` → `reply` with no parse retries — but it is a prompt-level guarantee, not a structural one like `--tools ""`. +- **Codex exits 0 even when the turn fails.** A bad model id, an expired login and a rate limit all produce a clean exit with a `turn.failed` event, so the adapter treats a missing `turn.completed` as a failure rather than trusting the exit code. +- **No streaming.** `codex exec --json` emits the answer in one `item.completed`, with no incremental text events, so this provider buffers instead of pretending to stream. + +Not supported on either CLI: vision, embeddings (they stay on the local daemon), and the sampling knobs `temperature` / `top_p` / `top_k` / `seed` / `stop` / `maxTokens` — neither CLI exposes a flag for them, so they are dropped rather than silently approximated. Reconfiguring `binPath` or `extraArgs` means editing `config.json`; the model is changeable from the LLM tab. -Two things worth knowing before you switch a long-running agent onto it: each completion pays roughly 0.8 s of process startup, and subscription plans have session and weekly caps that an autonomous multi-step agent reaches much faster than interactive use. When a cap is hit, the CLI's own message is surfaced verbatim. +Two things worth knowing before you switch a long-running agent onto either: each completion pays roughly 0.8 s of process startup, and subscription plans have session and weekly caps that an autonomous multi-step agent reaches much faster than interactive use. When a cap is hit, the CLI's own message is surfaced verbatim. > [!NOTE] > Whether driving a subscription CLI from another agent is acceptable use is the vendor's call, not this project's. Atomic uses the officially documented headless mode and nothing else; the decision to use it is yours. diff --git a/src/llm/provider/subscription-cli/claude-cli-adapter.ts b/src/llm/provider/subscription-cli/claude-cli-adapter.ts index c99f5ba2..5d74631a 100644 --- a/src/llm/provider/subscription-cli/claude-cli-adapter.ts +++ b/src/llm/provider/subscription-cli/claude-cli-adapter.ts @@ -41,8 +41,7 @@ function baseArgs(input: CliArgsInput): string[] { "--print", "--input-format", "text", - "--model", - input.model, + ...(input.model ? ["--model", input.model] : []), "--system-prompt", input.systemPrompt, // Safety-critical, not an optimisation: Claude Code's built-in @@ -238,11 +237,16 @@ export const claudeCliAdapter: CliAdapterDescriptor = { systemPrompt: CLAUDE_CLI_SYSTEM_PROMPT, staticModels: CLAUDE_CLI_CHAT_MODELS, contextWindow: CLAUDE_CLI_CONTEXT_WINDOW, - supportsJsonSchema: true, + schemaDelivery: "inline", streamMode: "ndjson", installHint: "Install Claude Code (https://claude.com/claude-code) and run `claude` once to sign in, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.", authHint: "Run `claude` in a terminal and complete /login, then retry.", + buildStdin(prompt) { + // Claude takes the steering through --system-prompt, so the prompt + // reaches the model exactly as atomic-agent built it. + return prompt; + }, completeArgs(input) { return [...baseArgs(input), "--output-format", "json", ...tailArgs(input)]; }, diff --git a/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts index 8683d9a6..d3066323 100644 --- a/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts +++ b/src/llm/provider/subscription-cli/cli-adapter-descriptor.ts @@ -3,10 +3,17 @@ import type { CompletionResult } from "../completion-types.js"; /** Everything the argv builders need from one completion request. */ export interface CliArgsInput { + /** + * Empty when the operator set no model and the CLI resolves one + * itself — Codex under a ChatGPT login rejects every explicit id, so + * the flag has to be omitted rather than guessed at. + */ model: string; systemPrompt: string; /** JSON Schema from `CompletionRequest.responseFormat`, when set. */ responseSchema?: Record; + /** Path to that schema on disk, for CLIs that take a file. */ + responseSchemaPath?: string; maxBudgetUsd?: number; extraArgs: readonly string[]; } @@ -35,11 +42,21 @@ export interface CliAdapterDescriptor { readonly systemPrompt: string; readonly staticModels: readonly string[]; readonly contextWindow: number; - readonly supportsJsonSchema: boolean; + /** + * How the CLI accepts a structured-output schema: `claude` takes it + * inline on argv, `codex` takes a path to a file on disk. + */ + readonly schemaDelivery: "inline" | "file" | "none"; /** `"none"` means `completeStream` must fall back to buffering. */ readonly streamMode: "ndjson" | "none"; readonly installHint: string; readonly authHint: string; + /** + * The text written to the child's stdin. Exists because only some + * CLIs have a system-prompt flag; the rest must carry that steering + * inside the prompt itself. + */ + buildStdin(prompt: string, systemPrompt: string): string; completeArgs(input: CliArgsInput): string[]; streamArgs(input: CliArgsInput): string[]; healthArgs(): string[]; diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts new file mode 100644 index 00000000..fdba207a --- /dev/null +++ b/src/llm/provider/subscription-cli/codex-cli-adapter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { codexCliAdapter } from "./codex-cli-adapter.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, +} from "./subscription-cli-errors.js"; + +const input = { model: "", systemPrompt: "SYSTEM", extraArgs: [] as readonly string[] }; + +/** Captured verbatim from `codex exec --json` v0.148.0. */ +const SUCCESS = [ + JSON.stringify({ type: "thread.started", thread_id: "01a0" }), + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_0", type: "agent_message", text: "OK" }, + }), + JSON.stringify({ + type: "turn.completed", + usage: { + input_tokens: 13459, + cached_input_tokens: 5888, + cache_write_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 27, + }, + }), +].join("\n"); + +describe("codexCliAdapter argv", () => { + it("runs exec headless, sandboxed, and stateless", () => { + const args = codexCliAdapter.completeArgs(input); + expect(args[0]).toBe("exec"); + expect(args).toContain("--json"); + expect(args).toContain("--ephemeral"); + expect(args).toContain("--skip-git-repo-check"); + expect(args).toContain("--ignore-user-config"); + expect(args.slice(args.indexOf("-s"), args.indexOf("-s") + 2)).toEqual([ + "-s", + "read-only", + ]); + // Trailing `-` is what makes Codex read the prompt from stdin. + expect(args[args.length - 1]).toBe("-"); + }); + + it("omits -m entirely when no model is configured", () => { + // Verified live: under a ChatGPT login Codex rejects every explicit + // model id and resolves one server-side. + expect(codexCliAdapter.completeArgs(input)).not.toContain("-m"); + expect(codexCliAdapter.defaultChatModel).toBe(""); + expect(codexCliAdapter.staticModels).toEqual([]); + }); + + it("passes an operator-chosen model when one is set", () => { + const args = codexCliAdapter.completeArgs({ ...input, model: "gpt-5.1" }); + expect(args[args.indexOf("-m") + 1]).toBe("gpt-5.1"); + }); + + it("takes the schema as a file path, never inline", () => { + expect(codexCliAdapter.schemaDelivery).toBe("file"); + const args = codexCliAdapter.completeArgs({ + ...input, + responseSchemaPath: "/tmp/s/schema.json", + }); + expect(args[args.indexOf("--output-schema") + 1]).toBe("/tmp/s/schema.json"); + expect(args).not.toContain("--json-schema"); + }); + + it("never passes the dangerous escape hatches", () => { + const args = codexCliAdapter.completeArgs({ + ...input, + extraArgs: ["--enable", "x"], + }); + expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(args).not.toContain("--dangerously-bypass-hook-trust"); + expect(args).not.toContain("--add-dir"); + }); + + it("carries the steering in stdin, since codex has no system-prompt flag", () => { + const stdin = codexCliAdapter.buildStdin("PROMPT", "SYSTEM"); + expect(stdin).toBe("SYSTEM\n\nPROMPT"); + expect(codexCliAdapter.completeArgs(input)).not.toContain("--system-prompt"); + }); +}); + +describe("codexCliAdapter parseResult", () => { + it("maps the real success stream", () => { + const result = codexCliAdapter.parseResult(SUCCESS, ""); + expect(result.content).toBe("OK"); + expect(result.finishReason).toBe("stop"); + expect(result.slotId).toBe(-1); + // cached_input_tokens is a subset of input_tokens here, unlike + // Claude's disjoint counters, so it is reported and not added. + expect(result.usage).toEqual({ + promptTokens: 13459, + completionTokens: 5 + 27, + totalTokens: 13459 + 32, + }); + expect(result.cacheHitTokens).toBe(5888); + }); + + it("throws on turn.failed even though codex exits 0", () => { + // The whole reason this parser cannot trust the exit code. + const failed = [ + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "turn.failed", + error: { message: "The 'x' model is not supported when using Codex with a ChatGPT account." }, + }), + ].join("\n"); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + SubscriptionCliInvocationError, + ); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + /not supported when using Codex/, + ); + }); + + it("treats a stream with no turn.completed as a failure, not empty content", () => { + expect(() => + codexCliAdapter.parseResult( + JSON.stringify({ type: "thread.started" }), + "", + ), + ).toThrow(/no turn.completed/); + }); + + it("classifies a signed-out failure as an auth error", () => { + const failed = JSON.stringify({ + type: "turn.failed", + error: { message: "401 Unauthorized" }, + }); + expect(() => codexCliAdapter.parseResult(failed, "")).toThrow( + SubscriptionCliAuthError, + ); + }); + + it("ignores the non-fatal metadata warning when the turn still completes", () => { + const withWarning = [ + JSON.stringify({ + type: "item.completed", + item: { id: "item_0", type: "error", message: "Model metadata not found" }, + }), + JSON.stringify({ + type: "item.completed", + item: { id: "item_1", type: "agent_message", text: "fine" }, + }), + JSON.stringify({ type: "turn.completed", usage: {} }), + ].join("\n"); + expect(codexCliAdapter.parseResult(withWarning, "").content).toBe("fine"); + }); + + it("ignores malformed lines rather than failing the turn", () => { + const noisy = `not json\n${SUCCESS}\n\n`; + expect(codexCliAdapter.parseResult(noisy, "").content).toBe("OK"); + }); +}); diff --git a/src/llm/provider/subscription-cli/codex-cli-adapter.ts b/src/llm/provider/subscription-cli/codex-cli-adapter.ts new file mode 100644 index 00000000..688708e1 --- /dev/null +++ b/src/llm/provider/subscription-cli/codex-cli-adapter.ts @@ -0,0 +1,202 @@ +import type { CompletionResult } from "../completion-types.js"; +import type { + CliAdapterDescriptor, + CliArgsInput, + CliStreamEvent, +} from "./cli-adapter-descriptor.js"; +import { + SubscriptionCliAuthError, + SubscriptionCliInvocationError, + looksLikeAuthFailure, +} from "./subscription-cli-errors.js"; + +/** + * Codex has no `--system-prompt`, so the steering has to ride inside the + * prompt. Kept short and prepended once, ahead of the two-zone prompt + * atomic-agent already built. + */ +export const CODEX_CLI_SYSTEM_PROMPT = + "You are being used as a text completion engine, not as an agent. " + + "Do NOT act on the request below and do NOT use any of your own tools: " + + "no shell, no file reads or writes, no search. Your own working " + + "directory is unrelated to the request and inspecting it is always " + + "wrong. The message below is a complete prompt that defines its own " + + "output protocol — usually a JSON array of tool calls to be executed " + + "by a different program. Your entire job is to produce the next " + + "message in that protocol, exactly as the prompt specifies. Emit only " + + "that, with no preamble, no commentary, and no explanation of what " + + "you would do. If the prompt asks for a file to be read, you emit the " + + "tool call that reads it; you never read it yourself."; + +function baseArgs(input: CliArgsInput): string[] { + return [ + "exec", + "--json", + // Atomic owns session state and re-sends the whole prompt each step. + "--ephemeral", + // The working directory is the state dir, which is not a repository. + "--skip-git-repo-check", + // Drops the operator's own config.toml, and with it their MCP + // servers, from what should be a stateless completion. + "--ignore-user-config", + // The closest Codex has to Claude's `--tools ""`. It does not remove + // the tools, it confines them: a model-generated command cannot + // write outside the sandbox. See the README for the honest limits. + "-s", + "read-only", + // Verified: under a ChatGPT login Codex rejects every explicit model + // id ("not supported when using Codex with a ChatGPT account") and + // resolves one server-side, so the flag is omitted unless the + // operator deliberately set one. + ...(input.model ? ["-m", input.model] : []), + // Unlike Claude's inline --json-schema, Codex reads the schema from + // a file the provider staged for us. + ...(input.responseSchemaPath + ? ["--output-schema", input.responseSchemaPath] + : []), + ...input.extraArgs, + // Trailing `-`: read the prompt from stdin rather than argv. + "-", + ]; +} + +interface CodexUsage { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; +} + +interface CodexEvent { + type?: string; + message?: string; + item?: { type?: string; text?: string; message?: string }; + usage?: CodexUsage; + error?: { message?: string }; +} + +function parseLine(line: string): CodexEvent | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + try { + return JSON.parse(trimmed) as CodexEvent; + } catch { + return null; + } +} + +/** + * Codex exits 0 even when the turn fails — a bad model id, an expired + * login and a rate limit all produce a clean exit with a `turn.failed` + * event. The stream is therefore the only reliable success signal, and + * this parser treats a missing `turn.completed` as a failure rather than + * returning empty content. + */ +function parseResult(stdout: string, fallbackModel: string): CompletionResult { + let text = ""; + let usage: CodexUsage | undefined; + let completed = false; + let failure: string | null = null; + + for (const line of stdout.split("\n")) { + const event = parseLine(line); + if (!event) continue; + if (event.type === "item.completed" && event.item) { + if (event.item.type === "agent_message" && event.item.text) { + text = event.item.text; + } else if (event.item.type === "error" && event.item.message) { + // Non-fatal on its own (e.g. "model metadata not found"); only + // a turn.failed decides the turn. + failure ??= event.item.message; + } + } else if (event.type === "turn.completed") { + completed = true; + usage = event.usage; + } else if (event.type === "turn.failed") { + failure = event.error?.message ?? failure ?? "turn failed"; + completed = false; + } else if (event.type === "error" && event.message) { + failure = event.message; + } + } + + if (!completed) { + const detail = failure ?? "codex produced no turn.completed event"; + if (looksLikeAuthFailure(detail)) { + throw new SubscriptionCliAuthError( + "codex", + "Run `codex login` and sign in with your ChatGPT account, then retry.", + detail.slice(0, 500), + ); + } + throw new SubscriptionCliInvocationError(`codex turn failed: ${detail}`); + } + + // `cached_input_tokens` is a subset of `input_tokens` here, unlike + // Claude's disjoint cache counters — so it is reported, not added. + const promptTokens = usage?.input_tokens ?? 0; + const completionTokens = + (usage?.output_tokens ?? 0) + (usage?.reasoning_output_tokens ?? 0); + + return { + content: text, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 0, + predictedMs: 0, + promptTokens, + predictedTokens: completionTokens, + }, + cacheHitTokens: usage?.cached_input_tokens ?? 0, + slotId: -1, + modelId: fallbackModel || null, + usage: { + promptTokens, + completionTokens, + totalTokens: promptTokens + completionTokens, + }, + finishReason: "stop", + }; +} + +export const codexCliAdapter: CliAdapterDescriptor = { + cli: "codex", + displayName: "OpenAI Codex subscription", + defaultBinary: "codex", + // Empty on purpose: Codex picks the model the account supports. + defaultChatModel: "", + staticModels: [], + // Codex does not publish a context window per model here; this only + // feeds compaction timing, so a conservative floor is the safe choice. + contextWindow: 200_000, + schemaDelivery: "file", + // No incremental text events were observed on `exec --json` — output + // arrives in one `item.completed`. Buffering is therefore honest + // rather than a limitation we could paper over. + streamMode: "none", + systemPrompt: CODEX_CLI_SYSTEM_PROMPT, + installHint: + "Install the Codex CLI (`npm i -g @openai/codex`) and run `codex login`, or set llm.providers[].subscriptionCli.binPath to the binary's absolute path.", + authHint: + "Run `codex login` and sign in with your ChatGPT account, then retry.", + buildStdin(prompt, systemPrompt) { + return `${systemPrompt}\n\n${prompt}`; + }, + completeArgs(input) { + return baseArgs(input); + }, + streamArgs(input) { + // streamMode is "none", so the provider never calls this; keeping it + // identical means a future streaming opt-in cannot drift. + return baseArgs(input); + }, + healthArgs() { + return ["--version"]; + }, + parseResult, + parseStreamEvent(): CliStreamEvent { + return { kind: "ignore" }; + }, +}; diff --git a/src/llm/provider/subscription-cli/index.ts b/src/llm/provider/subscription-cli/index.ts index 83f8a079..d2e14491 100644 --- a/src/llm/provider/subscription-cli/index.ts +++ b/src/llm/provider/subscription-cli/index.ts @@ -2,6 +2,10 @@ export { claudeCliAdapter, CLAUDE_CLI_SYSTEM_PROMPT, } from "./claude-cli-adapter.js"; +export { + codexCliAdapter, + CODEX_CLI_SYSTEM_PROMPT, +} from "./codex-cli-adapter.js"; export { CLAUDE_CLI_CHAT_MODELS, CLAUDE_CLI_CONTEXT_WINDOW, diff --git a/src/llm/provider/subscription-cli/register-cli-adapters.ts b/src/llm/provider/subscription-cli/register-cli-adapters.ts index 73d966e5..85d72361 100644 --- a/src/llm/provider/subscription-cli/register-cli-adapters.ts +++ b/src/llm/provider/subscription-cli/register-cli-adapters.ts @@ -1,5 +1,6 @@ import { registerCliAdapter } from "./cli-adapter-descriptor.js"; import { claudeCliAdapter } from "./claude-cli-adapter.js"; +import { codexCliAdapter } from "./codex-cli-adapter.js"; let registered = false; @@ -12,4 +13,5 @@ export function registerBuiltInCliAdapters(): void { if (registered) return; registered = true; registerCliAdapter(claudeCliAdapter); + registerCliAdapter(codexCliAdapter); } diff --git a/src/llm/provider/subscription-cli/subscription-cli-provider.ts b/src/llm/provider/subscription-cli/subscription-cli-provider.ts index 30a123fe..b431cf87 100644 --- a/src/llm/provider/subscription-cli/subscription-cli-provider.ts +++ b/src/llm/provider/subscription-cli/subscription-cli-provider.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { CompletionRequest, CompletionResult, @@ -94,6 +97,8 @@ export class SubscriptionCliProvider implements LlmProvider { this.name = descriptor.displayName; this.binary = resolveCliBinary(descriptor.defaultBinary, options.binPath); this.cwd = options.cwd; + // May be empty: Codex under a ChatGPT login rejects explicit model + // ids and resolves one itself, so the flag is then omitted. this.model = options.model ?? descriptor.defaultChatModel; this.extraArgs = options.extraArgs ?? []; this.streamingEnabled = @@ -119,9 +124,42 @@ export class SubscriptionCliProvider implements LlmProvider { } async complete(request: CompletionRequest): Promise { - const args = this.descriptor.completeArgs(this.argsInput(request)); - const outcome = await this.runCli(this.runOptions(args, request)); - return this.descriptor.parseResult(outcome.stdout, this.model); + const staged = await this.stageSchema(request); + try { + const args = this.descriptor.completeArgs( + this.argsInput(request, staged.path), + ); + const outcome = await this.runCli(this.runOptions(args, request)); + return this.descriptor.parseResult(outcome.stdout, this.model); + } finally { + await staged.cleanup(); + } + } + + /** + * Some CLIs take the structured-output schema inline on argv, others + * only as a path. Writing that file is a side effect, so it lives here + * rather than inside the argv builders, which stay pure and testable. + */ + private async stageSchema( + request: CompletionRequest, + ): Promise<{ path?: string; cleanup: () => Promise }> { + const noop = { cleanup: async () => {} }; + if ( + this.descriptor.schemaDelivery !== "file" || + !request.responseFormat + ) { + return noop; + } + const dir = await mkdtemp(join(tmpdir(), "atomic-cli-schema-")); + const path = join(dir, "schema.json"); + await writeFile(path, JSON.stringify(request.responseFormat.schema), "utf8"); + return { + path, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + }, + }; } async *completeStream( @@ -210,13 +248,15 @@ export class SubscriptionCliProvider implements LlmProvider { // Nothing to release — every invocation is its own short-lived process. } - private argsInput(request: CompletionRequest) { + private argsInput(request: CompletionRequest, schemaPath?: string) { + const delivery = this.descriptor.schemaDelivery; return { model: this.model, systemPrompt: this.descriptor.systemPrompt, - ...(request.responseFormat + ...(request.responseFormat && delivery === "inline" ? { responseSchema: request.responseFormat.schema } : {}), + ...(schemaPath ? { responseSchemaPath: schemaPath } : {}), ...(this.maxBudgetUsd === undefined ? {} : { maxBudgetUsd: this.maxBudgetUsd }), @@ -232,7 +272,10 @@ export class SubscriptionCliProvider implements LlmProvider { // exceeds the 128 KiB single-argument limit once the conversation // zone fills, and argv delivery would fail with E2BIG on exactly // the long sessions that matter most. - input: request.prompt, + input: this.descriptor.buildStdin( + request.prompt, + this.descriptor.systemPrompt, + ), cwd: this.cwd, timeoutMs: this.timeoutMs, maxOutputBytes: MAX_COMPLETION_BYTES, diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index c5083eba..863cc09b 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -39,6 +39,8 @@ import { renderPickList } from "./wizard-pick-list.js"; const KIND_LABELS: Record = { "claude-cli": "Claude Code subscription (drives your signed-in `claude` CLI — no API key)", + "codex-cli": + "OpenAI Codex subscription (drives your signed-in `codex` CLI — no API key)", openrouter: "OpenRouter (cloud chat + optional cloud embed)", aimlapi: "AI/ML API (aimlapi.com — 500+ models, OpenAI-compatible)", gemini: "Gemini (Google AI)", diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts index dfa2936c..896eb45a 100644 --- a/src/tui/llm-panel/llm-panel-row-builders.ts +++ b/src/tui/llm-panel/llm-panel-row-builders.ts @@ -406,7 +406,11 @@ function inlineModelsForProvider( // spin on "loading" forever, because no request will ever complete for // a provider that has no HTTP endpoint. if (provider.kind === SUBSCRIPTION_CLI_KIND) { - for (const id of CLAUDE_CLI_CHAT_MODELS) out.add(id); + // Claude ships a curated list; Codex publishes none and resolves the + // model itself, so its pane shows only whatever the entry pinned. + if (provider.subscriptionCli?.cli === "claude") { + for (const id of CLAUDE_CLI_CHAT_MODELS) out.add(id); + } return { models: [...out], status: "ready", error: null }; } // gemini: no baseUrl on the entry, so the openai-compat URL-keyed cache diff --git a/src/tui/providers/providers-orchestrator.ts b/src/tui/providers/providers-orchestrator.ts index 23f173a5..d3108d29 100644 --- a/src/tui/providers/providers-orchestrator.ts +++ b/src/tui/providers/providers-orchestrator.ts @@ -216,6 +216,9 @@ export class ProvidersOrchestrator { hasApiKey: Boolean(resolveLlmProviderApiKey(p)?.length) || usesExternalCliAuth(p), baseUrl: fileEntry?.baseUrl ?? null, + subscriptionCli: fileEntry?.subscriptionCli + ? { cli: fileEntry.subscriptionCli.cli } + : null, chatModel: fileEntry?.defaultChatModel ?? fileEntry?.model ?? null, chatModelOptions: listChatModelOptionsForEntry(fileEntry), embeddingModel: fileEntry?.defaultEmbeddingModel ?? null, diff --git a/src/tui/providers/providers-panel-state.ts b/src/tui/providers/providers-panel-state.ts index 1954224b..02f585ca 100644 --- a/src/tui/providers/providers-panel-state.ts +++ b/src/tui/providers/providers-panel-state.ts @@ -19,6 +19,12 @@ export type ProviderRow = { * silently resets a custom endpoint to the OpenAI default. */ baseUrl: string | null; + /** + * Which vendor CLI a `subscription-cli` entry drives, `null` for every + * other kind. The panes need it because the CLIs differ in what they + * can offer — Claude publishes a model list, Codex does not. + */ + subscriptionCli: { cli: string } | null; chatModel: string | null; chatModelOptions?: readonly string[]; embeddingModel: string | null; diff --git a/src/tui/providers/providers-wizard-build-entry.test.ts b/src/tui/providers/providers-wizard-build-entry.test.ts index 0a5170d0..fb0ede01 100644 --- a/src/tui/providers/providers-wizard-build-entry.test.ts +++ b/src/tui/providers/providers-wizard-build-entry.test.ts @@ -184,4 +184,22 @@ describe("buildProviderEntryFromWizard", () => { expect(built.entry.defaultChatModel).toBe("sonnet"); }); + it("omits defaultChatModel for codex, which resolves the model itself", () => { + const built = buildProviderEntryFromWizard({ + kind: "codex-cli", + chatModelId: "", + embeddingChoiceId: "", + customChatModel: "", + }); + expect(built.entry).toEqual({ + id: "codex-cli", + kind: "subscription-cli", + subscriptionCli: { cli: "codex" }, + }); + // Writing "" would fail config validation (parseOptionalString + // rejects the empty string), and any pinned id is rejected by Codex + // under a ChatGPT login. + expect(built.entry.defaultChatModel).toBeUndefined(); + }); + }); diff --git a/src/tui/providers/providers-wizard-build-entry.ts b/src/tui/providers/providers-wizard-build-entry.ts index 4ee245ea..a4329dff 100644 --- a/src/tui/providers/providers-wizard-build-entry.ts +++ b/src/tui/providers/providers-wizard-build-entry.ts @@ -15,7 +15,10 @@ import { type ProvidersWizardKind, } from "./providers-wizard-state.js"; import { SUBSCRIPTION_CLI_KIND } from "../../config/provider-auth-mode.js"; -import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, +} from "../../llm/provider/subscription-cli/index.js"; export type BuiltWizardProvider = { entry: UserLlmProviderEntry; @@ -28,6 +31,7 @@ export type BuiltWizardProvider = { /** The fixed entry id each wizard kind maps to when no preset is involved. */ function baseIdForKind(kind: ProvidersWizardKind): string { if (kind === "claude-cli") return "claude-cli"; + if (kind === "codex-cli") return "codex-cli"; if (kind === "openrouter") return "openrouter"; if (kind === "aimlapi") return "aimlapi"; if (kind === "gemini") return "gemini"; @@ -86,12 +90,18 @@ export function buildProviderEntryFromWizard(input: { // Several wizard rows, one config kind: the CLI name is the only // thing that differs, and it rides in the entry rather than in the // kind so a new vendor CLI never needs a new provider kind. + // Codex resolves the model server-side under a ChatGPT login and + // rejects explicit ids, so its descriptor default is empty and the + // field is omitted rather than written as "". + registerBuiltInCliAdapters(); + const model = + input.customChatModel?.trim() || + resolveCliAdapter(subscriptionCli).defaultChatModel; return { entry: { id, kind: SUBSCRIPTION_CLI_KIND, - defaultChatModel: - input.customChatModel?.trim() || CLAUDE_CLI_DEFAULT_CHAT_MODEL, + ...(model ? { defaultChatModel: model } : {}), subscriptionCli: { cli: subscriptionCli }, }, // No embedding endpoint exists behind these CLIs, so notes keep diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index a2209e65..e24214df 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -93,12 +93,13 @@ describe("KIND_ROW_ORDER", () => { // A CLI-backed provider needs no key and no endpoint, so it is the // shortest path from a fresh install to a working agent. expect(KIND_ROW_ORDER[0]).toBe("claude-cli"); - expect(KIND_ROW_ORDER[1]).toBe("openrouter"); - expect(KIND_ROW_ORDER[2]).toBe("aimlapi"); - expect(KIND_ROW_ORDER[3]).toBe("gemini"); + expect(KIND_ROW_ORDER[1]).toBe("codex-cli"); + expect(KIND_ROW_ORDER[2]).toBe("openrouter"); + expect(KIND_ROW_ORDER[3]).toBe("aimlapi"); + expect(KIND_ROW_ORDER[4]).toBe("gemini"); expect(KIND_ROW_ORDER[KIND_ROW_ORDER.length - 1]).toBe("openai-compatible"); - const presetRows = KIND_ROW_ORDER.slice(4, -1); + const presetRows = KIND_ROW_ORDER.slice(5, -1); expect(presetRows).toEqual( PROVIDER_PRESETS.map((preset) => ({ presetId: preset.id })), ); @@ -122,6 +123,16 @@ describe("handleProvidersWizardKey", () => { expect(wizard.presetId).toBeNull(); }); + it("takes the Codex CLI row straight past the API-key screen too", () => { + let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("codex-cli") }; + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard).toMatchObject({ + kind: "codex-cli", + phase: "chat_model_line", + }); + }); + it("takes Gemini from API key directly to model selection", () => { let wizard = createProvidersWizardState("add"); wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("gemini") }; diff --git a/src/tui/providers/providers-wizard-phases.ts b/src/tui/providers/providers-wizard-phases.ts index 1f873323..3f270049 100644 --- a/src/tui/providers/providers-wizard-phases.ts +++ b/src/tui/providers/providers-wizard-phases.ts @@ -32,6 +32,7 @@ export const KIND_ROW_ORDER: readonly ProvidersWizardKindRow[] = [ // Subscription CLIs first: they need no key and no endpoint, so they // are the shortest path from a fresh install to a working agent. "claude-cli", + "codex-cli", "openrouter", "aimlapi", "gemini", diff --git a/src/tui/providers/providers-wizard-state.ts b/src/tui/providers/providers-wizard-state.ts index 017d750d..bf6864aa 100644 --- a/src/tui/providers/providers-wizard-state.ts +++ b/src/tui/providers/providers-wizard-state.ts @@ -3,6 +3,7 @@ import { presetForEntryId } from "./provider-presets.js"; export type ProvidersWizardKind = | "claude-cli" + | "codex-cli" | "openrouter" | "aimlapi" | "gemini" @@ -18,6 +19,7 @@ const SUBSCRIPTION_CLI_WIZARD_KINDS: Partial< Record > = { "claude-cli": "claude", + "codex-cli": "codex", }; /** The vendor CLI this row drives, or null for a key-based provider. */ diff --git a/src/tui/providers/save-provider-wizard.ts b/src/tui/providers/save-provider-wizard.ts index 6e814100..ccc7361d 100644 --- a/src/tui/providers/save-provider-wizard.ts +++ b/src/tui/providers/save-provider-wizard.ts @@ -21,10 +21,17 @@ import { type ProvidersWizardKind, type ProvidersWizardState, } from "./providers-wizard-state.js"; -import { CLAUDE_CLI_DEFAULT_CHAT_MODEL } from "../../llm/provider/subscription-cli/claude-cli-models.js"; +import { + registerBuiltInCliAdapters, + resolveCliAdapter, +} from "../../llm/provider/subscription-cli/index.js"; function defaultChatModelForKind(kind: ProvidersWizardKind): string { - if (subscriptionCliForWizardKind(kind)) return CLAUDE_CLI_DEFAULT_CHAT_MODEL; + const cli = subscriptionCliForWizardKind(kind); + if (cli) { + registerBuiltInCliAdapters(); + return resolveCliAdapter(cli).defaultChatModel; + } if (kind === "aimlapi") return AIMLAPI_DEFAULT_CHAT_MODEL; if (kind === "gemini") return GEMINI_DEFAULT_CHAT_MODEL; return OPENROUTER_DEFAULT_CHAT_MODEL; From 46b49cc661c52b230933f454098fce610db16980 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 07:13:49 +0300 Subject: [PATCH 29/57] =?UTF-8?q?refactor(tui):=20one=20menu=20registry=20?= =?UTF-8?q?=E2=80=94=20the=20slash=20palette=20becomes=20a=20projection=20?= =?UTF-8?q?of=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). --- src/tui/commands/slash-commands.ts | 118 +----- src/tui/menu/menu-registry.test.ts | 265 +++++++++++++ src/tui/menu/menu-registry.ts | 580 +++++++++++++++++++++++++++++ 3 files changed, 862 insertions(+), 101 deletions(-) create mode 100644 src/tui/menu/menu-registry.test.ts create mode 100644 src/tui/menu/menu-registry.ts diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..5b0fb080 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -1,5 +1,7 @@ import fuzzysort from "fuzzysort"; +import { toSlashCommands } from "../menu/menu-registry.js"; + export interface SlashCommandDef { /** Canonical command name (without leading `/`). */ readonly name: string; @@ -10,108 +12,22 @@ export interface SlashCommandDef { } /** - * Atomic-agent's slash command registry. Intentionally small: the - * handler-side dispatch in `slash-command-handler.ts` knows how to - * action each name. Additions live here so the palette + parser stay - * in sync by construction. + * Atomic-agent's slash command registry — a **projection** of the + * operator menu (`src/tui/menu/menu-registry.ts`), not a list of its + * own. Every command is one menu node carrying a `slash` field, so the + * palette and the menu cannot describe the same command differently. + * + * Order is the historical palette order, carried on `MenuSlash.rank`: + * an empty query lists the registry as-is, and fuzzy-search ties break + * by index, so both are user-visible. + * + * To add a command, add the node to `MENU`. The handler-side dispatch in + * `slash-command-handler.ts` still knows how to action each name. */ -export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ - { - name: "dump", - description: - "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", - }, - { name: "help", description: "list available slash commands" }, - { - name: "tools", - description: - "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", - }, - { - name: "theme", - description: - "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", - }, - { name: "clear", description: "clear chat transcript (keeps session)" }, - { 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: "observe", - description: - "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", - }, - { - name: "manage", - description: - "switch to the Manage section (tasks / skills / LLM / telegram)", - }, - { name: "feed", description: "jump to the Observe → Feed tab" }, - { name: "logs", description: "jump to the Observe → Logs tab" }, - { name: "reasoning", description: "jump to the Observe → Reasoning tab" }, - { name: "world", description: "jump to the Observe → World tab" }, - { name: "expand", description: "expand every tool card in the chat log" }, - { name: "collapse", description: "collapse every tool card in the chat log" }, - { name: "session", description: "show current session id" }, - { name: "sessions", description: "open session picker to switch threads" }, - { name: "new", description: "start a fresh session (keeps warm runtime)" }, - { - name: "skills", - description: - "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", - }, - { - name: "skill", - description: - "skill subcommand: `/skill enable ` | `/skill disable `", - }, - { - name: "memory", - description: - "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", - }, - { - name: "llm", - description: - "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", - }, - { - name: "mcp", - description: - "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", - }, - { - name: "model", - description: - "open chat model picker · subcommands: pull | use | status | ", - aliases: ["models", "local"], - }, - { name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)" }, - { - name: "task", - description: - "task subcommand: `/task new` | `/task cancel ` | `/task run `", - }, - { - name: "telegram", - description: - "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", - }, - { - name: "import", - description: "open the Import tab (one-shot Hermes -> atomic-agent migration)", - }, - { - name: "privacy", - description: - "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", - }, - { - name: "analytics", - description: "toggle anonymous analytics: `/analytics on|off|status`", - }, -]; +export const SLASH_COMMANDS: readonly SlashCommandDef[] = toSlashCommands().map( + ({ name, description, aliases }) => + aliases ? { name, description, aliases } : { name, description }, +); /** * Filter the registry by a slash query (the characters typed after `/`). diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts new file mode 100644 index 00000000..7d523d05 --- /dev/null +++ b/src/tui/menu/menu-registry.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; + +import { SLASH_COMMANDS } from "../commands/slash-commands.js"; +import { + MENU, + MENU_GROUP_ORDER, + menuChildren, + menuNodeByChord, + menuNodeById, + menuRoots, +} from "./menu-registry.js"; + +/** + * The slash palette exactly as it shipped in v0.2.2, before the registry + * refactor. `SLASH_COMMANDS` is now derived from `MENU`; this snapshot is + * what makes "no visible change" a claim the suite can check rather than + * a promise in a PR description. + */ +const V0_2_2_SLASH_COMMANDS = [ + { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + }, + { + name: "help", + description: + "list available slash commands", + }, + { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + }, + { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + }, + { + name: "clear", + description: + "clear chat transcript (keeps session)", + }, + { + 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: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + }, + { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + }, + { + name: "feed", + description: + "jump to the Observe → Feed tab", + }, + { + name: "logs", + description: + "jump to the Observe → Logs tab", + }, + { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + }, + { + name: "world", + description: + "jump to the Observe → World tab", + }, + { + name: "expand", + description: + "expand every tool card in the chat log", + }, + { + name: "collapse", + description: + "collapse every tool card in the chat log", + }, + { + name: "session", + description: + "show current session id", + }, + { + name: "sessions", + description: + "open session picker to switch threads", + }, + { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + }, + { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + }, + { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + }, + { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + }, + { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + }, + { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + }, + { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + }, + { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + }, + { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + }, + { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + }, + { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + }, + { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + }, + { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + }, +]; + +describe("menu registry", () => { + it("derives the v0.2.2 slash palette unchanged — same commands, same order", () => { + expect(SLASH_COMMANDS).toEqual(V0_2_2_SLASH_COMMANDS); + }); + + it("gives every node a unique id", () => { + const ids = MENU.map((node) => node.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("never lets two nodes claim the same ctrl+g chord", () => { + const chords = MENU.flatMap((node) => (node.chord ? [node.chord] : [])); + expect(chords.length).toBeGreaterThan(0); + expect(new Set(chords).size).toBe(chords.length); + }); + + it("never lets two nodes claim the same slash name or alias", () => { + const names = MENU.flatMap((node) => + node.slash ? [node.slash.name, ...(node.slash.aliases ?? [])] : [], + ); + expect(new Set(names).size).toBe(names.length); + }); + + it("gives every slash command a distinct palette rank", () => { + const ranks = MENU.flatMap((node) => (node.slash ? [node.slash.rank] : [])); + expect(new Set(ranks).size).toBe(ranks.length); + }); + + it("points every parent at a real submenu", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent, `${node.id} -> ${node.parent}`).not.toBeNull(); + expect(parent?.kind).toBe("submenu"); + } + }); + + it("keeps the tree exactly one level deep", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent?.parent).toBeUndefined(); + } + }); + + it("leaves no submenu empty", () => { + for (const node of MENU) { + if (node.kind !== "submenu") continue; + expect(menuChildren(node.id).length, node.id).toBeGreaterThan(0); + } + }); + + it("puts every node in a group the menu knows how to render", () => { + for (const node of MENU) { + expect(MENU_GROUP_ORDER).toContain(node.group); + } + }); + + it("resolves places by chord", () => { + expect(menuNodeByChord("t")?.id).toBe("go.manage.tasks"); + expect(menuNodeByChord("p")?.id).toBe("go.manage.privacy"); + expect(menuNodeByChord("§")).toBeNull(); + }); + + it("lists Observe and Manage as the browsable destinations under Go", () => { + const roots = menuRoots("go").map((node) => node.id); + expect(roots).toContain("go.observe"); + expect(roots).toContain("go.manage"); + expect(roots).not.toContain("go.manage.tasks"); + expect(menuChildren("go.manage").map((n) => n.label)).toEqual([ + "Tasks", + "Skills", + "Memory", + "MCP", + "LLM", + "Telegram", + "Import", + "Privacy", + ]); + }); +}); diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts new file mode 100644 index 00000000..0e390280 --- /dev/null +++ b/src/tui/menu/menu-registry.ts @@ -0,0 +1,580 @@ +import type { TuiSection } from "../section.js"; +import type { TuiTab } from "../tui-state.js"; + +/** + * Top-level grouping of the operator menu. `go` holds destinations and + * deliberately mirrors the product's own Run / Observe / Manage split + * rather than inventing a second taxonomy for the same rooms; the rest + * are verbs grouped by *what they act on* — the thread, the model, the + * turn in flight, the configuration — which is the only grouping that + * stays true as entries are added. + */ +export type MenuGroup = + | "go" + | "session" + | "model" + | "run" + | "setup" + | "help"; + +/** Display order of the groups in the menu. */ +export const MENU_GROUP_ORDER: readonly MenuGroup[] = [ + "go", + "session", + "model", + "run", + "setup", + "help", +]; + +export const MENU_GROUP_LABELS: Record = { + go: "Go", + session: "Session", + model: "Model", + run: "Run", + setup: "Setup", + help: "Help", +}; + +/** + * The slash command a node is also reachable as. A node that carries one + * is *activated* by running that command, so the menu never grows a + * second dispatch path alongside `slash-command-handler.ts`. + */ +export interface MenuSlash { + readonly name: string; + readonly description: string; + readonly aliases?: readonly string[]; + /** + * Position in the slash palette listing. Kept explicit because the + * palette order is user-visible (empty query lists the registry in + * order, and ties in a fuzzy search break by index) and is not the + * same as the menu's own order. + */ + readonly rank: number; +} + +interface MenuNodeBase { + /** Stable identifier, e.g. `go.manage.tasks`. Never shown to the operator. */ + readonly id: string; + readonly label: string; + readonly group: MenuGroup; + /** + * Single key pressed after the `ctrl+g` leader. Unique across the whole + * registry — `menu-registry.test.ts` fails the build if two nodes claim + * the same one. + */ + readonly chord?: string; + readonly slash?: MenuSlash; + /** Parent submenu id, for nodes one level down. */ + readonly parent?: string; +} + +/** A destination: a section, or a tab inside one. */ +export interface MenuPlaceNode extends MenuNodeBase { + readonly kind: "place"; + readonly section: TuiSection; + readonly tab?: TuiTab; +} + +/** A one-level-deep grouping of places. The tree never goes deeper. */ +export interface MenuSubmenuNode extends MenuNodeBase { + readonly kind: "submenu"; +} + +/** A verb. Activating it runs `slash.name` through the existing handler. */ +export interface MenuActionNode extends MenuNodeBase { + readonly kind: "action"; +} + +export type MenuNode = MenuPlaceNode | MenuSubmenuNode | MenuActionNode; + +/** + * The single source of truth for the operator menu, the slash palette and + * the `ctrl+g` chord table. Everything that used to be a hand-kept + * parallel list is now a projection of this array — see + * `toSlashCommands()` below and `slash-commands.ts`. + */ +export const MENU: readonly MenuNode[] = [ + { + kind: "place", + id: "go.run", + label: "Run", + group: "go", + chord: "r", + slash: { + name: "chat", + description: + "return to single-view chat mode", + aliases: ["run"], + rank: 8, + }, + section: "run", + }, + { + kind: "action", + id: "go.debug", + label: "Toggle debug pane", + group: "go", + slash: { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + rank: 7, + }, + }, + { + kind: "submenu", + id: "go.observe", + label: "Observe", + group: "go", + slash: { + name: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + rank: 9, + }, + }, + { + kind: "place", + id: "go.observe.feed", + label: "Feed", + group: "go", + chord: "f", + slash: { + name: "feed", + description: + "jump to the Observe → Feed tab", + rank: 11, + }, + section: "observe", + tab: "feed", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.world", + label: "World", + group: "go", + chord: "w", + slash: { + name: "world", + description: + "jump to the Observe → World tab", + rank: 14, + }, + section: "observe", + tab: "world", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.reasoning", + label: "Reasoning", + group: "go", + chord: "e", + slash: { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + rank: 13, + }, + section: "observe", + tab: "reasoning", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.logs", + label: "Logs", + group: "go", + chord: "o", + slash: { + name: "logs", + description: + "jump to the Observe → Logs tab", + rank: 12, + }, + section: "observe", + tab: "logs", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.llm-logs", + label: "LLM logs", + group: "go", + chord: "L", + section: "observe", + tab: "llm-logs", + parent: "go.observe", + }, + { + kind: "submenu", + id: "go.manage", + label: "Manage", + group: "go", + slash: { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + rank: 10, + }, + }, + { + kind: "place", + id: "go.manage.tasks", + label: "Tasks", + group: "go", + chord: "t", + slash: { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + rank: 26, + }, + section: "manage", + tab: "tasks", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.skills", + label: "Skills", + group: "go", + chord: "s", + slash: { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + rank: 20, + }, + section: "manage", + tab: "skills", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.memory", + label: "Memory", + group: "go", + chord: "m", + slash: { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + rank: 22, + }, + section: "manage", + tab: "memory", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.mcp", + label: "MCP", + group: "go", + chord: "c", + slash: { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + rank: 24, + }, + section: "manage", + tab: "mcp", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.llm", + label: "LLM", + group: "go", + chord: "l", + slash: { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + rank: 23, + }, + section: "manage", + tab: "llm", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.telegram", + label: "Telegram", + group: "go", + chord: "g", + slash: { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + rank: 28, + }, + section: "manage", + tab: "telegram", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.import", + label: "Import", + group: "go", + chord: "i", + slash: { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + rank: 29, + }, + section: "manage", + tab: "import", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.privacy", + label: "Privacy", + group: "go", + chord: "p", + slash: { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + rank: 30, + }, + section: "manage", + tab: "privacy", + parent: "go.manage", + }, + { + kind: "action", + id: "session.new", + label: "New session", + group: "session", + chord: "n", + slash: { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + rank: 19, + }, + }, + { + kind: "action", + id: "session.switch", + label: "Switch session…", + group: "session", + chord: "u", + slash: { + name: "sessions", + description: + "open session picker to switch threads", + rank: 18, + }, + }, + { + kind: "action", + id: "session.clear", + label: "Clear transcript", + group: "session", + slash: { + name: "clear", + description: + "clear chat transcript (keeps session)", + rank: 4, + }, + }, + { + kind: "action", + id: "session.id", + label: "Show session id", + group: "session", + slash: { + name: "session", + description: + "show current session id", + rank: 17, + }, + }, + { + kind: "action", + id: "model.chat", + label: "Switch chat model…", + group: "model", + chord: "k", + slash: { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + rank: 25, + }, + }, + { + kind: "action", + id: "run.abort", + label: "Abort turn", + group: "run", + chord: "a", + slash: { + name: "abort", + description: + "abort the running turn", + rank: 5, + }, + }, + { + kind: "action", + id: "run.expand", + label: "Expand all tool cards", + group: "run", + slash: { + name: "expand", + description: + "expand every tool card in the chat log", + rank: 15, + }, + }, + { + kind: "action", + id: "run.collapse", + label: "Collapse all tool cards", + group: "run", + slash: { + name: "collapse", + description: + "collapse every tool card in the chat log", + rank: 16, + }, + }, + { + kind: "action", + id: "setup.theme", + label: "Theme…", + group: "setup", + chord: "h", + slash: { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + rank: 3, + }, + }, + { + kind: "action", + id: "setup.analytics", + label: "Analytics", + group: "setup", + slash: { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + rank: 31, + }, + }, + { + kind: "action", + id: "setup.skill", + label: "Enable or disable a skill…", + group: "setup", + slash: { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + rank: 21, + }, + }, + { + kind: "action", + id: "setup.task", + label: "Create, cancel or run a task…", + group: "setup", + slash: { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + rank: 27, + }, + }, + { + kind: "action", + id: "help.commands", + label: "Commands", + group: "help", + slash: { + name: "help", + description: + "list available slash commands", + rank: 1, + }, + }, + { + kind: "action", + id: "help.tools", + label: "List built-in tools", + group: "help", + slash: { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + rank: 2, + }, + }, + { + kind: "action", + id: "help.dump", + label: "Write debug bundle", + group: "help", + chord: "d", + slash: { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + rank: 0, + }, + }, + { + kind: "action", + id: "help.quit", + label: "Quit", + group: "help", + chord: "q", + slash: { + name: "quit", + description: + "exit atomic-agent", + aliases: ["exit"], + rank: 6, + }, + }, +]; + +/** Every node that is also a slash command, in palette order. */ +export function toSlashCommands(): readonly MenuSlash[] { + return MENU.flatMap((node) => (node.slash ? [node.slash] : [])).sort( + (a, b) => a.rank - b.rank, + ); +} + +/** Children of a submenu, in registry order. */ +export function menuChildren(parentId: string): readonly MenuNode[] { + return MENU.filter((node) => node.parent === parentId); +} + +/** Top-level nodes of a group — submenu children are excluded. */ +export function menuRoots(group: MenuGroup): readonly MenuNode[] { + return MENU.filter((node) => node.group === group && node.parent === undefined); +} + +/** Resolve a node by id. */ +export function menuNodeById(id: string): MenuNode | null { + return MENU.find((node) => node.id === id) ?? null; +} + +/** Resolve the node bound to a `ctrl+g` chord key. */ +export function menuNodeByChord(key: string): MenuNode | null { + return MENU.find((node) => node.chord === key) ?? null; +} From ac5e2877f70cdbe7d3aaf051d38936566bc22e4b Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 07:33:04 +0300 Subject: [PATCH 30/57] feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. --- src/tui/app-key-bindings.ts | 33 ++++++ src/tui/components/hotkey-hint.tsx | 11 +- src/tui/menu/menu-behaviour.test.ts | 150 +++++++++++++++++++++++++ src/tui/menu/menu-keys.ts | 157 ++++++++++++++++++++++++++ src/tui/menu/menu-popup.tsx | 144 ++++++++++++++++++++++++ src/tui/menu/menu-selectors.ts | 166 ++++++++++++++++++++++++++++ src/tui/reduce-ui-actions.ts | 32 ++++++ src/tui/theme/theme.ts | 58 ++++++++++ src/tui/tui-action.ts | 6 + src/tui/tui-app.test.tsx | 70 ++++++++++++ src/tui/tui-app.tsx | 40 ++++++- src/tui/tui-state.ts | 15 +++ 12 files changed, 876 insertions(+), 6 deletions(-) create mode 100644 src/tui/menu/menu-behaviour.test.ts create mode 100644 src/tui/menu/menu-keys.ts create mode 100644 src/tui/menu/menu-popup.tsx create mode 100644 src/tui/menu/menu-selectors.ts diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..77870064 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -6,6 +6,13 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import { + handleMenuKey, + isMenuLeaderKey, + isMenuOpenKey, + resolveLeaderChord, +} from "./menu/menu-keys.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; @@ -72,6 +79,11 @@ export interface AppKeyContext { * the sidebar steals plain Tab. */ sidebarVisible: boolean; + /** True while a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed: boolean; + setMenuLeaderArmed: (armed: boolean) => void; + /** Navigate to a place, or run an action's slash command. */ + activateMenuNode: (node: MenuNode) => void; } /** @@ -102,6 +114,27 @@ export function handleAppKey( if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { return true; } + // The menu and its leader sit above every panel guard on purpose: they are + // the way out of a panel, so a panel must never be able to swallow them. + if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) { + return true; + } + if (ctx.menuLeaderArmed) { + ctx.setMenuLeaderArmed(false); + const node = resolveLeaderChord(input, key); + if (node) ctx.activateMenuNode(node); + // An unclaimed chord is swallowed rather than passed on: a mistyped + // leader must not leak a letter into the prompt or fire a panel hotkey. + return true; + } + if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) { + ctx.setMenuLeaderArmed(true); + return true; + } + if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) { + dispatch({ type: "menu_opened" }); + return true; + } if ( ctx.sidebarVisible && state.uiMode === "chat" && diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..dbb0f5d2 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -85,7 +85,7 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { { key: "tab", label: "next panel" }, { key: "shift+tab", label: "prev panel" }, { key: "esc", label: "back to Run" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -104,15 +104,16 @@ 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. + // Six chips is the cap for one row on narrow terminals. `ctrl+p` takes + // the slot `/` used to hold: the menu contains every slash command as + // well as every destination, so advertising the superset costs nothing + // and `/` keeps working for anyone who already reaches for it. return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, { key: "tab", label: "sidebar" }, { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts new file mode 100644 index 00000000..9581cd16 --- /dev/null +++ b/src/tui/menu/menu-behaviour.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { handleMenuKey, resolveLeaderChord } from "./menu-keys.js"; +import type { MenuNode } from "./menu-registry.js"; +import { + selectMenuItems, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import type { TuiAction } from "../tui-action.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; + +const 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, +} as const; + +function open(patch: Partial = {}): TuiState { + return { ...createInitialTuiState(fakeSession()), menuOpen: true, ...patch }; +} + +function drive(state: TuiState, input: string, key: Partial) { + const actions: TuiAction[] = []; + const activated: MenuNode[] = []; + const handled = handleMenuKey(input, { ...KEY, ...key } as never, { + state, + dispatch: (a) => actions.push(a), + activate: (n) => activated.push(n), + }); + return { handled, actions, activated }; +} + +describe("menu rows", () => { + it("shows group headings and the two submenus at the root", () => { + const rows = selectMenuRows(open()); + const headers = rows.flatMap((r) => (r.kind === "header" ? [r.label] : [])); + expect(headers).toEqual(["Go", "Session", "Model", "Run", "Setup", "Help"]); + const go = rows.filter((r) => r.kind === "item" && r.node.group === "go"); + expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([ + "Run", + "Toggle debug pane", + "Observe", + "Manage", + ]); + }); + + it("lists a submenu's children and titles the popup with a breadcrumb", () => { + const state = open({ menuPath: "go.manage" }); + expect(selectMenuTitle(state)).toContain("Manage"); + const labels = selectMenuItems(state).map((r) => r.node.label); + expect(labels).toEqual([ + "Tasks", "Skills", "Memory", "MCP", "LLM", "Telegram", "Import", "Privacy", + ]); + }); + + it("flattens the tree when searching and keeps a breadcrumb on each hit", () => { + const state = open({ menuQuery: "privacy" }); + const items = selectMenuItems(state); + const privacy = items.find((r) => r.node.id === "go.manage.privacy"); + expect(privacy).toBeDefined(); + expect(privacy?.crumb).toBe("Manage"); + expect(items.some((r) => r.node.kind === "submenu")).toBe(false); + }); + + it("searching from inside a submenu still reaches the whole registry", () => { + const state = open({ menuPath: "go.manage", menuQuery: "feed" }); + const ids = selectMenuItems(state).map((r) => r.node.id); + expect(ids).toContain("go.observe.feed"); + }); + + it("carries live counts onto destinations", () => { + const base = createInitialTuiState(fakeSession()); + const state = open({ + tasksPanel: { ...base.tasksPanel, rows: [{}, {}] as never }, + menuPath: "go.manage", + }); + const tasks = selectMenuItems(state).find((r) => r.node.id === "go.manage.tasks"); + expect(tasks?.status).toBe("2 tasks"); + }); +}); + +describe("menu keys", () => { + it("moves the cursor with the arrows only, so letters stay available for search", () => { + expect(drive(open(), "", { downArrow: true }).actions).toEqual([ + { type: "menu_cursor_moved", delta: 1 }, + ]); + expect(drive(open(), "j", {}).actions).toEqual([ + { type: "menu_query_changed", query: "j" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("opens a submenu with the right arrow and leaves it with the left", () => { + const atManage = open({ menuCursor: 2 }); + expect(drive(atManage, "", { rightArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: "go.observe" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + const inside = open({ menuPath: "go.manage" }); + expect(drive(inside, "", { leftArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: null }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("closes before activating, so the menu is never left over a new screen", () => { + const state = open({ menuPath: "go.manage" }); + const { actions, activated } = drive(state, "", { return: true }); + expect(actions).toEqual([{ type: "menu_closed" }]); + expect(activated.map((n) => n.id)).toEqual(["go.manage.tasks"]); + }); + + it("takes a whole burst into the search box, so a paste is not swallowed", () => { + expect(drive(open(), "privacy", {}).actions).toEqual([ + { type: "menu_query_changed", query: "privacy" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("keeps escape-sequence fragments out of the query", () => { + const arrow = String.fromCharCode(27) + "[A"; + expect(drive(open(), arrow, {}).actions).toEqual([]); + }); + + it("swallows every key while open so no panel below can act on it", () => { + for (const [input, key] of [["x", {}], ["", { tab: true }], ["", { pageUp: true }]] as const) { + expect(drive(open(), input, key).handled).toBe(true); + } + }); + + it("declines every key when closed", () => { + const closed = { ...createInitialTuiState(fakeSession()), menuOpen: false }; + expect(drive(closed, "x", {}).handled).toBe(false); + }); +}); + +describe("leader chords", () => { + it("resolves a place from the key pressed after ctrl+g", () => { + expect(resolveLeaderChord("t", KEY as never)?.id).toBe("go.manage.tasks"); + expect(resolveLeaderChord("f", KEY as never)?.id).toBe("go.observe.feed"); + }); + + it("resolves nothing for an unclaimed key or an escape", () => { + expect(resolveLeaderChord("z", KEY as never)).toBeNull(); + expect(resolveLeaderChord("", { ...KEY, escape: true } as never)).toBeNull(); + }); +}); diff --git a/src/tui/menu/menu-keys.ts b/src/tui/menu/menu-keys.ts new file mode 100644 index 00000000..22db0efe --- /dev/null +++ b/src/tui/menu/menu-keys.ts @@ -0,0 +1,157 @@ +import type { Key } from "ink"; + +import type { TuiAction } from "../tui-action.js"; +import type { TuiState } from "../tui-state.js"; +import { menuNodeByChord, type MenuNode } from "./menu-registry.js"; +import { clampMenuCursor, selectMenuSelection } from "./menu-selectors.js"; + +/** + * Prefix for direct jumps: `ctrl+g` then a single key. A leader is what + * keeps the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) + * usable — the chord namespace is disjoint from both those letters and from + * ordinary typing, so nothing had to be renamed to make room for it. + * + * `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix + * and is intercepted by some terminals. + */ +export const MENU_LEADER_LABEL = "ctrl+g"; + +export interface MenuKeyContext { + state: TuiState; + dispatch: (action: TuiAction) => void; + /** Run the node — navigate to a place, or run an action's slash command. */ + activate: (node: MenuNode) => void; +} + +/** True when the keypress opens the menu. */ +export function isMenuOpenKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "p"; +} + +/** True when the keypress arms the `ctrl+g` leader. */ +export function isMenuLeaderKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "g"; +} + +/** + * Resolve the key pressed after the leader. Returns the node to activate, + * or `null` when nothing claims that key — an unknown chord is swallowed + * rather than falling through, so a mistyped leader can never land a stray + * letter in the prompt or fire a panel hotkey. + */ +export function resolveLeaderChord(input: string, key: Key): MenuNode | null { + if (key.escape || input.length === 0) return null; + return menuNodeByChord(input); +} + +/** + * Key layer for the open menu. Runs before every other handler, and claims + * every printable key — the search box owns typing, which is why the list is + * navigated with arrows only and never with `j`/`k`. + * + * Returns `true` when the key was consumed. + */ +export function handleMenuKey( + input: string, + key: Key, + ctx: MenuKeyContext, +): boolean { + const { state, dispatch } = ctx; + if (!state.menuOpen) return false; + + if (key.escape) { + dispatch({ type: "menu_closed" }); + return true; + } + if (key.downArrow) { + dispatch({ type: "menu_cursor_moved", delta: 1 }); + return true; + } + if (key.upArrow) { + dispatch({ type: "menu_cursor_moved", delta: -1 }); + return true; + } + + const searching = state.menuQuery.trim().length > 0; + const selection = selectMenuSelection(state); + + if (key.rightArrow) { + if (!searching && selection?.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + } + return true; + } + if (key.leftArrow) { + if (!searching && state.menuPath !== null) { + dispatch({ type: "menu_path_set", path: null }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); + } + return true; + } + if (key.return) { + if (!selection) return true; + if (selection.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + return true; + } + dispatch({ type: "menu_closed" }); + ctx.activate(selection.node); + return true; + } + if (key.backspace || key.delete) { + if (state.menuQuery.length > 0) { + setQuery(dispatch, state.menuQuery.slice(0, -1)); + } + return true; + } + if (isPrintable(input, key)) { + setQuery(dispatch, state.menuQuery + input); + return true; + } + // Anything else (Tab, page keys, stray control bytes) is swallowed so the + // panel layer underneath cannot act on a key aimed at the menu. + return true; +} + +/** Clamp helper shared with the reducer so cursor moves stay in range. */ +export function nextMenuCursor(state: TuiState, delta: number): number { + return clampMenuCursor(state, state.menuCursor + delta); +} + +function enterSubmenu( + dispatch: (action: TuiAction) => void, + id: string, +): void { + dispatch({ type: "menu_path_set", path: id }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Typing flattens the tree: a query ranks across the whole registry, so any + * submenu the operator had walked into is dropped at the same time. + */ +function setQuery( + dispatch: (action: TuiAction) => void, + query: string, +): void { + dispatch({ type: "menu_query_changed", query }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Printable text destined for the search box. + * + * Accepts a whole burst, not just one character: a paste arrives as a single + * input event, and so does fast typing under a slow render. Control bytes and + * escape-sequence fragments are rejected per code point so a stray arrow can + * never end up inside the query. + */ +function isPrintable(input: string, key: Key): boolean { + if (input.length === 0) return false; + if (key.ctrl || key.meta || key.tab || key.return || key.escape) return false; + for (const char of input) { + const code = char.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx new file mode 100644 index 00000000..5e1b0696 --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,144 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { chromeTheme } from "../theme/theme.js"; +import type { TuiState } from "../tui-state.js"; +import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import { + clampMenuCursor, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import { MENU_LEADER_LABEL } from "./menu-keys.js"; + +/** Rows of list body. Keeps the popup shorter than a short terminal. */ +const MAX_ROWS = 16; + +interface MenuPopupProps { + state: TuiState; +} + +/** + * The operator menu: one key (`ctrl+p`) to every destination and every verb. + * + * Rendered directly above the prompt on the same left rail rather than as a + * full-screen takeover, so it reads as belonging to the input you were + * already typing in. The app behind it is dimmed by `setBackdropDimmed` + * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores + * that flag, so the menu stays at full contrast against a faded backdrop. + * + * Pure presentation: every key is handled by `handleMenuKey`. + */ +export function MenuPopup({ state }: MenuPopupProps): ReactElement { + const rows = selectMenuRows(state); + const cursor = clampMenuCursor(state, state.menuCursor); + const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); + const cursorRowIdx = itemIndexes[cursor] ?? -1; + const start = windowStart(rows, cursorRowIdx); + const visible = rows.slice(start, start + MAX_ROWS); + const hiddenAfter = Math.max(0, rows.length - start - visible.length); + + return ( + + + + {selectMenuTitle(state)} + + + {" "} + {chromeTheme.glyphs.promptCaret} {state.menuQuery} + {"█"} + + + {start > 0 ? ( + {"↑"} {start} above + ) : null} + {visible.map((row, idx) => + row.kind === "header" ? ( + + {row.label.toUpperCase()} + + ) : ( + + ), + )} + {hiddenAfter > 0 ? ( + + {"↓"} {hiddenAfter} below + + ) : null} + {rows.length === 0 ? ( + nothing matches + ) : null} + {footer(state)} + + ); +} + +function MenuItem({ + row, + selected, +}: { + row: MenuItemRow; + selected: boolean; +}): ReactElement { + const { node } = row; + const isSubmenu = node.kind === "submenu"; + const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + return ( + + + + {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} + {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} + + + + {detail} + + {node.chord ? ( + + + {MENU_LEADER_LABEL} {node.chord} + + + ) : null} + + ); +} + +/** + * Footer names exactly the moves that are legal right now — `←` only appears + * once there is a level to go back to. + */ +function footer(state: TuiState): string { + const parts = [`${"↑↓"} move`]; + if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { + parts.push(`${"←"} back`); + } + if (state.menuQuery.trim().length === 0 && state.menuPath === null) { + parts.push(`${"→"} open`); + } + parts.push("enter go", "type to search", "esc close"); + return parts.join(" "); +} + +/** Scroll window that keeps the cursor row visible. */ +function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { + if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; + if (cursorRowIdx < MAX_ROWS) return 0; + return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +} diff --git a/src/tui/menu/menu-selectors.ts b/src/tui/menu/menu-selectors.ts new file mode 100644 index 00000000..c502dcde --- /dev/null +++ b/src/tui/menu/menu-selectors.ts @@ -0,0 +1,166 @@ +import fuzzysort from "fuzzysort"; + +import { + MENU, + MENU_GROUP_LABELS, + MENU_GROUP_ORDER, + menuChildren, + menuNodeById, + menuRoots, + type MenuNode, +} from "./menu-registry.js"; +import type { TuiState } from "../tui-state.js"; + +/** A group heading. Rendered, never selectable. */ +export interface MenuHeaderRow { + readonly kind: "header"; + readonly label: string; +} + +/** A selectable entry. */ +export interface MenuItemRow { + readonly kind: "item"; + readonly node: MenuNode; + /** Live state for a destination, e.g. `3 scheduled`. Empty when unknown. */ + readonly status: string; + /** Where the node lives, shown only while searching flattens the tree. */ + readonly crumb: string; +} + +export type MenuRow = MenuHeaderRow | MenuItemRow; + +/** + * Rows the menu should render for the current state. + * + * Three modes, and the rule that decides between them is the whole design: + * **hierarchy to browse, flat to search.** With a query, every node in the + * registry competes on one ranked list and the tree is irrelevant; without + * one, the operator walks groups and submenus. + */ +export function selectMenuRows(state: TuiState): readonly MenuRow[] { + const query = state.menuQuery.trim(); + if (query.length > 0) return searchRows(state, query); + if (state.menuPath !== null) return submenuRows(state, state.menuPath); + return rootRows(state); +} + +/** Only the selectable rows, in render order — the cursor indexes these. */ +export function selectMenuItems(state: TuiState): readonly MenuItemRow[] { + return selectMenuRows(state).flatMap((row) => + row.kind === "item" ? [row] : [], + ); +} + +/** The row under the cursor, or `null` when the list is empty. */ +export function selectMenuSelection(state: TuiState): MenuItemRow | null { + const items = selectMenuItems(state); + if (items.length === 0) return null; + return items[clampMenuCursor(state, state.menuCursor)] ?? null; +} + +/** Clamp a cursor into the current item list. */ +export function clampMenuCursor(state: TuiState, cursor: number): number { + const max = selectMenuItems(state).length - 1; + if (max < 0) return 0; + return Math.max(0, Math.min(cursor, max)); +} + +/** Title shown in the popup border — `Menu` or `Menu › Manage`. */ +export function selectMenuTitle(state: TuiState): string { + if (state.menuPath === null || state.menuQuery.trim().length > 0) { + return "Menu"; + } + const parent = menuNodeById(state.menuPath); + return parent ? `Menu ${String.fromCodePoint(0x203a)} ${parent.label}` : "Menu"; +} + +function rootRows(state: TuiState): readonly MenuRow[] { + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const nodes = menuRoots(group); + if (nodes.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const node of nodes) { + rows.push(itemRow(state, node, "")); + } + } + return rows; +} + +function submenuRows(state: TuiState, parentId: string): readonly MenuRow[] { + return menuChildren(parentId).map((node) => itemRow(state, node, "")); +} + +function searchRows(state: TuiState, query: string): readonly MenuRow[] { + // Submenus are excluded: "open the Manage submenu" is a browsing move, and + // a search that already found `Privacy` should offer Privacy, not the + // folder it happens to sit in. + const candidates = MENU.filter((node) => node.kind !== "submenu"); + const scored = candidates + .map((node, idx) => { + const haystacks = [node.label, node.slash?.name ?? "", crumbFor(node)]; + const best = Math.max( + ...haystacks.map( + (h) => (h ? (fuzzysort.single(query, h)?.score ?? -Infinity) : -Infinity), + ), + ); + return { node, score: best, idx }; + }) + .filter(({ score }) => score > -Infinity) + .sort((a, b) => b.score - a.score || a.idx - b.idx); + + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const hits = scored.filter(({ node }) => node.group === group); + if (hits.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const { node } of hits) { + rows.push(itemRow(state, node, crumbFor(node))); + } + } + return rows; +} + +function crumbFor(node: MenuNode): string { + if (node.parent === undefined) return ""; + return menuNodeById(node.parent)?.label ?? ""; +} + +function itemRow(state: TuiState, node: MenuNode, crumb: string): MenuItemRow { + return { kind: "item", node, status: statusFor(state, node), crumb }; +} + +/** + * Live one-liner for a destination. Deliberately reads the same state slices + * the sub-tab strip already counts (`debug-pane.tsx`), so opening the menu + * costs a few array lengths and never a refresh. + */ +function statusFor(state: TuiState, node: MenuNode): string { + switch (node.id) { + case "go.manage.tasks": + return countLabel(state.tasksPanel.rows.length, "task"); + case "go.manage.skills": + return countLabel(state.skillsPanel.rows.length, "skill"); + case "go.manage.memory": + return countLabel(state.memoryPanel.rows.length, "note"); + case "go.manage.mcp": + return countLabel(state.mcpPanel.rows.length, "server"); + case "go.observe.feed": + return countLabel(state.feed.length, "event"); + case "go.observe.reasoning": + return countLabel(state.reasoning.length, "entry"); + case "go.observe.logs": + return countLabel(state.logs.length, "line"); + case "go.run": + return countLabel(state.messages.length, "message"); + case "session.switch": + return countLabel(state.recentSessions.length, "recent"); + default: + return ""; + } +} + +function countLabel(count: number, noun: string): string { + if (count === 0) return ""; + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..bffecadd 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -1,3 +1,4 @@ +import { clampMenuCursor } from "./menu/menu-selectors.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { THEME_NAMES } from "./theme/theme.js"; @@ -62,6 +63,37 @@ export function reduceUiAction( for (const card of state.streamingToolCards) next[card.id] = action.expanded; return { ...state, toolsExpandedById: next }; } + case "menu_opened": + // Always reopen at the root with an empty query: a menu that resumes + // where it was last left makes the same keypress mean different + // things on different days. + return { + ...state, + menuOpen: true, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_closed": + return { + ...state, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_query_changed": + // A query flattens the tree, so any open submenu is dropped with it. + return { ...state, menuQuery: action.query, menuPath: null }; + case "menu_path_set": + return { ...state, menuPath: action.path }; + case "menu_cursor_set": + return { ...state, menuCursor: clampMenuCursor(state, action.cursor) }; + case "menu_cursor_moved": + return { + ...state, + menuCursor: clampMenuCursor(state, state.menuCursor + action.delta), + }; case "slash_palette_opened": return { ...state, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 7578c3fa..bf3e9356 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -209,12 +209,70 @@ export function getActiveThemeName(): ThemeName { return "github-dark"; } +/** + * Backdrop dimming. While the operator menu is open the whole app behind it + * fades, so the popup reads as the foreground rather than as one more panel + * competing with the chat log. + * + * Implemented here rather than by threading a `dimmed` prop through every + * component because {@link theme} is already a read-at-render proxy — the + * same machinery that makes `/theme` live-preview repaint the whole UI. One + * flag flips every colour; the menu itself reads {@link chromeTheme}, which + * ignores the flag, so it stays at full contrast. + * + * Every colour collapses to the active theme's `muted`: a real terminal has + * no alpha channel, so "faded" has to mean "one low-contrast tone" rather + * than "the same colours, weaker". + */ +let backdropDimmed = false; +let dimmedColorsFor: TuiColors | null = null; +let dimmedColorsCache: TuiColors | null = null; + +export function setBackdropDimmed(next: boolean): void { + backdropDimmed = next; +} + +export function isBackdropDimmed(): boolean { + return backdropDimmed; +} + +function dimColors(colors: TuiColors): TuiColors { + if (dimmedColorsFor === colors && dimmedColorsCache) return dimmedColorsCache; + const flat = Object.fromEntries( + Object.keys(colors).map((key) => [key, colors.muted]), + ) as unknown as TuiColors; + dimmedColorsFor = colors; + dimmedColorsCache = flat; + return flat; +} + /** * The themed palette consumed across the TUI. A `Proxy` that always forwards * to the current {@link activeTheme}, so `theme.colors.X` reflects the active * theme at read time even after a `setActiveTheme` swap. */ export const theme: TuiTheme = new Proxy({} as TuiTheme, { + get(_target, prop: string | symbol): unknown { + if (prop === "colors" && backdropDimmed) return dimColors(activeTheme.colors); + return activeTheme[prop as keyof TuiTheme]; + }, + has(_target, prop: string | symbol): boolean { + return prop in activeTheme; + }, + ownKeys(): ArrayLike { + return Reflect.ownKeys(activeTheme); + }, + getOwnPropertyDescriptor(_target, prop: string | symbol) { + return Reflect.getOwnPropertyDescriptor(activeTheme, prop); + }, +}); + +/** + * The palette for chrome that must stay legible while the backdrop is dimmed — + * i.e. the operator menu. Identical to {@link theme} except that it ignores + * {@link setBackdropDimmed}. + */ +export const chromeTheme: TuiTheme = new Proxy({} as TuiTheme, { get(_target, prop: string | symbol): unknown { return activeTheme[prop as keyof TuiTheme]; }, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..36b08735 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -93,6 +93,12 @@ export type TuiAction = | { type: "slash_palette_queried"; query: string } /** Close the slash palette without committing a selection. */ | { type: "slash_palette_closed" } + | { type: "menu_opened" } + | { type: "menu_closed" } + | { type: "menu_query_changed"; query: string } + | { type: "menu_cursor_moved"; delta: number } + | { type: "menu_cursor_set"; cursor: number } + | { type: "menu_path_set"; path: string | null } /** Move the highlight in the open slash palette by delta rows. */ | { type: "slash_palette_cursor_moved"; delta: 1 | -1 } /** Reset the slash palette highlight to a specific row. */ diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 480215dc..9ceb8390 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -344,4 +344,74 @@ describe("TuiApp (smoke)", () => { expect(text).not.toContain("▸ Manage"); unmount(); }); + + it("ctrl+p opens the operator menu over the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Menu"); + expect(text).toContain("GO"); + expect(text).toContain("Manage"); + expect(text).toContain("esc close"); + unmount(); + }); + + it("typing in the menu searches instead of reaching the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + stdin.write("privacy"); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Privacy"); + // The query lives in the menu, never in the editor buffer underneath. + expect(text).not.toContain("> privacy"); + unmount(); + }); + + it("ctrl+g then a chord jumps straight to a panel, and the chord letter never reaches the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + await new Promise((r) => setTimeout(r, 30)); + stdin.write("t"); + await new Promise((r) => setTimeout(r, 30)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Manage"); + expect(text).toContain("Tasks"); + // Ink delivers every key to every useInput, child first — so the editor + // sees the chord letter too. If the leader did not disable it, a stray + // "t" would be sitting in the prompt right now. + expect(text).not.toMatch(/[>\u276f]\s+t\s*$/m); + unmount(); + }); + + it("esc closes the menu and leaves the screen it was opened over", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + expect(strip(lastFrame() ?? "")).toContain("esc close"); + stdin.write(String.fromCharCode(27)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).not.toContain("esc close"); + expect(text).toContain("Run"); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..f814d35b 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,8 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { MenuPopup } from "./menu/menu-popup.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; @@ -22,6 +24,7 @@ import { ThemePicker } from "./components/theme-picker.js"; import { isThemeName, setActiveTheme, + setBackdropDimmed, theme, THEME_NAMES, THEMES, @@ -37,7 +40,7 @@ import { UpdateRestartPrompt } from "./components/update-restart-prompt.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; -import { handleEditorSubmit } from "./submit-handler.js"; +import { handleEditorSubmit, runSlashCommand } from "./submit-handler.js"; import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { @@ -380,6 +383,7 @@ export function TuiApp({ ); const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); + const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); useEffect(() => bus.subscribe(dispatch), [bus]); @@ -485,6 +489,8 @@ export function TuiApp({ state.uiMode === "chat" && terminalSize.columns >= SIDEBAR_MIN_COLUMNS; const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = + !state.menuOpen && + !menuLeaderArmed && !state.pendingApproval && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update @@ -522,6 +528,26 @@ export function TuiApp({ } }, [sidebarVisible, state.chatFocus]); + const activateMenuNode = useCallback( + (node: MenuNode) => { + // A node that carries a slash name is *run as that command*, so the + // menu never grows a second dispatch path beside the slash handler. + if (node.slash) { + runSlashCommand(`/${node.slash.name}`, state, dispatch, callbacks); + return; + } + if (node.kind === "place") { + if (node.tab) { + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: node.tab }); + } else { + dispatch({ type: "ui_mode_set", mode: "chat" }); + } + } + }, + [state, callbacks], + ); + useInput((input, key) => { const appHandled = handleAppKey(input, key, { state, @@ -530,6 +556,9 @@ export function TuiApp({ ctrlCArmed, setCtrlCArmed, sidebarVisible, + menuLeaderArmed, + setMenuLeaderArmed, + activateMenuNode, }); if (appHandled) return; // While the slash-command palette is open, let the (now-focused) @@ -686,6 +715,10 @@ export function TuiApp({ // the smoke tests assert against an overlapped frame. In production // the alt-screen + `height={rows}` combo gives us the opencode-style // pinned-input-at-bottom UX. + // Render-phase on purpose: `theme` is a read-at-render proxy, and children + // render after this body runs, so the flag is already correct for them. + setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; const promptLlm = selectPromptLlmMeta(state); @@ -749,6 +782,11 @@ export function TuiApp({ ) : null} + {state.menuOpen ? ( + + + + ) : null} {state.sessionPickerOpen ? ( >; /** Is the session picker overlay visible? */ @@ -489,6 +500,10 @@ export function createInitialTuiState( slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, toolsExpandedById: {}, sessionPickerOpen: false, sessionPickerList: [], From 60320f2624e5c67e8872462063092743c9868890 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 10:45:29 +0300 Subject: [PATCH 31/57] =?UTF-8?q?fix(tui):=20make=20the=20menu=20a=20real?= =?UTF-8?q?=20overlay=20=E2=80=94=20it=20floats,=20nothing=20reflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. --- src/tui/components/debug-pane.tsx | 2 +- src/tui/menu/menu-popup.tsx | 199 +++++++++++++++++++----------- src/tui/tui-app.test.tsx | 19 +++ src/tui/tui-app.tsx | 25 +++- 4 files changed, 169 insertions(+), 76 deletions(-) diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0946055c..27d0fdf7 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -138,7 +138,7 @@ function buildManageTabs(state: TuiState): SubTab[] { * terminal — it overlaps/garbles earlier lines instead (verified) — so * the per-tab budget must subtract this accurately and err generous. */ -const APP_CHROME_ROWS = 9; +export const APP_CHROME_ROWS = 9; /** * Height consumed INSIDE the debug pane above the active tab: the * `SubTabBar` (1 row) + the `DebugDiagnosticsLine`. The diagnostics line diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5e1b0696..5deff17a 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { chromeTheme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; -import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import type { MenuItemRow } from "./menu-selectors.js"; import { clampMenuCursor, selectMenuRows, @@ -11,134 +11,195 @@ import { } from "./menu-selectors.js"; import { MENU_LEADER_LABEL } from "./menu-keys.js"; -/** Rows of list body. Keeps the popup shorter than a short terminal. */ -const MAX_ROWS = 16; +/** Popup width, clamped to the terminal on narrow windows. */ +const PREFERRED_WIDTH = 64; +/** Rows of list body at most, before the window starts scrolling. */ +const MAX_BODY_ROWS = 16; +/** Border (2) + title row + footer row. */ +const CHROME_ROWS = 4; +/** Column reserved for the entry label. */ +const LABEL_WIDTH = 26; interface MenuPopupProps { state: TuiState; + /** Rows available in the pane the menu floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; } /** * The operator menu: one key (`ctrl+p`) to every destination and every verb. * - * Rendered directly above the prompt on the same left rail rather than as a - * full-screen takeover, so it reads as belonging to the input you were - * already typing in. The app behind it is dimmed by `setBackdropDimmed` - * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores - * that flag, so the menu stays at full contrast against a faded backdrop. + * Rendered as a true overlay — `position="absolute"` inside the content pane, + * so it floats **on top of** the chat log or the active panel instead of + * displacing them. Nothing below it reflows when the menu opens or closes. + * + * Terminals have no compositing and Ink has no z-index, so occlusion has to + * be earned: every interior line is padded to the popup's exact inner width, + * which paints spaces over whatever was underneath. That is also why the rows + * are laid out as fixed-width columns rather than with `flexGrow` — a flexed + * row stops at its content and lets the background show through. + * + * A background colour would do the same job in one line, but only by picking + * a colour, and the TUI ships eleven themes across light and dark grounds. + * Spaces are theme-agnostic. + * + * The app behind is dimmed by `setBackdropDimmed` (see `theme.ts`); this + * component reads {@link chromeTheme}, which ignores that flag, so the menu + * stays at full contrast against a faded backdrop. * * Pure presentation: every key is handled by `handleMenuKey`. */ -export function MenuPopup({ state }: MenuPopupProps): ReactElement { +export function MenuPopup({ + state, + availableRows, + availableColumns, +}: MenuPopupProps): ReactElement { + const width = Math.max(28, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + // Interior columns between the two border columns. Ink's own `paddingX` + // is NOT painted by our rows — it leaves real gaps the backdrop shows + // through — so the one-column gutter is baked into every string instead. + const inner = width - 2; + const rows = selectMenuRows(state); const cursor = clampMenuCursor(state, state.menuCursor); const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); const cursorRowIdx = itemIndexes[cursor] ?? -1; - const start = windowStart(rows, cursorRowIdx); - const visible = rows.slice(start, start + MAX_ROWS); + + const bodyRows = Math.max( + 3, + Math.min(MAX_BODY_ROWS, availableRows - CHROME_ROWS), + ); + const start = windowStart(rows.length, cursorRowIdx, bodyRows); + const visible = rows.slice(start, start + bodyRows); const hiddenAfter = Math.max(0, rows.length - start - visible.length); + // Anchor to the bottom of the pane so the menu sits just above the prompt, + // the way a dropdown hangs off the control that opened it. + const height = visible.length + CHROME_ROWS; + const offsetTop = Math.max(0, availableRows - height); + return ( - - - {selectMenuTitle(state)} - - - {" "} - {chromeTheme.glyphs.promptCaret} {state.menuQuery} - {"█"} - - - {start > 0 ? ( - {"↑"} {start} above - ) : null} + {visible.map((row, idx) => row.kind === "header" ? ( - {row.label.toUpperCase()} + {fit(` ${row.label.toUpperCase()}`, inner)} ) : ( ), )} - {hiddenAfter > 0 ? ( - - {"↓"} {hiddenAfter} below - - ) : null} {rows.length === 0 ? ( - nothing matches + {fit(" nothing matches", inner)} ) : null} - {footer(state)} + + {fit(` ${footer(state, hiddenAfter)}`, inner)} + + + ); +} + +function TitleRow({ + state, + inner, +}: { + state: TuiState; + inner: number; +}): ReactElement { + const title = selectMenuTitle(state); + const caret = `${chromeTheme.glyphs.promptCaret} ${state.menuQuery}`; + const left = fit(` ${title}`, Math.min(title.length + 3, inner)); + const rest = inner - left.length; + return ( + + + {left} + + {fit(caret, Math.max(0, rest))} ); } function MenuItem({ row, + inner, selected, }: { row: MenuItemRow; + inner: number; selected: boolean; }): ReactElement { const { node } = row; - const isSubmenu = node.kind === "submenu"; - const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + const marker = selected ? chromeTheme.glyphs.chevronRight : " "; + const arrow = node.kind === "submenu" ? ` ${chromeTheme.glyphs.arrowRight}` : ""; + // Leading and trailing space are part of the row, not Box padding, so the + // whole line is opaque edge to edge. + const label = fit(` ${marker} ${node.label}${arrow}`, Math.min(LABEL_WIDTH, inner)); + const chordText = node.chord ? `${MENU_LEADER_LABEL} ${node.chord} ` : " "; + const chord = fit(chordText, Math.min(chordText.length, Math.max(0, inner - label.length))); + const detailWidth = Math.max(0, inner - label.length - chord.length); + const detail = fit( + [row.crumb, row.status].filter((part) => part.length > 0).join(" "), + detailWidth, + ); return ( - - - {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} - {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} - - - - {detail} - - {node.chord ? ( - - - {MENU_LEADER_LABEL} {node.chord} - - - ) : null} + + {label} + + {detail} + {chord} ); } /** * Footer names exactly the moves that are legal right now — `←` only appears - * once there is a level to go back to. + * once there is a level to go back to, `→` only while one is reachable. */ -function footer(state: TuiState): string { - const parts = [`${"↑↓"} move`]; - if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { - parts.push(`${"←"} back`); - } - if (state.menuQuery.trim().length === 0 && state.menuPath === null) { - parts.push(`${"→"} open`); - } - parts.push("enter go", "type to search", "esc close"); +function footer(state: TuiState, hiddenAfter: number): string { + const searching = state.menuQuery.trim().length > 0; + const parts = ["↑↓ move"]; + if (!searching && state.menuPath !== null) parts.push("← back"); + if (!searching && state.menuPath === null) parts.push("→ open"); + parts.push("enter go", "esc close"); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); return parts.join(" "); } +/** + * Pad or truncate to exactly `width` columns. Every interior line goes + * through this — it is what makes the popup opaque. + */ +function fit(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text.padEnd(width); +} + /** Scroll window that keeps the cursor row visible. */ -function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { - if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; - if (cursorRowIdx < MAX_ROWS) return 0; - return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +function windowStart(total: number, cursorRowIdx: number, size: number): number { + if (total <= size || cursorRowIdx < 0) return 0; + if (cursorRowIdx < size) return 0; + return Math.min(cursorRowIdx - size + 1, total - size); } diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 9ceb8390..58cd1819 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -414,4 +414,23 @@ describe("TuiApp (smoke)", () => { expect(text).toContain("Run"); unmount(); }); + + it("floats over the UI without moving it — the frame below is unchanged", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + const before = strip(lastFrame() ?? "").split("\n"); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 25)); + const after = strip(lastFrame() ?? "").split("\n"); + + // A popup composites on top; it must not add rows or push the prompt and + // the hint strip down the way an inline panel would. + expect(after.length).toBe(before.length); + expect(after.at(-1)).toBe(before.at(-1)); + expect(after.some((line) => line.includes("Menu"))).toBe(true); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index f814d35b..3a52f7b6 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,7 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { APP_CHROME_ROWS } from "./components/debug-pane.js"; import { MenuPopup } from "./menu/menu-popup.js"; import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; @@ -719,8 +720,12 @@ export function TuiApp({ // render after this body runs, so the flag is already correct for them. setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; + // Rows the content pane actually has, so the overlay can sit on its bottom + // edge and cap its own height. Same budget the debug pane already uses. + const menuPaneRows = Math.max(6, terminalSize.rows - APP_CHROME_ROWS); const promptLlm = selectPromptLlmMeta(state); // No local backend chosen yet ⇒ no local health to report. Without this the // splash screen of a fresh install announces that a server the user never @@ -758,7 +763,13 @@ export function TuiApp({ - + {state.uiMode === "chat" ? ( ) : ( @@ -776,17 +787,19 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( ) : null} - {state.menuOpen ? ( - - - - ) : null} {state.sessionPickerOpen ? ( Date: Wed, 19 Aug 2026 10:59:06 +0300 Subject: [PATCH 32/57] =?UTF-8?q?test(providers):=20pick=20the=20openroute?= =?UTF-8?q?r=20row=20explicitly=20=E2=80=94=20row=200=20is=20now=20a=20CLI?= =?UTF-8?q?=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/providers/providers-wizard-key-bindings.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index c99ad37f..1994546e 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -587,7 +587,10 @@ describe("handleProvidersWizardKey", () => { }); it("keeps Enter on the key screen when nothing was typed", () => { + // Row 0 is a subscription CLI, which has no key screen at all — + // walk to the first key-based row before testing the key gate. let wizard = createProvidersWizardState("add"); + wizard = { ...wizard, cursor: KIND_ROW_ORDER.indexOf("openrouter") }; wizard = next(wizard, "", emptyKey({ return: true })); expect(wizard).toMatchObject({ kind: "openrouter", phase: "api_key" }); From 3e6ba4a6236a1f0248d030d058864859b0543517 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 11:07:50 +0300 Subject: [PATCH 33/57] =?UTF-8?q?atomic-agent:=20integration=20release=20v?= =?UTF-8?q?0.3.0=20(PRs=20#150=E2=80=93#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b537c551..20bed2d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "atomic-agent", - "version": "0.2.2", + "version": "0.3.0", "description": "Lightweight local operator agent (browser + OS) runtime for Tauri apps. Connects to an external llama.cpp server via HTTP and exposes a sidecar NDJSON protocol plus a debug CLI.", "license": "MIT", "type": "module", From 3229cfdfc961fb5f9a50f2ef00d9960567a90a43 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 11:33:28 +0300 Subject: [PATCH 34/57] feat(tui): status bar shows where you are, not a menu of where to go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. --- src/tui/components/status-bar.tsx | 57 +++++++++++++++---------------- src/tui/menu/menu-registry.ts | 8 +++++ src/tui/tui-app.test.tsx | 26 +++++++------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 33750987..c3609b53 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,11 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { - getCurrentSection, - SECTION_ORDER, - type TuiSection, -} from "../section.js"; +import { getCurrentSection, type TuiSection } from "../section.js"; +import { menuPlaceByTab } from "../menu/menu-registry.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; @@ -15,7 +12,13 @@ interface StatusBarProps { } /** - * One-row operator status bar. Replaces the legacy `header-line` + + * One-row operator status bar. Shows **where you are**, not where you could + * go: the three-section pill row was a menu, and the menu now lives behind + * `ctrl+p` where it can hold every destination instead of only the top three. + * What is left is a breadcrumb — `Manage › Tasks` — which is the one thing + * the popup cannot tell you, because you have to open it to read it. + * + * Replaces the legacy `header-line` + * `status-line` + `footer-line` trio: only signal that needs to be * visible at every glance stays on screen — current section and a * short session id when one exists. Verbose details (full cwd, llama @@ -37,7 +40,7 @@ export function StatusBar({ state }: StatusBarProps): ReactElement { v{getAppVersion()} - + ); @@ -49,30 +52,26 @@ const SECTION_LABELS: Record = { manage: "Manage", }; -function SectionPills({ active }: { active: TuiSection }): ReactElement { +function Breadcrumb({ + state, + section, +}: { + state: TuiState; + section: TuiSection; +}): ReactElement { + const tabLabel = + state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined; return ( - {SECTION_ORDER.map((id, idx) => { - const isActive = id === active; - return ( - - - {isActive ? `${theme.glyphs.chevronRight} ` : " "} - {SECTION_LABELS[id]} - - {idx < SECTION_ORDER.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + + {SECTION_LABELS[section]} + + {tabLabel ? ( + + {" "} + {theme.glyphs.chevronRight} {tabLabel} + + ) : null} ); } diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index 0e390280..7b518cfd 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -578,3 +578,11 @@ export function menuNodeById(id: string): MenuNode | null { export function menuNodeByChord(key: string): MenuNode | null { return MENU.find((node) => node.chord === key) ?? null; } + +/** The destination that owns a debug tab, for breadcrumbs and status text. */ +export function menuPlaceByTab(tab: TuiTab): MenuPlaceNode | null { + for (const node of MENU) { + if (node.kind === "place" && node.tab === tab) return node; + } + return null; +} diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 58cd1819..891c1d44 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -41,9 +41,11 @@ describe("TuiApp (smoke)", () => { ); const text = strip(lastFrame() ?? ""); expect(text).toContain("atomic-agent"); + // The status bar shows where you are, not a menu of where you could go — + // the three-section pill row moved into the ctrl+p menu. expect(text).toContain("Run"); - expect(text).toContain("Observe"); - expect(text).toContain("Manage"); + expect(text).not.toContain("Observe"); + expect(text).not.toContain("Manage"); expect(text).toContain("Local AI-First Agent"); expect(text).toContain("commands"); unmount(); @@ -146,20 +148,19 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("▸ Run"); + expect(before).toContain("Run"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); if (before.includes("Sessions")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("▸ Run"); - expect(after).not.toContain("▸ Observe"); + expect(after).toContain("Run"); + expect(after).not.toContain("Observe \u25b8"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("▸ Observe"); - expect(after).toContain("▸ Feed"); + expect(after).toContain("Observe \u25b8 Feed"); } unmount(); }); @@ -173,8 +174,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Observe"); - expect(text).toContain("▸ Feed"); + expect(text).toContain("Observe \u25b8 Feed"); unmount(); }); @@ -188,7 +188,7 @@ describe("TuiApp (smoke)", () => { await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). - expect(text).toContain("▸ Manage"); + expect(text).toContain("Manage \u25b8"); expect(text).toContain("▸ Telegram"); unmount(); }); @@ -335,13 +335,13 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); - expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + expect(strip(lastFrame() ?? "")).toContain("Manage \u25b8"); stdin.write("\u001b"); await new Promise((r) => setTimeout(r, 60)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Run"); - expect(text).not.toContain("▸ Manage"); + expect(text).toContain("Run"); + expect(text).not.toContain("Manage \u25b8"); unmount(); }); From ec578727df1ed2131677201b93baa088c222f249 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 15:16:56 +0300 Subject: [PATCH 35/57] fix(config): read a newer config version instead of refusing every command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belongs to #161/#165 — the two PRs that bumped USER_CONFIG_VERSION to 38. Two builds share one ~/.atomic-agent/config.json. The moment the 0.3.0 build wrote v38, the installed v0.2.2 release died on every command with 'unsupported config version 38; expected one of 5, 6, ... 37'. There was no way out short of hand-editing the file. Every bump this schema has taken is additive, so a newer file parses correctly: the parser reads field by field and unknown keys are simply not read. The version allow-list only ever needed a floor, not a ceiling. ensureUserConfigFileSync now also refuses to rewrite a newer file back down — reading it is safe, overwriting would delete the newer build's keys, and with a shared file the two builds would take turns destroying each other's config on every launch. Knowingly replaces the 'rejects unsupported version' test, which pinned the broken behaviour. --- src/config/config-file.test.ts | 47 ++++++++++++++++++++++++++++++++ src/config/config-file.ts | 11 ++++++++ src/config/config-schema.test.ts | 20 ++++++++++++-- src/config/config-schema.ts | 29 +++++++++++++++++--- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/config/config-file.test.ts b/src/config/config-file.test.ts index fcb676ee..ed3d03a5 100644 --- a/src/config/config-file.test.ts +++ b/src/config/config-file.test.ts @@ -554,3 +554,50 @@ describe("user config file IO", () => { warn.mockRestore(); }); }); + +describe("a config written by a newer build", () => { + // Regression: two builds share one `~/.atomic-agent/config.json`. The + // 0.3.0 build bumped it to v38 and the installed v0.2.2 release then + // died on every single command with "unsupported config version 38". + // An additive schema has no reason to make version skew fatal. + it("is read, not refused", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }), + "utf8", + ); + const parsed = ensureUserConfigFileSync(path); + expect(parsed.localModels.url).toBe("http://127.0.0.1:9999"); + }); + + it("is never rewritten back down to this build's version", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + const future = { + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }; + writeFileSync(path, JSON.stringify(future), "utf8"); + ensureUserConfigFileSync(path); + const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5); + expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true }); + }); + + it("still refuses a version older than the oldest supported one", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-old-config-")); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ version: 2 }), "utf8"); + expect(() => ensureUserConfigFileSync(path)).toThrow(/unsupported config version/); + }); +}); diff --git a/src/config/config-file.ts b/src/config/config-file.ts index 197db5dc..633ecd60 100644 --- a/src/config/config-file.ts +++ b/src/config/config-file.ts @@ -95,6 +95,17 @@ export function ensureUserConfigFileSync(path: string): UserConfigFile { return USER_CONFIG_DEFAULTS; } const parsed = parseUserConfigFile(raw.parsed); + // A file written by a NEWER build is read and left exactly as it is. + // Rewriting it would silently delete whatever that build added — and + // since both builds share one `config.json`, the two would then take + // turns destroying each other's keys on every launch. Reading is safe + // (the schema is additive); writing is not ours to do. + if ( + raw.originalVersion !== null && + raw.originalVersion > USER_CONFIG_VERSION + ) { + return parsed; + } if (raw.originalVersion !== USER_CONFIG_VERSION) { writeUserConfigFileSync(path, parsed); process.stderr.write( diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 7c6d8396..d7715efb 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -84,8 +84,24 @@ describe("parseUserConfigFile", () => { ).toBe(1); }); - it("rejects unsupported version", () => { - expect(() => parseUserConfigFile({ version: 99 })).toThrow( + it("reads a version newer than this build instead of refusing it", () => { + // Knowingly replaces "rejects unsupported version", which pinned + // `{version: 99}` as fatal. That is what made two builds sharing one + // `config.json` mutually exclusive: the newer one wrote v38 and the + // installed v0.2.2 then failed every command. The schema is additive, + // so a newer file parses fine — unknown keys are simply not read. + const parsed = parseUserConfigFile({ + version: 99, + localModels: { url: "http://127.0.0.1:9999" }, + }); + expect(parsed.localModels.url).toBe("http://127.0.0.1:9999"); + }); + + it("still rejects a non-integer version", () => { + expect(() => parseUserConfigFile({ version: "38" })).toThrow( + ConfigValidationError, + ); + expect(() => parseUserConfigFile({ version: 38.5 })).toThrow( ConfigValidationError, ); }); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 51f548b9..4e60bbcf 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -2690,10 +2690,31 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { } const obj = raw as Record; const version = obj.version ?? USER_CONFIG_VERSION; - if ( - typeof version !== "number" || - !SUPPORTED_INPUT_VERSIONS.includes(version) - ) { + if (typeof version !== "number" || !Number.isInteger(version)) { + throw new ConfigValidationError( + "version", + `expected an integer version; got ${JSON.stringify(version)}`, + ); + } + // A version *newer* than this build is read, not refused. + // + // Every bump this schema has ever taken is additive: new keys arrive + // with defaults and the parser reads field by field, so a file written + // by a newer build parses correctly here — the keys this build does not + // know are simply not read, and `writeUserConfigFileSync` preserves + // unknown top-level keys rather than dropping them. + // + // Refusing was actively harmful. Two builds share one `config.json`, + // so the moment the newer one wrote its version the older one died on + // *every* command — `models status`, `config get`, the TUI — with a + // validation error naming 33 acceptable versions and no way out. + // Running two versions side by side is normal (a release plus a build + // under test), and an additive schema has no reason to make it fatal. + // + // The other half of this contract lives in `ensureUserConfigFileSync`, + // which must not rewrite a newer file back down to this build's shape — + // reading it is safe, overwriting it would delete the newer build's keys. + if (version < USER_CONFIG_VERSION && !SUPPORTED_INPUT_VERSIONS.includes(version)) { throw new ConfigValidationError( "version", `unsupported config version ${JSON.stringify(version)}; expected one of ${SUPPORTED_INPUT_VERSIONS.join(", ")}`, From c065957968713d6d29892d1a28be4214fc3f7d29 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 15:36:43 +0300 Subject: [PATCH 36/57] fix(tui): scale one brand mark instead of hand-drawing each size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belongs to #151 (adaptive start page) and #165 (mouse), plus #163/#171 for the clickable run-mode strip and menu. Issue 3: the mark looked wrong as the window shrank. It shipped as three hand-drawn copies and the half-size one had lost the taper of its lower-right tail — it read as a blob, and every new breakpoint meant drawing the shape again by eye. There is one drawing now. logo-raster.ts scales it with half-block glyphs at the terminal's ~2:1 cell aspect, area-averaged with a coverage threshold, so 'small' and 'mini' are the same silhouette at a smaller scale by construction. Proportions are preserved because there is nothing left to get wrong by hand. 'mini' drops to 7x4 — the smallest size the silhouette survives — and a surface with no room even for that now draws no mark at all rather than painting it over the rows above, which is the bug the whole fit module exists to prevent. The mark also gets its own colour, theme.colors.brandMark: a lighter, whiter blue than 'accent' in all eleven palettes. It is not a control, and sharing the accent made the start page read as one highlighted widget. Knowingly rewrites the splash-fit expectations that pinned the old 17x10 art, the one-line text fallback and 'mini on a two-row surface'. --- src/tui/components/chat-log.test.tsx | 9 +- src/tui/components/logo-raster.test.ts | 80 ++++++++ src/tui/components/logo-raster.ts | 174 ++++++++++++++++++ src/tui/components/logo.tsx | 94 +++++----- src/tui/components/run-mode-bar.tsx | 83 ++++++--- src/tui/components/run-mode-picker.tsx | 132 ++++++++++--- src/tui/components/splash-banner.test.tsx | 15 +- src/tui/components/splash-banner.tsx | 9 +- src/tui/components/splash-fit.render.test.tsx | 14 +- src/tui/components/splash-fit.test.ts | 37 ++-- src/tui/components/splash-fit.ts | 32 +++- src/tui/menu/menu-popup.tsx | 61 +++++- src/tui/run-mode/run-mode-selectors.ts | 12 +- src/tui/theme/theme-palettes.ts | 11 ++ src/tui/theme/theme.ts | 7 + src/tui/tui-app.test.tsx | 4 +- src/tui/tui-app.tsx | 1 + 17 files changed, 644 insertions(+), 131 deletions(-) create mode 100644 src/tui/components/logo-raster.test.ts create mode 100644 src/tui/components/logo-raster.ts diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx index 1bfda8fc..aebef8fd 100644 --- a/src/tui/components/chat-log.test.tsx +++ b/src/tui/components/chat-log.test.tsx @@ -25,10 +25,11 @@ describe("ChatLog", () => { const state = createInitialTuiState(BASE_SESSION); const { lastFrame } = render(); const text = strip(lastFrame() ?? ""); - // The mark shrinks with the surface, so assert on the plus bar the - // artwork always keeps rather than on a wordmark that only a tall - // terminal earns. See `splash-fit.render.test.tsx`. - expect(text).toContain(":::"); + // The mark shrinks with the surface — shaded art at full size, half + // block art below it — so assert that *some* mark is drawn rather + // than on a wordmark only a tall terminal earns. See + // `splash-fit.render.test.tsx`. + expect(text).toMatch(/:::|[█▀▄]/u); expect(text).toContain("/help"); }); diff --git a/src/tui/components/logo-raster.test.ts b/src/tui/components/logo-raster.test.ts new file mode 100644 index 00000000..22409edb --- /dev/null +++ b/src/tui/components/logo-raster.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { LOGO_ART } from "./logo.js"; +import { rasteriseMark, toInkMask } from "./logo-raster.js"; +import { LOGO_METRICS } from "./splash-fit.js"; + +const source = toInkMask(LOGO_ART.full); + +/** Ink coverage as a fraction of the box — a crude "does it look like the mark". */ +function density(rows: readonly string[]): number { + const total = rows.reduce((acc, row) => acc + row.length, 0); + if (total === 0) return 0; + const ink = rows.reduce( + (acc, row) => acc + [...row].filter((ch) => ch !== " ").length, + 0, + ); + return ink / total; +} + +describe("rasteriseMark", () => { + it("returns exactly the box it was asked for", () => { + for (const [columns, rows] of [ + [20, 12], + [13, 8], + [7, 4], + ] as const) { + const art = rasteriseMark(source, { columns, rows }); + expect(art).toHaveLength(rows); + for (const line of art) expect(line).toHaveLength(columns); + } + }); + + it("keeps the mark's aspect ratio instead of stretching it", () => { + // The source is 34 cells wide and 20 tall = 34x40 half-block pixels. + // Asked for a box twice as wide as that shape needs, the drawing must + // stay its own shape and sit centred, not stretch to the edges. + const art = rasteriseMark(source, { columns: 60, rows: 12 }); + const drawn = art.filter((row) => row.trim().length > 0); + const leading = Math.min( + ...drawn.map((row) => row.length - row.trimStart().length), + ); + const trailing = Math.min( + ...drawn.map((row) => row.length - row.trimEnd().length), + ); + // 12 cell rows = 24 pixels tall, so a 34x40 source scales to 0.6 and + // draws ~20 columns wide — nowhere near the 60 it was offered. + const inkWidth = 60 - leading - trailing; + expect(inkWidth).toBeLessThanOrEqual(22); + expect(leading).toBeGreaterThan(0); + }); + + it("still draws something recognisable at the smallest size", () => { + // Regression on the reason this module exists: the hand-drawn + // half-size mark had lost its arms and read as a solid blob. + // A blob is ~100% ink; empty is 0. The real mark sits in between. + const mini = rasteriseMark(source, { + columns: LOGO_METRICS.mini.width, + rows: LOGO_METRICS.mini.height, + }); + expect(mini).toHaveLength(LOGO_METRICS.mini.height); + expect(density(mini)).toBeGreaterThan(0.25); + expect(density(mini)).toBeLessThan(0.85); + }); + + it("never scales the drawing up past its natural size", () => { + const art = rasteriseMark(source, { columns: 200, rows: 60 }); + const widest = art.reduce( + (acc, row) => Math.max(acc, row.trimEnd().length), + 0, + ); + expect(widest).toBeLessThanOrEqual(200); + const drawn = art.filter((row) => row.trim().length > 0); + expect(drawn.length).toBeLessThanOrEqual(20); + }); + + it("degrades to nothing rather than throwing on a zero-sized box", () => { + expect(rasteriseMark(source, { columns: 0, rows: 0 })).toEqual([]); + expect(rasteriseMark([], { columns: 10, rows: 4 })).toEqual([]); + }); +}); diff --git a/src/tui/components/logo-raster.ts b/src/tui/components/logo-raster.ts new file mode 100644 index 00000000..f998f963 --- /dev/null +++ b/src/tui/components/logo-raster.ts @@ -0,0 +1,174 @@ +/** + * Scales the brand mark to any size from one drawing. + * + * The mark used to ship as three hand-drawn copies — 34×20, 17×10 and a + * one-line text fallback. Hand copies drift: the half-size one had lost + * the taper of the lower-right tail and read as a blob, and every new + * breakpoint meant drawing the shape again by eye. + * + * So there is one drawing now, and every smaller size is measured off it. + * Two details make that work in a terminal: + * + * - **Half blocks.** `▀` `▄` `█` split a cell into an upper and a lower + * pixel, so a cell grid of W×H carries a pixel grid of W×2H. Vertical + * resolution doubles, which is what stops a downscaled mark turning + * into a staircase. + * - **Cell aspect.** A terminal cell is about twice as tall as it is + * wide, so one half-block pixel is roughly square. Scaling in that + * pixel space — rather than in cells — is what keeps the mark from + * being squashed, and it is why the source is measured as 34×40 rather + * than 34×20. + * + * Sampling is an area average with a coverage threshold, not + * nearest-neighbour: at small sizes a thin arm covers only part of a + * destination pixel, and nearest-neighbour drops exactly those arms — + * which is what made the old half-size copy look broken. + */ + +/** Upper pixel set. */ +const UPPER = "▀"; +/** Lower pixel set. */ +const LOWER = "▄"; +/** Both set. */ +const BOTH = "█"; + +/** + * Fraction of a destination pixel that must be covered by ink for it to + * be drawn. Below 0.5 the mark fattens and the counter-space between the + * arms fills in; above it, thin arms drop out at the smallest sizes. + */ +const COVERAGE_THRESHOLD = 0.38; + +export interface RasterSize { + /** Width in terminal cells. */ + columns: number; + /** Height in terminal cells. */ + rows: number; +} + +/** + * A boolean ink mask. `true` is drawn, `false` is background — the + * source art's shading characters (`:`, `-`, `@`, `#`, …) all count as + * ink, because at any reduced size shading is noise. + */ +export type InkMask = readonly (readonly boolean[])[]; + +/** Turn character rows into an ink mask, padded to the widest row. */ +export function toInkMask(rows: readonly string[]): InkMask { + const width = rows.reduce((acc, row) => Math.max(acc, row.length), 0); + return rows.map((row) => { + const cells: boolean[] = []; + for (let x = 0; x < width; x += 1) { + const ch = row[x] ?? " "; + cells.push(ch !== " "); + } + return cells; + }); +} + +/** + * Expand a cell mask into a pixel mask by doubling every row — one cell + * row is two half-block pixels tall. This is what puts the source into + * the square-pixel space the scaling maths assumes. + */ +function toPixels(mask: InkMask): InkMask { + return mask.flatMap((row) => [row, row]); +} + +/** + * Fraction of the source rectangle `[x0,x1) × [y0,y1)` that is ink. + * Partial cells at the edges count partially, which is the whole point: + * it is what keeps a one-pixel arm visible when it lands between two + * destination pixels. + */ +function coverage( + pixels: InkMask, + x0: number, + x1: number, + y0: number, + y1: number, +): number { + let ink = 0; + let total = 0; + const yStart = Math.floor(y0); + const yEnd = Math.ceil(y1); + const xStart = Math.floor(x0); + const xEnd = Math.ceil(x1); + for (let y = yStart; y < yEnd; y += 1) { + const row = pixels[y]; + if (!row) continue; + const yWeight = Math.min(y + 1, y1) - Math.max(y, y0); + if (yWeight <= 0) continue; + for (let x = xStart; x < xEnd; x += 1) { + const xWeight = Math.min(x + 1, x1) - Math.max(x, x0); + if (xWeight <= 0) continue; + const weight = yWeight * xWeight; + total += weight; + if (row[x]) ink += weight; + } + } + return total === 0 ? 0 : ink / total; +} + +/** + * Draw `source` at `size`, preserving its aspect ratio and centring the + * result in the requested box. Returns exactly `size.rows` strings, each + * exactly `size.columns` wide. + * + * The mark is never scaled **up** past its natural size — the source is + * a drawing, not a vector, and enlarging it only exposes the pixel grid. + * Callers that have room for the full mark should draw the source art + * directly. + */ +export function rasteriseMark( + source: InkMask, + size: RasterSize, +): readonly string[] { + const columns = Math.max(0, Math.floor(size.columns)); + const rows = Math.max(0, Math.floor(size.rows)); + if (columns === 0 || rows === 0) return []; + + const pixels = toPixels(source); + const srcWidth = pixels[0]?.length ?? 0; + const srcHeight = pixels.length; + if (srcWidth === 0 || srcHeight === 0) return []; + + // Destination pixel grid: full width, two pixels per cell row. + const boxWidth = columns; + const boxHeight = rows * 2; + const scale = Math.min(boxWidth / srcWidth, boxHeight / srcHeight, 1); + const drawWidth = Math.max(1, Math.round(srcWidth * scale)); + const drawHeight = Math.max(2, Math.round(srcHeight * scale)); + const padX = Math.floor((boxWidth - drawWidth) / 2); + const padY = Math.floor((boxHeight - drawHeight) / 2); + + const lit: boolean[][] = []; + for (let y = 0; y < boxHeight; y += 1) { + const row: boolean[] = new Array(boxWidth).fill(false); + const srcY0 = ((y - padY) * srcHeight) / drawHeight; + const srcY1 = ((y - padY + 1) * srcHeight) / drawHeight; + if (y >= padY && y < padY + drawHeight) { + for (let x = 0; x < boxWidth; x += 1) { + if (x < padX || x >= padX + drawWidth) continue; + const srcX0 = ((x - padX) * srcWidth) / drawWidth; + const srcX1 = ((x - padX + 1) * srcWidth) / drawWidth; + row[x] = coverage(pixels, srcX0, srcX1, srcY0, srcY1) >= COVERAGE_THRESHOLD; + } + } + lit.push(row); + } + + const out: string[] = []; + for (let r = 0; r < rows; r += 1) { + const top = lit[r * 2] ?? []; + const bottom = lit[r * 2 + 1] ?? []; + let line = ""; + for (let x = 0; x < boxWidth; x += 1) { + const t = top[x] === true; + const b = bottom[x] === true; + line += t && b ? BOTH : t ? UPPER : b ? LOWER : " "; + } + out.push(line.trimEnd().padEnd(boxWidth)); + } + return out; +} diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx index 2996c6ac..a1aae8d3 100644 --- a/src/tui/components/logo.tsx +++ b/src/tui/components/logo.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; +import { rasteriseMark, toInkMask } from "./logo-raster.js"; import type { LogoVariant } from "./splash-fit.js"; /** @@ -12,9 +13,17 @@ import type { LogoVariant } from "./splash-fit.js"; * Rendered as plain Ink primitives — no animations, no alpha. The mark * comes in three sizes so the same component can serve a 200-column * desktop terminal and a 40-column SSH window: `full` (34×20), `small` - * (17×10) and `mini` (a single 14-column line). `SplashBanner` picks - * one via `computeSplashFit`; callers that just want the classic - * artwork can keep using the defaults. + * (20×12) and `mini` (9×5). `SplashBanner` picks one via + * `computeSplashFit`; callers that just want the classic artwork can + * keep using the defaults. + * + * Only `full` is drawn by hand. The smaller two are **measured off it** + * by `logo-raster.ts` — half-block glyphs at the terminal's ~2:1 cell + * aspect, so they are the same shape at a smaller scale rather than a + * second and third attempt at drawing it. The hand-drawn half-size copy + * they replace had lost the taper of the lower-right tail and read as a + * blob; a redrawn mark also drifts from the original every time either + * is touched, which is a maintenance cost with no upside. */ export interface LogoProps { /** Which mark to draw. Defaults to the full 34×20 artwork. */ @@ -37,44 +46,42 @@ export interface LogoProps { * single line. `splash-fit.ts` mirrors these dimensions in * `LOGO_METRICS`; `logo-fit.test.ts` fails if the two ever disagree. */ -export const LOGO_ART: Readonly> = { +const FULL_ART: readonly string[] = [ // Leading padding has been uniformly trimmed so the middle bar sits // at column 0 — keeps the art within ~34 columns for narrow terminals. - full: [ - " -:::::::--", - " -::::::::-", - " -:::::::::-", - " -::::::::::-", - " -:::::::::::-", - " -:::::::::::::-", - " -::::::::::::::::-", - "-::::::::::::::::::::::::::::::::-", - "::::::::::::::::::::::::::::::::::", - "::::::::::::::::::::::::::::::::::", - "-:::::::::::::::::::::::::::::::::", - "=------------:::::::::::::::::---=", - " @@@@@@@@@@@*-::::::::::::-=+#%%@", - " -:::::::::::-+#@", - " -::::::::::=#@", - " -:::::::::=#", - " -::::::::-*", - " -::::::::=", - " +--------*", - " %%%%%%", - ], - small: [ - " -:::--", - " -::::-", - " -:::::-", - " -:::::::-", - "-:::::::::::::::-", - ":::::::::::::::::", - "=-----:::::::::-=", - " @@@@@*-::::-=#%", - " -::::-*", - " +----*", - ], - mini: ["+ ATOMIC AGENT"], + " -:::::::--", + " -::::::::-", + " -:::::::::-", + " -::::::::::-", + " -:::::::::::-", + " -:::::::::::::-", + " -::::::::::::::::-", + "-::::::::::::::::::::::::::::::::-", + "::::::::::::::::::::::::::::::::::", + "::::::::::::::::::::::::::::::::::", + "-:::::::::::::::::::::::::::::::::", + "=------------:::::::::::::::::---=", + " @@@@@@@@@@@*-::::::::::::-=+#%%@", + " -:::::::::::-+#@", + " -::::::::::=#@", + " -:::::::::=#", + " -::::::::-*", + " -::::::::=", + " +--------*", + " %%%%%%", +]; + +/** + * Mark artwork keyed by variant. `full` is the original drawing and the + * single source of truth; `small` and `mini` are scaled from it at load + * time, so all three are the same shape by construction. `splash-fit.ts` + * mirrors these dimensions in `LOGO_METRICS`; `logo-fit.test.ts` fails if + * the two ever disagree. + */ +export const LOGO_ART: Readonly> = { + full: FULL_ART, + small: rasteriseMark(toInkMask(FULL_ART), { columns: 20, rows: 12 }), + mini: rasteriseMark(toInkMask(FULL_ART), { columns: 7, rows: 4 }), }; export const WORDMARK_ROWS: readonly string[] = [ @@ -92,13 +99,6 @@ export function Logo({ }: LogoProps): ReactElement { const showWordmark = wordmark ?? !compact; const showTagline = tagline ?? showWordmark; - if (variant === "mini") { - return ( - - {LOGO_ART.mini[0]} - - ); - } return ( @@ -122,7 +122,7 @@ function LogoMark({ variant }: { variant: LogoVariant }): ReactElement { return ( {LOGO_ART[variant].map((row, idx) => ( - + {row} ))} diff --git a/src/tui/components/run-mode-bar.tsx b/src/tui/components/run-mode-bar.tsx index ade3b43e..04fe0c82 100644 --- a/src/tui/components/run-mode-bar.tsx +++ b/src/tui/components/run-mode-bar.tsx @@ -1,10 +1,13 @@ -import { Text } from "ink"; +import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; 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"; +import type { RunModeName } from "../../config/index.js"; export interface RunModeBarProps { panel: RunModePanelState; @@ -20,37 +23,73 @@ export interface RunModeBarProps { * 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. + * + * Each pill is its own `` rather than one flat `` run so the + * mouse layer can measure it — the same shape the nav pills took in + * #165. A visible control that cannot be clicked reads as broken once + * every neighbouring control can be. */ 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)} + + {RUN_MODES.map((mode, idx) => ( + + + {idx < RUN_MODES.length - 1 ? ( + + {" "} + {theme.glyphs.dotSeparator} + {" "} - {idx < RUN_MODES.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + ) : null} + + ))} {panel.lastError ? ( {" "} {theme.glyphs.pipeSeparator} {panel.lastError} ) : null} + + ); +} + +function RunModePill({ + mode, + panel, +}: { + mode: RunModeName; + panel: RunModePanelState; +}): ReactElement { + const mouse = useMouseCommands(); + const active = mode === panel.effective; + const label = ( + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {runModePillLabel(mode, panel)} ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // Clicking the mode already in effect opens the dial instead of + // re-applying it: on Fusion that is the only way to reach the + // cloud-share slider with the mouse, and re-applying a mode the + // agent is already in would be a wasted provider swap. + if (active) { + mouse.dispatch({ type: "run_mode_picker_opened" }); + return true; + } + mouse.callbacks.onRunModeChangeRequested?.(mode); + return true; + }} + > + {label} + + ); } diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx index 3cae37e6..5bc4517b 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -1,9 +1,12 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { theme } from "../theme/theme.js"; import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js"; import { + CLOUD_SHARE_BAR_WIDTH, describeCloudShare, formatCloudShareBar, } from "../run-mode/run-mode-selectors.js"; @@ -25,6 +28,12 @@ const MODE_BLURBS: Record = { * 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. + * + * Mouse: a row click moves the cursor to that mode, and clicking the row + * already under the cursor applies it — the two-step rule the rest of the + * mouse layer uses for lists, because applying a mode swaps providers. + * The dial is the exception: it is a slider, and clicking a slider at a + * position means "put it here", so one click sets the share. */ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | null { const picker = panel.picker; @@ -40,28 +49,19 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul 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)} - + {RUN_MODES.map((mode, idx) => ( + + ))} + {" "} {fusionSelected @@ -77,3 +77,91 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul ); } + +function ModeRow({ + mode, + index, + selected, + current, +}: { + mode: (typeof RUN_MODES)[number]; + index: number; + selected: boolean; + current: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {selected ? `${theme.glyphs.chevronRight} ` : " "} + {RUN_MODE_LABELS[mode]} + — {MODE_BLURBS[mode]} + {current ? (current) : null} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + if (selected) { + const state = mouse.getState(); + const share = state.runModePanel.picker?.draftCloudShare; + mouse.callbacks.onRunModeChangeRequested?.(mode, share); + mouse.dispatch({ type: "run_mode_picker_closed" }); + return true; + } + mouse.dispatch({ type: "run_mode_picker_cursor_set", cursor: index }); + return true; + }} + > + {label} + + ); +} + +/** + * The 0-100 dial. Clicking column N of the bar sets the share to the + * value that column represents, so the gesture matches what the bar + * shows rather than nudging by a fixed step. + */ +function ShareDial({ + cloudShare, + active, +}: { + cloudShare: number; + active: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {" "} + cloud share {String(cloudShare).padStart(3, " ")}%{" "} + {formatCloudShareBar(cloudShare)} + + ); + if (!mouse) return label; + // Columns before the bar: two spaces + "cloud share " + a 3-wide + // percentage + "%" + two spaces. + const barStartColumn = 2 + "cloud share ".length + 3 + 1 + 2; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + const column = hit.localX - barStartColumn; + if (column < 0) return false; + const share = Math.round( + (Math.min(column, CLOUD_SHARE_BAR_WIDTH - 1) / + (CLOUD_SHARE_BAR_WIDTH - 1)) * + 100, + ); + mouse.dispatch({ type: "run_mode_picker_share_set", cloudShare: share }); + return true; + }} + > + {label} + + ); +} diff --git a/src/tui/components/splash-banner.test.tsx b/src/tui/components/splash-banner.test.tsx index cb4804db..51d9fae2 100644 --- a/src/tui/components/splash-banner.test.tsx +++ b/src/tui/components/splash-banner.test.tsx @@ -50,16 +50,25 @@ describe("SplashBanner", () => { expect(frame).not.toContain("list all slash commands"); }); - it("still shows a brand mark and a tip on a 40x12 window", () => { + it("keeps the tips and drops the mark when four rows is all there is", () => { + // The mark is scaled artwork at every size now — the smallest is 4 + // rows, so on a 4-row surface it would leave nothing for the tips + // and Ink would paint it over the chat above. Tips win. const frame = frameAt(38, 4); - expect(frame).toContain("+ ATOMIC AGENT"); + expect(frame).toContain("Enter"); + expect(frame).not.toMatch(/:::|[█▀▄]/u); + }); + + it("still shows a brand mark and a tip once there is room for both", () => { + const frame = frameAt(38, 8); + expect(frame).toMatch(/:::|[█▀▄]/u); expect(frame).toContain("Enter"); }); it("measures the terminal itself when no size is given", () => { const { lastFrame } = render(); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain(":::"); + expect(frame).toMatch(/:::|[█▀▄]/u); expect(frame).toContain("/help"); }); }); diff --git a/src/tui/components/splash-banner.tsx b/src/tui/components/splash-banner.tsx index fcf610ef..8fd99afe 100644 --- a/src/tui/components/splash-banner.tsx +++ b/src/tui/components/splash-banner.tsx @@ -49,9 +49,14 @@ export function SplashBanner({ size }: SplashBannerProps = {}): ReactElement { return ( - + {fit.logo === "none" ? null : ( + + )} {tips.length > 0 ? ( - + {tips.map((tip) => ( ))} diff --git a/src/tui/components/splash-fit.render.test.tsx b/src/tui/components/splash-fit.render.test.tsx index fbfe2f24..b3ef03f3 100644 --- a/src/tui/components/splash-fit.render.test.tsx +++ b/src/tui/components/splash-fit.render.test.tsx @@ -46,8 +46,13 @@ describe("SplashBanner fit", () => { const widest = rendered.reduce((acc, line) => Math.max(acc, line.length), 0); expect(widest).toBeLessThanOrEqual(size.columns); expect(rendered.length).toBeLessThanOrEqual(size.rows); - // A splash with no recognisable brand mark is not a splash. - expect(rendered.join("\n")).toMatch(/ATOMIC AGENT|:::/); + // A splash with no recognisable brand mark is not a splash — except + // on a surface with no room for one, where drawing it anyway is the + // bug this file guards against. The mark is half-block art below + // full size, so match the glyphs rather than the source shading. + if (size.rows >= 6) { + expect(rendered.join("\n")).toMatch(/ATOMIC AGENT|:::|[█▀▄]/u); + } }); it("renders the full artwork, wordmark and every tip when there is room", () => { @@ -64,7 +69,7 @@ describe("SplashBanner fit", () => { expect(frame).toContain("/import"); }); - it("collapses to the one-line mark and bare labels on a tiny surface", () => { + it("collapses to the smallest mark and bare labels on a tiny surface", () => { const size = { columns: 24, rows: 10 }; const { lastFrame } = render( @@ -72,7 +77,8 @@ describe("SplashBanner fit", () => { , ); const frame = lines(lastFrame() ?? "").join("\n"); - expect(frame).toContain("+ ATOMIC AGENT"); + // The mini mark is scaled from the full drawing, not a text stand-in. + expect(frame).toMatch(/[█▀▄]/u); expect(frame).not.toContain("▄▀█ ▀█▀ █▀█"); expect(frame).toContain("/help"); expect(frame).not.toContain("list all slash commands"); diff --git a/src/tui/components/splash-fit.test.ts b/src/tui/components/splash-fit.test.ts index af670e66..5a155533 100644 --- a/src/tui/components/splash-fit.test.ts +++ b/src/tui/components/splash-fit.test.ts @@ -34,18 +34,23 @@ describe("computeSplashFit", () => { logo: "small", wordmark: false, tagline: false, - tipCount: 5, + // `small` is 12 rows now, not 10 — it is scaled from the full mark + // rather than hand-drawn, and the honest half-scale of a 20-row + // drawing is 12 half-block rows. Two of those rows come out of the + // tip list, which is the documented mark-over-tips priority. + tipCount: 3, labelWidth: 24, descriptions: "full", }); }); - it("falls back to the one-line mark and terse copy on a small window", () => { + it("falls back to the smallest mark and terse copy on a small window", () => { expect(computeSplashFit({ columns: 38, rows: 12 })).toEqual({ logo: "mini", wordmark: false, tagline: false, - tipCount: SPLASH_TIPS.length, + // 12 rows − 4 for the mark − 1 margin leaves 7 of the 8 tips. + tipCount: SPLASH_TIPS.length - 1, labelWidth: 10, descriptions: "short", }); @@ -59,11 +64,15 @@ describe("computeSplashFit", () => { expect(fit.tipCount).toBeGreaterThan(0); }); - it("drops the tip list entirely rather than overflow a two-row surface", () => { + it("drops the mark rather than overflow a two-row surface", () => { + // Reversed deliberately. The old floor was a one-line text mark, so + // the tips were what got dropped. The mark is real artwork at every + // size now, and on a two-row surface the tips are the half worth + // keeping — Ink paints an over-tall frame over the rows above it, so + // "draw the mark anyway" is the bug this whole module exists for. expect(computeSplashFit({ columns: 92, rows: 2 })).toMatchObject({ - logo: "mini", - tipCount: 0, - descriptions: "none", + logo: "none", + tipCount: 2, }); }); @@ -71,16 +80,19 @@ describe("computeSplashFit", () => { const fit = computeSplashFit({ columns: 0, rows: 0 }); expect(fit.tipCount).toBe(0); expect(fit.labelWidth).toBe(0); - expect(fit.logo).toBe("mini"); + expect(fit.logo).toBe("none"); }); it("plans a layout that fits the surface it was given", () => { for (let columns = 10; columns <= 200; columns += 3) { for (let rows = 2; rows <= 60; rows += 3) { const fit = computeSplashFit({ columns, rows }); + const markHeight = + fit.logo === "none" ? 0 : LOGO_METRICS[fit.logo].height; const height = - LOGO_METRICS[fit.logo].height + (fit.tipCount > 0 ? 1 + fit.tipCount : 0); - expect(height).toBeLessThanOrEqual(Math.max(rows, LOGO_METRICS.mini.height)); + markHeight + + (fit.tipCount > 0 ? (markHeight > 0 ? 1 : 0) + fit.tipCount : 0); + expect(height).toBeLessThanOrEqual(rows); expect(fit.tipCount).toBeGreaterThanOrEqual(0); expect(fit.labelWidth).toBeGreaterThanOrEqual(0); if (fit.wordmark) expect(fit.logo).toBe("full"); @@ -89,9 +101,10 @@ describe("computeSplashFit", () => { }); it("never shrinks the mark as the terminal gets wider", () => { - let previous = 0; + let previous = -1; for (let columns = 10; columns <= 200; columns += 1) { - const rank = SIZE_ORDER.indexOf(computeSplashFit({ columns, rows: 60 }).logo); + const choice = computeSplashFit({ columns, rows: 60 }).logo; + const rank = choice === "none" ? -1 : SIZE_ORDER.indexOf(choice); expect(rank).toBeGreaterThanOrEqual(previous); previous = rank; } diff --git a/src/tui/components/splash-fit.ts b/src/tui/components/splash-fit.ts index 362b01aa..b100d4ca 100644 --- a/src/tui/components/splash-fit.ts +++ b/src/tui/components/splash-fit.ts @@ -21,6 +21,15 @@ export type LogoVariant = "full" | "small" | "mini"; +/** + * What the splash draws for a mark. `"none"` is a real outcome, not a + * failure: below ~8 rows the mark and the tips cannot both fit, and Ink + * paints an over-tall frame *over* the rows above it rather than + * clipping — so drawing it anyway is what garbled the start page in the + * first place. The tips are the useful half at that size. + */ +export type LogoChoice = LogoVariant | "none"; + export interface SplashSize { columns: number; rows: number; @@ -29,8 +38,8 @@ export interface SplashSize { export type TipDescriptions = "full" | "short" | "none"; export interface SplashFit { - /** Which brand mark to draw. */ - logo: LogoVariant; + /** Which brand mark to draw, or `"none"` when nothing fits. */ + logo: LogoChoice; /** Whether the `ATOMIC AGENT` wordmark sits beside the mark. */ wordmark: boolean; /** Whether the "Local AI-First Agent" tagline is drawn. */ @@ -103,8 +112,8 @@ interface LogoMetrics { */ export const LOGO_METRICS: Readonly> = { full: { width: 34, height: 20 }, - small: { width: 17, height: 10 }, - mini: { width: 14, height: 1 }, + small: { width: 20, height: 12 }, + mini: { width: 7, height: 4 }, }; /** `ATOMIC AGENT` half-block wordmark, plus the gap that precedes it. */ @@ -158,14 +167,25 @@ export function computeSplashFit(size: SplashSize): SplashFit { ) { index += 1; } - const logo = VARIANTS_WIDEST_FIRST[index]!; + let logo: LogoChoice = VARIANTS_WIDEST_FIRST[index]!; + if ( + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.height + + TIP_LIST_MARGIN_ROWS + + 1 > + rows || + LOGO_METRICS[VARIANTS_WIDEST_FIRST[index]!]!.width > inner + ) { + logo = "none"; + } // The wordmark is a 46-column luxury; it only rides along with the // full mark, and only once both fit side by side. const wordmark = logo === "full" && inner >= FULL_WITH_WORDMARK_WIDTH; const tagline = wordmark; - const spare = rows - LOGO_METRICS[logo].height - TIP_LIST_MARGIN_ROWS; + const markRows = + logo === "none" ? 0 : LOGO_METRICS[logo].height + TIP_LIST_MARGIN_ROWS; + const spare = rows - markRows; const tipCount = Math.max(0, Math.min(SPLASH_TIPS.length, spare)); const visible = SPLASH_TIPS.slice(0, tipCount); diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5deff17a..05a2861e 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -1,8 +1,11 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { chromeTheme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; +import type { MenuNode } from "./menu-registry.js"; import type { MenuItemRow } from "./menu-selectors.js"; import { clampMenuCursor, @@ -26,6 +29,12 @@ interface MenuPopupProps { availableRows: number; /** Columns available in that pane. */ availableColumns: number; + /** + * Runs a node. The very same callback `handleMenuKey` fires on Enter — + * passed down rather than reached through the mouse context so a click + * and a keypress cannot drift into two different activation paths. + */ + onActivate: (node: MenuNode) => void; } /** @@ -35,6 +44,11 @@ interface MenuPopupProps { * so it floats **on top of** the chat log or the active panel instead of * displacing them. Nothing below it reflows when the menu opens or closes. * + * It sits **centred** in that pane, both axes. A dropdown hanging off the + * prompt was the first shape, but this is not a dropdown: it is the app's + * one modal surface, and a modal belongs in the middle of the window with + * the app faded behind it — the same thing a web app would do. + * * Terminals have no compositing and Ink has no z-index, so occlusion has to * be earned: every interior line is padded to the popup's exact inner width, * which paints spaces over whatever was underneath. That is also why the rows @@ -55,6 +69,7 @@ export function MenuPopup({ state, availableRows, availableColumns, + onActivate, }: MenuPopupProps): ReactElement { const width = Math.max(28, Math.min(PREFERRED_WIDTH, availableColumns - 2)); // Interior columns between the two border columns. Ink's own `paddingX` @@ -75,15 +90,16 @@ export function MenuPopup({ const visible = rows.slice(start, start + bodyRows); const hiddenAfter = Math.max(0, rows.length - start - visible.length); - // Anchor to the bottom of the pane so the menu sits just above the prompt, - // the way a dropdown hangs off the control that opened it. + // Centred in the pane on both axes. const height = visible.length + CHROME_ROWS; - const offsetTop = Math.max(0, availableRows - height); + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); return ( ), )} @@ -139,11 +157,17 @@ function MenuItem({ row, inner, selected, + itemIndex, + onActivate, }: { row: MenuItemRow; inner: number; selected: boolean; + /** Index among the *item* rows — what `menuCursor` counts. */ + itemIndex: number; + onActivate: (node: MenuNode) => void; }): ReactElement { + const mouse = useMouseCommands(); const { node } = row; const marker = selected ? chromeTheme.glyphs.chevronRight : " "; const arrow = node.kind === "submenu" ? ` ${chromeTheme.glyphs.arrowRight}` : ""; @@ -157,8 +181,8 @@ function MenuItem({ [row.crumb, row.status].filter((part) => part.length > 0).join(" "), detailWidth, ); - return ( - + const body = ( + <> {detail} {chord} - + + ); + if (!mouse) return {body}; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + // One click acts, the way a menu item does everywhere else. The + // rest of the mouse layer selects first and acts on the second + // click, because there a mis-click starts a download or switches + // sessions. Here the operator opened a menu to pick something — + // making them click twice would be the surprising choice. + if (itemIndex >= 0) { + mouse.dispatch({ type: "menu_cursor_set", cursor: itemIndex }); + } + if (node.kind === "submenu") { + mouse.dispatch({ type: "menu_path_set", path: node.id }); + return true; + } + mouse.dispatch({ type: "menu_closed" }); + onActivate(node); + return true; + }} + > + {body} + ); } diff --git a/src/tui/run-mode/run-mode-selectors.ts b/src/tui/run-mode/run-mode-selectors.ts index 1851ea13..5bf5c1a9 100644 --- a/src/tui/run-mode/run-mode-selectors.ts +++ b/src/tui/run-mode/run-mode-selectors.ts @@ -35,7 +35,17 @@ export function runModeModelSummary(panel: RunModePanelState): string | null { } /** Dial rendered as a fixed-width bar so the row never reflows. */ -export function formatCloudShareBar(cloudShare: number, width = 20): string { +/** + * Bar width in columns. Exported because the mouse layer maps a click + * column back to a share value and would otherwise hardcode a second + * copy of this number. + */ +export const CLOUD_SHARE_BAR_WIDTH = 20; + +export function formatCloudShareBar( + cloudShare: number, + width = CLOUD_SHARE_BAR_WIDTH, +): string { const filled = Math.round((cloudShare / 100) * width); return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`; } diff --git a/src/tui/theme/theme-palettes.ts b/src/tui/theme/theme-palettes.ts index 39edae31..0a5e2ad3 100644 --- a/src/tui/theme/theme-palettes.ts +++ b/src/tui/theme/theme-palettes.ts @@ -41,6 +41,7 @@ export const GITHUB_DARK_COLORS: TuiColors = { warnStrong: "#db6d28", success: "#3fb950", info: "#4493f8", + brandMark: "#a5c9ff", }; // GitHub Primer "light (default)" — @primer/primitives functional tokens @@ -62,6 +63,7 @@ export const GITHUB_LIGHT_COLORS: TuiColors = { warnStrong: "#bc4c00", success: "#1a7f37", info: "#0969da", + brandMark: "#54a3ff", }; // Catppuccin Mocha — official palette (catppuccin/palette palette.json). @@ -84,6 +86,7 @@ export const CATPPUCCIN_MOCHA_COLORS: TuiColors = { warnStrong: "#fab387", success: "#a6e3a1", info: "#89b4fa", + brandMark: "#b4cffa", }; // Catppuccin Latte — official palette (light flavour). @@ -104,6 +107,7 @@ export const CATPPUCCIN_LATTE_COLORS: TuiColors = { warnStrong: "#fe640b", success: "#40a02b", info: "#1e66f5", + brandMark: "#5f9bf5", }; // Dracula — official spec (draculatheme.com). accent=purple, green, @@ -126,6 +130,7 @@ export const DRACULA_COLORS: TuiColors = { warnStrong: "#ffb86c", success: "#50fa7b", info: "#bd93f9", + brandMark: "#b9c9ff", }; // Nord — official spec (nordtheme.com). accent=nord8 frost, nord14 green, @@ -148,6 +153,7 @@ export const NORD_COLORS: TuiColors = { warnStrong: "#d08770", success: "#a3be8c", info: "#88c0d0", + brandMark: "#b3ccec", }; // Tokyo Night ("Night" variant) — official spec @@ -170,6 +176,7 @@ export const TOKYO_NIGHT_COLORS: TuiColors = { warnStrong: "#ff9e64", success: "#9ece6a", info: "#7aa2f7", + brandMark: "#a9c7ff", }; // Gruvbox Dark — official "bright" palette (morhetz/gruvbox). accent=blue, @@ -192,6 +199,7 @@ export const GRUVBOX_DARK_COLORS: TuiColors = { warnStrong: "#fe8019", success: "#b8bb26", info: "#83a598", + brandMark: "#b3d1e6", }; // Gruvbox Light — official "faded" palette on light bg (morhetz/gruvbox). @@ -212,6 +220,7 @@ export const GRUVBOX_LIGHT_COLORS: TuiColors = { warnStrong: "#af3a03", success: "#79740e", info: "#076678", + brandMark: "#5a9ec9", }; // Solarized Dark — official spec (Ethan Schoonover). Accents are shared @@ -234,6 +243,7 @@ export const SOLARIZED_DARK_COLORS: TuiColors = { warnStrong: "#cb4b16", success: "#859900", info: "#268bd2", + brandMark: "#9fc7e8", }; // Solarized Light — same accent set, light base. base1(muted), base2(border). @@ -254,4 +264,5 @@ export const SOLARIZED_LIGHT_COLORS: TuiColors = { warnStrong: "#cb4b16", success: "#859900", info: "#268bd2", + brandMark: "#4a95c9", }; diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index bf3e9356..7f3d3390 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -42,6 +42,13 @@ export interface TuiColors { readonly toolError: string; readonly accent: string; readonly accentSoft: string; + /** + * The brand mark's own blue — deliberately lighter and whiter than + * `accent`. The mark is not a control, and painting it in the same + * blue as every accented control made the start page read as one big + * highlighted widget. + */ + readonly brandMark: string; readonly border: string; readonly muted: string; readonly error: string; diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 70f4dc16..fe9e9e12 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -50,8 +50,8 @@ describe("TuiApp (smoke)", () => { expect(text).not.toContain("Manage"); // The splash mark scales with the window (#151); ink-testing-library's // stdout reports no rows, so the fallback surface gets the compact - // mark. Assert on what every size keeps. - expect(text).toContain(":::"); + // mark, which is half-block art rather than the source shading. + expect(text).toMatch(/:::|[█▀▄]/u); expect(text).toContain("commands"); unmount(); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 3fb459b4..c87ac2fb 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -959,6 +959,7 @@ export function TuiApp({ state={state} availableRows={menuPaneRows} availableColumns={terminalSize.columns - 4} + onActivate={activateMenuNode} /> ) : null} From 4252e97a38eb68822f1c6d21da2915e136ff16e5 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 15:58:45 +0300 Subject: [PATCH 37/57] feat(tui): the menu is a centred modal, and every visible control takes a click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues 1 and 2 from hand testing. Belongs to #165 (mouse), #171 (menu) and #163 (run modes) — the three PRs whose surfaces meet here. Menu (#171 + #165): - Centred in the chat pane on both axes instead of hanging off the prompt. It is the app's one modal surface, and a modal belongs in the middle of the window with the app faded behind it. - Centring measures the chat column, not the terminal: with the sidebar open the old maths ran the popup under the rail and clipped its right border. - Rows take a click. One click acts, unlike the two-step rule elsewhere in the mouse layer — the operator opened a menu to pick something, and a submenu row opens the submenu the way the right arrow does. Run modes (#163 + #165): - The Local / Cloud / Fusion strip is per-item boxes now, so each pill is measurable and clickable. Clicking the mode already in effect opens the dial rather than re-applying it: re-applying is a wasted provider swap, and it is the only mouse route to the cloud-share slider. - The dial itself is a slider — clicking column N sets the share that column represents. The load-bearing fix behind all of it: modal surfaces have to register their own targets at MOUSE_LAYER_MODAL. TuiApp raises the registry floor when a modal opens, so targets left at the base layer are ignored — the menu and the picker rendered fine and swallowed every click. Five of #165's mouse tests waited on the '▸ Run' pill #172 removed; they now go through the breadcrumb, which is the mouse route into navigation that replaced the pills. --- src/tui/components/run-mode-picker.tsx | 3 + src/tui/menu/menu-popup.tsx | 2 + src/tui/mouse/mouse-app.test.tsx | 102 +++++++++++++++++++++---- src/tui/tui-app.tsx | 4 +- 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx index 5bc4517b..611232ca 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from "react"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { theme } from "../theme/theme.js"; import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js"; import { @@ -104,6 +105,7 @@ function ModeRow({ if (!mouse) return label; return ( { if (!isPrimaryPress(hit.event)) return false; if (selected) { @@ -148,6 +150,7 @@ function ShareDial({ const barStartColumn = 2 + "cloud share ".length + 3 + 1 + 2; return ( { if (!isPrimaryPress(hit.event)) return false; const column = hit.localX - barStartColumn; diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 05a2861e..5b6261ce 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from "react"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { chromeTheme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import type { MenuNode } from "./menu-registry.js"; @@ -196,6 +197,7 @@ function MenuItem({ if (!mouse) return {body}; return ( { if (!isPrimaryPress(hit.event)) return false; // One click acts, the way a menu item does everywhere else. The diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx index 5103cc3b..fe84e4fd 100644 --- a/src/tui/mouse/mouse-app.test.tsx +++ b/src/tui/mouse/mouse-app.test.tsx @@ -162,27 +162,58 @@ function mountApp(): { } describe("TuiApp mouse", () => { - it("switches section when the nav bar is clicked", async () => { + it("opens the menu when the breadcrumb is clicked", async () => { + // #172 retired the Run / Observe / Manage pills for a breadcrumb, so + // the mouse route into navigation is the breadcrumb itself: a click + // opens the same menu ctrl+p does. Without this the mouse would have + // no way to change section at all. const app = mountApp(); - await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); await clickUntil( app.mouse, - () => locate(app.frame(), "Observe"), - () => app.frame().includes("▸ Observe"), - "click on the Observe pill", + () => locate(app.frame(), "Run"), + () => app.frame().includes("Menu"), + "click on the breadcrumb", ); - expect(app.frame()).toContain("▸ Observe"); + expect(app.frame()).toContain("Menu"); + app.unmount(); + }); + + it("navigates when a menu row is clicked", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Run"), + () => app.frame().includes("Menu"), + "click on the breadcrumb", + ); + // One click acts on a menu row — the menu is the one surface where + // the two-step select-then-activate rule would be the surprise. + await clickUntil( + app.mouse, + () => locate(app.frame(), "Toggle debug pane"), + () => app.frame().includes("▸ Feed"), + "click on a menu row", + ); + expect(app.frame()).toContain("▸ Feed"); app.unmount(); }); it("switches sub-tab when a tab label is clicked", async () => { const app = mountApp(); - await waitUntil(() => app.frame().includes("Observe"), "the nav bar"); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Run"), + () => app.frame().includes("Menu"), + "click on the breadcrumb", + ); await clickUntil( app.mouse, - () => locate(app.frame(), "Observe"), - () => app.frame().includes("▸ Observe"), - "click on the Observe pill", + () => locate(app.frame(), "Toggle debug pane"), + () => app.frame().includes("▸ Feed"), + "click on a menu row", ); await waitUntil(() => app.frame().includes("Logs"), "the Observe sub-tabs"); await clickUntil( @@ -197,7 +228,7 @@ describe("TuiApp mouse", () => { it("ignores a click that lands on no target", async () => { const app = mountApp(); - await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); const before = app.frame(); app.mouse.emit(click(0, 0)); await delay(150); @@ -207,7 +238,7 @@ describe("TuiApp mouse", () => { it("places the editor caret where the prompt is clicked", async () => { const app = mountApp(); - await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); app.stdin.write("hello"); await waitUntil(() => app.frame().includes("hello"), "the typed buffer"); // Click the second "l" (index 3) then type: the character has to land @@ -232,7 +263,7 @@ describe("TuiApp mouse", () => { it("clamps a click past the end of a line to the line end", async () => { const app = mountApp(); - await waitUntil(() => app.frame().includes("▸ Run"), "the Run screen"); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); app.stdin.write("hi"); await waitUntil(() => app.frame().includes("hi"), "the typed buffer"); await clickUntil( @@ -295,3 +326,48 @@ describe("TuiApp mouse", () => { app.unmount(); }); }); + +describe("TuiApp mouse — run modes", () => { + it("asks for the run mode a clicked pill names", async () => { + const asked: string[] = []; + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const { lastFrame, unmount } = render( + asked.push(mode), + }} + mouse={mouse} + />, + ); + const frame = (): string => strip(lastFrame() ?? ""); + await waitUntil(() => frame().includes("Fusion"), "the run-mode strip"); + await clickUntil( + mouse, + () => locate(frame(), "Fusion"), + () => asked.includes("fusion"), + "click on the Fusion pill", + ); + expect(asked).toContain("fusion"); + unmount(); + }); + + it("opens the dial when the pill already in effect is clicked", async () => { + // Re-applying the mode you are already in would be a wasted provider + // swap, and on Fusion the dial is otherwise unreachable by mouse. + const app = mountApp(); + await waitUntil(() => app.frame().includes("Local"), "the run-mode strip"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Local"), + () => app.frame().includes("cloud share"), + "click on the active pill", + ); + expect(app.frame()).toContain("Run mode"); + expect(app.frame()).toContain("cloud share"); + app.unmount(); + }); +}); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index c87ac2fb..f447f234 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -958,7 +958,9 @@ export function TuiApp({ ) : null} From c561a33fba07f724069c63929911d937ae93835e Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 17:20:00 +0300 Subject: [PATCH 38/57] test(tui): follow #172's breadcrumb, and fix the two drifted assertions #170 named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar stopped listing sections and started naming the current one, so every test that waited for '▸ Run' / '▸ Observe' / '▸ Manage' broke: the four Esc suites (#153/#154/#158) and five of #165's mouse tests. They assert on the breadcrumb now. Also fixes the two assertions #170's body called out as evidence that the TUI had several hand-kept parallel lists which had drifted, and deliberately left alone because 'fixing them belongs with the commit that changes the behaviour they describe'. #172 is that change: - Shift+Tab from Run wraps to the last Manage tab, which has been Privacy since MANAGE_TABS gained import and privacy — not Telegram. - The LLM panel test wanted 'Active chat route', 'Mode:' and 'Press ←/→ to switch mode'; with no provider configured the panel shows its section headings and a compact footer instead. --- src/tui/escape-chat-editor.test.tsx | 5 ++++- src/tui/escape-import-tab.test.tsx | 7 +++++-- src/tui/escape-observe-tabs.test.tsx | 4 ++-- src/tui/tui-app.test.tsx | 18 +++++++++++++----- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/tui/escape-chat-editor.test.tsx b/src/tui/escape-chat-editor.test.tsx index e4c68578..40bf6759 100644 --- a/src/tui/escape-chat-editor.test.tsx +++ b/src/tui/escape-chat-editor.test.tsx @@ -95,7 +95,10 @@ describe("Esc in the chat editor", () => { stdin.write(ESC); await settle(); - expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + // #172 replaced the section pill row with a breadcrumb: the status + // bar names where you are (`Manage ▸ Tasks`) rather than listing + // where you could go. "on the Run screen" reads as a bare `Run`. + expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); expect(counts.quit).toBe(0); stdin.write(ESC); diff --git a/src/tui/escape-import-tab.test.tsx b/src/tui/escape-import-tab.test.tsx index 2c6d0ef7..d9cf2f98 100644 --- a/src/tui/escape-import-tab.test.tsx +++ b/src/tui/escape-import-tab.test.tsx @@ -49,7 +49,7 @@ describe("Esc on the Import tab", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "import" }); await settle(); - expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + expect(strip(lastFrame() ?? "")).toContain("Manage ▸"); stdin.write(ESC); await settle(); @@ -57,7 +57,10 @@ describe("Esc on the Import tab", () => { // The configure-mode handler ends in a catch-all `return true` that // swallows stray letters; before the fix it swallowed Esc too, so the // operator was stuck on the tab with no "back" gesture at all. - expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + // #172 replaced the section pill row with a breadcrumb: the status + // bar names where you are (`Manage ▸ Tasks`) rather than listing + // where you could go. "on the Run screen" reads as a bare `Run`. + expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); expect(quit).toBe(0); unmount(); }); diff --git a/src/tui/escape-observe-tabs.test.tsx b/src/tui/escape-observe-tabs.test.tsx index ec7582a3..fef0cddc 100644 --- a/src/tui/escape-observe-tabs.test.tsx +++ b/src/tui/escape-observe-tabs.test.tsx @@ -57,7 +57,7 @@ describe("Esc on the Observe tabs", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab }); await settle(); - expect(strip(lastFrame() ?? "")).toContain("▸ Observe"); + expect(strip(lastFrame() ?? "")).toContain("Observe ▸"); stdin.write(ESC); await settle(); @@ -67,7 +67,7 @@ describe("Esc on the Observe tabs", () => { // reached the still-focused chat editor and quit the process. expect(counts.quit).toBe(0); expect(counts.abort).toBe(0); - expect(strip(lastFrame() ?? "")).toContain("▸ Run"); + expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); unmount(); }); } diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index fe9e9e12..2f84781d 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -192,9 +192,14 @@ describe("TuiApp (smoke)", () => { stdin.write("\u001b[Z"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). + // Shift+Tab from Run wraps to the last Manage sub-tab. That was + // Telegram when this test was written; MANAGE_TABS has gained + // `import` and `privacy` since, and the literal was never updated — + // one of the two drifted assertions #170 called out as the reason a + // single menu registry exists. #172 changed the surface they + // describe, so this is the commit that owes them a fix. expect(text).toContain("Manage \u25b8"); - expect(text).toContain("▸ Telegram"); + expect(text).toContain("▸ Privacy"); unmount(); }); @@ -290,11 +295,14 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "tab_changed", tab: "llm" }); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Active chat route"); - expect(text).toContain("Mode:"); + // Stale in three places, all pre-existing: with no provider + // configured the panel leads with its section headings rather than a + // resolved route, and the verbose "Mode:" line and "Press ←/→ to + // switch mode" hint were replaced by the compact footer long ago. + // Assert what the panel actually shows a fresh install. expect(text).toContain("Local text models"); expect(text).toContain("Local embeddings"); - expect(text).toContain("Press ←/→ to switch mode"); + expect(text).toContain("←/→ mode"); expect(text).not.toContain("Local runtime"); unmount(); }); From 2bdd69efb13416f4bf50342c8fc894ba1a2dc05b Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 18:53:46 +0300 Subject: [PATCH 39/57] feat(tui): the left rail becomes the app frame; top bar removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-test round 3. - The rail moves to the LEFT, on its own inverted ground (per-palette, not a literal white — #fff vanishes on the four light themes), and carries the brand mark, the version, a Menu button, the breadcrumb and the session id. The top bar is gone: it and the rail were two pieces of chrome doing one job. - The rail is visible in EVERY mode now. It was chat-only, which was fine for a list of sessions and wrong for the app frame — switching to a panel used to take all the chrome off screen. - Its ground is one backgroundColor on the container. Painting it line by line needs filler rows to reach the bottom, and a rail taller than the terminal makes Ink 7 overlap earlier lines rather than clip. - Esc joins the chat hint strip ('clear'), and the newline gesture is advertised as shift+enter. - The LLM pane switcher marks the active pane with the same chevron everything else uses, and each name is clickable. Colour alone was not a marker on that screen: pressing left/right visibly changed the rows and still read as 'does not work at all'. - The run-mode strip says how to change it, and the overlay can now set up the provider a mode needs (n, or click) instead of being a dead end on a fresh install. - The run-mode overlay takes focus off the editor while it is open. Ink delivers every keypress to every live useInput, child first, so its n / digits leaked into the prompt. --- src/tui/components/hotkey-hint.tsx | 10 +- src/tui/components/llm-panel.tsx | 63 +++-- src/tui/components/multi-line-editor.tsx | 9 +- src/tui/components/run-mode-bar.tsx | 10 + src/tui/components/run-mode-picker.tsx | 31 +++ src/tui/components/sidebar.test.tsx | 13 +- src/tui/components/sidebar.tsx | 268 ++++++++++++++++++---- src/tui/escape-chat-editor.test.tsx | 7 +- src/tui/escape-import-tab.test.tsx | 7 +- src/tui/escape-observe-tabs.test.tsx | 2 +- src/tui/mouse/mouse-app.test.tsx | 47 ++-- src/tui/mouse/rail-click.test.tsx | 133 +++++++++++ src/tui/run-mode/run-mode-key-bindings.ts | 30 +++ src/tui/theme/theme-palettes.ts | 33 +++ src/tui/theme/theme.ts | 18 ++ src/tui/tui-app.test.tsx | 3 +- src/tui/tui-app.tsx | 89 ++++--- 17 files changed, 644 insertions(+), 129 deletions(-) create mode 100644 src/tui/mouse/rail-click.test.tsx diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 637c3d7e..5add36c1 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -22,7 +22,7 @@ interface HotkeyChip { readonly label: string; /** * What a click on this chip does. Only chips with one unambiguous - * meaning get one — "alt+enter newline" or "↑↓ select" describe a + * meaning get one — "shift+enter newline" or "↑↓ select" describe a * gesture, not a command, so they stay plain text rather than * pretending to be buttons. */ @@ -205,7 +205,7 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { // menu now lists Local / Cloud / Fusion outright. return [ { key: "enter", label: "send" }, - { key: "alt+enter", label: "newline" }, + { key: "shift+enter", label: "newline" }, { key: "tab", label: "sidebar", @@ -214,6 +214,12 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { }, { key: SCROLL_KEY, label: "scroll" }, { key: "ctrl+p", label: "menu", onClick: openOperatorMenu }, + // Esc means something here — it clears the draft — and nothing said + // so. It is the one key an operator reaches for expecting "get me + // out of this", and leaving it off the strip is how it ended up + // feeling like the quit key. (The running branch above already + // advertises it as `abort`.) + { key: "esc", label: "clear" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/components/llm-panel.tsx b/src/tui/components/llm-panel.tsx index b0f80a5e..1ba1d045 100644 --- a/src/tui/components/llm-panel.tsx +++ b/src/tui/components/llm-panel.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { @@ -166,30 +168,65 @@ function footerHint(mode: LlmPanelMode, useFull: boolean): string { : "j/k · Enter · ←/→ mode · f filter · r"; } +/** + * The Local / Cloud / External / Fallback switcher. + * + * The active pane used to be marked by colour alone. On a screen this + * busy that is not a marker — pressing ←/→ visibly changed the rows + * underneath and still read as "nothing happened", which is exactly how + * it was reported. It carries the same `▸` every other selected thing in + * this TUI carries, and each label is its own clickable box. + */ function ModeHeader({ mode }: { mode: LlmPanelMode }): ReactElement { return ( - - Mode:{" "} + + Mode: {LLM_PANEL_MODES.map((candidate, index) => ( - + {index > 0 ? | : null} - - {MODE_LABELS[candidate]} - - + + ))} + + + ←/→ switches pane · click a name to jump straight to it - Press ←/→ to switch mode ); } +function ModeTab({ + candidate, + active, +}: { + candidate: LlmPanelMode; + active: boolean; +}): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {active ? `${theme.glyphs.chevronRight} ` : " "} + {MODE_LABELS[candidate]} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "llm_mode_set", mode: candidate }); + return true; + }} + > + {label} + + ); +} + function StatusLines({ state, compact = false, diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index 334f817a..753f9bc1 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -56,7 +56,12 @@ export interface MultiLineEditorProps { * * Key handling: * - Enter submits the trimmed buffer (and emits empty-submit as no-op) - * - Alt/Meta+Enter or Ctrl+J insert a newline + * - Shift+Enter inserts a newline. Alt/Meta+Enter and Ctrl+J do the + * same, deliberately: plenty of terminals cannot report Shift with + * Return at all (it needs modifyOtherKeys or the kitty protocol), and + * a multi-line editor that some terminals cannot reach is worse than + * one advertised gesture plus quiet fallbacks. The hint strip names + * Shift+Enter because that is what an operator expects to try. * - Backslash at end-of-line before Enter also forces a newline * - Up/Down trigger `onHistoryPrev` / `onHistoryNext` when the cursor * is at the top/bottom of the buffer @@ -223,6 +228,8 @@ function handleKey(ctx: KeyContext): void { return; } if (key.return) { + // Any Return modifier means "newline" — see the note above on why + // this is wider than the gesture the hint strip advertises. const newline = key.meta || key.shift || key.ctrl; const trailingBackslash = value.endsWith("\\") && cursor === value.length; if (newline) { diff --git a/src/tui/components/run-mode-bar.tsx b/src/tui/components/run-mode-bar.tsx index 04fe0c82..b7d30977 100644 --- a/src/tui/components/run-mode-bar.tsx +++ b/src/tui/components/run-mode-bar.tsx @@ -44,6 +44,16 @@ export function RunModeBar({ panel }: RunModeBarProps): ReactElement { ) : null} ))} + {/* + The strip showed three names and nothing else, so there was no + way to learn it was a control at all — reported as "there is no + hint anywhere how to switch them". Naming the key is cheap; the + pills are clickable too. + */} + + {" "} + {theme.glyphs.pipeSeparator} ctrl+r or click · /run to configure + {panel.lastError ? ( {" "} diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx index 611232ca..e886ceed 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -4,6 +4,7 @@ import type { ReactElement } from "react"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; +import { openProviderSetupFromRunMode } from "../run-mode/run-mode-key-bindings.js"; import { theme } from "../theme/theme.js"; import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js"; import { @@ -72,6 +73,7 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul {panel.degradedMessage ? ( {panel.degradedMessage} ) : null} + {panel.cloudProviderMissing ? : null} ↑↓ mode · ←→ share (shift ±25) · digits set · enter apply · esc cancel @@ -79,6 +81,35 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul ); } +/** + * On a fresh install two of the three modes cannot be entered at all, + * and this overlay was where you found that out and then had nowhere to + * go. The fix belongs on the screen that raises the problem. + */ +function SetUpProviderRow(): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {" "} + {theme.glyphs.chevronRight} Set up a cloud provider…{" "} + (n) + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + openProviderSetupFromRunMode(mouse.dispatch); + return true; + }} + > + {label} + + ); +} + function ModeRow({ mode, index, diff --git a/src/tui/components/sidebar.test.tsx b/src/tui/components/sidebar.test.tsx index 2e7a20e6..efa149d9 100644 --- a/src/tui/components/sidebar.test.tsx +++ b/src/tui/components/sidebar.test.tsx @@ -72,8 +72,11 @@ describe("Sidebar", () => { />, ); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Sessions"); - expect(text).toContain("Tasks"); + // Upper-case since the rail became the app frame — it carries the + // brand, the version and the menu button now, so its own headings + // read as labels rather than as content. + expect(text).toContain("SESSIONS"); + expect(text).toContain("TASKS"); expect(text).not.toContain("Workspace"); expect(text).not.toContain("LLM"); }); @@ -178,8 +181,10 @@ describe("Sidebar", () => { // Both panes admit what they are hiding. expect(text).toContain("9 more"); expect(text).toContain("6 more"); - // Two headers + 3 sessions + 2 tasks + 2 "more" rows + blank row. - expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(10); + // Two headers + 3 sessions + 2 tasks + 2 "more" rows + spacers, plus + // the brand block (mark, wordmark, version), the menu button and the + // breadcrumb slot the rail gained when it replaced the top bar. + expect(strip(lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(22); }); it("scrolls the Tasks pane to keep the cursor visible", () => { diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index 80f930de..b33c0916 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -10,6 +10,8 @@ import { computeRowWindow } from "../row-window.js"; import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; +import { getAppVersion } from "../../version.js"; +import { LOGO_ART } from "./logo.js"; export type SidebarSection = "sessions" | "tasks"; @@ -24,6 +26,10 @@ export interface SidebarProps { activeSection: SidebarSection; /** Whether the sidebar owns keyboard focus right now. */ focused: boolean; + /** Short session id, shown under the wordmark. */ + sessionId?: string | null; + /** Where the operator is, e.g. `Manage › Tasks`. */ + location?: string; /** * Row budget for each pane, normally derived from the terminal height * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep @@ -46,19 +52,33 @@ const ROW_CHROME_COLUMNS = 7; const MIN_PREVIEW_COLUMNS = 6; /** - * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and - * Tasks (bottom) — both navigable when the sidebar has focus. Tab - * cycles editor → sessions → tasks → editor (handled by - * `app-key-bindings.ts`); the sidebar component itself is purely - * presentational and never measures the terminal directly so the - * same component works under ink-testing-library's static viewport. - * Its width and per-pane row budgets arrive as props from `TuiApp`, - * which owns the terminal measurement. + * The app rail: brand mark, menu button, where you are, then Sessions + * and Tasks. Always on screen, on the **left**, drawn on its own + * inverted ground. * - * Focus is layered: `focused` toggles the section header colour for - * the active pane, and `activeSection` decides which pane gets the - * cursor highlight. When `focused` is false, both panes render in - * their muted resting state. + * It used to be a plain right-hand list of sessions with the app title + * on a separate bar across the top. That is two pieces of chrome doing + * one job. Everything that says "which app, which version, where am I, + * what else is there" now lives in one column, which is where a reader + * coming from any normal application will look for it — and the top bar + * is gone entirely. + * + * **Why the inverted ground.** A terminal has no borders-and-shadows to + * separate regions, so two columns of the same text on the same ground + * read as one wrapped document. Giving the rail its own ground is the + * cheapest honest way to say "this is chrome, that is content". It is + * per-palette rather than literally white: `#fff` would vanish on the + * four light themes, and the property that has to hold is inversion. + * + * The ground is one `backgroundColor` on the rail container, so it fills + * the column's whole height on its own. Painting it line by line instead + * needs filler rows to reach the bottom, and a rail taller than the + * terminal makes Ink 7 overlap earlier lines rather than clip — the same + * trap `splash-fit.ts` exists to avoid. + * + * Purely presentational: it never measures the terminal, so the same + * component works under ink-testing-library's static viewport. Width and + * per-pane row budgets arrive as props from `TuiApp`. */ export function Sidebar(props: SidebarProps): ReactElement { const { @@ -70,11 +90,14 @@ export function Sidebar(props: SidebarProps): ReactElement { tasksCursor, activeSection, focused, + sessionId = null, + location, maxSessionRows = DEFAULT_MAX_SESSION_ROWS, maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; const sessionsActive = focused && activeSection === "sessions"; const tasksActive = focused && activeSection === "tasks"; + const inner = Math.max(1, width - 2); const previewWidth = Math.max( MIN_PREVIEW_COLUMNS, width - ROW_CHROME_COLUMNS, @@ -101,16 +124,14 @@ export function Sidebar(props: SidebarProps): ReactElement { width={width} flexShrink={0} flexDirection="column" - borderStyle="single" - borderTop={false} - borderRight={false} - borderBottom={false} - borderLeft - borderColor={theme.colors.border} - paddingLeft={1} - paddingRight={1} + backgroundColor={theme.colors.railBackground} + paddingX={1} > - + + + {location ? : null} + + - - - + + + ); } +/** Clip to `width` columns; the ground is painted by the container. */ +function clip(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text; +} + +/** + * One rail line. The text is clipped to the rail width but not padded — + * the container's `backgroundColor` paints the rest of the row. + */ +function RailLine({ + inner, + children, + color, + bold, +}: { + inner: number; + children: string; + color?: string; + bold?: boolean; +}): ReactElement { + return ( + + {clip(children, inner)} + + ); +} + +/** + * One row of breathing space. An empty `` collapses to zero height + * in Ink, so the spacer has to be a sized Box. + */ +function RailBlank(): ReactElement { + return ; +} + +/** Mark, wordmark, version, session id. */ +function RailBrand({ + inner, + sessionId, +}: { + inner: number; + sessionId: string | null; +}): ReactElement { + const art = LOGO_ART.mini; + return ( + + + {art.map((row, idx) => ( + + {row} + + ))} + + {"atomic-agent"} + + + {`v${getAppVersion()}${sessionId ? ` · ${shortenId(sessionId)}` : ""}`} + + + + ); +} + +/** + * The one control on the rail. `ctrl+p` opens the same menu; this is + * what makes it reachable without knowing that, which was the whole + * complaint about the old top bar — nothing on screen said the menu + * existed. + */ +function MenuButton({ inner }: { inner: number }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {`${theme.glyphs.menuGlyph} Menu${" ".repeat(Math.max(1, inner - 17))}ctrl+p`} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "menu_opened" }); + return true; + }} + > + {label} + + ); +} + +/** Where you are — the breadcrumb the removed top bar used to carry. */ +function RailLocation({ + inner, + label, +}: { + inner: number; + label: string; +}): ReactElement { + return ( + + {label} + + ); +} + +function shortenId(value: string): string { + if (value.length <= 8) return value; + return `${value.slice(0, 8)}…`; +} + interface SectionHeaderProps { title: string; active: boolean; + inner: number; } -function SectionHeader({ title, active }: SectionHeaderProps): ReactElement { +function SectionHeader({ title, active, inner }: SectionHeaderProps): ReactElement { return ( - - {title} - + + {title.toUpperCase()} + ); } @@ -153,6 +299,7 @@ interface SessionsListProps { currentSessionId: string | null; maxRows: number; previewWidth: number; + inner: number; } function SessionsList({ @@ -162,12 +309,13 @@ function SessionsList({ currentSessionId, maxRows, previewWidth, + inner, }: SessionsListProps): ReactElement { if (sessions.length === 0) { return ( - - (no sessions yet) - + + {"(no sessions yet)"} + ); } const window = computeRowWindow(sessions.length, cursor, maxRows); @@ -191,10 +339,11 @@ function SessionsList({ selected={focused && idx === visibleCursor} current={entry.sessionId === currentSessionId} previewWidth={previewWidth} + inner={inner} /> ))} - ); } @@ -204,6 +353,7 @@ interface SessionRowProps { selected: boolean; current: boolean; previewWidth: number; + inner: number; } function SessionRow({ @@ -211,18 +361,19 @@ function SessionRow({ selected, current, previewWidth, + inner, }: SessionRowProps): ReactElement { const preview = truncate(entry.preview, previewWidth); const marker = current ? theme.glyphs.assistantMarker : " "; const chevron = selected ? theme.glyphs.chevronRight : " "; return ( - - {chevron} {marker} {preview} - + {`${chevron} ${marker} ${preview}`} + ); } @@ -232,6 +383,7 @@ interface TasksListProps { focused: boolean; maxRows: number; previewWidth: number; + inner: number; } function TasksList({ @@ -240,12 +392,13 @@ function TasksList({ focused, maxRows, previewWidth, + inner, }: TasksListProps): ReactElement { if (tasks.length === 0) { return ( - - (no active tasks) - + + {"(no active tasks)"} + ); } const window = computeRowWindow(tasks.length, cursor, maxRows); @@ -268,10 +421,11 @@ function TasksList({ row={row} selected={focused && idx === visibleCursor} previewWidth={previewWidth} + inner={inner} /> ))} - ); } @@ -280,30 +434,42 @@ interface TaskRowProps { row: TaskSummaryRow; selected: boolean; previewWidth: number; + inner: number; } -function TaskRow({ row, selected, previewWidth }: TaskRowProps): ReactElement { +function TaskRow({ + row, + selected, + previewWidth, + inner, +}: TaskRowProps): ReactElement { const preview = truncate(row.userMessage, previewWidth); const chevron = selected ? theme.glyphs.chevronRight : " "; const badge = statusBadge(row); return ( - - {chevron} {badge} {preview} - + {`${chevron} ${badge} ${preview}`} + ); } /** "↓ N more" footer, or nothing at all when the tail is visible. */ -function MoreRow({ hidden }: { hidden: number }): ReactElement | null { +function MoreRow({ + hidden, + inner, +}: { + hidden: number; + inner: number; +}): ReactElement | null { if (hidden <= 0) return null; return ( - - ↓ {hidden} more - + + {`↓ ${hidden} more`} + ); } diff --git a/src/tui/escape-chat-editor.test.tsx b/src/tui/escape-chat-editor.test.tsx index 40bf6759..0d6a2101 100644 --- a/src/tui/escape-chat-editor.test.tsx +++ b/src/tui/escape-chat-editor.test.tsx @@ -95,10 +95,9 @@ describe("Esc in the chat editor", () => { stdin.write(ESC); await settle(); - // #172 replaced the section pill row with a breadcrumb: the status - // bar names where you are (`Manage ▸ Tasks`) rather than listing - // where you could go. "on the Run screen" reads as a bare `Run`. - expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); + // The breadcrumb lives on the left rail now — the top bar is gone — + // so "on the Run screen" is a rail line reading exactly `Run`. + expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); expect(counts.quit).toBe(0); stdin.write(ESC); diff --git a/src/tui/escape-import-tab.test.tsx b/src/tui/escape-import-tab.test.tsx index d9cf2f98..5ee0c626 100644 --- a/src/tui/escape-import-tab.test.tsx +++ b/src/tui/escape-import-tab.test.tsx @@ -57,10 +57,9 @@ describe("Esc on the Import tab", () => { // The configure-mode handler ends in a catch-all `return true` that // swallows stray letters; before the fix it swallowed Esc too, so the // operator was stuck on the tab with no "back" gesture at all. - // #172 replaced the section pill row with a breadcrumb: the status - // bar names where you are (`Manage ▸ Tasks`) rather than listing - // where you could go. "on the Run screen" reads as a bare `Run`. - expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); + // The breadcrumb lives on the left rail now — the top bar is gone — + // so "on the Run screen" is a rail line reading exactly `Run`. + expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); expect(quit).toBe(0); unmount(); }); diff --git a/src/tui/escape-observe-tabs.test.tsx b/src/tui/escape-observe-tabs.test.tsx index fef0cddc..71b35ff6 100644 --- a/src/tui/escape-observe-tabs.test.tsx +++ b/src/tui/escape-observe-tabs.test.tsx @@ -67,7 +67,7 @@ describe("Esc on the Observe tabs", () => { // reached the still-focused chat editor and quit the process. expect(counts.quit).toBe(0); expect(counts.abort).toBe(0); - expect(strip(lastFrame() ?? "")).toMatch(/\|\s+Run(\s|$)/m); + expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); unmount(); }); } diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx index fe84e4fd..2afae925 100644 --- a/src/tui/mouse/mouse-app.test.tsx +++ b/src/tui/mouse/mouse-app.test.tsx @@ -4,6 +4,7 @@ import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; import type { TuiSessionInfo } from "../tui-state.js"; import { makeMouseSource, type MouseSourceEmitter } from "./mouse-source.js"; import type { TuiMouseEvent } from "./mouse-event.js"; +import { computeSidebarWidth } from "../layout.js"; const SESSION: TuiSessionInfo = { sessionId: null, @@ -162,18 +163,17 @@ function mountApp(): { } describe("TuiApp mouse", () => { - it("opens the menu when the breadcrumb is clicked", async () => { - // #172 retired the Run / Observe / Manage pills for a breadcrumb, so - // the mouse route into navigation is the breadcrumb itself: a click - // opens the same menu ctrl+p does. Without this the mouse would have - // no way to change section at all. + it("opens the menu when the rail's Menu button is clicked", async () => { + // The pills are gone and the top bar with them; the rail's Menu + // button is the mouse route into navigation. Without it the mouse + // would have no way to change section at all. const app = mountApp(); await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); await clickUntil( app.mouse, - () => locate(app.frame(), "Run"), - () => app.frame().includes("Menu"), - "click on the breadcrumb", + () => locate(app.frame(), "Menu"), + () => app.frame().includes("GO"), + "click on the rail's Menu button", ); expect(app.frame()).toContain("Menu"); app.unmount(); @@ -184,9 +184,9 @@ describe("TuiApp mouse", () => { await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); await clickUntil( app.mouse, - () => locate(app.frame(), "Run"), - () => app.frame().includes("Menu"), - "click on the breadcrumb", + () => locate(app.frame(), "Menu"), + () => app.frame().includes("GO"), + "click on the rail's Menu button", ); // One click acts on a menu row — the menu is the one surface where // the two-step select-then-activate rule would be the surprise. @@ -205,9 +205,9 @@ describe("TuiApp mouse", () => { await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); await clickUntil( app.mouse, - () => locate(app.frame(), "Run"), - () => app.frame().includes("Menu"), - "click on the breadcrumb", + () => locate(app.frame(), "Menu"), + () => app.frame().includes("GO"), + "click on the rail's Menu button", ); await clickUntil( app.mouse, @@ -287,16 +287,23 @@ describe("TuiApp mouse", () => { it("moves a panel cursor with the wheel", async () => { const app = mountApp(); app.openSkillsPanel(); + // The rail shares every terminal row with the panel, so the first + // glyph on a line belongs to the rail, not to the row. Slice the + // rail off first — ink-testing-library pins stdout at 100 columns, + // which is the width the rail sizes itself against. + const railColumns = computeSidebarWidth(100); const marker = (name: string): string => { const line = app .frame() .split("\n") .find((candidate) => candidate.includes(name)); - return line?.trimStart().slice(0, 1) ?? ""; + if (!line) return ""; + return line.slice(railColumns).trimStart().slice(0, 1); }; await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows"); for (let attempt = 0; attempt < 40; attempt += 1) { - app.mouse.emit(wheel("down", 10, 6)); + // Aim at the panel column: x=10 is inside the rail now. + app.mouse.emit(wheel("down", 60, 6)); await delay(50); if (marker("beta-skill") === "▸") break; } @@ -307,12 +314,18 @@ describe("TuiApp mouse", () => { it("routes a click to a list row and moves the cursor there", async () => { const app = mountApp(); app.openSkillsPanel(); + // The rail shares every terminal row with the panel, so the first + // glyph on a line belongs to the rail, not to the row. Slice the + // rail off first — ink-testing-library pins stdout at 100 columns, + // which is the width the rail sizes itself against. + const railColumns = computeSidebarWidth(100); const marker = (name: string): string => { const line = app .frame() .split("\n") .find((candidate) => candidate.includes(name)); - return line?.trimStart().slice(0, 1) ?? ""; + if (!line) return ""; + return line.slice(railColumns).trimStart().slice(0, 1); }; await waitUntil(() => marker("alpha-skill") === "▸", "the seeded skill rows"); await clickUntil( diff --git a/src/tui/mouse/rail-click.test.tsx b/src/tui/mouse/rail-click.test.tsx new file mode 100644 index 00000000..6c7efa73 --- /dev/null +++ b/src/tui/mouse/rail-click.test.tsx @@ -0,0 +1,133 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import type { TuiSessionInfo } from "../tui-state.js"; +import { makeMouseSource, type MouseSourceEmitter } from "./mouse-source.js"; +import type { TuiMouseEvent } from "./mouse-event.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "sess-current", + workingDir: "/tmp/rail", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of strip(frame).split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function clickUntil( + mouse: MouseSourceEmitter, + point: () => { x: number; y: number }, + settled: () => boolean, + what: string, +): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + const { x, y } = point(); + mouse.emit(click(x, y)); + await delay(50); + if (settled()) return; + } + throw new Error(`click never took effect: ${what}`); +} + +function mount(callbacks: Partial = {}): { + frame: () => string; + mouse: MouseSourceEmitter; + seedSessions: () => void; + unmount: () => void; +} { + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const { lastFrame, unmount } = render( + {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + ...callbacks, + }} + mouse={mouse} + />, + ); + return { + frame: () => strip(lastFrame() ?? ""), + mouse, + seedSessions: () => + bus.emit({ + type: "recent_sessions_updated", + sessions: [ + { sessionId: "sess-alpha", preview: "alpha thread", updatedAt: 2 }, + { sessionId: "sess-beta", preview: "beta thread", updatedAt: 1 }, + ], + }), + unmount, + }; +} + +/** + * The rail is the app's chrome now — brand, menu, location, sessions, + * tasks — so "can you click it" is the whole question for it. + */ +describe("left rail mouse", () => { + it("opens the menu when the Menu button is clicked", async () => { + const app = mount(); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Menu"), + () => app.frame().includes("SESSION"), + "click on the rail's Menu button", + ); + expect(app.frame()).toContain("Menu"); + app.unmount(); + }); + + it("selects a session row on the first click and opens it on the second", async () => { + const opened: string[] = []; + const app = mount({ onSessionSwitchRequested: (id) => opened.push(id) }); + // The bus subscription is installed by an effect; emitting before it + // runs drops the event on the floor. + await delay(50); + app.seedSessions(); + await delay(100); + await clickUntil( + app.mouse, + () => locate(app.frame(), "beta thread"), + () => opened.includes("sess-beta"), + "two clicks on a session row", + ); + expect(opened).toContain("sess-beta"); + app.unmount(); + }); +}); diff --git a/src/tui/run-mode/run-mode-key-bindings.ts b/src/tui/run-mode/run-mode-key-bindings.ts index def1435d..3ae4a9a4 100644 --- a/src/tui/run-mode/run-mode-key-bindings.ts +++ b/src/tui/run-mode/run-mode-key-bindings.ts @@ -1,6 +1,7 @@ import type { Key } from "ink"; import type { TuiAction } from "../tui-action.js"; import type { TuiState } from "../tui-state.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import { clampCloudShare, CLOUD_SHARE_COARSE_STEP, @@ -30,7 +31,13 @@ export interface RunModePickerKeyContext { * - `←`/`→` — dial ±5 (shift: ±25). * - digits — type a dial value directly. * - `Enter` — apply the highlighted mode + dial. + * - `n` — set up the provider this mode needs. * - `Esc` — close, reverting to what was in force. + * + * `n` exists because the overlay was a dead end on a fresh install: + * Cloud and Fusion both refuse without a cloud provider, and the only + * cure was to leave, find Manage -> LLM, and add one. The screen that + * tells you what is missing is the screen that should be able to fix it. */ export function handleRunModePickerKey( input: string, @@ -45,6 +52,10 @@ export function handleRunModePickerKey( dispatch({ type: "run_mode_picker_closed" }); return true; } + if (input === "n" || input === "N") { + openProviderSetupFromRunMode(dispatch); + return true; + } if (key.return) { dispatch({ type: "run_mode_change_requested", @@ -88,3 +99,22 @@ export function handleRunModePickerKey( // Anything else is swallowed while the picker owns the keyboard. return true; } + + +/** + * Leave the overlay and land on the provider wizard with the Cloud pane + * selected — the one place a cloud provider can be added. Exported so + * the mouse layer runs exactly this, not a second copy of it. + */ +export function openProviderSetupFromRunMode( + dispatch: (action: TuiAction) => void, +): void { + dispatch({ type: "run_mode_picker_closed" }); + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: "llm" }); + dispatch({ type: "llm_mode_set", mode: "cloud" }); + dispatch({ + type: "providers_wizard_opened", + wizard: createProvidersWizardState("add"), + }); +} diff --git a/src/tui/theme/theme-palettes.ts b/src/tui/theme/theme-palettes.ts index 0a5e2ad3..d1e6455e 100644 --- a/src/tui/theme/theme-palettes.ts +++ b/src/tui/theme/theme-palettes.ts @@ -42,6 +42,9 @@ export const GITHUB_DARK_COLORS: TuiColors = { success: "#3fb950", info: "#4493f8", brandMark: "#a5c9ff", + railBackground: "#f0f6fc", + railForeground: "#0d1117", + railMuted: "#57606a", }; // GitHub Primer "light (default)" — @primer/primitives functional tokens @@ -64,6 +67,9 @@ export const GITHUB_LIGHT_COLORS: TuiColors = { success: "#1a7f37", info: "#0969da", brandMark: "#54a3ff", + railBackground: "#1f2328", + railForeground: "#f6f8fa", + railMuted: "#8c959f", }; // Catppuccin Mocha — official palette (catppuccin/palette palette.json). @@ -87,6 +93,9 @@ export const CATPPUCCIN_MOCHA_COLORS: TuiColors = { success: "#a6e3a1", info: "#89b4fa", brandMark: "#b4cffa", + railBackground: "#eff1f5", + railForeground: "#1e1e2e", + railMuted: "#6c6f85", }; // Catppuccin Latte — official palette (light flavour). @@ -108,6 +117,9 @@ export const CATPPUCCIN_LATTE_COLORS: TuiColors = { success: "#40a02b", info: "#1e66f5", brandMark: "#5f9bf5", + railBackground: "#1e1e2e", + railForeground: "#eff1f5", + railMuted: "#9ca0b0", }; // Dracula — official spec (draculatheme.com). accent=purple, green, @@ -131,6 +143,9 @@ export const DRACULA_COLORS: TuiColors = { success: "#50fa7b", info: "#bd93f9", brandMark: "#b9c9ff", + railBackground: "#f8f8f2", + railForeground: "#282a36", + railMuted: "#6272a4", }; // Nord — official spec (nordtheme.com). accent=nord8 frost, nord14 green, @@ -154,6 +169,9 @@ export const NORD_COLORS: TuiColors = { success: "#a3be8c", info: "#88c0d0", brandMark: "#b3ccec", + railBackground: "#eceff4", + railForeground: "#2e3440", + railMuted: "#6b7a90", }; // Tokyo Night ("Night" variant) — official spec @@ -177,6 +195,9 @@ export const TOKYO_NIGHT_COLORS: TuiColors = { success: "#9ece6a", info: "#7aa2f7", brandMark: "#a9c7ff", + railBackground: "#c0caf5", + railForeground: "#1a1b26", + railMuted: "#565f89", }; // Gruvbox Dark — official "bright" palette (morhetz/gruvbox). accent=blue, @@ -200,6 +221,9 @@ export const GRUVBOX_DARK_COLORS: TuiColors = { success: "#b8bb26", info: "#83a598", brandMark: "#b3d1e6", + railBackground: "#fbf1c7", + railForeground: "#282828", + railMuted: "#7c6f64", }; // Gruvbox Light — official "faded" palette on light bg (morhetz/gruvbox). @@ -221,6 +245,9 @@ export const GRUVBOX_LIGHT_COLORS: TuiColors = { success: "#79740e", info: "#076678", brandMark: "#5a9ec9", + railBackground: "#282828", + railForeground: "#fbf1c7", + railMuted: "#928374", }; // Solarized Dark — official spec (Ethan Schoonover). Accents are shared @@ -244,6 +271,9 @@ export const SOLARIZED_DARK_COLORS: TuiColors = { success: "#859900", info: "#268bd2", brandMark: "#9fc7e8", + railBackground: "#fdf6e3", + railForeground: "#002b36", + railMuted: "#657b83", }; // Solarized Light — same accent set, light base. base1(muted), base2(border). @@ -265,4 +295,7 @@ export const SOLARIZED_LIGHT_COLORS: TuiColors = { success: "#859900", info: "#268bd2", brandMark: "#4a95c9", + railBackground: "#002b36", + railForeground: "#fdf6e3", + railMuted: "#93a1a1", }; diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 7f3d3390..7515b8a9 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -49,6 +49,21 @@ export interface TuiColors { * highlighted widget. */ readonly brandMark: string; + /** + * The left rail is drawn inverted — a light ground under dark text on + * a dark theme, and the reverse on a light one. It is the app's one + * piece of chrome that is always on screen, and giving it its own + * ground is what makes the layout read as a sidebar next to a document + * rather than two columns of the same text. + * + * Per-palette rather than a literal white: `#fff` would disappear on + * the four light palettes, and "inverted" is the property that has to + * hold, not the exact colour. + */ + readonly railBackground: string; + readonly railForeground: string; + /** Secondary text on the rail — same role as `muted`, on the rail ground. */ + readonly railMuted: string; readonly border: string; readonly muted: string; readonly error: string; @@ -80,6 +95,8 @@ export interface TuiGlyphs { readonly ellipsis: string; readonly promptCaret: string; readonly chevronRight: string; + /** Hamburger, for the rail's menu button. */ + readonly menuGlyph: string; readonly dotSeparator: string; readonly pipeSeparator: string; } @@ -127,6 +144,7 @@ const GLYPHS: TuiGlyphs = { ellipsis: "…", promptCaret: "❯", chevronRight: "▸", + menuGlyph: "☰", dotSeparator: "·", pipeSeparator: "|", }; diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 2f84781d..92457b4d 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -157,7 +157,8 @@ describe("TuiApp (smoke)", () => { stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); - if (before.includes("Sessions")) { + // The rail headers are upper-case since it became the app frame. + if (before.includes("SESSIONS")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. expect(after).toContain("Run"); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index f447f234..b0766048 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -39,8 +39,9 @@ import { 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 { menuPlaceByTab } from "./menu/menu-registry.js"; +import { getCurrentSection } from "./section.js"; import { RunModePicker } from "./components/run-mode-picker.js"; import type { RunModeName } from "../config/index.js"; import { TasksCancelModal } from "./components/tasks-cancel-modal.js"; @@ -426,6 +427,24 @@ const PROMPT_PLACEHOLDERS: readonly string[] = [ "Inspect a file, run a search, draft a fix…", ]; +const SECTION_LABELS: Record = { + run: "Run", + observe: "Observe", + manage: "Manage", +}; + +/** + * `Manage › Tasks` — the one thing the ctrl+p menu cannot tell you, + * because you have to open it to read it. Lived in the top bar until the + * rail took over the app chrome. + */ +function describeLocation(state: TuiState): string { + const section = SECTION_LABELS[getCurrentSection(state)] ?? "Run"; + if (state.uiMode !== "debug") return section; + const tab = menuPlaceByTab(state.activeTab)?.label; + return tab ? `${section} ${theme.glyphs.chevronRight} ${tab}` : section; +} + export function TuiApp({ session, bus, @@ -556,12 +575,11 @@ export function TuiApp({ const privacyTabActive = state.uiMode === "debug" && state.activeTab === "privacy"; const terminalSize = useTerminalSize(); - const sidebarVisible = - state.uiMode === "chat" && isSidebarVisible(terminalSize.columns); - // The rail takes a share of the terminal rather than a flat 30 - // columns, and its two panes get a row budget cut from the terminal - // height — Ink 7 overlaps rather than clips an over-tall frame, so - // an unbudgeted rail garbles short windows. + // The rail is the app frame now — brand, version, menu, breadcrumb — + // so it stays on screen in every mode. It used to be chat-only, which + // was fine when it was just a list of sessions; hiding all the chrome + // the moment you open a panel is not. + const sidebarVisible = isSidebarVisible(terminalSize.columns); const sidebarWidth = computeSidebarWidth(terminalSize.columns); const sidebarRows = computeSidebarRowBudget(terminalSize.rows); const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; @@ -569,6 +587,12 @@ export function TuiApp({ !state.menuOpen && !menuLeaderArmed && !state.pendingApproval && + // The run-mode overlay claims every key — digits for the dial, `n` + // for provider setup. Returning `true` from its handler does not + // stop the editor: Ink delivers each keypress to every live + // `useInput`, child first, so the only way to keep a letter out of + // the prompt is to take focus off the editor while it is open. + state.runModePanel.picker === null && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update // "press any key to restart" prompt claims every key for the same reason. @@ -916,20 +940,37 @@ export function TuiApp({ > - - - - {state.uiMode === "chat" ? ( - - - - ) : null} - + {sidebarVisible ? ( + + ) : null} + + {state.uiMode === "chat" ? ( + + + + ) : null} - {sidebarVisible ? ( - - ) : null} From bff27861f39ad934f89b414d54259c987726b8da Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:02:12 +0300 Subject: [PATCH 40/57] fix(tui): a narrow terminal keeps its chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the top bar in favour of the rail left terminals below the rail's width threshold with no brand, no version, no breadcrumb and no visible way to reach the menu — the rail hides at that width and there was nothing to take its place. The one-row bar comes back when, and only when, there is no rail. It carries the same four things in a row instead of a column, and its breadcrumb is clickable to open the menu. The hint strip also stopped promising a 'tab sidebar' that is not on screen at that width. --- src/tui/components/hotkey-hint.tsx | 38 +++++++++++++++++++++++------- src/tui/tui-app.tsx | 19 ++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 5add36c1..4e0d1aac 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -15,6 +15,12 @@ interface HotkeyHintProps { state: TuiState; /** Whether a Ctrl+C was recently pressed and is armed for exit. */ ctrlCArmed?: boolean; + /** + * Whether the left rail is on screen. Below its width threshold there + * is no rail, and a `tab sidebar` chip would name a surface the + * operator cannot see. + */ + sidebarVisible?: boolean; } interface HotkeyChip { @@ -42,8 +48,12 @@ const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgd * to fit one terminal row and let slash commands take care of the long * tail. */ -export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement { - const chips = resolveChips(state, ctrlCArmed ?? false); +export function HotkeyHint({ + state, + ctrlCArmed, + sidebarVisible = true, +}: HotkeyHintProps): ReactElement { + const chips = resolveChips(state, ctrlCArmed ?? false, sidebarVisible); return ( {chips.map((chip, idx) => ( @@ -93,7 +103,11 @@ function openOperatorMenu(mouse: MouseContextValue): void { mouse.dispatch({ type: "menu_opened" }); } -function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { +function resolveChips( + state: TuiState, + ctrlCArmed: boolean, + sidebarVisible: boolean, +): HotkeyChip[] { if (state.pendingApproval) { const approval = state.pendingApproval; return [ @@ -206,12 +220,18 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { return [ { key: "enter", label: "send" }, { key: "shift+enter", label: "newline" }, - { - key: "tab", - label: "sidebar", - onClick: (mouse) => - mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }), - }, + // Tab only reaches the rail when there is one; below its width + // threshold the chip would name a surface that is not on screen. + ...(sidebarVisible + ? [ + { + key: "tab", + label: "sidebar", + onClick: (mouse: MouseContextValue) => + mouse.dispatch({ type: "chat_focus_set", focus: "sidebar" }), + }, + ] + : []), { key: SCROLL_KEY, label: "scroll" }, { key: "ctrl+p", label: "menu", onClick: openOperatorMenu }, // Esc means something here — it clears the draft — and nothing said diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index b0766048..02129213 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -40,6 +40,7 @@ import { Sidebar } from "./components/sidebar.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { SlashPalette } from "./components/slash-palette.js"; import { RunModeBar } from "./components/run-mode-bar.js"; +import { StatusBar } from "./components/status-bar.js"; import { menuPlaceByTab } from "./menu/menu-registry.js"; import { getCurrentSection } from "./section.js"; import { RunModePicker } from "./components/run-mode-picker.js"; @@ -943,6 +944,18 @@ export function TuiApp({ ref={contentMouseRef} {...(rootHeight ? { height: rootHeight } : {})} > + {/* + Below the rail's width threshold there is no rail, and the top + bar it replaced is gone — which left a narrow terminal with no + brand, no version, no breadcrumb and no visible way to reach the + menu at all. The one-row bar carries the same four things when + there is no column to put them in. + */} + {sidebarVisible ? null : ( + + + + )} {sidebarVisible ? ( - + From 4ee7921970688cb61601133155eb1ae23e8ab8ac Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:03:12 +0300 Subject: [PATCH 41/57] fix(config): resolve asset dirs relative to the module, not the cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+N opens a new terminal that starts the agent by absolute path from whatever directory the operator happens to be in. resolveAssetDir's last resort was `/grammars`, so the child died before the first token: Error: ENOENT: no such file or directory, open '/Users/valerii/grammars/tool-call.gbnf' at async buildGrammar (dist/llm/grammar/build-grammar.js:16:25) at async createAgentRuntime (dist/runtime/bootstrap.js:559:19) buildGrammar has its own module-relative fallback, but bootstrap passes config.paths.grammarsDir explicitly, so it never got a chance to run. Precedence is now: explicit env var, then binary-adjacent (the SEA layout), then two levels up from this module — `grammars/` sits beside `dist/` in the published package and at the repo root under tsx — then cwd. cwd is kept last rather than dropped so any layout that only ever worked by being run from the project root keeps working. Ctrl+N also has to hand the child every env var that steers this resolution: a spawned login shell inherits nothing, so a parent pointed at a non-default grammars/ or starter-skills/ would otherwise be silently disagreed with by its own new window. They now travel inline next to ATOMIC_AGENT_STATE_DIR on both the POSIX and cmd.exe paths. Co-Authored-By: Claude Opus 5 --- src/config/load-config.test.ts | 28 +++++++++++++++++ src/config/load-config.ts | 25 +++++++++++++-- src/tui/build-terminal-launch.test.ts | 44 +++++++++++++++++++++++++++ src/tui/build-terminal-launch.ts | 43 ++++++++++++++++++++------ 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/config/load-config.test.ts b/src/config/load-config.test.ts index 96cf2e85..25f0f996 100644 --- a/src/config/load-config.test.ts +++ b/src/config/load-config.test.ts @@ -31,6 +31,7 @@ describe("loadConfig", () => { delete process.env.ATOMIC_AGENT_LLAMA_API_KEY; delete process.env.ATOMIC_AGENT_LLAMA_MAX_TOKENS; delete process.env.ATOMIC_AGENT_BROWSER_CHANNEL; + delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; delete process.env.ATOMIC_LOADCONFIG_TEST_KEY; resetConfigCache(); vi.restoreAllMocks(); @@ -212,4 +213,31 @@ describe("loadConfig", () => { resetConfigCache(); expect(loadConfig().paths.localModelsDataDir).toBe(override); }); + + it("resolves grammarsDir without consulting the working directory", () => { + // The Ctrl+N "new terminal window" spawn starts the agent by absolute + // path from the operator's home, so cwd holds no `grammars/` and the + // old cwd-relative default died on ENOENT tool-call.gbnf. Standing in + // an empty temp dir reproduces exactly that shape. + const elsewhere = mkdtempSync(join(tmpdir(), "atomic-cwd-")); + const originalCwd = process.cwd(); + try { + process.chdir(elsewhere); + resetConfigCache(); + const grammarsDir = loadConfig().paths.grammarsDir; + expect(grammarsDir.startsWith(elsewhere)).toBe(false); + expect(existsSync(join(grammarsDir, "tool-call.gbnf"))).toBe(true); + } finally { + process.chdir(originalCwd); + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it("still lets ATOMIC_AGENT_GRAMMARS_DIR win over the packaged copy", () => { + const override = join(stateDir, "custom-grammars"); + mkdirSync(override); + process.env.ATOMIC_AGENT_GRAMMARS_DIR = override; + resetConfigCache(); + expect(loadConfig().paths.grammarsDir).toBe(override); + }); }); diff --git a/src/config/load-config.ts b/src/config/load-config.ts index e6952240..71757c79 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ENV_DEFAULTS, @@ -64,8 +65,19 @@ function resolvePath(raw: string | undefined, fallback: string): string { // Asset directories (e.g. `grammars/`) ship next to the Node SEA binary in // installed layouts but live under the project root during dev. Env overrides -// win first; otherwise prefer the binary-adjacent copy and fall back to -// `/` so `npm run`-style dev invocations still work. +// win first; otherwise prefer the binary-adjacent copy, then the copy that +// ships alongside this module, and only then `/`. +// +// The module-relative step is what makes `node /abs/path/dist/cli/index.js` +// work from an unrelated directory — exactly what the Ctrl+N "new terminal +// window" spawn does, which used to die on `ENOENT .../grammars/tool-call.gbnf` +// because cwd was the operator's home rather than the install root. Two levels +// up from this file is the tree root in both layouts: `dist/config/` under a +// build, `src/config/` under tsx. +// +// cwd stays last rather than being dropped: a checkout whose `dist/` was +// copied elsewhere, or any layout we have not thought of, still resolves as it +// always did when run from the project root. function resolveAssetDir(envKey: string, relativeDefault: string): string { const raw = readEnv(envKey); if (raw) { @@ -75,6 +87,15 @@ function resolveAssetDir(envKey: string, relativeDefault: string): string { if (existsSync(nextToBinary)) { return nextToBinary; } + const nextToModule = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + relativeDefault, + ); + if (existsSync(nextToModule)) { + return nextToModule; + } return resolve(process.cwd(), relativeDefault); } diff --git a/src/tui/build-terminal-launch.test.ts b/src/tui/build-terminal-launch.test.ts index 4aca94fa..b30032bf 100644 --- a/src/tui/build-terminal-launch.test.ts +++ b/src/tui/build-terminal-launch.test.ts @@ -83,6 +83,32 @@ describe("buildTerminalLaunch — macOS", () => { ); }); + it("carries the asset dir overrides alongside the state dir", () => { + // Same login-shell problem: if the parent was pointed at a + // non-default `grammars/` or `starter-skills/` and the child is not + // told, the new window quietly resolves different assets. + const launch = buildTerminalLaunch( + input({ + env: { + ATOMIC_AGENT_GRAMMARS_DIR: "/opt/atomic/grammars", + ATOMIC_AGENT_STARTER_SKILLS_DIR: "/opt/atomic/starter skills", + }, + }), + ); + expect(launch?.args[1]).toContain( + "ATOMIC_AGENT_GRAMMARS_DIR='/opt/atomic/grammars'", + ); + expect(launch?.args[1]).toContain( + "ATOMIC_AGENT_STARTER_SKILLS_DIR='/opt/atomic/starter skills'", + ); + }); + + it("emits no assignments when nothing is overridden", () => { + // The common case must stay a bare `cd … && node …` line. + const launch = buildTerminalLaunch(input()); + expect(launch?.args[1]).not.toContain("ATOMIC_AGENT_"); + }); + it("escapes quotes in paths for both the shell and AppleScript layers", () => { const launch = buildTerminalLaunch(input({ cwd: `/home/o'brien/work` })); const script = launch?.args[1] ?? ""; @@ -167,4 +193,22 @@ describe("buildTerminalLaunch — Windows", () => { "/k", ]); }); + + it("forwards the state and asset dirs through `set` on the cmd.exe path", () => { + const launch = buildTerminalLaunch( + input({ + platform: "win32", + cwd: "C:\\work", + env: { + ATOMIC_AGENT_STATE_DIR: "C:\\state", + ATOMIC_AGENT_GRAMMARS_DIR: "C:\\opt\\grammars", + }, + }), + ); + const command = String(launch?.args.at(-1)); + expect(command).toContain(`set "ATOMIC_AGENT_STATE_DIR=C:\\state" && `); + expect(command).toContain( + `set "ATOMIC_AGENT_GRAMMARS_DIR=C:\\opt\\grammars" && `, + ); + }); }); diff --git a/src/tui/build-terminal-launch.ts b/src/tui/build-terminal-launch.ts index 3c5f126d..a4673774 100644 --- a/src/tui/build-terminal-launch.ts +++ b/src/tui/build-terminal-launch.ts @@ -106,16 +106,37 @@ export function agentArgv(input: TerminalLaunchInput): readonly string[] { } /** - * A freshly spawned terminal starts a login shell and does **not** - * inherit our environment, so a non-default state dir has to travel - * inside the command line — otherwise the second window silently talks - * to a different `~/.atomic-agent`. + * Env vars the child must be told about explicitly, in the order they + * are emitted. A freshly spawned terminal starts a login shell and does + * **not** inherit our environment, so anything that steers where the + * agent reads its state or its packaged assets has to travel inside the + * command line — otherwise the second window silently talks to a + * different `~/.atomic-agent`, or resolves `grammars/` and + * `starter-skills/` from its own cwd and disagrees with the parent about + * which copy is authoritative. */ +const FORWARDED_ENV: readonly string[] = [ + "ATOMIC_AGENT_STATE_DIR", + "ATOMIC_AGENT_GRAMMARS_DIR", + "ATOMIC_AGENT_STARTER_SKILLS_DIR", +]; + +/** `NAME='value' ` for every forwarded var that is actually set. */ +function forwardedEnv( + input: TerminalLaunchInput, + format: (name: string, value: string) => string, +): string { + return FORWARDED_ENV.map((name) => { + const value = input.env[name]; + return value ? format(name, value) : ""; + }).join(""); +} + function posixCommandLine(input: TerminalLaunchInput): string { - const stateDir = input.env.ATOMIC_AGENT_STATE_DIR; - const prefix = stateDir - ? `ATOMIC_AGENT_STATE_DIR=${shellQuote(stateDir)} ` - : ""; + const prefix = forwardedEnv( + input, + (name, value) => `${name}=${shellQuote(value)} `, + ); const agent = agentArgv(input).map(shellQuote).join(" "); return `cd ${shellQuote(input.cwd)} && ${prefix}${agent}`; } @@ -168,8 +189,10 @@ function win32Launch(input: TerminalLaunchInput): TerminalLaunch { label: "Windows Terminal", }; } - const stateDir = input.env.ATOMIC_AGENT_STATE_DIR; - const prefix = stateDir ? `set "ATOMIC_AGENT_STATE_DIR=${stateDir}" && ` : ""; + const prefix = forwardedEnv( + input, + (name, value) => `set "${name}=${value}" && `, + ); const command = `${prefix}${agent.map(cmdQuote).join(" ")}`; return { cmd: "cmd.exe", From 3336ba4379ed3a55e0d015a76dd781ad12382102 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:11:17 +0300 Subject: [PATCH 42/57] fix(tui): lay the task table out for the width the panel actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The columns were sized from constants adding up to ~123 characters no matter how wide the panel was. That was survivable while the debug pane owned the whole terminal; under the permanent left rail the panel gets 88 columns at 120x40 and 73 at 100x30, so every row wrapped onto a second line — the columns collided into each other ("pendincron: 0 1 * * *"), the status column was clipped mid-word, and the session id fell onto the wrap line under the schedule. Column widths, the header labels and the footer hint strip now all come from one width-aware fit module, so a row is always exactly one line and the header always sits over its own data. Narrow panels shed the session id first (the detail view prints it in full) and defend the message column, which is what identifies a task to a human. Co-Authored-By: Claude Opus 5 --- src/tui/components/tasks-list.tsx | 61 ++--- src/tui/components/tasks-panel-fit.test.tsx | 110 +++++++++ src/tui/components/tasks-panel.tsx | 18 +- src/tui/tasks/tasks-list-fit.test.ts | 141 +++++++++++ src/tui/tasks/tasks-list-fit.ts | 248 ++++++++++++++++++++ 5 files changed, 549 insertions(+), 29 deletions(-) create mode 100644 src/tui/components/tasks-panel-fit.test.tsx create mode 100644 src/tui/tasks/tasks-list-fit.test.ts create mode 100644 src/tui/tasks/tasks-list-fit.ts diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx index ec1140b4..9b9c57e3 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-list.tsx @@ -3,27 +3,41 @@ import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; +import { + computeTaskListLayout, + fitTaskListHints, + formatTaskListHeader, + formatTaskRowCells, + type TaskListLayout, +} from "../tasks/tasks-list-fit.js"; import type { TaskSummaryRow, TasksPanelState, } from "../tasks/tasks-panel-state.js"; import type { TaskStatus } from "../../tasks/task-types.js"; -import { formatRelativeMs } from "../tasks/tasks-summary.js"; export interface TasksListProps { panel: TasksPanelState; visibleRows: readonly TaskSummaryRow[]; maxRows: number; now: number; + /** Columns the panel owns — see `tasks-list-fit.ts` for why it matters. */ + width: number; } /** * Scrollable table view. `visibleRows` is already filtered + sorted by * the caller; this component owns only the cursor windowing and the * per-row rendering contract. + * + * Every column width — including the footer hints — comes from + * `tasks-list-fit.ts` for the panel's real width, because a row that + * wraps takes two terminal lines and collides its own columns into + * each other. */ export function TasksList(props: TasksListProps): ReactElement { - const { panel, visibleRows, maxRows, now } = props; + const { panel, visibleRows, maxRows, now, width } = props; + const layout = computeTaskListLayout(width); if (visibleRows.length === 0) { return ( @@ -41,7 +55,7 @@ export function TasksList(props: TasksListProps): ReactElement { const hiddenAfter = Math.max(0, visibleRows.length - windowStart - pageRows.length); return ( - + {hiddenBefore > 0 ? ( ↑ {hiddenBefore} above @@ -58,6 +72,7 @@ export function TasksList(props: TasksListProps): ReactElement { > @@ -68,54 +83,54 @@ export function TasksList(props: TasksListProps): ReactElement { ↓ {hiddenAfter} below ) : null} - + ); } -function HeaderRow(): ReactElement { +function HeaderRow({ layout }: { layout: TaskListLayout }): ReactElement { return ( - {" "}status schedule next-run session message + {formatTaskListHeader(layout)} ); } -function HintsRow(): ReactElement { +function HintsRow({ width }: { width: number }): ReactElement { return ( - - j/k move · Enter detail · n new · c cancel · R run-now · r refresh - · a auto · f filter · / search · Esc clear search - + {fitTaskListHints(width)} ); } function TaskRow({ row, + layout, selected, now, }: { row: TaskSummaryRow; + layout: TaskListLayout; selected: boolean; now: number; }): ReactElement { const chevron = selected ? theme.glyphs.chevronRight : " "; - const statusText = row.status.padEnd(9); - const scheduleText = truncate(row.scheduleLabel, 22).padEnd(22); - const nextRunText = formatRelativeMs(row.scheduledFor, now).padEnd(14); - const sessionText = (row.sessionId ? shortId(row.sessionId) : "—").padEnd(10); + const cells = formatTaskRowCells(row, layout, now); const color = selected ? theme.colors.accentSoft : undefined; + // The middle columns are one `` per cell rather than one joined + // string so a dropped column takes its separating space with it. return ( - {chevron} {statusText} + {chevron} {cells.status} - {scheduleText} {nextRunText} {sessionText} + {cells.schedule ? ` ${cells.schedule}` : ""} + {cells.nextRun ? ` ${cells.nextRun}` : ""} + {cells.session ? ` ${cells.session}` : ""} - {truncate(row.userMessage, 64)} + {cells.message ? ` ${cells.message}` : ""} ); } @@ -137,16 +152,6 @@ function statusColor(status: TaskStatus): string { } } -function shortId(id: string): string { - if (id.length <= 10) return id; - return `${id.slice(0, 10)}…`; -} - -function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return `${text.slice(0, max - 1)}…`; -} - function computeWindowStart(cursor: number, total: number, size: number): number { if (total <= size) return 0; if (cursor < size) return 0; diff --git a/src/tui/components/tasks-panel-fit.test.tsx b/src/tui/components/tasks-panel-fit.test.tsx new file mode 100644 index 00000000..7f711d0f --- /dev/null +++ b/src/tui/components/tasks-panel-fit.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { Box } from "ink"; +import React from "react"; +import { TasksPanel } from "./tasks-panel.js"; +import { + createInitialTasksPanelState, + type TaskSummaryRow, + type TasksPanelState, +} from "../tasks/tasks-panel-state.js"; + +/** + * Regression guard for the "Tasks screen is completely broken" report. + * + * The table laid its columns out to ~123 characters regardless of the + * panel's real width, so under the permanent left rail (88 columns at + * 120×40, 73 at 100×30) every row wrapped onto a second line. That + * collided the columns into each other and doubled the table's height — + * and Ink 7 does not clip an over-tall frame, it paints later lines + * over earlier ones, so the filter bar and the column header were + * overwritten by task text. + * + * The invariant is therefore about *lines*, not characters: one task + * must occupy exactly one row of the frame. + */ + +const NOW = Date.UTC(2026, 7, 19, 12, 0, 0); + +/** Panel widths the rail leaves at 120×40, 80×24 and 100×30. */ +const REAL_WIDTHS = [88, 78, 73]; + +function taskRow(index: number): TaskSummaryRow { + return { + id: `t-${index}`, + status: "pending", + origin: "cli", + triggerSource: null, + sessionId: `s-${index}0e7169e-7491-418b-9a1d-6b4a2f0d1c33`, + userMessage: `task number ${index} — do the thing that needs doing regularly`, + scheduleKind: "cron", + scheduleLabel: `cron: 0 ${index} * * * (Europe/Berlin)`, + recurring: true, + scheduledFor: NOW + index * 3_600_000, + createdAt: NOW, + updatedAt: NOW, + startedAt: null, + completedAt: null, + attempts: 0, + maxAttempts: 3, + lastError: null, + }; +} + +function panelWithRows(count: number): TasksPanelState { + return { + ...createInitialTasksPanelState(), + rows: Array.from({ length: count }, (_, i) => taskRow(i + 1)), + lastRefreshedAt: NOW, + }; +} + +function frameFor(width: number, rowCount: number, maxRows: number): string[] { + const { lastFrame } = render( + + + , + ); + return (lastFrame() ?? "").split("\n"); +} + +describe("Tasks list rows stay on one line", () => { + for (const width of REAL_WIDTHS) { + it(`draws one line per task at width ${width}`, () => { + const rowCount = 6; + const lines = frameFor(width, rowCount, rowCount); + // filter bar + header + rows + blank spacer + hints. + expect(lines.length).toBe(rowCount + 4); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(width); + } + }); + + it(`keeps status and message of a task on the same line at ${width}`, () => { + const lines = frameFor(width, 3, 3); + const messageLines = lines.filter((l) => l.includes("task number 2")); + expect(messageLines).toHaveLength(1); + expect(messageLines[0]).toContain("pending"); + }); + + it(`keeps the filter bar and the column header intact at ${width}`, () => { + const lines = frameFor(width, 6, 6); + expect(lines[0]).toContain("filter: all"); + expect(lines[1]).toContain("status"); + expect(lines[1]).toContain("schedule"); + }); + + it(`spells the action keys in the footer at ${width}`, () => { + const lines = frameFor(width, 3, 3); + const hints = lines[lines.length - 1] ?? ""; + expect(hints).toContain("Enter detail"); + expect(hints).toContain("n new"); + expect(hints).toContain("c cancel"); + }); + } +}); diff --git a/src/tui/components/tasks-panel.tsx b/src/tui/components/tasks-panel.tsx index 785d96b1..899932e0 100644 --- a/src/tui/components/tasks-panel.tsx +++ b/src/tui/components/tasks-panel.tsx @@ -1,6 +1,8 @@ import { Box } from "ink"; import { useMemo } from "react"; import type { ReactElement } from "react"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { computeChatWidth } from "../layout.js"; import type { TasksPanelState } from "../tasks/tasks-panel-state.js"; import { selectVisibleTaskRows } from "../tasks/tasks-filter.js"; import { TasksFilterBar } from "./tasks-filter-bar.js"; @@ -12,6 +14,11 @@ export interface TasksPanelProps { panel: TasksPanelState; now: number; maxRows?: number; + /** + * Columns the panel may draw into. Defaults to the live chat-column + * width; tests pass it explicitly to pin a size. + */ + width?: number; } /** @@ -19,15 +26,23 @@ export interface TasksPanelProps { * and create-form views based on `panel.mode`. The cancel-confirm * modal is rendered separately by `TuiApp` above the editor, not * inside the panel, so it never shifts the table layout. + * + * The panel measures itself: the debug pane hands down a row budget but + * not a width, and the table below needs one, because the left rail + * takes a quarter of the terminal away from every panel (see + * `../layout.ts`). Deriving it here keeps the wiring inside the Tasks + * tab rather than threading another prop through the shared pane. */ export function TasksPanel(props: TasksPanelProps): ReactElement { + const terminal = useTerminalSize(); const { panel, now, maxRows = 14 } = props; + const width = props.width ?? computeChatWidth(terminal.columns); const visibleRows = useMemo( () => selectVisibleTaskRows(panel), [panel.rows, panel.filterStatus, panel.searchQuery], ); return ( - + ) : null} {panel.mode === "detail" ? ( diff --git a/src/tui/tasks/tasks-list-fit.test.ts b/src/tui/tasks/tasks-list-fit.test.ts new file mode 100644 index 00000000..7b608154 --- /dev/null +++ b/src/tui/tasks/tasks-list-fit.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + computeTaskListLayout, + fitTaskListHints, + formatTaskListHeader, + formatTaskRowCells, + taskRowWidth, + TASK_LIST_HINTS, +} from "./tasks-list-fit.js"; +import type { TaskSummaryRow } from "./tasks-panel-state.js"; + +const NOW = Date.UTC(2026, 7, 19, 12, 0, 0); + +function row(overrides: Partial = {}): TaskSummaryRow { + return { + id: "t-1", + status: "pending", + origin: "cli", + triggerSource: null, + sessionId: "s-114e4b54-aba7-4264-9aff-07f86eebe388", + userMessage: + "task number 1 — do the thing that needs doing regularly, at length", + scheduleKind: "cron", + scheduleLabel: "cron: 0 1 * * * (Europe/Berlin)", + recurring: true, + scheduledFor: NOW + 7 * 3_600_000, + createdAt: NOW, + updatedAt: NOW, + startedAt: null, + completedAt: null, + attempts: 0, + maxAttempts: 3, + lastError: null, + ...overrides, + }; +} + +/** + * The panel widths the left rail actually leaves behind: 88 on a + * 120-column terminal, 73 on a 100-column one, 78 once the rail + * collapses at 80. + */ +const REAL_WIDTHS = [88, 78, 73]; + +describe("task list column layout", () => { + for (const width of REAL_WIDTHS) { + it(`fits a row into ${width} columns`, () => { + const layout = computeTaskListLayout(width); + expect(taskRowWidth(layout)).toBeLessThanOrEqual(width); + // Every column an operator needs to tell two cron jobs apart + // survives at the widths the app is actually used at. + expect(layout.status).toBeGreaterThan(0); + expect(layout.schedule).toBeGreaterThan(0); + expect(layout.nextRun).toBeGreaterThan(0); + expect(layout.message).toBeGreaterThan(0); + }); + } + + it("never plans a row wider than the panel, at any width", () => { + for (let width = 12; width <= 200; width += 1) { + const layout = computeTaskListLayout(width); + expect(taskRowWidth(layout)).toBeLessThanOrEqual(width); + } + }); + + it("spends slack on the message column", () => { + const narrow = computeTaskListLayout(88); + const wide = computeTaskListLayout(140); + expect(wide.message).toBeGreaterThan(narrow.message); + }); + + it("drops the session id before the schedule loses its expression", () => { + const layout = computeTaskListLayout(56); + expect(layout.session).toBe(0); + expect(layout.schedule).toBeGreaterThan(0); + }); +}); + +describe("task row cells", () => { + for (const width of REAL_WIDTHS) { + it(`renders one line of exactly the planned width at ${width}`, () => { + const layout = computeTaskListLayout(width); + const cells = formatTaskRowCells(row(), layout, NOW); + const line = [ + `▸ ${cells.status}`, + cells.schedule, + cells.nextRun, + cells.session, + cells.message, + ] + .filter((cell) => cell.length > 0) + .join(" "); + expect(line.length).toBeLessThanOrEqual(width); + expect(line).not.toContain("\n"); + }); + } + + it("keeps the header aligned with its own columns", () => { + const layout = computeTaskListLayout(88); + const header = formatTaskListHeader(layout); + const cells = formatTaskRowCells(row(), layout, NOW); + expect(header.indexOf("status")).toBe(2); + expect(header.indexOf("schedule")).toBe(2 + cells.status.length + 1); + expect(header.length).toBeLessThanOrEqual(88); + }); + + it("truncates rather than wraps a long message", () => { + const layout = computeTaskListLayout(73); + const cells = formatTaskRowCells( + row({ userMessage: "x".repeat(400) }), + layout, + NOW, + ); + expect(cells.message.length).toBe(layout.message); + expect(cells.message.endsWith("…")).toBe(true); + }); +}); + +describe("footer hints", () => { + for (const width of REAL_WIDTHS) { + it(`fits the hint strip on one line at ${width}`, () => { + const hints = fitTaskListHints(width); + expect(hints.length).toBeLessThanOrEqual(width); + // The keys that let a newcomer act, not just look, always survive. + expect(hints).toContain("Enter detail"); + expect(hints).toContain("n new"); + expect(hints).toContain("c cancel"); + }); + } + + it("keeps every hint when the panel is wide enough", () => { + const hints = fitTaskListHints(200); + for (const hint of TASK_LIST_HINTS) expect(hints).toContain(hint); + }); + + it("still names one key on an absurdly narrow panel", () => { + const hints = fitTaskListHints(6); + expect(hints.length).toBeLessThanOrEqual(6); + expect(hints.length).toBeGreaterThan(0); + }); +}); diff --git a/src/tui/tasks/tasks-list-fit.ts b/src/tui/tasks/tasks-list-fit.ts new file mode 100644 index 00000000..59f3aee1 --- /dev/null +++ b/src/tui/tasks/tasks-list-fit.ts @@ -0,0 +1,248 @@ +import type { TaskSummaryRow } from "./tasks-panel-state.js"; +import { formatRelativeMs } from "./tasks-summary.js"; + +/** + * Fit maths for the Tasks list — how wide each column may be, which + * footer hints survive, and how many rows the table may draw. + * + * The table used to lay out its columns from constants that added up to + * ~123 characters no matter how wide the panel actually was. That was + * survivable while the debug pane owned the full terminal; once the + * left rail became permanent app frame the panel only gets + * `computeChatWidth()` columns (88 on a 120-column terminal, 73 on a + * 100-column one), so every row wrapped onto a second line — which + * collided the columns into each other (`pendincron: 0 1 * * *`) and + * silently doubled the height of the table. Ink 7 does not clip an + * over-tall frame, it paints later lines over earlier ones (see + * `../row-window.ts`), so the doubled height then overwrote the filter + * bar and the header row with task text and diagnostics fragments. + * + * That is geometry, not rendering, so it lives here as pure functions: + * the component asks for a layout and renders it, and the invariant — + * a row never exceeds the panel width — can be unit-tested as a table + * instead of through screenshots. + */ + +/** Chevron gutter in front of every row: the glyph plus one space. */ +const CHEVRON_COLUMNS = 2; +/** Single space between two columns. */ +const COLUMN_GAP = 1; +/** + * One spare column held back from the row. Ink wraps a row whose + * content matches the box width exactly as soon as anything (a + * double-width glyph in a message, a padding change upstream) costs one + * more cell than we predicted, and a wrapped row is the very failure + * this module exists to prevent — so we buy the insurance. + */ +const SAFETY_COLUMNS = 1; + +/** Widest `TaskStatus` string (`cancelled`). */ +const STATUS_WIDTH = 9; +/** Narrowest status that still separates `pending` from `completed`. */ +const STATUS_MIN_WIDTH = 4; +const SCHEDULE_WIDTH = 22; +const SCHEDULE_MIN_WIDTH = 12; +const NEXT_RUN_WIDTH = 9; +const NEXT_RUN_MIN_WIDTH = 7; +const SESSION_WIDTH = 10; +/** Below this the message column stops carrying any information. */ +const MESSAGE_MIN_WIDTH = 16; + +/** + * Column widths for one table row, in characters. `0` means the column + * is dropped entirely (no cell, no separating gap). + */ +export interface TaskListLayout { + status: number; + schedule: number; + nextRun: number; + session: number; + message: number; +} + +type MutableLayout = { -readonly [K in keyof TaskListLayout]: number }; + +/** + * Order in which columns give up room when the panel is too narrow for + * all of them. The message is what identifies a task to a human, so it + * is defended to `MESSAGE_MIN_WIDTH` while everything else shrinks; the + * session id goes early because it is only ever a truncated prefix here + * and the detail view prints it in full. + */ +const DEGRADATIONS: ReadonlyArray<(layout: MutableLayout) => void> = [ + (layout) => { + layout.schedule = SCHEDULE_MIN_WIDTH; + }, + (layout) => { + layout.session = 0; + }, + (layout) => { + layout.nextRun = NEXT_RUN_MIN_WIDTH; + }, + (layout) => { + layout.schedule = 0; + }, + (layout) => { + layout.nextRun = 0; + }, + (layout) => { + layout.status = STATUS_MIN_WIDTH; + }, +]; + +/** Width a laid-out row occupies, including the chevron and every gap. */ +export function taskRowWidth(layout: TaskListLayout): number { + return ( + CHEVRON_COLUMNS + + layout.status + + cellWidth(layout.schedule) + + cellWidth(layout.nextRun) + + cellWidth(layout.session) + + cellWidth(layout.message) + ); +} + +function cellWidth(width: number): number { + return width > 0 ? width + COLUMN_GAP : 0; +} + +/** + * Resolve the column widths for a panel `width` columns wide. The + * result always satisfies `taskRowWidth(layout) <= width`, which is + * what keeps a row on one line. + */ +export function computeTaskListLayout(width: number): TaskListLayout { + const usable = Math.max(0, Math.floor(width) - SAFETY_COLUMNS); + const layout: MutableLayout = { + status: STATUS_WIDTH, + schedule: SCHEDULE_WIDTH, + nextRun: NEXT_RUN_WIDTH, + session: SESSION_WIDTH, + message: MESSAGE_MIN_WIDTH, + }; + for (const degrade of DEGRADATIONS) { + if (taskRowWidth(layout) <= usable) break; + degrade(layout); + } + // Whatever survives the degradations goes to the message: a wide + // terminal should spend its extra columns on the prompt text, not on + // padding between fixed-width cells. + const slack = usable - taskRowWidth(layout); + if (slack > 0) layout.message += slack; + if (taskRowWidth(layout) > usable) { + layout.message = Math.max( + 0, + layout.message - (taskRowWidth(layout) - usable), + ); + } + if (taskRowWidth(layout) > usable) { + // Nothing but the status fits. Still better than a wrapped row: + // the operator can read the table's shape and widen the window. + layout.schedule = 0; + layout.nextRun = 0; + layout.session = 0; + layout.message = 0; + layout.status = Math.max(1, usable - CHEVRON_COLUMNS); + } + return layout; +} + +/** Text of one rendered cell, already padded / truncated to its width. */ +export interface TaskRowCells { + status: string; + /** Columns after the chevron+status pair, in render order. Empty when dropped. */ + schedule: string; + nextRun: string; + session: string; + message: string; +} + +/** + * Project a summary row onto a layout. Every cell comes back at exactly + * its column width (the message is only truncated — trailing padding on + * the last column buys nothing), so the header and the rows below it + * can never drift apart. + */ +export function formatTaskRowCells( + row: TaskSummaryRow, + layout: TaskListLayout, + now: number, +): TaskRowCells { + return { + status: padCell(row.status, layout.status), + schedule: padCell(row.scheduleLabel, layout.schedule), + nextRun: padCell(formatRelativeMs(row.scheduledFor, now), layout.nextRun), + session: padCell(row.sessionId ?? "—", layout.session), + message: truncate(row.userMessage, layout.message), + }; +} + +/** + * Header labels for a layout. Built from the same widths as the rows so + * the column titles always sit over their own data — the old header was + * a hand-spaced string literal, which stopped lining up the first time + * anyone touched a column width. + */ +export function formatTaskListHeader(layout: TaskListLayout): string { + const cells = [ + " ".repeat(CHEVRON_COLUMNS) + padCell("status", layout.status), + padCell("schedule", layout.schedule), + padCell("next-run", layout.nextRun), + padCell("session", layout.session), + truncate("message", layout.message), + ].filter((cell) => cell.length > 0); + return cells.join(" ".repeat(COLUMN_GAP)).trimEnd(); +} + +function padCell(text: string, width: number): string { + if (width <= 0) return ""; + return truncate(text, width).padEnd(width); +} + +function truncate(text: string, max: number): string { + if (max <= 0) return ""; + if (text.length <= max) return text; + if (max === 1) return "…"; + return `${text.slice(0, max - 1)}…`; +} + +/** + * Footer hints in priority order. The tail is dropped first when the + * panel is too narrow to spell all of them, so the keys that let a + * newcomer *do* something (move, open, create, cancel, run) have to + * come before the ones that only adjust the view. + */ +export const TASK_LIST_HINTS: readonly string[] = [ + "j/k move", + "Enter detail", + "n new", + "c cancel", + "R run-now", + "f filter", + "/ search", + "r refresh", + "a auto", + "Esc clear search", +]; + +const HINT_SEPARATOR = " · "; + +/** + * Longest prefix of `TASK_LIST_HINTS` that fits on one line. One line + * is the point: the hint strip used to wrap onto a second row that the + * height budget had not reserved, which is one of the rows that pushed + * the panel over its budget and into Ink's overpainting. + */ +export function fitTaskListHints(width: number): string { + const usable = Math.max(0, Math.floor(width) - SAFETY_COLUMNS); + let line = ""; + for (const hint of TASK_LIST_HINTS) { + const next = line.length === 0 ? hint : `${line}${HINT_SEPARATOR}${hint}`; + if (next.length > usable) break; + line = next; + } + // Even a panel too narrow for the first hint gets *something*: a + // truncated `j/k move` still says the list is navigable. + if (line.length === 0) return truncate(TASK_LIST_HINTS[0] ?? "", usable); + return line; +} From 13fdc0204f605ba544523c1df5c38dc987dda1e6 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:14:10 +0300 Subject: [PATCH 43/57] feat(tui): the composer becomes a framed field with Send and file buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt was an opencode-style left "tail": one border column down the side of the editor, capped by a `╹`. That reads as a quote block rather than as a place you type into, and it left the two things a message box has to advertise — send, and reference a file — with nowhere to live. It is now a closed frame around the field with an action bar under it, drawn on the same inverted ground the rail established: per-palette, never a literal white, because `#fff` disappears on the four light themes. The bar carries the model on the left and two button chips on the right. Every chip colour is a token *pair* the palette already guarantees to be opposite (`border` against `railBackground`, `accent` against `railForeground`), so the buttons stay legible on all eleven palettes without a per-theme table. Send goes through the same `onSubmit` callback Enter fires, with the same buffer — one submit path, so slash commands and the busy-mode queue cannot drift. It greys to a ghost button when the buffer is blank or the editor is disabled, and registers no click target then. The file button is honest about what it is: there is no attachment pipeline in this codebase, so it appends the prose marker `file: ` to the draft and leaves the caret after it. The agent reads files by path with `os.fs.read`; the button's job is to say that affordance exists and put the caret where the path goes. It does not read, upload or embed anything. The frame is one row shorter than the tail it replaced (border, field, bar, border — where the tail spent a top pad, a blank row and the cap), and its height is bounded by the buffer alone. That matters: Ink 7 does not clip a frame taller than the terminal, it overlaps the lines above. --- src/tui/components/prompt-meta-bar.test.tsx | 184 ++++++++++++++++ src/tui/components/prompt-meta-bar.tsx | 226 ++++++++++++++++++++ src/tui/components/prompt-shell.test.tsx | 95 +++++++- src/tui/components/prompt-shell.tsx | 162 +++++--------- 4 files changed, 561 insertions(+), 106 deletions(-) create mode 100644 src/tui/components/prompt-meta-bar.test.tsx create mode 100644 src/tui/components/prompt-meta-bar.tsx diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx new file mode 100644 index 00000000..1f1781c4 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.test.tsx @@ -0,0 +1,184 @@ +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { appendFileReference } from "./prompt-meta-bar.js"; +import { PromptShell } from "./prompt-shell.js"; + +function strip(value: string): string { + return value + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); +} + +/** + * Screen position of `needle`'s first cell. Stripping SGR codes leaves + * the visual grid intact, so the column/row returned here are the same + * cells a terminal would report for a click on that label. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + const lines = strip(frame).split("\n"); + for (const [y, line] of lines.entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${strip(frame)}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const noopCallbacks = {} as TuiAppCallbacks; + +/** + * `PromptShell` inside a real registry. The buttons only need dispatch + * to exist — they act through their own props — but the registry is the + * real one so the click goes through genuine Yoga hit-testing rather + * than a hand-fed rectangle. + */ +async function mountWithMouse(node: ReactElement): Promise<{ + registry: MouseTargetRegistry; + frame: () => string; + unmount: () => void; +}> { + const registry = new MouseTargetRegistry(); + const { lastFrame, unmount } = render( + {}} + callbacks={noopCallbacks} + getState={() => ({}) as TuiState} + > + {node} + , + ); + // Ink commits on its own throttle and React registers the click + // targets in the effect after that commit, so a freshly mounted + // button is not hit-testable on the very first tick. + await new Promise((resolve) => setTimeout(resolve, 120)); + return { registry, frame: () => lastFrame() ?? "", unmount }; +} + +describe("appendFileReference", () => { + it("seeds an empty draft", () => { + expect(appendFileReference("")).toBe("file: "); + }); + + it("separates the marker from existing prose", () => { + expect(appendFileReference("summarise")).toBe("summarise file: "); + }); + + it("does not double the space when the draft already ends in one", () => { + expect(appendFileReference("summarise ")).toBe("summarise file: "); + expect(appendFileReference("summarise\n")).toBe("summarise\nfile: "); + }); +}); + +describe("composer buttons", () => { + it("submits the live buffer when Send is clicked", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(true); + expect(sent).toEqual(["ship it"]); + unmount(); + }); + + it("stays inert while the buffer is blank", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("stays inert while the editor is disabled", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect(registry.dispatch(click(x, y))).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("appends the file marker when the file button is clicked", async () => { + const changed: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + changed.push(value)} + onSubmit={() => {}} + />, + ); + const { x, y } = locate(frame(), "+ file"); + expect(registry.dispatch(click(x, y))).toBe(true); + expect(changed).toEqual(["explain file: "]); + unmount(); + }); + + it("ignores a right-button press on Send", async () => { + const sent: string[] = []; + const { registry, frame, unmount } = await mountWithMouse( + {}} + onSubmit={(value) => sent.push(value)} + />, + ); + const { x, y } = locate(frame(), "send"); + expect( + registry.dispatch({ ...click(x, y), button: "right" }), + ).toBe(false); + expect(sent).toEqual([]); + unmount(); + }); + + it("renders without a mouse provider at all", () => { + const { lastFrame, unmount } = render( + {}} onSubmit={() => {}} />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("+ file"); + expect(frame).toContain("send"); + unmount(); + }); +}); diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx new file mode 100644 index 00000000..c0218d50 --- /dev/null +++ b/src/tui/components/prompt-meta-bar.tsx @@ -0,0 +1,226 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { theme } from "../theme/theme.js"; + +/** + * The composer's action bar: what the model is on the left, the two + * buttons on the right, drawn on the same inverted ground as the rail. + * + * **Why inverted.** The bar is the composer's chrome, not its content. + * A terminal has no borders-and-shadows to say "this strip is a + * toolbar", so it borrows the one device the rail already established: + * its own ground, per-palette rather than a literal white, because + * `#fff` disappears on the four light themes. Reading the composer as + * "a field with a toolbar under it" instead of "two lines of text" is + * the whole point of the change. + * + * The ground is one `backgroundColor` on the bar container, which Ink 7 + * paints across the empty space between the meta text and the buttons — + * no filler cells, and no risk of the row growing taller than it looks. + * + * **A caveat about the slots.** `leftSlot` / `rightSlot` arrive from the + * chat surface already coloured (the LLM health pill, the context-window + * counter), and those colours were chosen against the *normal* ground. + * On the rail ground they read as low-contrast secondary text — which is + * what they are — but on `github-dark` and `catppuccin-mocha` the muted + * tone is close enough to the light rail ground to be genuinely faint. + * Recolouring them would mean reaching into components outside this + * file; the glyph in each pill carries a saturated status colour and + * stays legible, so the signal survives even where the label dims. + */ +export interface PromptMetaBarProps { + /** Chat-surface content rendered first — normally the LLM health pill. */ + leftSlot: ReactElement | null; + model: string | null; + provider: string | null; + /** Chat-surface content rendered just before the buttons. */ + rightSlot: ReactElement | null; + /** Whether Send has something to send; drives the primary/ghost look. */ + canSend: boolean; + onSend: () => void; + onAttachFile: () => void; +} + +/** + * Marker the file button drops into the draft. It is prose, not syntax: + * nothing in this codebase resolves it, and pretending otherwise would + * be a lie dressed as a feature. The agent reads files by path with + * `os.fs.read`, so a message that says `file: src/x.ts` gets the file + * read — the button's only job is to say that affordance exists and put + * the caret where the path goes. + */ +const FILE_REFERENCE_MARKER = "file: "; + +/** + * Append the file-reference marker to `value`, inserting a separating + * space only when the draft does not already end in whitespace. + */ +export function appendFileReference(value: string): string { + if (value.length === 0) return FILE_REFERENCE_MARKER; + if (/\s$/.test(value)) return `${value}${FILE_REFERENCE_MARKER}`; + return `${value} ${FILE_REFERENCE_MARKER}`; +} + +/** Labels carry their own padding so the chip's ground reads as a button. */ +const ATTACH_LABEL = " + file "; +const SEND_LABEL = " send → "; + +const MODEL_LABEL_MAX_LEN = 32; + +export function PromptMetaBar({ + leftSlot, + model, + provider, + rightSlot, + canSend, + onSend, + onAttachFile, +}: PromptMetaBarProps): ReactElement { + return ( + + {/* + The meta group is the only thing allowed to give up columns: at + 60 the buttons must survive intact, because a half-drawn button + is worse than a truncated model name. + */} + + + + + {rightSlot ? ( + + {rightSlot} + + ) : null} + + + + + + + ); +} + +interface ComposerButtonProps { + label: string; + /** Filled in the accent colour — the bar's one primary action. */ + primary?: boolean; + /** A disabled button still renders: it says the affordance exists. */ + enabled: boolean; + onPress: () => void; +} + +/** + * One button chip. + * + * Every colour here is a *pair* taken from the theme rather than a + * literal, and each pair is one the palette already guarantees to be + * opposite: `border` against `railBackground`, `accent` against + * `railForeground`. That is what keeps the chips legible across all + * eleven palettes without a per-theme table — the tokens flip polarity + * with the theme, so the contrast holds on light and dark alike. + * + * A disabled Send drops its ground entirely and dims to `railMuted`, + * which is the terminal's version of a ghost button: still there, still + * labelled, visibly not pressable. + */ +function ComposerButton({ + label, + primary = false, + enabled, + onPress, +}: ComposerButtonProps): ReactElement { + const background = !enabled + ? theme.colors.railBackground + : primary + ? theme.colors.accent + : theme.colors.border; + const foreground = !enabled + ? theme.colors.railMuted + : primary + ? theme.colors.railForeground + : theme.colors.railBackground; + const chip = ( + + {label} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree) or + // nothing to do: render the label and stop. Registering a target that + // swallows the click without acting would be worse than no target. + if (!mouse || !enabled) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onPress(); + return true; + }} + > + {chip} + + ); +} + +interface MetaLeftProps { + leftSlot: ReactElement | null; + model: string | null; + provider: string | null; +} + +function MetaLeft({ leftSlot, model, provider }: MetaLeftProps): ReactElement { + if (!leftSlot && !model && !provider) { + return ; + } + const cleanModel = model ? formatModel(model) : null; + // Wrap the optional `leftSlot` in a `` so neighbouring spans + // (a leading dot separator before the model) stay on the same line + // without Yoga inserting an inline break between Box children. + // `truncate` rather than wrap: a second line here would push the + // frame's bottom border down and change the composer's height, which + // is exactly the kind of drift a bounded frame exists to prevent. + return ( + + {leftSlot ? {leftSlot} : null} + {leftSlot && (cleanModel || provider) ? ( + + {" "} + {theme.glyphs.dotSeparator}{" "} + + ) : null} + {cleanModel ? ( + + {cleanModel} + + ) : null} + {cleanModel && provider ? ( + + {" "} + {theme.glyphs.dotSeparator}{" "} + + ) : null} + {provider ? ( + {provider} + ) : null} + + ); +} + +function formatModel(model: string): string { + const stripped = model.replace(/\.gguf$/i, ""); + if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped; + return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`; +} diff --git a/src/tui/components/prompt-shell.test.tsx b/src/tui/components/prompt-shell.test.tsx index a354b95f..1bd1ecff 100644 --- a/src/tui/components/prompt-shell.test.tsx +++ b/src/tui/components/prompt-shell.test.tsx @@ -1,3 +1,4 @@ +import { Box, Text } from "ink"; import { render } from "ink-testing-library"; import { describe, expect, it } from "vitest"; import { PromptShell } from "./prompt-shell.js"; @@ -9,7 +10,7 @@ function strip(value: string): string { } describe("PromptShell", () => { - it("renders the left tail cap (╹) below the editor", () => { + it("closes a frame around the editor and the action bar", () => { const { lastFrame, unmount } = render( { />, ); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("╹"); + expect(frame).toContain("╭"); + expect(frame).toContain("╰"); expect(frame).toContain("hello"); + // The tail cap the frame replaced. + expect(frame).not.toContain("╹"); + unmount(); + }); + + it("shows both buttons", () => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("+ file"); + expect(frame).toContain("send"); + unmount(); + }); + + /** + * The composer's whole height budget: four rows of chrome plus the + * buffer. If this grows, the chat viewport shrinks — and Ink 7 will + * overlap the lines above rather than clip, so a drift here is not a + * cosmetic one. + */ + it("spends four rows on chrome regardless of the buffer", () => { + const heightOf = (value: string): number => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const rows = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0).length; + unmount(); + return rows; + }; + expect(heightOf("one")).toBe(4); + expect(heightOf("one\ntwo\nthree")).toBe(6); + }); + + /** + * 60 columns is the narrowest terminal the composer has to survive: + * the chat column is 56 wide once the root padding is taken, and the + * rail is already hidden at that width. The meta group is the only + * thing allowed to give up columns — a clipped button reads as a + * rendering bug, a clipped model name reads as a long model name. + */ + it("keeps both buttons whole in a 56-column chat column", () => { + const { lastFrame, unmount } = render( + // A column, like the chat surface: the composer takes the + // column's full width rather than its own intrinsic one. + + {"● healthy"}} + rightSlot={ctx 32768} + onChange={() => {}} + onSubmit={() => {}} + /> + , + ); + const lines = strip(lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0); + expect(lines).toHaveLength(4); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(56); + } + const bar = lines[2] ?? ""; + expect(bar).toContain(" + file "); + expect(bar).toContain(" send → "); unmount(); }); @@ -107,7 +190,12 @@ describe("PromptShell", () => { unmount(); }); - it("omits the meta-row when neither model nor right-slot is set", () => { + /** + * The bar is unconditional now — it carries the buttons, so it cannot + * come and go with the model label the way the old meta-row did + * without the composer changing height mid-session. + */ + it("keeps the action bar with no model and no slots", () => { const { lastFrame, unmount } = render( { ); const frame = strip(lastFrame() ?? ""); expect(frame).not.toContain("llama.cpp"); + expect(frame).toContain("send"); unmount(); }); }); diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx index 18a31934..a5e1c5bf 100644 --- a/src/tui/components/prompt-shell.tsx +++ b/src/tui/components/prompt-shell.tsx @@ -1,21 +1,32 @@ -import { Box, Text } from "ink"; +import { Box } from "ink"; import type { ReactElement } from "react"; import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js"; import { theme } from "../theme/theme.js"; import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js"; +import { appendFileReference, PromptMetaBar } from "./prompt-meta-bar.js"; /** - * Visual shell around `MultiLineEditor` modelled after the opencode - * prompt: a left "tail" column terminated by a `╹` cap, optional - * rotating placeholder, and a meta-row underneath that surfaces the - * active model. The editor itself runs in `bare` mode so the chrome is - * fully owned here. + * The composer: a framed input field with a toolbar under it. + * + * It used to be an opencode-style left "tail" — a single border column + * down the left of the editor, capped by a `╹`. That reads as a quote + * block, not as a place you type into, and it gave the two things the + * composer needs to advertise (send, reference a file) nowhere to live. + * A closed frame plus an action bar is the shape every operator already + * knows from every other message box they have used, and it costs one + * row *less* than the tail did: border, editor, bar, border — where the + * tail spent a top pad, a blank row above the meta and the cap glyph. + * + * The frame is deliberately the app's only fully-boxed surface besides + * modals. Bounded height matters: Ink 7 does not clip a frame taller + * than the terminal, it overlaps the lines above it (the hazard + * `splash-fit.ts` exists to document), so the composer grows only with + * the buffer the operator typed and never with its own chrome. * * Out-of-scope (deferred for parity with opencode): * - bracketed paste with image bytes (Ink delivers cooked stdin) - * - mouse interactions / hover (Ink has no mouse layer) * - extmark "chips" inside the textarea (e.g. coloured `@file.ts`) - * - alpha / fade-in animations on the meta-row + * - alpha / fade-in animations on the action bar * * The shell does **not** open the autocomplete popup — slash-palette * stays where it lived before, rendered by the parent above the editor. @@ -33,7 +44,7 @@ export interface PromptShellProps rotatingPlaceholders?: readonly string[]; /** Rotation period in milliseconds. Defaults to 4000. */ placeholderRotationMs?: number; - /** Active model alias rendered into the meta-row (e.g. `qwen3-30b`). */ + /** Active model alias rendered into the action bar (e.g. `qwen3-30b`). */ model?: string | null; /** * Optional provider hint shown after the model (e.g. `llama.cpp`). @@ -41,13 +52,13 @@ export interface PromptShellProps */ provider?: string | null; /** - * Optional content rendered at the start of the meta-row, before the + * Optional content rendered at the start of the action bar, before the * model/provider labels. Used by the chat surface to show the live * LLM health pill. Separated by a dot from the model when both are * present. */ leftSlot?: ReactElement | null; - /** Optional content rendered on the right-hand side of the meta-row. */ + /** Optional content rendered just before the buttons on the right. */ rightSlot?: ReactElement | null; } @@ -63,6 +74,8 @@ export function PromptShell(props: PromptShellProps): ReactElement { focus, disabled, value, + onChange, + onSubmit, ...editorProps } = props; const rotated = useRotatingPlaceholder( @@ -72,102 +85,45 @@ export function PromptShell(props: PromptShellProps): ReactElement { const effectivePlaceholder = value.length === 0 ? (rotated ?? placeholder ?? "") : ""; const accent = focus && !disabled ? theme.colors.accent : theme.colors.border; - // Render the meta-row whenever any slot is occupied. With the live - // LLM-health pill being a permanent left-slot tenant, this means the - // row is effectively always rendered after mount — keeping the - // layout stable so the input does not jump up by one cell the moment - // `/props` lands. - const showMeta = - Boolean(model) || - Boolean(provider) || - Boolean(leftSlot) || - Boolean(rightSlot); + // Send is live on exactly the condition Enter is: a non-blank buffer + // in an editor that is accepting input. `handleEditorSubmit` drops a + // blank buffer anyway, but a button that visibly does nothing when + // pressed is a bug report waiting to happen. + const canSend = !disabled && value.trim().length > 0; return ( - - + {/* + Padding lives on the editor row, not on the frame: the action + bar has to reach both borders for its ground to read as a + toolbar rather than as a floating stripe. + */} + + + + onSubmit(value)} + onAttachFile={() => onChange(appendFileReference(value))} /> - {showMeta ? ( - - - {rightSlot ? {rightSlot} : null} - - ) : null} - ); } - -interface MetaLeftProps { - leftSlot: ReactElement | null; - model: string | null; - provider: string | null; -} - -const MODEL_LABEL_MAX_LEN = 32; - -function MetaLeft({ - leftSlot, - model, - provider, -}: MetaLeftProps): ReactElement { - if (!leftSlot && !model && !provider) { - return ; - } - const cleanModel = model ? formatModel(model) : null; - // Wrap the optional `leftSlot` in a `` so neighbouring spans - // (a leading dot separator before the model) stay on the same line - // without Yoga inserting an inline break between Box children. - return ( - - {leftSlot ? {leftSlot} : null} - {leftSlot && (cleanModel || provider) ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {cleanModel ? ( - - {cleanModel} - - ) : null} - {cleanModel && provider ? ( - - {" "} - {theme.glyphs.dotSeparator}{" "} - - ) : null} - {provider ? ( - {provider} - ) : null} - - ); -} - -function formatModel(model: string): string { - const stripped = model.replace(/\.gguf$/i, ""); - if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped; - return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`; -} From 55994ac2e741d97bb113b99b93ef7379b17c9c62 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:18:08 +0300 Subject: [PATCH 44/57] fix(tui): budget the whole Tasks table, not just its rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `maxRows` is what the debug pane reserved for everything the tab draws, but the list spent all of it on task rows and then added a column header, two scroll markers and a hint strip on top — five rows the pane had not reserved. Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones, so on a 100x30 or 80x24 terminal the overflow landed on the filter bar and the column header: task text and fragments of the diagnostics line ("| approval L1 | s") appeared inside the table, and a "↓ 4 below" marker sat on top of a task row. The table now splits the budget before it windows: chrome first, then whatever rows are left. Both scroll markers are reserved as soon as the list is longer than the window, because reserving only the one that happens to be on screen overflows again the moment the cursor scrolls the other into view. A budget too small for header + hints + three rows sheds the blank spacer, then the hints, then the header, rather than overflowing. Windowing itself now uses the shared `computeRowWindow` instead of a private copy of the same maths. Co-Authored-By: Claude Opus 5 --- src/tui/components/tasks-list.tsx | 40 +++++--- src/tui/components/tasks-panel-fit.test.tsx | 62 +++++++++++- src/tui/components/tasks-panel.tsx | 9 +- src/tui/tasks/tasks-list-fit.test.ts | 63 ++++++++++++ src/tui/tasks/tasks-list-fit.ts | 102 +++++++++++++++++++- 5 files changed, 252 insertions(+), 24 deletions(-) diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx index 9b9c57e3..90943c55 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-list.tsx @@ -5,11 +5,13 @@ import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; import { computeTaskListLayout, + computeTasksListFit, fitTaskListHints, formatTaskListHeader, formatTaskRowCells, type TaskListLayout, } from "../tasks/tasks-list-fit.js"; +import { computeRowWindow } from "../row-window.js"; import type { TaskSummaryRow, TasksPanelState, @@ -19,6 +21,7 @@ import type { TaskStatus } from "../../tasks/task-types.js"; export interface TasksListProps { panel: TasksPanelState; visibleRows: readonly TaskSummaryRow[]; + /** Rows the whole table may occupy, chrome included. */ maxRows: number; now: number; /** Columns the panel owns — see `tasks-list-fit.ts` for why it matters. */ @@ -33,7 +36,10 @@ export interface TasksListProps { * Every column width — including the footer hints — comes from * `tasks-list-fit.ts` for the panel's real width, because a row that * wraps takes two terminal lines and collides its own columns into - * each other. + * each other. `maxRows` is the budget for the *whole* table, header and + * hints included: the header, the scroll markers and the hint strip + * used to be drawn on top of it, which is how the panel outgrew the + * space the debug pane had reserved. */ export function TasksList(props: TasksListProps): ReactElement { const { panel, visibleRows, maxRows, now, width } = props; @@ -48,14 +54,15 @@ export function TasksList(props: TasksListProps): ReactElement { ); } + const fit = computeTasksListFit(maxRows, visibleRows.length); const clamped = Math.max(0, Math.min(panel.cursor, visibleRows.length - 1)); - const windowStart = computeWindowStart(clamped, visibleRows.length, maxRows); - const pageRows = visibleRows.slice(windowStart, windowStart + maxRows); - const hiddenBefore = windowStart; - const hiddenAfter = Math.max(0, visibleRows.length - windowStart - pageRows.length); + const rowWindow = computeRowWindow(visibleRows.length, clamped, fit.listRows); + const windowStart = rowWindow.start; + const pageRows = visibleRows.slice(windowStart, windowStart + rowWindow.count); + const { hiddenBefore, hiddenAfter } = rowWindow; return ( - + {fit.header ? : null} {hiddenBefore > 0 ? ( ↑ {hiddenBefore} above @@ -83,7 +90,7 @@ export function TasksList(props: TasksListProps): ReactElement { ↓ {hiddenAfter} below ) : null} - + {fit.hints ? : null} ); } @@ -96,9 +103,18 @@ function HeaderRow({ layout }: { layout: TaskListLayout }): ReactElement { ); } -function HintsRow({ width }: { width: number }): ReactElement { +function HintsRow({ + width, + spacer, +}: { + width: number; + spacer: boolean; +}): ReactElement { + // The blank row above the hints is the house look for a manage panel, + // but it is the first thing to go when the budget is tight: a hint + // strip the operator can read beats the whitespace around it. return ( - + {fitTaskListHints(width)} ); @@ -151,9 +167,3 @@ function statusColor(status: TaskStatus): string { return theme.colors.muted; } } - -function computeWindowStart(cursor: number, total: number, size: number): number { - if (total <= size) return 0; - if (cursor < size) return 0; - return Math.min(cursor - size + 1, total - size); -} diff --git a/src/tui/components/tasks-panel-fit.test.tsx b/src/tui/components/tasks-panel-fit.test.tsx index 7f711d0f..acfd2266 100644 --- a/src/tui/components/tasks-panel-fit.test.tsx +++ b/src/tui/components/tasks-panel-fit.test.tsx @@ -73,11 +73,16 @@ function frameFor(width: number, rowCount: number, maxRows: number): string[] { return (lastFrame() ?? "").split("\n"); } +/** Budget that comfortably carries `rowCount` tasks plus the chrome. */ +function roomyBudget(rowCount: number): number { + return rowCount + 6; +} + describe("Tasks list rows stay on one line", () => { for (const width of REAL_WIDTHS) { it(`draws one line per task at width ${width}`, () => { const rowCount = 6; - const lines = frameFor(width, rowCount, rowCount); + const lines = frameFor(width, rowCount, roomyBudget(rowCount)); // filter bar + header + rows + blank spacer + hints. expect(lines.length).toBe(rowCount + 4); for (const line of lines) { @@ -86,21 +91,21 @@ describe("Tasks list rows stay on one line", () => { }); it(`keeps status and message of a task on the same line at ${width}`, () => { - const lines = frameFor(width, 3, 3); + const lines = frameFor(width, 3, roomyBudget(3)); const messageLines = lines.filter((l) => l.includes("task number 2")); expect(messageLines).toHaveLength(1); expect(messageLines[0]).toContain("pending"); }); it(`keeps the filter bar and the column header intact at ${width}`, () => { - const lines = frameFor(width, 6, 6); + const lines = frameFor(width, 6, roomyBudget(6)); expect(lines[0]).toContain("filter: all"); expect(lines[1]).toContain("status"); expect(lines[1]).toContain("schedule"); }); it(`spells the action keys in the footer at ${width}`, () => { - const lines = frameFor(width, 3, 3); + const lines = frameFor(width, 3, roomyBudget(3)); const hints = lines[lines.length - 1] ?? ""; expect(hints).toContain("Enter detail"); expect(hints).toContain("n new"); @@ -108,3 +113,52 @@ describe("Tasks list rows stay on one line", () => { }); } }); + +/** + * The row budget is the other half of the same bug. `maxRows` is what + * the debug pane reserved for the whole tab; the table drew that many + * *task rows* and then added a header, scroll markers and a hint strip + * on top, so the frame ran past the reservation — and Ink paints the + * overflow over the filter bar instead of clipping it. + * + * Budgets below are the ones `debug-pane.tsx` computes for a 120×40, + * 100×30 and 80×24 terminal, plus the extremes. + */ +describe("Tasks panel never exceeds its row budget", () => { + for (const budget of [4, 8, 11, 17, 27]) { + it(`fits budget ${budget} with more tasks than rows`, () => { + const lines = frameFor(88, 40, budget); + expect(lines.length).toBeLessThanOrEqual(budget); + }); + + it(`fits budget ${budget} with a short list`, () => { + const lines = frameFor(73, 2, budget); + expect(lines.length).toBeLessThanOrEqual(budget); + }); + } + + it("keeps the scroll markers inside the budget from any cursor row", () => { + // The window slides as the cursor moves; the marker rows have to be + // reserved up front or the frame grows a row mid-scroll. + for (const cursor of [0, 1, 7, 20, 39]) { + const { lastFrame } = render( + + + , + ); + expect((lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(11); + } + }); + + it("still shows a task row and the hints at a realistic small budget", () => { + const lines = frameFor(78, 12, 11); + expect(lines.length).toBeLessThanOrEqual(11); + expect(lines.some((l) => l.includes("task number"))).toBe(true); + expect(lines.some((l) => l.includes("Enter detail"))).toBe(true); + }); +}); diff --git a/src/tui/components/tasks-panel.tsx b/src/tui/components/tasks-panel.tsx index 899932e0..4d9d978d 100644 --- a/src/tui/components/tasks-panel.tsx +++ b/src/tui/components/tasks-panel.tsx @@ -21,6 +21,13 @@ export interface TasksPanelProps { width?: number; } +/** + * Rows the panel spends on its own chrome before the table starts: the + * one-line filter bar. `maxRows` is the budget for everything the tab + * draws, so the table below only ever gets what is left. + */ +const FILTER_BAR_ROWS = 1; + /** * Top-level router for the Tasks tab. Switches between list, detail * and create-form views based on `panel.mode`. The cancel-confirm @@ -53,7 +60,7 @@ export function TasksPanel(props: TasksPanelProps): ReactElement { diff --git a/src/tui/tasks/tasks-list-fit.test.ts b/src/tui/tasks/tasks-list-fit.test.ts index 7b608154..fecdeb11 100644 --- a/src/tui/tasks/tasks-list-fit.test.ts +++ b/src/tui/tasks/tasks-list-fit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeTaskListLayout, + computeTasksListFit, fitTaskListHints, formatTaskListHeader, formatTaskRowCells, @@ -139,3 +140,65 @@ describe("footer hints", () => { expect(hints.length).toBeGreaterThan(0); }); }); + +describe("row budget", () => { + /** Rows a fit actually draws, given how many rows the filter matched. */ + function drawnRows(budget: number, totalRows: number): number { + const fit = computeTasksListFit(budget, totalRows); + const window = Math.min(fit.listRows, totalRows); + return ( + (fit.header ? 1 : 0) + + (fit.hints ? 1 : 0) + + (fit.hintsSpacer ? 1 : 0) + + fit.scrollMarkerRows + + window + ); + } + + it("never plans more rows than the budget", () => { + for (let budget = 1; budget <= 40; budget += 1) { + for (const totalRows of [1, 3, 12, 200]) { + expect(drawnRows(budget, totalRows)).toBeLessThanOrEqual(budget); + } + } + }); + + it("reserves both scroll markers once the table cannot show everything", () => { + // Reserving only the marker that happens to be on screen overflows + // by one row as soon as the cursor scrolls the other one into view. + const fit = computeTasksListFit(12, 100); + expect(fit.scrollMarkerRows).toBe(2); + expect(computeTasksListFit(12, 3).scrollMarkerRows).toBe(0); + }); + + it("keeps header and hints while the budget can carry a real list", () => { + const fit = computeTasksListFit(20, 12); + expect(fit.header).toBe(true); + expect(fit.hints).toBe(true); + expect(fit.hintsSpacer).toBe(true); + expect(fit.listRows).toBeGreaterThanOrEqual(12); + }); + + it("sheds the spacer, then the hints, then the header", () => { + expect(computeTasksListFit(5, 3)).toMatchObject({ + header: true, + hints: true, + hintsSpacer: false, + }); + expect(computeTasksListFit(4, 3)).toMatchObject({ + header: true, + hints: false, + hintsSpacer: false, + }); + expect(computeTasksListFit(2, 3)).toMatchObject({ + header: false, + hints: false, + }); + }); + + it("always leaves at least one task row", () => { + for (let budget = 1; budget <= 6; budget += 1) { + expect(computeTasksListFit(budget, 50).listRows).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/src/tui/tasks/tasks-list-fit.ts b/src/tui/tasks/tasks-list-fit.ts index 59f3aee1..571ec26a 100644 --- a/src/tui/tasks/tasks-list-fit.ts +++ b/src/tui/tasks/tasks-list-fit.ts @@ -17,10 +17,18 @@ import { formatRelativeMs } from "./tasks-summary.js"; * `../row-window.ts`), so the doubled height then overwrote the filter * bar and the header row with task text and diagnostics fragments. * - * That is geometry, not rendering, so it lives here as pure functions: - * the component asks for a layout and renders it, and the invariant — - * a row never exceeds the panel width — can be unit-tested as a table - * instead of through screenshots. + * The height side is the same story from the other axis: the list used + * to treat its whole row budget as *table rows* and then draw a header, + * two scroll markers and a hint strip on top of it, so even with + * one-line rows the panel asked for ~5 rows more than the debug pane + * had budgeted — and those 5 rows are what Ink paints over the top of + * the filter bar. + * + * All of that is geometry, not rendering, so it lives here as pure + * functions: the component asks for a layout and renders it, and the + * invariants — a row never exceeds the panel width, a frame never + * exceeds its row budget — can be unit-tested as a table instead of + * through screenshots. */ /** Chevron gutter in front of every row: the glyph plus one space. */ @@ -246,3 +254,89 @@ export function fitTaskListHints(width: number): string { if (line.length === 0) return truncate(TASK_LIST_HINTS[0] ?? "", usable); return line; } +/** How the list spends a row budget across its chrome and its rows. */ +export interface TasksListFit { + /** Rows the table body may draw. */ + listRows: number; + /** Whether the column-header row is drawn. */ + header: boolean; + /** Whether the footer hint strip is drawn. */ + hints: boolean; + /** Whether a blank row separates the table from the hints. */ + hintsSpacer: boolean; + /** Rows reserved for the `↑ N above` / `↓ N below` markers. */ + scrollMarkerRows: number; +} + +/** + * Rows the table keeps before it starts shedding chrome. Below three + * the list stops being a list, so the header and then the hints go + * first — a garbled panel helps nobody, but neither does a header with + * a single row under it. + */ +const MIN_LIST_ROWS = 3; + +/** + * Chrome combinations in the order they are given up. Everything is + * kept while it fits; then the blank spacer, then the hints, then the + * header. + */ +const CHROME_LADDER: ReadonlyArray< + Pick +> = [ + { header: true, hints: true, hintsSpacer: true }, + { header: true, hints: true, hintsSpacer: false }, + { header: true, hints: false, hintsSpacer: false }, + { header: false, hints: false, hintsSpacer: false }, +]; + +/** + * Split `budget` rows between the table's chrome and its body so the + * rendered frame is never taller than the budget. `totalRows` is the + * number of rows the filter currently matches — it decides whether the + * scroll markers need reserving at all. + */ +export function computeTasksListFit( + budget: number, + totalRows: number, +): TasksListFit { + const rows = Math.max(1, Math.floor(budget)); + for (const [index, chrome] of CHROME_LADDER.entries()) { + const cost = + (chrome.header ? 1 : 0) + + (chrome.hints ? 1 : 0) + + (chrome.hintsSpacer ? 1 : 0); + const listRows = rows - cost; + const last = index === CHROME_LADDER.length - 1; + if (listRows >= MIN_LIST_ROWS || last) { + return withScrollMarkers(chrome, listRows, totalRows); + } + } + /* c8 ignore next 2 -- the ladder's last entry always returns above */ + return withScrollMarkers( + { header: false, hints: false, hintsSpacer: false }, + rows, + totalRows, + ); +} + +/** + * Reserve both marker rows as soon as the table cannot show everything. + * Reserving lazily (only for the marker that is on screen right now) + * overflows by one row the moment the cursor scrolls far enough for the + * second marker to appear, and one row is all Ink needs to overpaint. + */ +function withScrollMarkers( + chrome: Pick, + listRows: number, + totalRows: number, +): TasksListFit { + const available = Math.max(1, listRows); + const markers = + totalRows > available ? Math.min(2, Math.max(0, available - 1)) : 0; + return { + ...chrome, + scrollMarkerRows: markers, + listRows: Math.max(1, available - markers), + }; +} From 70ecf563daff97f1852a3020fdeab568002386e0 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:19:32 +0300 Subject: [PATCH 45/57] fix(tui): the add-provider wizard stops painting over its own rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported twice: "there is only aimlapi in the config menu, there should be other providers on the list", and "some issue with rendering openrouter, I don't see it on some screen sizes at all". No row was ever missing. `KIND_ROW_ORDER` has had all 24 since #69, and the counter on screen said `(1/24)` the whole time. The wizard was drawn by `LlmPanelModals` at the TOP of `LlmPanel`, with the RouteCard, the mode header, the windowed row list and the footer still rendered underneath it — and the panel already spends the entire `maxRows` tab budget on those. The frame therefore ran ~16 rows past the terminal, and Ink 7 does not clip an over-tall frame, it paints later lines over earlier ones. At 120x40 that arrived as seven half-eaten rows: OpenRouter wearing the tail of the Codex row, Gemini wearing aimlapi's, and the box title gone entirely. Which rows survived depended on the terminal height, which is why the same list looked different at different sizes. Two changes, both about height: * A modal now owns the frame. `hasLlmModal` covers exactly the states `handleLlmModalKey` claims every key for, so the panel behind one is already unreachable — drawing it only spent the budget twice. * `renderPickList` sizes its viewport from the budget it is handed (`pickWindowRows`) instead of always asking for 12 rows plus chrome. PgUp/PgDn keeps the fixed `PICK_WINDOW` distance: paging a 300-row catalog three rows at a time is not paging. The hint line gained `truncate-end` for the same reason the option rows have it — a wrapped hint is a row nobody budgeted for. Co-Authored-By: Claude Opus 5 --- src/tui/components/llm-panel-modals.tsx | 31 ++++++++++++- src/tui/components/llm-panel.test.tsx | 59 +++++++++++++++++++++++++ src/tui/components/llm-panel.tsx | 18 +++++++- src/tui/components/providers-wizard.tsx | 42 ++++++++++++------ src/tui/components/wizard-pick-list.tsx | 51 ++++++++++++++++++--- 5 files changed, 177 insertions(+), 24 deletions(-) diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx index be15965d..97bdc004 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -36,6 +36,30 @@ function blankRows(count: number): ReactElement[] { )); } +/** + * True when one of the boxes below owns the screen. + * + * `handleLlmModalKey` returns non-null for exactly these states, i.e. + * the panel behind a modal cannot be driven while one is open. It must + * therefore not be DRAWN either: the panel already spends the whole tab + * budget, so drawing a modal on top of it is a frame taller than the + * terminal, and Ink 7 resolves that by overwriting earlier lines rather + * than clipping. Callers use this to hand the modal the full budget and + * render nothing else. + */ +export function hasLlmModal(state: TuiState): boolean { + return ( + state.providersPanel.wizard !== null || + state.providersPanel.removeConfirm !== null || + state.localModelsPanel.embeddingOnboardingPrompt !== null || + state.localModelsPanel.removeConfirmId !== null || + state.localModelsPanel.embeddingRemoveConfirmId !== null || + state.providersPanel.chatModelPicker !== null || + state.llmPanel.externalUrlDraft !== null || + state.llmPanel.stopLocalDaemonsPrompt !== null + ); +} + export function LlmPanelModals({ state, maxRows, @@ -44,7 +68,12 @@ export function LlmPanelModals({ maxRows?: number; }): ReactElement | null { if (state.providersPanel.wizard) { - return ; + return ( + + ); } if (state.providersPanel.removeConfirm) { return ( diff --git a/src/tui/components/llm-panel.test.tsx b/src/tui/components/llm-panel.test.tsx index 22b4584a..cf6002de 100644 --- a/src/tui/components/llm-panel.test.tsx +++ b/src/tui/components/llm-panel.test.tsx @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { createInitialTuiState, type TuiState } from "../tui-state.js"; import { fakeSession } from "../test-fixtures.js"; import type { ProvidersChatModelPickerState } from "../providers/providers-panel-state.js"; +import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import { LlmPanel } from "./llm-panel.js"; function stateWithPicker( @@ -115,6 +117,63 @@ describe("LlmPanel", () => { }); }); +/** + * Reported as "there is only aimlapi in the provider list" and "I don't + * see OpenRouter on some screen sizes". + * + * Neither was a missing row: `KIND_ROW_ORDER` has always had all of + * them. The wizard was drawn ON TOP of the whole LLM panel, so the frame + * ran ~16 rows past the tab budget, and Ink 7 answers an over-tall frame + * by painting later lines over earlier ones instead of clipping. Half + * the provider rows arrived on screen wearing the tail of the row below + * them. The budgets below are what `tabContentBudget` hands the tab at + * 120x40, 100x30 and 80x24 — the three sizes the reports came from. + */ +describe("the add-provider wizard fits the terminal", () => { + function stateWithWizard(): TuiState { + const base = createInitialTuiState(fakeSession()); + return { + ...base, + uiMode: "debug" as const, + activeTab: "llm" as const, + llmPanel: { ...base.llmPanel, mode: "cloud" as const }, + providersPanel: { + ...base.providersPanel, + wizard: createProvidersWizardState("add"), + }, + }; + } + + for (const budget of [27, 17, 11]) { + it(`never exceeds a ${budget}-row budget`, () => { + const { lastFrame } = render( + , + ); + expect((lastFrame() ?? "").split("\n").length).toBeLessThanOrEqual(budget); + }); + } + + it("still shows OpenRouter and the full-list counter on a short terminal", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("OpenRouter"); + expect(text).toContain(`(1/${KIND_ROW_ORDER.length})`); + }); + + it("draws the modal alone, not stacked over the panel it covers", () => { + // The panel is unreachable while the wizard owns the keyboard, and + // drawing it was what spent the row budget twice. + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).not.toContain("Active chat route"); + expect(text).not.toContain("n add provider"); + }); +}); + describe("model picker fixed height", () => { const MAX_ROWS = 20; diff --git a/src/tui/components/llm-panel.tsx b/src/tui/components/llm-panel.tsx index 1ba1d045..f65809de 100644 --- a/src/tui/components/llm-panel.tsx +++ b/src/tui/components/llm-panel.tsx @@ -11,7 +11,7 @@ import { import type { LocalModelsPanelState } from "../local-models/local-models-panel-state.js"; import { LLM_PANEL_MODES, type LlmPanelMode } from "../llm-panel/llm-panel-state.js"; import { LlmModeRows } from "./llm-mode-rows.js"; -import { LlmPanelModals } from "./llm-panel-modals.js"; +import { hasLlmModal, LlmPanelModals } from "./llm-panel-modals.js"; /** * Rows consumed by the full fixed chrome: RouteCard (~7) + ModeHeader (3) @@ -49,9 +49,23 @@ export function LlmPanel({ const useFull = maxRows >= FULL_HEADER_ROWS + FULL_HEADER_MIN_LIST; const headerRows = useFull ? FULL_HEADER_ROWS : COMPACT_HEADER_ROWS; const listBudget = Math.max(1, maxRows - headerRows); + // A modal takes the whole budget and the panel behind it is not drawn. + // The two used to be stacked, which spent the budget twice over: Ink 7 + // does not clip an over-tall frame, it paints later lines over earlier + // ones, so the add-provider list arrived on screen with most of its + // rows overwritten by the panel underneath (reports #1 and #2). The + // panel is unreachable while a modal is open anyway — + // `handleLlmModalKey` claims every key — so nothing is lost by hiding + // it, and the modal finally gets a height it can size itself against. + if (hasLlmModal(state)) { + return ( + + + + ); + } return ( - {/* The starting banner and active-download banners are important feedback — keep them visible regardless of the compact/full header decision. */} diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index f9e8fd2f..614ccc11 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -137,6 +137,7 @@ function renderLineField(props: { function CompatChatModelStep(props: { wizard: ProvidersWizardState; + maxRows?: number; }): ReactElement { const w = props.wizard; const baseUrl = baseUrlForWizard(w); @@ -190,6 +191,7 @@ function CompatChatModelStep(props: { moveHint: "↑/↓ move", actionsHint: "PgUp/PgDn jump · Enter select · type to enter an id by hand · Esc back", + ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), }); } @@ -230,6 +232,7 @@ function CompatChatModelStep(props: { function CatalogChatModelStep(props: { wizard: ProvidersWizardState; kind: "openrouter" | "aimlapi"; + maxRows?: number; }): ReactElement { const { wizard: w, kind } = props; const getCached = @@ -275,13 +278,26 @@ function CatalogChatModelStep(props: { cursor: w.cursor, moveHint: "j/k move", actionsHint, + ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), }); } +/** + * `maxRows` is the terminal budget the wizard must fit in, not a + * preference. The wizard is a modal: `LlmPanel` hands it the whole tab + * budget and renders nothing behind it, and every box below sizes + * itself so the frame cannot outgrow the terminal. It used to be drawn + * on top of the full LLM panel with no budget at all, and Ink 7 answers + * an over-tall frame by painting later lines over earlier ones — which + * is how a 24-row provider list arrived on screen as seven half-eaten + * rows with OpenRouter's row wearing Codex's tail (reports #1 and #2). + */ export function ProvidersWizard(props: { wizard: ProvidersWizardState; + maxRows?: number; }): ReactElement { const w = props.wizard; + const maxRows = props.maxRows === undefined ? {} : { maxRows: props.maxRows }; const modeLabel = w.mode === "configure" ? `configure ${w.providerId}` : "add provider"; if (w.phase === "pick_kind") { @@ -291,6 +307,7 @@ export function ProvidersWizard(props: { cursor: w.cursor, moveHint: "j/k move", actionsHint: "Enter pick · Esc cancel", + ...maxRows, }); } @@ -336,26 +353,23 @@ export function ProvidersWizard(props: { w.phase === "pick_chat_model" && (w.kind === "openrouter" || w.kind === "aimlapi") ) { - return ; + return ; } - if (w.phase === "pick_embedding" && w.kind === "openrouter") { - return renderPickList({ - title: "Embedding backend", - options: listOpenRouterEmbeddingModels(), - cursor: w.cursor, - moveHint: "j/k move", - actionsHint: "PgUp/PgDn jump · Enter finish · Esc back", - }); - } - - if (w.phase === "pick_embedding" && w.kind === "aimlapi") { + if ( + w.phase === "pick_embedding" && + (w.kind === "openrouter" || w.kind === "aimlapi") + ) { return renderPickList({ title: "Embedding backend", - options: listAimlapiEmbeddingModels(), + options: + w.kind === "openrouter" + ? listOpenRouterEmbeddingModels() + : listAimlapiEmbeddingModels(), cursor: w.cursor, moveHint: "j/k move", actionsHint: "PgUp/PgDn jump · Enter finish · Esc back", + ...maxRows, }); } @@ -370,7 +384,7 @@ export function ProvidersWizard(props: { } if (w.phase === "chat_model_line") { - return ; + return ; } return ( diff --git a/src/tui/components/wizard-pick-list.tsx b/src/tui/components/wizard-pick-list.tsx index e90e319e..7cd3dd72 100644 --- a/src/tui/components/wizard-pick-list.tsx +++ b/src/tui/components/wizard-pick-list.tsx @@ -3,12 +3,46 @@ import type { ReactElement } from "react"; import { theme } from "../theme/theme.js"; /** - * Viewport height for every wizard pick list, and the jump distance for - * PgUp/PgDn in `providers-wizard-key-bindings`. Keep the two in sync by - * importing this constant, never by copying the number. + * Largest viewport any wizard pick list will use, and the jump distance + * for PgUp/PgDn in `providers-wizard-key-bindings`. Keep the two in sync + * by importing this constant, never by copying the number. + * + * The rendered viewport shrinks below this on short terminals (see + * `pickWindowRows`); the paging distance deliberately does not. PgDn is + * "go a screenful further down a 300-row catalog", and pinning it to a + * 3-row window on an 80x24 terminal would turn it into ↓↓↓. */ export const PICK_WINDOW = 12; +/** Never shrink the viewport below this — one row is not a list. */ +export const PICK_MIN_WINDOW = 3; + +/** + * Rows the box spends on things that are not options: two border lines, + * the top and bottom margins, the title, and the hint. + */ +const PICK_CHROME_ROWS = 6; + +/** + * How many option rows fit in `maxRows` total rows of terminal. + * + * `undefined` means "no budget was passed" and keeps the historical + * fixed viewport. Callers that know the budget must pass it: Ink 7 does + * not clip a frame taller than the terminal, it paints later lines over + * earlier ones, so a 16-row box on an 11-row budget does not lose its + * bottom — it eats whatever was above it. + */ +export function pickWindowRows( + maxRows: number | undefined, + extraChromeRows = 0, +): number { + if (maxRows === undefined) return PICK_WINDOW; + return Math.max( + PICK_MIN_WINDOW, + Math.min(PICK_WINDOW, maxRows - PICK_CHROME_ROWS - extraChromeRows), + ); +} + /** * Bordered option list windowed around the cursor. * @@ -30,14 +64,17 @@ export function renderPickList(props: { moveHint: string; /** Actions part of the hint, e.g. "Enter select · Esc cancel". */ actionsHint: string; + /** Total terminal rows this box may occupy; omit for the fixed viewport. */ + maxRows?: number; }): ReactElement { const total = props.options.length; const clamped = Math.min(Math.max(props.cursor, 0), Math.max(0, total - 1)); + const window = pickWindowRows(props.maxRows); const start = Math.min( - Math.max(0, clamped - Math.floor(PICK_WINDOW / 2)), - Math.max(0, total - PICK_WINDOW), + Math.max(0, clamped - Math.floor(window / 2)), + Math.max(0, total - window), ); - const visible = props.options.slice(start, start + PICK_WINDOW); + const visible = props.options.slice(start, start + window); const position = total === 0 ? "(0/0)" : `(${clamped + 1}/${total})`; return ( ); })} - + {props.moveHint} {position} · {props.actionsHint} From 52f1d2147ec557ae7413576231c7fdbe85e5a51b Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:20:41 +0300 Subject: [PATCH 46/57] fix(tui): a refused key says so on the screen that refused it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "key validation is still not working — I added a random key and got stuck on embedding selection". The validation was working. A bogus OpenRouter key reached `verifyWizardBeforeSave`, came back `invalid_key`, and `completeWizard` returned before `saveProviderWizardToConfig` — the PTY run confirms nothing was written to config.json or .env. What failed was saying so. `renderPickList` had no error line and no busy state, so on the three list phases (`pick_kind`, `pick_chat_model`, `pick_embedding`) the `wizard.error` the reducer stores from `providers_wizard_failed` had nowhere to go, and neither did the seconds the check spends waiting on the provider. Enter set `submitting`, swallowed every key but Esc, then cleared it and painted an identical screen. Pressing Enter again did the same. From the operator's chair that is a wizard that has stopped responding on the embedding screen, which is exactly what was reported. The refusal now renders where it happened, over at most two lines split at the sentence boundary — the verdicts are "what happened" plus "what to do about it", and truncating the pair to one line drops the half that tells you to check which service the key belongs to. Those rows come out of the option viewport, so the box height is still bounded by the budget the previous commit gave it. While the check runs the actions hint is REPLACED by "checking the key with the provider… (Esc cancels)": every other key is swallowed until it settles, so listing them would be a lie. `providerLabelForWizard` also stopped handing the raw config token to prose. `"openrouter" rejected this key` on a screen whose own row says "OpenRouter" reads as some other, lower-case product. Co-Authored-By: Claude Opus 5 --- src/tui/components/providers-wizard.test.tsx | 73 +++++++++++++++++++- src/tui/components/providers-wizard.tsx | 38 ++++++++-- src/tui/components/wizard-pick-list.tsx | 39 ++++++++++- src/tui/providers/providers-wizard-target.ts | 18 ++++- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/src/tui/components/providers-wizard.test.tsx b/src/tui/components/providers-wizard.test.tsx index 7406e08b..d3fb5028 100644 --- a/src/tui/components/providers-wizard.test.tsx +++ b/src/tui/components/providers-wizard.test.tsx @@ -1,11 +1,14 @@ import { render } from "ink-testing-library"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { refreshAimlapiChatCatalogFromApi } from "../../llm/provider/aimlapi/fetch-aimlapi-chat-catalog.js"; import { refreshOpenRouterChatCatalogFromApi } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; import { KIND_ROW_ORDER } from "../providers/providers-wizard-phases.js"; import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; -import type { ProvidersWizardKind } from "../providers/providers-wizard-state.js"; +import type { + ProvidersWizardKind, + ProvidersWizardState, +} from "../providers/providers-wizard-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; function stripAnsi(value: string): string { @@ -287,3 +290,69 @@ describe("ProvidersWizard cloud model pickers", () => { expect(text).not.toContain("vendor/model-000"); }); }); + +/** + * Reported as "I added a random key and got stuck on embedding + * selection". The key check did fire and did refuse the save — nothing + * was written — but a list screen had nowhere to print `wizard.error` + * and nowhere to say a check was running, so Enter looked like a key + * that did nothing, forever. + */ +describe("ProvidersWizard surfaces the key check on list screens", () => { + // The chat-model case mounts `CatalogChatModelStep`, which fires a + // live catalog refresh on mount. Keep it offline: a real response + // would replace the module cache the windowing tests assert against. + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function embeddingStep(overrides: Partial) { + return { + ...createProvidersWizardState("add", { kind: "openrouter" }), + phase: "pick_embedding" as const, + cursor: 0, + ...overrides, + }; + } + + it("prints the refusal on the embedding screen", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("OpenRouter does not recognize this key"); + }); + + it("says a check is in flight while the save waits on the provider", () => { + const { lastFrame } = render( + , + ); + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("checking the key with the provider"); + expect(text).toContain("Esc cancels"); + }); + + it("prints the refusal on the chat-model screen too", () => { + const { lastFrame } = render( + , + ); + expect(stripAnsi(lastFrame() ?? "")).toContain("no balance"); + }); +}); diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index 614ccc11..75cf9b2f 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -92,7 +92,7 @@ function explainModelListError(error: string, w: ProvidersWizardState): string { * What `submitting` means now that a save starts with a live key check: * the wait is the provider answering, and Esc gets out of it. */ -const CHECKING_KEY_HINT = " · checking the key with the provider… (Esc cancels)"; +const CHECKING_KEY_HINT = "checking the key with the provider… (Esc cancels)"; function maskedKey(buffer: string): string { const masked = "•".repeat(Math.min(buffer.length, 48)); @@ -100,6 +100,21 @@ function maskedKey(buffer: string): string { return masked + extra; } +/** + * Actions hint for a list screen, with the key check folded in. + * + * A pick screen is where the save happens for the curated kinds, so it + * is also where the operator waits on the provider answering. Saying + * nothing for those seconds is what made a refused key read as a frozen + * wizard. While the check runs the normal actions are REPLACED rather + * than appended to: every key but Esc is swallowed until it settles, so + * listing them would be a lie, and the combined line was long enough to + * lose "(Esc cancels)" off the right edge of a 100-column terminal. + */ +function listActionsHint(base: string, submitting: boolean): string { + return submitting ? CHECKING_KEY_HINT : base; +} + function renderLineField(props: { title: string; value: string; @@ -189,14 +204,17 @@ function CompatChatModelStep(props: { options: picks.map((id) => ({ label: id })), cursor: w.cursor, moveHint: "↑/↓ move", - actionsHint: + actionsHint: listActionsHint( "PgUp/PgDn jump · Enter select · type to enter an id by hand · Esc back", + w.submitting, + ), ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + error: w.error, }); } const hint = w.submitting - ? CHECKING_KEY_HINT.trimStart() + ? CHECKING_KEY_HINT : !canList ? "Enter to save · Esc back" : status.loading @@ -277,8 +295,9 @@ function CatalogChatModelStep(props: { options: listChatModelsForKind(kind), cursor: w.cursor, moveHint: "j/k move", - actionsHint, + actionsHint: listActionsHint(actionsHint, w.submitting), ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + error: w.error, }); } @@ -308,6 +327,7 @@ export function ProvidersWizard(props: { moveHint: "j/k move", actionsHint: "Enter pick · Esc cancel", ...maxRows, + error: w.error, }); } @@ -343,7 +363,7 @@ export function ProvidersWizard(props: { ) : null} Enter to continue · Esc back · Backspace edit - {w.submitting ? CHECKING_KEY_HINT : ""} + {w.submitting ? ` · ${CHECKING_KEY_HINT}` : ""} ); @@ -368,8 +388,14 @@ export function ProvidersWizard(props: { : listAimlapiEmbeddingModels(), cursor: w.cursor, moveHint: "j/k move", - actionsHint: "PgUp/PgDn jump · Enter finish · Esc back", + // This is the last screen of the curated flow, so Enter here is + // the save — and the save is what runs the key check. + actionsHint: listActionsHint( + "PgUp/PgDn jump · Enter finish · Esc back", + w.submitting, + ), ...maxRows, + error: w.error, }); } diff --git a/src/tui/components/wizard-pick-list.tsx b/src/tui/components/wizard-pick-list.tsx index 7cd3dd72..65ce7536 100644 --- a/src/tui/components/wizard-pick-list.tsx +++ b/src/tui/components/wizard-pick-list.tsx @@ -43,6 +43,26 @@ export function pickWindowRows( ); } +/** Most error lines the box will spend rows on. */ +const MAX_ERROR_ROWS = 2; + +/** + * Break a refusal into at most two truncated lines, split at the first + * sentence end. + * + * The verdicts from `describeProviderVerifyOutcome` are two sentences — + * what happened, then what to do about it — and run past 80 columns + * together. Truncating the pair to one line keeps the verdict and throws + * away the instruction, which is the half the operator needs. Splitting + * on the sentence boundary is width-independent, so the box height stays + * predictable at any terminal width. + */ +function errorLines(error: string): readonly string[] { + const split = error.indexOf(". "); + if (split === -1) return [error]; + return [error.slice(0, split + 1), error.slice(split + 2)]; +} + /** * Bordered option list windowed around the cursor. * @@ -66,10 +86,17 @@ export function renderPickList(props: { actionsHint: string; /** Total terminal rows this box may occupy; omit for the fixed viewport. */ maxRows?: number; + /** + * Why the last action was refused. A list screen used to have nowhere + * to say this, so a save the key check rejected looked exactly like a + * keypress that did nothing — the whole of report #3. + */ + error?: string | null; }): ReactElement { const total = props.options.length; const clamped = Math.min(Math.max(props.cursor, 0), Math.max(0, total - 1)); - const window = pickWindowRows(props.maxRows); + const errors = props.error ? errorLines(props.error).slice(0, MAX_ERROR_ROWS) : []; + const window = pickWindowRows(props.maxRows, errors.length); const start = Math.min( Math.max(0, clamped - Math.floor(window / 2)), Math.max(0, total - window), @@ -101,6 +128,16 @@ export function renderPickList(props: { ); })} + {errors.map((line, i) => ( + + {i === 0 ? "! " : " "} + {line} + + ))} {props.moveHint} {position} · {props.actionsHint} diff --git a/src/tui/providers/providers-wizard-target.ts b/src/tui/providers/providers-wizard-target.ts index e669a399..572466fe 100644 --- a/src/tui/providers/providers-wizard-target.ts +++ b/src/tui/providers/providers-wizard-target.ts @@ -130,10 +130,26 @@ export function apiKeyPhaseError( return `API key required — paste the key, or set ${envHintForWizard(wizard)} in .env first`; } +/** + * How each built-in kind is named in prose. The raw kind is a config + * token, not a service name: `"openrouter" rejected this key` in the + * middle of a screen whose own row says "OpenRouter" reads as a + * different, lower-case product. + */ +const KIND_SERVICE_LABELS: Record = { + "claude-cli": "Claude Code", + "codex-cli": "OpenAI Codex", + openrouter: "OpenRouter", + aimlapi: "AI/ML API", + gemini: "Gemini", + "openai-compatible": "this endpoint", +}; + /** Service name for headings and for every sentence about a failure. */ export function providerLabelForWizard(wizard: ProvidersWizardState): string { const preset = wizard.presetId ? findProviderPreset(wizard.presetId) : undefined; - return preset?.label ?? wizard.kind ?? "provider"; + if (preset) return preset.label; + return wizard.kind ? KIND_SERVICE_LABELS[wizard.kind] : "provider"; } /** The model this wizard run is about to save, before any defaulting. */ From ad7265bdc5b8550e4a44013594239a02c1e6dc29 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:21:43 +0300 Subject: [PATCH 47/57] fix(tui): stop telling an empty task queue to check its filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/tasks` on a fresh install answered "no tasks match the current filter" with `filter: all` in the bar above it, which sends a first-time operator hunting for a filter that was never set instead of telling them what a task is. The empty table now distinguishes the three reasons it can be empty — nothing created yet, a status filter, a live search — and the first case says what tasks are and which key makes one. The footer hint strip is drawn on the empty screen too. The keys are the only thing to learn on this tab, and withholding them until a task exists is a chicken-and-egg for the operator who has none. Co-Authored-By: Claude Opus 5 --- src/tui/components/tasks-list.tsx | 48 +++++++++++++++++---- src/tui/components/tasks-panel-fit.test.tsx | 21 +++++++++ src/tui/tasks/tasks-list-fit.test.ts | 34 +++++++++++++++ src/tui/tasks/tasks-list-fit.ts | 38 ++++++++++++++++ 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/tui/components/tasks-list.tsx b/src/tui/components/tasks-list.tsx index 90943c55..60c63650 100644 --- a/src/tui/components/tasks-list.tsx +++ b/src/tui/components/tasks-list.tsx @@ -6,6 +6,7 @@ import { handleTasksTabKey } from "../tasks/tasks-key-bindings.js"; import { computeTaskListLayout, computeTasksListFit, + describeEmptyTaskList, fitTaskListHints, formatTaskListHeader, formatTaskRowCells, @@ -45,14 +46,7 @@ export function TasksList(props: TasksListProps): ReactElement { const { panel, visibleRows, maxRows, now, width } = props; const layout = computeTaskListLayout(width); if (visibleRows.length === 0) { - return ( - - - no tasks match the current filter — press `n` to create one, - `f` to cycle filter, `r` to refresh. - - - ); + return ; } const fit = computeTasksListFit(maxRows, visibleRows.length); const clamped = Math.max(0, Math.min(panel.cursor, visibleRows.length - 1)); @@ -95,6 +89,44 @@ export function TasksList(props: TasksListProps): ReactElement { ); } +/** + * What the tab shows before the first task exists — and the screen a + * first-run operator meets on `/tasks`. It carries the same hint strip + * as the populated table: the keys are the only thing to learn here, + * and hiding them until a task exists is a chicken-and-egg. + */ +function EmptyState({ + panel, + width, + maxRows, +}: { + panel: TasksPanelState; + width: number; + maxRows: number; +}): ReactElement { + const { headline, detail } = describeEmptyTaskList({ + totalRows: panel.rows.length, + filterStatus: panel.filterStatus, + searchQuery: panel.searchQuery, + }); + // Same ladder as the table: spend rows on the message, then the + // context line, then the breathing room around them. + const roomy = maxRows >= 5; + return ( + + {headline} + {detail && maxRows >= 3 ? ( + {detail} + ) : null} + {maxRows >= 4 ? ( + + {fitTaskListHints(width)} + + ) : null} + + ); +} + function HeaderRow({ layout }: { layout: TaskListLayout }): ReactElement { return ( diff --git a/src/tui/components/tasks-panel-fit.test.tsx b/src/tui/components/tasks-panel-fit.test.tsx index acfd2266..99b8877c 100644 --- a/src/tui/components/tasks-panel-fit.test.tsx +++ b/src/tui/components/tasks-panel-fit.test.tsx @@ -162,3 +162,24 @@ describe("Tasks panel never exceeds its row budget", () => { expect(lines.some((l) => l.includes("Enter detail"))).toBe(true); }); }); + +describe("Tasks panel with an empty queue", () => { + it("tells a first-run operator what a task is and which key makes one", () => { + const { lastFrame } = render( + + + , + ); + const frame = lastFrame() ?? ""; + expect(frame).toContain("no tasks yet"); + expect(frame).not.toContain("match the current filter"); + // The key surface stays on screen with nothing in the list. + expect(frame).toContain("Enter detail"); + expect(frame.split("\n").length).toBeLessThanOrEqual(11); + }); +}); diff --git a/src/tui/tasks/tasks-list-fit.test.ts b/src/tui/tasks/tasks-list-fit.test.ts index fecdeb11..79382e66 100644 --- a/src/tui/tasks/tasks-list-fit.test.ts +++ b/src/tui/tasks/tasks-list-fit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { computeTaskListLayout, computeTasksListFit, + describeEmptyTaskList, fitTaskListHints, formatTaskListHeader, formatTaskRowCells, @@ -202,3 +203,36 @@ describe("row budget", () => { } }); }); + +describe("empty table copy", () => { + it("does not blame a filter when the queue is simply empty", () => { + const text = describeEmptyTaskList({ + totalRows: 0, + filterStatus: "all", + searchQuery: "", + }); + expect(text.headline).not.toContain("filter"); + expect(text.headline).toContain("`n`"); + expect(text.detail).toContain("cron"); + }); + + it("names the filter that is hiding the rows", () => { + const text = describeEmptyTaskList({ + totalRows: 12, + filterStatus: "running", + searchQuery: "", + }); + expect(text.headline).toContain("running"); + expect(text.detail).toContain("`f`"); + }); + + it("names the search that is hiding the rows", () => { + const text = describeEmptyTaskList({ + totalRows: 12, + filterStatus: "all", + searchQuery: "digest ", + }); + expect(text.headline).toContain("digest"); + expect(text.detail).toContain("Esc"); + }); +}); diff --git a/src/tui/tasks/tasks-list-fit.ts b/src/tui/tasks/tasks-list-fit.ts index 571ec26a..30c7f103 100644 --- a/src/tui/tasks/tasks-list-fit.ts +++ b/src/tui/tasks/tasks-list-fit.ts @@ -340,3 +340,41 @@ function withScrollMarkers( listRows: Math.max(1, available - markers), }; } + +/** Copy for an empty table, split so a narrow panel can drop the detail. */ +export interface EmptyTaskListText { + headline: string; + /** Second line — context, safe to drop when rows are scarce. */ + detail: string | null; +} + +/** + * What to say when the table has nothing to draw. A fresh install hits + * this screen first, and it used to answer with "no tasks match the + * current filter" even when the queue was simply empty — sending a + * first-time operator hunting for a filter that was never set instead + * of telling them what a task is and which key makes one. + */ +export function describeEmptyTaskList(args: { + totalRows: number; + filterStatus: string; + searchQuery: string; +}): EmptyTaskListText { + const query = args.searchQuery.trim(); + if (args.totalRows === 0) { + return { + headline: "no tasks yet — press `n` to create one.", + detail: "tasks fire on a cron / interval schedule, or once at a time.", + }; + } + if (query.length > 0) { + return { + headline: `nothing matches “${query}”.`, + detail: "Esc clears the search · `f` cycles the status filter.", + }; + } + return { + headline: `no ${args.filterStatus} tasks right now.`, + detail: "`f` cycles the status filter · `r` refreshes the list.", + }; +} From d162cb2c3d5076a07e14d35eda24e135763899ab Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:24:46 +0300 Subject: [PATCH 48/57] tui: per-message copy button + honest answer to mouse text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user wants two things: to select text with the mouse and copy it, and a copy control at the end of each message. Selection. Mouse reporting (1000+1006) takes the terminal's own drag-to-select away; that is the trade-off PR #165 accepted. Rather than rebuild selection inside the app — which needs motion reports, a per-cell readback Ink does not expose, and every component under the rectangle to become selection-aware, and would still copy borders and wrap points instead of the message source — this leans on the terminal's own bypass and makes it reachable: - Shift+drag already works on iTerm2, kitty, WezTerm, Alacritty, foot, Windows Terminal and VS Code. It was simply never advertised; the `/mouse on` confirmation now says so. - A shift-modified press that *reaches* the app is proof the terminal has no bypass (Apple Terminal). `selection-passthrough.ts` reads it as "I was trying to select", suspends reporting for 10s and says so. Inert on every terminal where the bypass works. - `MouseTrackingController` grows `suspend()`/`resume()` for that, keeping the `process.on("exit")` restore installed across the gap and refusing to re-write the disable pair on a suspended controller. Copy button. `[copy]` under every finalised bubble in the palette's `muted` grey, dimmed, flipping to `[copied!]` for 2s. It copies the raw message text, not the rendered frame. The badge timer lives in a ref so a re-render cannot strand it, restarts rather than stacks on a re-click, and is cleared on unmount. Clipboard. New `src/tui/clipboard/`: OSC 52 *and* the platform command, because they fail in opposite situations — OSC 52 is the only route that survives SSH but is advisory and never answers, the platform command is authoritative but targets the wrong machine remotely. Nothing is attempted on a non-TTY stdout, which is also what keeps the test suite off the developer's clipboard. Fully injectable. Verified on a real pty (pty.fork + pyte, 120x40): click flips `[copy]` → `[copied!]` → back, OSC 52 goes out, pbcopy receives the message text, Shift+click emits `1000l`, and quitting restores 1006l/1000l/1049l. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 15 +- src/tui/clipboard/clipboard-context.tsx | 59 ++++ src/tui/clipboard/copy-to-clipboard.test.ts | 212 +++++++++++++ src/tui/clipboard/copy-to-clipboard.ts | 211 +++++++++++++ src/tui/clipboard/index.ts | 19 ++ src/tui/components/chat-copy-button.test.tsx | 294 ++++++++++++++++++ src/tui/components/chat-copy-button.tsx | 124 ++++++++ src/tui/components/chat-log.tsx | 30 +- .../components/chat-message-height.test.ts | 14 +- src/tui/components/chat-message-height.ts | 9 +- src/tui/mouse/index.ts | 7 + src/tui/mouse/mouse-app.test.tsx | 26 ++ src/tui/mouse/mouse-tracking.test.ts | 66 ++++ src/tui/mouse/mouse-tracking.ts | 59 +++- src/tui/mouse/selection-passthrough.test.ts | 151 +++++++++ src/tui/mouse/selection-passthrough.ts | 151 +++++++++ src/tui/tui-command.ts | 25 +- 17 files changed, 1449 insertions(+), 23 deletions(-) create mode 100644 src/tui/clipboard/clipboard-context.tsx create mode 100644 src/tui/clipboard/copy-to-clipboard.test.ts create mode 100644 src/tui/clipboard/copy-to-clipboard.ts create mode 100644 src/tui/clipboard/index.ts create mode 100644 src/tui/components/chat-copy-button.test.tsx create mode 100644 src/tui/components/chat-copy-button.tsx create mode 100644 src/tui/mouse/selection-passthrough.test.ts create mode 100644 src/tui/mouse/selection-passthrough.ts diff --git a/AGENTS.md b/AGENTS.md index 825f091b..35641985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,18 @@ The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse **The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v38, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys. +## Selecting and copying text + +Three mechanisms, deliberately, because no single one covers every terminal. + +1. **Shift+drag** — on iTerm2, kitty, WezTerm, Alacritty, foot, Windows Terminal and VS Code the terminal keeps a shift-modified drag for itself and never reports it, so native selection (and the terminal's own copy-on-select / ⌘C) is one modifier away at zero cost. This is the primary answer and it is now advertised in the `/mouse on` confirmation. +2. **Selection pause** (`mouse/selection-passthrough.ts`) — a shift-modified press that *does* reach the app is proof this terminal has no bypass (Apple Terminal). The app reads it as "I was trying to select", calls `MouseTrackingController.suspend()` for 10s and says so in chat; reporting comes back on its own. Inert everywhere the bypass works. `suspend()`/`resume()` write the same escape pairs `disable()` does but keep the controller — and its `process.on("exit")` restore — alive, and `disable()` while suspended writes nothing rather than disabling modes the terminal never re-enabled. +3. **The per-message `[copy]` button** (`components/chat-copy-button.tsx`) — one under every finalised bubble, in `muted` + `dimColor` so a column of them stays quiet. It copies the message *source*: raw text, before markdown rendering, borders and wrapping, which is strictly better than what a drag over the same rows would have given. Label flips to `[copied!]` for 2s (`[copy failed]` when the clipboard refuses); the timer lives in a ref, is restarted rather than stacked on a re-click, and is cleared on unmount. + +**In-app selection was considered and rejected.** It needs motion reports (1002/1003, off on purpose), a readback of what character is painted in each cell (Ink exposes sizes only — `mouse-registry` reconstructs geometry from Yoga, never from painted text), and every component under the selection rectangle to become selection-aware. The result would still be worse: no access to the scrollback above the alt screen, no honouring of the terminal's own copy gesture, and the copied text would carry borders and wrap points. The reasoning is written out at the top of `selection-passthrough.ts`. + +**Clipboard** (`src/tui/clipboard/`) — `createClipboardWriter` emits **OSC 52** *and* runs the platform command (`pbcopy` / `wl-copy` / `xclip` / `clip`), because the two fail in opposite situations: OSC 52 is the only thing that survives SSH but is advisory and never answers, while the platform command is authoritative but targets the wrong machine remotely and does not exist headless. Success is reported if either landed — optimistic for OSC 52 by design, since the alternative is a false "failed" on every SSH session. Payloads too large for a safe OSC 52 skip it and rely on the platform command. **Nothing is attempted when stdout is not a TTY**, which is also what keeps `vitest` from overwriting the clipboard of whoever runs the suite. Everything is injectable; `ClipboardProvider` overrides the shared default in tests. + **Testing.** Escape sequences, decoder and stream split are unit-tested; `mouse-app.test.tsx` drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target. ## Module map @@ -196,7 +208,8 @@ The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse | `src/channels/telegram/` | `TelegramChannel` (lifecycle + live-control), `inbound-handler` (slash commands + dispatch into `runTurn`), `outbound-sender` (chunked replies + 429 retry), `approval-bridge` (inline-keyboard approvals with 8-min auto-deny), `pairing-mode` (60s window for first-DM owner claim), `telegram-settings` (`config.json` + `.env` persistence), `telegram-bot-factory` (grammy adapter). The **only** module that imports `grammy`. See §"Telegram remote-control channel". | | `src/tui/telegram/` | TUI "Telegram" tab: `telegram-panel-state` + `telegram-actions` + `telegram-panel-reducer` (pure UI state slice), `tui-telegram-orchestrator` (the only TUI module that touches `runtime.telegramChannel`), `telegram-key-bindings`, and the `telegram-panel` / `telegram-token-prompt` / `telegram-pairing-modal` components. See §"Telegram remote-control channel". | | `src/mcp/` | MCP (Model Context Protocol) **client** subsystem. `McpManager` (lifecycle for N `McpClient` instances), `mcp-client` (the **only** file that imports `@modelcontextprotocol/sdk` — together with `mcp-sampling-handler` for SDK type shapes), `mcp-tool-adapter` (`McpToolMeta` → `ToolDefinition`), `mcp-resource-class` (per-server trust → `ResourceClass` resolver), `mcp-descriptor-builder` (rare-tier descriptors), `mcp-grammar-builder` (dynamic `mcp-server-tool` GBNF fragment), `mcp-sampling-handler` (forwards `sampling/createMessage` to `LlamaServerClient` with `slotId: -1`), `mcp-resource-tools` + `mcp-prompt-tools` (aggregate read-only `mcp.{resource,prompt}.*` tools dispatching by `server` arg). See §"MCP client". | -| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler). See §"Mouse support". | +| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler), `selection-passthrough` (momentary hand-back of native drag-to-select). See §"Mouse support" and §"Selecting and copying text". | +| `src/tui/clipboard/` | Clipboard writer (OSC 52 + platform command, both injectable) and its React provider. See §"Selecting and copying text". | ## Secrets and process environment diff --git a/src/tui/clipboard/clipboard-context.tsx b/src/tui/clipboard/clipboard-context.tsx new file mode 100644 index 00000000..2bcf83b0 --- /dev/null +++ b/src/tui/clipboard/clipboard-context.tsx @@ -0,0 +1,59 @@ +/** + * React access to the clipboard writer. + * + * Shaped like `mouse-context.tsx` and for the same reason: the chat + * bubbles are presentational and prop-drilling a writer down through + * `ChatLog` → `FinalisedMessage` → every bubble would be a bigger change + * than the feature earns. + * + * Unlike the mouse context there is a **default** when no provider is + * mounted, because a copy button with no clipboard is not a degraded + * button, it is a broken one. The default is created lazily and shared, + * so the common case — the real TUI, which mounts no provider — needs no + * wiring at all. `createClipboardWriter` refuses to act on a non-TTY + * stdout, which is what keeps that default from touching a real human's + * clipboard when a component test happens to render a copy button. + * + * Tests that want to *observe* a copy mount `ClipboardProvider` with a + * fake and get an exact record of what was copied. + */ +import { createContext, useContext, type ReactElement, type ReactNode } from "react"; +import { + createClipboardWriter, + type ClipboardWriter, +} from "./copy-to-clipboard.js"; + +const ClipboardContext = createContext(null); + +let defaultWriter: ClipboardWriter | null = null; + +/** + * The process-wide writer used when no provider is mounted. Lazy so that + * merely importing a chat component does not read `process.platform` or + * capture a `process.stdout` that a harness may still replace. + */ +export function getDefaultClipboardWriter(): ClipboardWriter { + defaultWriter ??= createClipboardWriter(); + return defaultWriter; +} + +export interface ClipboardProviderProps { + readonly writer: ClipboardWriter; + readonly children: ReactNode; +} + +export function ClipboardProvider({ + writer, + children, +}: ClipboardProviderProps): ReactElement { + return ( + + {children} + + ); +} + +/** The active clipboard writer — the provider's, or the shared default. */ +export function useClipboard(): ClipboardWriter { + return useContext(ClipboardContext) ?? getDefaultClipboardWriter(); +} diff --git a/src/tui/clipboard/copy-to-clipboard.test.ts b/src/tui/clipboard/copy-to-clipboard.test.ts new file mode 100644 index 00000000..1c5fa595 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommandRunner, +} from "./copy-to-clipboard.js"; + +interface FakeStdout { + isTTY: boolean; + writes: string[]; + write(chunk: string): boolean; +} + +function makeStdout(isTty: boolean): FakeStdout { + const writes: string[] = []; + return { + isTTY: isTty, + writes, + write(chunk: string): boolean { + writes.push(chunk); + return true; + }, + }; +} + +interface RunLog { + runner: ClipboardCommandRunner; + calls: Array<{ command: string; args: readonly string[]; text: string }>; +} + +function makeRunner(result: boolean): RunLog { + const calls: RunLog["calls"] = []; + return { + calls, + runner: async (command, args, text) => { + calls.push({ command, args, text }); + return result; + }, + }; +} + +describe("osc52Sequence", () => { + it("wraps base64 in the OSC 52 clipboard sequence", () => { + expect(osc52Sequence("hi")).toBe("\u001B]52;c;aGk=\u0007"); + }); + + it("encodes non-ASCII as UTF-8 bytes, not UTF-16 units", () => { + // A terminal decodes the payload as bytes; encoding "é" as its + // UTF-16 code unit would paste a replacement character. + expect(osc52Sequence("é")).toBe( + `\u001B]52;c;${Buffer.from("é", "utf8").toString("base64")}\u0007`, + ); + }); + + it("carries newlines through untouched", () => { + const decoded = Buffer.from( + osc52Sequence("a\nb").slice("\u001B]52;c;".length, -1), + "base64", + ).toString("utf8"); + expect(decoded).toBe("a\nb"); + }); +}); + +describe("fitsInOsc52", () => { + it("accepts an ordinary chat message", () => { + expect(fitsInOsc52("a normal reply")).toBe(true); + }); + + it("rejects a payload past the terminal-safe ceiling", () => { + const tooBig = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(fitsInOsc52(tooBig)).toBe(false); + }); +}); + +describe("platformClipboardCommand", () => { + it("uses pbcopy on macOS", () => { + expect(platformClipboardCommand("darwin", {})).toEqual({ + command: "pbcopy", + args: [], + }); + }); + + it("uses clip on Windows", () => { + expect(platformClipboardCommand("win32", {})?.command).toBe("clip"); + }); + + it("prefers wl-copy over xclip when both sessions advertise themselves", () => { + const command = platformClipboardCommand("linux", { + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + }); + expect(command?.command).toBe("wl-copy"); + }); + + it("falls back to xclip under X11", () => { + expect(platformClipboardCommand("linux", { DISPLAY: ":0" })).toEqual({ + command: "xclip", + args: ["-selection", "clipboard"], + }); + }); + + it("has nothing to offer on a headless box", () => { + // Not a failure: OSC 52 is the correct — and only — route back to + // the clipboard of whoever is on the other end of the ssh pipe. + expect(platformClipboardCommand("linux", {})).toBeNull(); + }); +}); + +describe("createClipboardWriter", () => { + it("emits OSC 52 and runs the platform command for one copy", () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => { + expect(ok).toBe(true); + expect(stdout.writes).toEqual([osc52Sequence("hello")]); + expect(run.calls).toEqual([ + { command: "pbcopy", args: [], text: "hello" }, + ]); + }); + }); + + it("still reports success when the platform command fails but OSC 52 went out", () => { + // The SSH case: pbcopy would target the wrong machine anyway, and a + // terminal that honoured OSC 52 has the text. + const stdout = makeStdout(true); + const run = makeRunner(false); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + return writer.copy("hello").then((ok) => expect(ok).toBe(true)); + }); + + it("reports success from the platform command alone when the payload is too big for OSC 52", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + const huge = "x".repeat(OSC52_MAX_BASE64_CHARS); + expect(await writer.copy(huge)).toBe(true); + expect(stdout.writes).toEqual([]); + expect(run.calls[0]?.text).toBe(huge); + }); + + it("reports failure when there is no platform command and the payload is too big", async () => { + const stdout = makeStdout(true); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "linux", + env: {}, + }); + expect(await writer.copy("x".repeat(OSC52_MAX_BASE64_CHARS))).toBe(false); + expect(run.calls).toEqual([]); + }); + + it("does nothing at all when stdout is not a TTY", async () => { + // This guard is what keeps `npx vitest` from overwriting the + // clipboard of whoever is running the suite. + const stdout = makeStdout(false); + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(false); + expect(stdout.writes).toEqual([]); + expect(run.calls).toEqual([]); + }); + + it("falls back to the platform command when the stdout write throws", async () => { + const run = makeRunner(true); + const writer = createClipboardWriter({ + stdout: { + isTTY: true, + write: () => { + throw new Error("EIO"); + }, + }, + runCommand: run.runner, + platform: "darwin", + env: {}, + }); + expect(await writer.copy("hello")).toBe(true); + expect(run.calls).toHaveLength(1); + }); +}); + +describe("createNullClipboardWriter", () => { + it("always reports failure", async () => { + expect(await createNullClipboardWriter().copy("hello")).toBe(false); + }); +}); diff --git a/src/tui/clipboard/copy-to-clipboard.ts b/src/tui/clipboard/copy-to-clipboard.ts new file mode 100644 index 00000000..fe2fdd62 --- /dev/null +++ b/src/tui/clipboard/copy-to-clipboard.ts @@ -0,0 +1,211 @@ +/** + * Writing to the *user's* clipboard from a TUI. + * + * There is no single mechanism that works everywhere, and the two that + * exist fail in exactly opposite situations — so this module runs both + * and reports success if either one landed. + * + * - **OSC 52** (`ESC ] 52 ; c ; BEL`) asks the terminal + * emulator itself to set the clipboard. It is the only mechanism + * that survives SSH: the bytes travel back up the same pty the + * frames come down, so the text lands on the machine the human is + * sitting at rather than on the box the agent happens to run on. + * Its weakness is that it is advisory — the terminal may ignore it + * (Apple Terminal does), gate it behind a preference (iTerm2's + * "Applications in terminal may access clipboard"), or swallow it + * in a multiplexer (tmux needs `set -g set-clipboard on`; GNU screen + * needs DCS wrapping we do not emit). Crucially, **there is no + * reply**: a terminal that ignores 52 is indistinguishable from one + * that honoured it, so we can never report "OSC 52 worked". + * - **The platform clipboard command** (`pbcopy`, `wl-copy`, `xclip`, + * `clip.exe`) is authoritative — it either exits 0 or it does not — + * but it writes to the clipboard of the machine the *process* runs + * on, which is the wrong machine over SSH, and it does not exist at + * all on a headless box. + * + * Doing both is not belt-and-braces sloppiness; it is the only way to + * cover Apple Terminal (native only) and a remote session (OSC 52 only) + * with one code path. Writing the same string twice is harmless: the + * clipboard ends up holding that string either way. + * + * Safety of interleaving OSC 52 with Ink's frames: the sequence moves no + * cursor, sets no mode, and paints no cell, so a terminal that + * understands it consumes it invisibly wherever it lands between Ink's + * writes, and one that does not silently drops an unknown OSC. That is + * why it can be written straight to the same stdout Ink is rendering to + * without coordinating with the renderer or leaving the alt screen. + * + * Everything the writer touches — stdout, process spawning, platform, + * env — is injected, so tests exercise the real decision logic without + * going anywhere near the developer's actual clipboard. + */ +import { spawn } from "node:child_process"; + +export interface ClipboardWriter { + /** + * Copies `text`. Resolves `true` when at least one mechanism is + * believed to have worked — see {@link createClipboardWriter} for what + * "believed" can and cannot mean. + */ + copy(text: string): Promise; +} + +/** Minimal shape of the stream OSC 52 is written to. */ +export interface ClipboardStdout { + write(chunk: string): unknown; + readonly isTTY?: boolean; +} + +/** Runs a clipboard command with `text` on stdin; resolves `true` on exit 0. */ +export type ClipboardCommandRunner = ( + command: string, + args: readonly string[], + text: string, +) => Promise; + +export interface ClipboardWriterOptions { + readonly stdout?: ClipboardStdout; + readonly runCommand?: ClipboardCommandRunner; + readonly platform?: NodeJS.Platform; + readonly env?: Readonly>; +} + +export interface ClipboardCommand { + readonly command: string; + readonly args: readonly string[]; +} + +/** + * Terminals differ on how much base64 they will accept in one OSC 52, + * and the ones that dislike a long payload tend to drop it *silently* + * rather than truncate — which would leave the user with a stale + * clipboard and a cheerful "copied!". Past this size we skip OSC 52 and + * let the platform command carry the copy alone; a paste that big is + * overwhelmingly a local one anyway. + */ +export const OSC52_MAX_BASE64_CHARS = 100_000; + +/** BEL terminator: accepted everywhere `ESC \` is, and by a few terminals that mis-parse ST. */ +const BEL = "\u0007"; + +/** The OSC 52 sequence that sets the system clipboard (`c`) to `text`. */ +export function osc52Sequence(text: string): string { + const payload = Buffer.from(text, "utf8").toString("base64"); + return `\u001B]52;c;${payload}${BEL}`; +} + +/** `true` when `text` is small enough to be worth sending as OSC 52. */ +export function fitsInOsc52(text: string): boolean { + // 4 base64 chars per 3 input bytes, rounded up — cheaper than encoding + // a megabyte of transcript just to find out it is too big. + const bytes = Buffer.byteLength(text, "utf8"); + return Math.ceil(bytes / 3) * 4 <= OSC52_MAX_BASE64_CHARS; +} + +/** + * The platform's clipboard command, or `null` when there is none worth + * trying. On Linux the answer depends on the *session*, not the OS: + * `wl-copy` under Wayland, `xclip` under X11, and nothing at all on a + * headless box — where returning `null` is the honest answer and OSC 52 + * is the only route back to the human's clipboard. + */ +export function platformClipboardCommand( + platform: NodeJS.Platform, + env: Readonly>, +): ClipboardCommand | null { + if (platform === "darwin") return { command: "pbcopy", args: [] }; + if (platform === "win32") return { command: "clip", args: [] }; + if (env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] }; + if (env.DISPLAY) { + return { command: "xclip", args: ["-selection", "clipboard"] }; + } + return null; +} + +/** + * Default runner: spawns the command, feeds `text` on stdin, resolves on + * the exit code. A missing binary surfaces as an `error` event rather + * than a non-zero exit, so both collapse to `false` — the caller only + * ever needs "did the clipboard change". + */ +const spawnClipboardCommand: ClipboardCommandRunner = ( + command, + args, + text, +) => + new Promise((resolve) => { + let settled = false; + const done = (ok: boolean): void => { + if (settled) return; + settled = true; + resolve(ok); + }; + try { + const child = spawn(command, [...args], { + stdio: ["pipe", "ignore", "ignore"], + }); + child.on("error", () => done(false)); + child.on("close", (code) => done(code === 0)); + // EPIPE here means the child died before reading — `close` already + // has that case covered, so the write error is not interesting. + child.stdin?.on("error", () => {}); + child.stdin?.end(text); + } catch { + done(false); + } + }); + +/** + * Builds the clipboard writer used by the TUI. + * + * `copy` resolves `true` if the platform command succeeded, **or** if we + * emitted OSC 52 to a TTY. The second half is optimism, and deliberately + * so: OSC 52 never answers, so the alternative is to report failure on + * every terminal that only supports OSC 52 (i.e. every SSH session), + * which would be wrong far more often than the optimism is. A stale + * clipboard is recoverable; a "copy failed" badge on a copy that worked + * teaches the user the button is broken. + * + * When stdout is not a TTY nothing is attempted at all. There is no + * terminal to talk to, and — the reason this guard matters in practice — + * it keeps every non-interactive run, the test suite included, from + * reaching out and overwriting a real human's clipboard. + */ +export function createClipboardWriter( + options: ClipboardWriterOptions = {}, +): ClipboardWriter { + const stdout = options.stdout ?? process.stdout; + const runCommand = options.runCommand ?? spawnClipboardCommand; + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + return { + async copy(text: string): Promise { + if (stdout.isTTY !== true) return false; + let claimed = false; + if (fitsInOsc52(text)) { + try { + stdout.write(osc52Sequence(text)); + claimed = true; + } catch { + // A stdout that rejects a write is a dead terminal; the + // platform command may still be able to do the job. + } + } + const command = platformClipboardCommand(platform, env); + if (command) { + const ok = await runCommand(command.command, command.args, text); + claimed = claimed || ok; + } + return claimed; + }, + }; +} + +/** + * A writer that does nothing and reports failure. Used where a clipboard + * is structurally unavailable, and as the explicit stand-in in tests + * that must not touch a real one. + */ +export function createNullClipboardWriter(): ClipboardWriter { + return { copy: async () => false }; +} diff --git a/src/tui/clipboard/index.ts b/src/tui/clipboard/index.ts new file mode 100644 index 00000000..cf8f8122 --- /dev/null +++ b/src/tui/clipboard/index.ts @@ -0,0 +1,19 @@ +export { + createClipboardWriter, + createNullClipboardWriter, + fitsInOsc52, + osc52Sequence, + platformClipboardCommand, + OSC52_MAX_BASE64_CHARS, + type ClipboardCommand, + type ClipboardCommandRunner, + type ClipboardStdout, + type ClipboardWriter, + type ClipboardWriterOptions, +} from "./copy-to-clipboard.js"; +export { + ClipboardProvider, + getDefaultClipboardWriter, + useClipboard, + type ClipboardProviderProps, +} from "./clipboard-context.js"; diff --git a/src/tui/components/chat-copy-button.test.tsx b/src/tui/components/chat-copy-button.test.tsx new file mode 100644 index 00000000..cc088a9a --- /dev/null +++ b/src/tui/components/chat-copy-button.test.tsx @@ -0,0 +1,294 @@ +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { ClipboardProvider } from "../clipboard/clipboard-context.js"; +import type { ClipboardWriter } from "../clipboard/copy-to-clipboard.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiSessionInfo } from "../tui-state.js"; +import { ChatCopyButton } from "./chat-copy-button.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "copy", + workingDir: "/tmp/copy", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +/** + * Screen position of `needle`. Stripping SGR leaves the visual grid + * intact, so these are the cells a terminal would report for a click — + * the same trick `mouse-app.test.tsx` uses. + */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +/** + * Captured before any `vi.useFakeTimers()` call so the polling below + * keeps running on real time. The fake-timer tests here deliberately + * fake **only** `setTimeout`/`clearTimeout` — the component's badge + * window and nothing else. Faking the whole clock would also freeze + * React's scheduler and Ink's own bookkeeping, and the frame under + * assertion would simply never repaint. + */ +const realSetTimeout = globalThis.setTimeout; + +const delay = (ms: number): Promise => + new Promise((resolve) => realSetTimeout(resolve, ms)); + +/** Fakes the badge window only. See {@link realSetTimeout}. */ +function fakeBadgeTimerOnly(): void { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +} + +/** + * Ink commits a frame and React flushes the effect that registers the + * click target on its own schedule, so a freshly rendered button is not + * clickable for a tick or two. Everything here polls rather than + * sleeping a fixed interval. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + clickAt: (needle: string) => void; + clickCell: (x: number, y: number) => void; + unmount: () => void; +} + +function noopCallbacks(): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + }; +} + +function mount( + writer: ClipboardWriter, + children: ReactNode, + { withMouse = true }: { withMouse?: boolean } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + const state = createInitialTuiState(SESSION); + const tree: ReactElement = withMouse ? ( + {}} + callbacks={noopCallbacks()} + getState={() => state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render( + {tree}, + ); + const frame = (): string => strip(lastFrame() ?? ""); + return { + frame, + clickAt: (needle) => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }, + clickCell: (x, y) => registry.dispatch(click(x, y)), + unmount, + }; +} + +/** + * Clicks `[copy]` until the click actually lands. The target is + * registered by an effect that runs after the frame the label first + * appears in, so the first click can fall on a cell nothing owns yet — + * the same reason `mouse-app.test.tsx` re-sends its clicks. + */ +async function clickCopy(app: Harness, copied: readonly string[]): Promise { + await waitUntil(() => app.frame().includes("[copy]"), "the idle label"); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (copied.length > 0) return; + app.clickAt("[copy]"); + await delay(25); + } + throw new Error("click never took effect on the copy button"); +} + +function recordingWriter(result = true): { + writer: ClipboardWriter; + copied: string[]; +} { + const copied: string[] = []; + return { + copied, + writer: { + copy: async (text: string) => { + copied.push(text); + return result; + }, + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ChatCopyButton", () => { + it("renders the quiet idle label", () => { + const app = mount(recordingWriter().writer, ); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + // `useMouseCommands()` is null under `--no-mouse` and in every + // component test; the button must degrade to a label, not vanish. + const app = mount(recordingWriter().writer, , { + withMouse: false, + }); + expect(app.frame()).toContain("[copy]"); + app.unmount(); + }); + + it("copies the message text and flips the label when clicked", async () => { + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + expect(copied).toEqual(["the exact reply"]); + await waitUntil( + () => app.frame().includes("[copied!]"), + "the copied badge", + ); + app.unmount(); + }); + + it("reports a refused copy instead of claiming success", async () => { + const { writer, copied } = recordingWriter(false); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil( + () => app.frame().includes("[copy failed]"), + "the failure badge", + ); + app.unmount(); + }); + + it("copies the message its own button belongs to, not a neighbour's", async () => { + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + <> + + + , + ); + await waitUntil(() => app.frame().split("[copy]").length === 3, "both buttons"); + // The second button is the second `[copy]` on screen — one row down. + const first = locate(app.frame(), "[copy]"); + for (let attempt = 0; attempt < 40 && copied.length === 0; attempt += 1) { + app.clickCell(first.x, first.y + 1); + await delay(25); + } + expect(copied).toEqual(["second message"]); + app.unmount(); + }); + + it("does not leave a timer behind when unmounted mid-badge", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount(writer, ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + // Only the badge window is faked, so this count is the component's + // pending revert and nothing else. + expect(vi.getTimerCount()).toBe(1); + app.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("ChatCopyButton label timer", () => { + it("reverts to the idle label once the badge window elapses", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_999); + expect(app.frame()).toContain("[copied!]"); + vi.advanceTimersByTime(1); + await waitUntil( + () => app.frame().includes("[copy]") && !app.frame().includes("[copied!]"), + "the label reverting on its own", + ); + app.unmount(); + }); + + it("a second click restarts the window instead of letting the first timer clear it", async () => { + fakeBadgeTimerOnly(); + const { writer, copied } = recordingWriter(); + const app = mount( + writer, + , + ); + await clickCopy(app, copied); + await waitUntil(() => app.frame().includes("[copied!]"), "the copied badge"); + vi.advanceTimersByTime(4_000); + const seen = copied.length; + app.clickAt("[copied!]"); + await waitUntil(() => copied.length > seen, "the second copy"); + // `copied` grows inside `copy()`; the badge timer is only restarted + // in the `.then` after it. Give that microtask a real tick. + await delay(25); + // The first click's timeout is due 1s from here. If it had not been + // cleared, the badge would blink off a second after the re-click. + vi.advanceTimersByTime(2_000); + expect(vi.getTimerCount()).toBe(1); + expect(app.frame()).toContain("[copied!]"); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-copy-button.tsx b/src/tui/components/chat-copy-button.tsx new file mode 100644 index 00000000..b5e0b851 --- /dev/null +++ b/src/tui/components/chat-copy-button.tsx @@ -0,0 +1,124 @@ +import { Box, Text } from "ink"; +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; +import { useClipboard } from "../clipboard/clipboard-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { theme } from "../theme/theme.js"; + +interface ChatCopyButtonProps { + /** Exactly the text that lands on the clipboard — no markdown, no borders. */ + readonly text: string; + /** How long `copied!` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-copied / copy-was-refused. Drives the label and nothing else. */ +type CopyStatus = "idle" | "copied" | "failed"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[copy]", + copied: "[copied!]", + failed: "[copy failed]", +}; + +/** + * The per-message copy affordance in the chat log. + * + * **Why a button at all.** Mouse reporting takes the terminal's own + * drag-to-select away (see `mouse-tracking.ts`), and "I want that reply + * on my clipboard" is overwhelmingly the reason anyone selects text in a + * chat TUI. A button answers that intent directly and, unlike a + * selection, copies the message *source* — the raw text, not the + * markdown-rendered, border-decorated, hard-wrapped thing on screen, + * which is what a drag would have given you. + * + * **Why brackets and no colour.** `[copy]` in the palette's `muted` + * grey, dimmed, is the quietest thing that still reads as a control. + * There is one of these under every message; anything with hue would + * turn the transcript into a column of badges. "Dark grey" is expressed + * as a theme token rather than a literal because the four light palettes + * would swallow a literal `#555` whole — `muted` + `dimColor` is the + * darkest grey each palette actually has. + * + * **Without a mouse provider** (component tests, `--no-mouse`) the + * button still renders — it is a legible hint that the message has a + * copy affordance when the mouse is on — but registers no target. + */ +export function ChatCopyButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatCopyButtonProps): ReactElement { + const clipboard = useClipboard(); + const mouse = useMouseCommands(); + const [status, setStatus] = useState("idle"); + // Both refs exist to survive re-renders, which happen constantly here: + // every streamed token repaints the whole chat log. A timer id in + // component state would be replaced mid-flight and the old timeout + // would fire against a stale closure, stranding the label. + const timerRef = useRef(null); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, []); + + const flash = useCallback( + (next: CopyStatus) => { + if (!mountedRef.current) return; + setStatus(next); + // Restart rather than stack: a second click while `copied!` is up + // must extend the badge, not leave an older timeout to clear it + // early and blink the label back mid-feedback. + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (mountedRef.current) setStatus("idle"); + }, revertAfterMs); + }, + [revertAfterMs], + ); + + const copy = useCallback(() => { + // Fire-and-forget: the click handler runs outside React's render + // pass and the clipboard write can outlive the frame. `flash` is the + // only thing that touches state, and it checks `mountedRef` first. + void clipboard + .copy(text) + .then((ok) => flash(ok ? "copied" : "failed")) + .catch(() => flash("failed")); + }, [clipboard, text, flash]); + + const label = ( + + {LABELS[status]} + + ); + + // A row wrapper, not a column child: in a column Yoga stretches the + // target to the full chat width and every click on the line would + // copy. In a row it hugs the six cells the label actually occupies. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + copy(); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index bc020054..eb367c77 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -6,6 +6,7 @@ import type { TuiAction } from "../tui-action.js"; import type { ChatMessage, TuiState } from "../tui-state.js"; import { theme } from "../theme/theme.js"; import { AssistantBubble } from "./assistant-bubble.js"; +import { ChatCopyButton } from "./chat-copy-button.js"; import { estimateMessageHeight, estimateStreamingTailHeight, @@ -165,12 +166,27 @@ interface FinalisedMessageProps { toolsExpandedById: Readonly>; } +/** + * One finalised message plus its copy affordance. + * + * The button hangs below the bubble rather than inside it so the bubble + * components stay purely presentational, and so the copied text is the + * message's own `text` — the raw source, before markdown rendering, + * borders and wrapping. It is attached only to **finalised** messages: + * the streaming tail is by definition half a message, and a copy taken + * mid-stream would silently truncate. + */ function FinalisedMessage({ message, toolsExpandedById, }: FinalisedMessageProps): ReactElement { if (message.role === "user") { - return ; + return ( + + + + + ); } if (message.role === "assistant") { return ( @@ -202,14 +218,18 @@ function FinalisedMessage({ text={message.text} toolSteps={message.toolSteps ?? 0} /> + ); } return ( - + + + + ); } diff --git a/src/tui/components/chat-message-height.test.ts b/src/tui/components/chat-message-height.test.ts index 218fe73a..1e2b5f0a 100644 --- a/src/tui/components/chat-message-height.test.ts +++ b/src/tui/components/chat-message-height.test.ts @@ -28,8 +28,8 @@ function assistantMsg( describe("estimateMessageHeight", () => { it("counts a single-line user message as body + bubble overhead", () => { const h = estimateMessageHeight(userMsg("u1", "hello")); - // 1 body + 3 overhead (margin + 2 padding) - expect(h).toBe(4); + // 1 body + 3 overhead (margin + 2 padding) + 1 copy-button row + expect(h).toBe(5); }); it("counts assistant footer when toolSteps > 0", () => { @@ -70,9 +70,9 @@ describe("selectVisibleMessages", () => { userMsg("u3", "c"), userMsg("u4", "d"), ]; - // Each 1-line user message costs 4 rows. Budget for 2 messages - // exactly: 8 rows. - const slice = selectVisibleMessages(msgs, 0, 8); + // Each 1-line user message costs 5 rows (4 of bubble + the copy + // button under it). Budget for 2 messages exactly: 10 rows. + const slice = selectVisibleMessages(msgs, 0, 10); expect(slice.visible.map((m) => m.id)).toEqual(["u3", "u4"]); expect(slice.hiddenAbove).toBe(2); }); @@ -92,8 +92,8 @@ describe("selectVisibleMessages", () => { it("respects multiline body length", () => { const longMsg = userMsg("u1", "line1\nline2\nline3\nline4\nline5"); - // 5 body + 3 overhead = 8 rows. - expect(estimateMessageHeight(longMsg)).toBe(8); + // 5 body + 3 overhead + 1 copy button = 9 rows. + expect(estimateMessageHeight(longMsg)).toBe(9); const slice = selectVisibleMessages( [longMsg, userMsg("u2", "tail")], 0, diff --git a/src/tui/components/chat-message-height.ts b/src/tui/components/chat-message-height.ts index d9236814..ec9849ec 100644 --- a/src/tui/components/chat-message-height.ts +++ b/src/tui/components/chat-message-height.ts @@ -25,6 +25,13 @@ const BUBBLE_OVERHEAD_ROWS = 3; // marginTop + paddingTop + paddingBottom const REASONING_BUBBLE_OVERHEAD_ROWS = 5; // marginTop + paddingTop + 1-line header + paddingBottom + safety const TOOL_CARD_BASE_ROWS = 2; const ASSISTANT_FOOTER_ROWS = 1; +/** + * `FinalisedMessage` hangs a `[copy]` button under every finalised + * bubble, whatever the role. One row, unconditional — the streaming tail + * has no button, which is why this is charged here and not in + * `estimateStreamingTailHeight`. + */ +const COPY_BUTTON_ROWS = 1; function bodyLines(text: string): number { if (text.length === 0) return 1; @@ -33,7 +40,7 @@ function bodyLines(text: string): number { export function estimateMessageHeight(message: ChatMessage): number { const bodyRows = bodyLines(message.text); - let total = bodyRows + BUBBLE_OVERHEAD_ROWS; + let total = bodyRows + BUBBLE_OVERHEAD_ROWS + COPY_BUTTON_ROWS; if (message.role === "assistant") { if (message.reasoningBlocks && message.reasoningBlocks.length > 0) { total += REASONING_BUBBLE_OVERHEAD_ROWS + 1; diff --git a/src/tui/mouse/index.ts b/src/tui/mouse/index.ts index 76a879fb..090c0d6c 100644 --- a/src/tui/mouse/index.ts +++ b/src/tui/mouse/index.ts @@ -37,5 +37,12 @@ export { useMouseTarget, type MouseContextValue, } from "./mouse-context.js"; +export { + createSelectionPassthrough, + DEFAULT_SELECTION_WINDOW_MS, + type SelectionPassthrough, + type SelectionPassthroughOptions, + type SelectionSuspendable, +} from "./selection-passthrough.js"; export { MouseListRow, pressEnter } from "./mouse-list-row.js"; export { arrowKey, returnKey } from "./synthetic-key.js"; diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx index 2afae925..cad01034 100644 --- a/src/tui/mouse/mouse-app.test.tsx +++ b/src/tui/mouse/mouse-app.test.tsx @@ -118,6 +118,7 @@ function mountApp(): { mouse: MouseSourceEmitter; stdin: { write: (data: string) => void }; openSkillsPanel: () => void; + say: (text: string) => void; unmount: () => void; } { const bus = makeTuiEventBus(); @@ -158,6 +159,7 @@ function mountApp(): { ], }); }, + say: (text) => bus.emit({ type: "system_message", text }), unmount, }; } @@ -226,6 +228,30 @@ describe("TuiApp mouse", () => { app.unmount(); }); + it("reaches the per-message copy button through the whole app", async () => { + // Proves the button survives the real tree — the chat viewport's + // `overflow: hidden` clip, the base-layer wheel target covering the + // entire content area, and the layer floor. The badge says "failed" + // because no `ClipboardProvider` is mounted and the default writer + // refuses to act on a non-TTY stdout, which is exactly the guard + // that keeps the suite off the developer's real clipboard. + const app = mountApp(); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + // The bus subscription is installed by an effect; emitting before it + // runs drops the event on the floor. + await delay(50); + app.say("a message worth copying"); + await waitUntil(() => app.frame().includes("[copy]"), "the copy button"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "[copy]"), + () => app.frame().includes("[copy failed]"), + "click on the message's copy button", + ); + expect(app.frame()).toContain("[copy failed]"); + app.unmount(); + }); + it("ignores a click that lands on no target", async () => { const app = mountApp(); await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); diff --git a/src/tui/mouse/mouse-tracking.test.ts b/src/tui/mouse/mouse-tracking.test.ts index 760864fc..32c5fa30 100644 --- a/src/tui/mouse/mouse-tracking.test.ts +++ b/src/tui/mouse/mouse-tracking.test.ts @@ -74,6 +74,72 @@ describe("enableMouseTracking", () => { expect(stdout.writes).toEqual([]); }); + it("hands selection back and takes it again across suspend/resume", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.suspend(); + expect(controller.isSuspended()).toBe(true); + expect(stdout.writes.slice(2)).toEqual([DISABLE_SGR, DISABLE_TRACKING]); + controller.resume(); + expect(controller.isSuspended()).toBe(false); + expect(stdout.writes.slice(4)).toEqual([ENABLE_TRACKING, ENABLE_SGR]); + }); + + it("ignores a repeated suspend and a resume that was never suspended", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.resume(); + controller.suspend(); + controller.suspend(); + expect(stdout.writes).toHaveLength(4); + }); + + it("keeps the exit hook installed while suspended", () => { + // A crash mid-selection must still leave a clean terminal, so the + // safety net cannot be tied to the reporting state. + const before = process.listenerCount("exit"); + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.suspend(); + expect(process.listenerCount("exit")).toBe(before + 1); + controller.disable(); + expect(process.listenerCount("exit")).toBe(before); + }); + + it("does not re-write the disable pair when disabled while suspended", () => { + // Reporting is already off; a second `1000l` would target modes the + // terminal never re-enabled. + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.suspend(); + controller.disable(); + expect(stdout.writes).toEqual([ + ENABLE_TRACKING, + ENABLE_SGR, + DISABLE_SGR, + DISABLE_TRACKING, + ]); + }); + + it("refuses to resume after disable", () => { + const stdout = makeStdout(true); + const controller = enableMouseTracking({ + stdout: stdout as unknown as NodeJS.WriteStream, + }); + controller.disable(); + controller.resume(); + controller.suspend(); + expect(stdout.writes).toHaveLength(4); + }); + it("detaches its exit hook when disabled explicitly", () => { const before = process.listenerCount("exit"); const stdout = makeStdout(true); diff --git a/src/tui/mouse/mouse-tracking.ts b/src/tui/mouse/mouse-tracking.ts index e98a56ca..3410f751 100644 --- a/src/tui/mouse/mouse-tracking.ts +++ b/src/tui/mouse/mouse-tracking.ts @@ -16,6 +16,16 @@ * toggle (`tui.mouse`, `--no-mouse`, `/mouse`) rather than a * hard-wired behaviour. `disable()` restores native selection * instantly, without restarting the TUI. + * + * `suspend()` / `resume()` are the *momentary* form of the same + * trade-off, for the case where the operator wants one selection rather + * than a mode change (see `selection-passthrough.ts`). They write the + * same escape pairs `disable()` does, but keep the controller — and + * critically its `process.on("exit")` restore — alive across the gap, so + * a crash mid-selection still leaves a clean terminal. A `disable()` + * while suspended writes nothing further and simply detaches the hook: + * reporting is already off, and repeating the disable pair would be a + * second `1000l` against a terminal that never saw a matching `1000h`. */ import type { Writable } from "node:stream"; @@ -29,6 +39,15 @@ const DISABLE_SGR_REPORTS = "\u001B[?1006l"; export interface MouseTrackingController { /** Stops mouse reporting and hands selection back to the terminal. Safe to call twice. */ disable(): void; + /** + * Hands selection back *temporarily*. The controller stays live and + * still cleans up on exit. Safe to call twice; a no-op once disabled. + */ + suspend(): void; + /** Re-requests reporting after a {@link suspend}. A no-op once disabled. */ + resume(): void; + /** `true` between a {@link suspend} and its {@link resume}. */ + isSuspended(): boolean; } export interface MouseTrackingOptions { @@ -45,19 +64,34 @@ export function enableMouseTracking( ): MouseTrackingController { const stdout = options.stdout ?? process.stdout; if (!streamIsTty(stdout)) { - return { disable: () => {} }; + return { + disable: () => {}, + suspend: () => {}, + resume: () => {}, + isSuspended: () => false, + }; } - stdout.write(ENABLE_BUTTON_TRACKING); - stdout.write(ENABLE_SGR_REPORTS); - let disabled = false; - const disable = (): void => { - if (disabled) return; - disabled = true; + const startReporting = (): void => { + stdout.write(ENABLE_BUTTON_TRACKING); + stdout.write(ENABLE_SGR_REPORTS); + }; + const stopReporting = (): void => { // Reverse order: stop the extended encoding first so a terminal // that only understood 1000 still sees a clean disable. stdout.write(DISABLE_SGR_REPORTS); stdout.write(DISABLE_BUTTON_TRACKING); }; + startReporting(); + let disabled = false; + let suspended = false; + const disable = (): void => { + if (disabled) return; + disabled = true; + // Already handed back by `suspend()` — writing the pair again would + // disable modes that are not currently on. + if (suspended) return; + stopReporting(); + }; // Last-chance cleanup. Without it an uncaught exception leaves the // terminal reporting clicks as escape sequences into the shell. const onExit = (): void => disable(); @@ -67,6 +101,17 @@ export function enableMouseTracking( process.off("exit", onExit); disable(); }, + suspend: () => { + if (disabled || suspended) return; + suspended = true; + stopReporting(); + }, + resume: () => { + if (disabled || !suspended) return; + suspended = false; + startReporting(); + }, + isSuspended: () => suspended, }; } diff --git a/src/tui/mouse/selection-passthrough.test.ts b/src/tui/mouse/selection-passthrough.test.ts new file mode 100644 index 00000000..6e41badb --- /dev/null +++ b/src/tui/mouse/selection-passthrough.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TuiMouseEvent } from "./mouse-event.js"; +import { + createSelectionPassthrough, + DEFAULT_SELECTION_WINDOW_MS, + type SelectionSuspendable, +} from "./selection-passthrough.js"; + +function press(overrides: Partial = {}): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x: 4, + y: 7, + shift: false, + alt: false, + ctrl: false, + ...overrides, + }; +} + +interface FakeTracking extends SelectionSuspendable { + suspends: number; + resumes: number; +} + +function makeTracking(): FakeTracking { + let suspended = false; + return { + suspends: 0, + resumes: 0, + suspend(): void { + suspended = true; + this.suspends += 1; + }, + resume(): void { + suspended = false; + this.resumes += 1; + }, + isSuspended: () => suspended, + }; +} + +describe("createSelectionPassthrough", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("hands the terminal back its drag on a shift-modified press", () => { + const tracking = makeTracking(); + const messages: string[] = []; + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + notify: (m) => messages.push(m), + }); + expect(passthrough.observe(press({ shift: true }))).toBe(true); + expect(tracking.isSuspended()).toBe(true); + expect(messages[0]).toContain("drag to select"); + }); + + it("leaves ordinary clicks alone so existing targets keep working", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + expect(passthrough.observe(press())).toBe(false); + expect(passthrough.observe(press({ ctrl: true }))).toBe(false); + expect(passthrough.observe(press({ alt: true }))).toBe(false); + expect( + passthrough.observe({ ...press({ shift: true }), kind: "release" }), + ).toBe(false); + expect( + passthrough.observe({ + ...press({ shift: true }), + kind: "wheel", + wheel: "up", + button: "none", + }), + ).toBe(false); + expect(tracking.suspends).toBe(0); + }); + + it("restores reporting when the window expires", () => { + const tracking = makeTracking(); + const messages: string[] = []; + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + notify: (m) => messages.push(m), + }); + passthrough.observe(press({ shift: true })); + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS - 1); + expect(tracking.isSuspended()).toBe(true); + vi.advanceTimersByTime(1); + expect(tracking.isSuspended()).toBe(false); + expect(messages[1]).toContain("mouse back on"); + }); + + it("does not extend the window with a report that was already in flight", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ + tracking: () => tracking, + windowMs: 1_000, + }); + passthrough.observe(press({ shift: true })); + vi.advanceTimersByTime(900); + // A press the terminal had already sent before it saw our disable. + expect(passthrough.observe(press({ shift: true }))).toBe(true); + expect(tracking.suspends).toBe(1); + vi.advanceTimersByTime(100); + expect(tracking.isSuspended()).toBe(false); + }); + + it("resumes early on request", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + passthrough.observe(press({ shift: true })); + passthrough.resumeNow(); + expect(tracking.isSuspended()).toBe(false); + // The pending timer must be gone, not merely ineffective. + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(1); + }); + + it("does nothing when mouse support is off entirely", () => { + const passthrough = createSelectionPassthrough({ tracking: () => null }); + expect(passthrough.observe(press({ shift: true }))).toBe(false); + }); + + it("never resumes a controller that was switched off mid-window", () => { + // `/mouse off` during the window: the operator asked for reporting to + // stay gone, and the pending timer must not undo that. + const tracking = makeTracking(); + let live: SelectionSuspendable | null = tracking; + const passthrough = createSelectionPassthrough({ tracking: () => live }); + passthrough.observe(press({ shift: true })); + live = null; + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(0); + }); + + it("drops the pending resume on dispose", () => { + const tracking = makeTracking(); + const passthrough = createSelectionPassthrough({ tracking: () => tracking }); + passthrough.observe(press({ shift: true })); + passthrough.dispose(); + vi.advanceTimersByTime(DEFAULT_SELECTION_WINDOW_MS); + expect(tracking.resumes).toBe(0); + }); +}); diff --git a/src/tui/mouse/selection-passthrough.ts b/src/tui/mouse/selection-passthrough.ts new file mode 100644 index 00000000..9ef8036c --- /dev/null +++ b/src/tui/mouse/selection-passthrough.ts @@ -0,0 +1,151 @@ +/** + * Giving the terminal its drag-to-select back, for one selection. + * + * ## Why this exists rather than in-app selection + * + * Selecting text inside the app — track the drag, paint an inverse-video + * span, copy the range — was considered and rejected. It needs three + * things this design does not have and would not cheaply gain: + * + * 1. Motion reports (1002/1003), so the highlight follows the drag. + * Those are off on purpose (see `mouse-tracking.ts`) and would have + * to come back on, at least for the duration of a drag. + * 2. A readback of *what character is painted in each cell*. Ink has + * no framebuffer API — `measureElement` returns sizes, and + * `mouse-registry` deliberately reconstructs geometry from Yoga + * rather than from painted text. There is nothing to slice. + * 3. Every component that could fall under the selection rectangle + * would have to become selection-aware to paint the highlight. + * + * And the result would still be worse than what the terminal already + * does: it could not select the scrollback above the alt screen, it + * could not honour the terminal's own copy-on-select or ⌘C, and it would + * copy the *rendered* text — borders, wrap points and all — where the + * `[copy]` button copies the message source. + * + * ## What this does instead + * + * On the terminals that matter most (iTerm2, kitty, WezTerm, Alacritty, + * foot, Windows Terminal, VS Code) **Shift+drag already works**: the + * terminal keeps the gesture for itself and never reports it, so native + * selection is one modifier away and costs zero code. Apple Terminal has + * no such bypass; there, a shift-modified press is *reported to the app* + * instead. + * + * That asymmetry is the trigger. A shift-modified press arriving here is + * positive evidence that this terminal did not bypass — i.e. exactly the + * terminal that needs help — and that the operator was reaching for a + * selection. So we hand reporting back for a short window and say so. + * The gesture that produced the trigger is lost (the terminal has + * already reported it rather than selected with it), so the operator + * drags a second time; that is the price of not having a bypass, and it + * is still cheaper than discovering `/mouse off`. + * + * On a terminal that *does* bypass, this code never fires. Being inert + * where it is not needed is the point. + * + * Reporting always comes back on its own after `windowMs`. Resuming on + * activity is not possible: while suspended the app receives no mouse + * events at all, which is the whole idea. + */ +import type { TuiMouseEvent } from "./mouse-event.js"; + +/** The part of `MouseTrackingController` this needs. */ +export interface SelectionSuspendable { + suspend(): void; + resume(): void; + isSuspended(): boolean; +} + +export interface SelectionPassthroughOptions { + /** + * Reads the live tracking controller. A getter rather than a value + * because `/mouse on|off` replaces the controller underneath us, and a + * captured one would resume a controller nobody is using any more. + */ + readonly tracking: () => SelectionSuspendable | null; + /** Surfaces the state change to the operator. */ + readonly notify?: (message: string) => void; + readonly windowMs?: number; + /** Injected for fake-timer tests. */ + readonly setTimer?: (fn: () => void, ms: number) => NodeJS.Timeout; + readonly clearTimer?: (handle: NodeJS.Timeout) => void; +} + +export interface SelectionPassthrough { + /** + * Offers a decoded mouse event. Returns `true` when the event was + * consumed as a selection gesture and must **not** reach the hit-test + * registry. + */ + observe(event: TuiMouseEvent): boolean; + /** Ends the window early. */ + resumeNow(): void; + /** Cancels any pending resume. Called during TUI teardown. */ + dispose(): void; +} + +/** + * Long enough to line up a drag on a long reply without rushing, short + * enough that an operator who triggered it by accident does not conclude + * the mouse broke. + */ +export const DEFAULT_SELECTION_WINDOW_MS = 10_000; + +export function createSelectionPassthrough( + options: SelectionPassthroughOptions, +): SelectionPassthrough { + const { + tracking, + notify, + windowMs = DEFAULT_SELECTION_WINDOW_MS, + setTimer = setTimeout, + clearTimer = clearTimeout, + } = options; + let timer: NodeJS.Timeout | null = null; + + const cancelTimer = (): void => { + if (!timer) return; + clearTimer(timer); + timer = null; + }; + + const resumeNow = (): void => { + cancelTimer(); + const controller = tracking(); + // `/mouse off` during the window: reporting is already gone for good + // and there is nothing to restore or announce. + if (!controller || !controller.isSuspended()) return; + controller.resume(); + notify?.("mouse back on — clicks and the wheel work again"); + }; + + return { + observe(event: TuiMouseEvent): boolean { + if (event.kind !== "press" || !event.shift) return false; + const controller = tracking(); + if (!controller) return false; + if (controller.isSuspended()) { + // Cannot happen while reporting is off, but a report already in + // flight when we suspended can still land here. Do not restart + // the window on it — that would be the terminal extending its + // own pause. + return true; + } + controller.suspend(); + cancelTimer(); + timer = setTimer(() => { + timer = null; + resumeNow(); + }, windowMs); + const seconds = Math.round(windowMs / 1000); + notify?.( + `text selection: mouse paused for ${seconds}s — drag to select, ` + + "then copy the way you normally would in this terminal", + ); + return true; + }, + resumeNow, + dispose: cancelTimer, + }; +} diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index e96e578d..959e0673 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -34,6 +34,7 @@ import { enableMouseTracking, type MouseTrackingController, } from "./mouse/mouse-tracking.js"; +import { createSelectionPassthrough } from "./mouse/selection-passthrough.js"; import { isLocalBackendConfigured, isManagedModeReadyOnDisk, @@ -222,10 +223,23 @@ export async function tuiCommand(args: string[]): Promise { // parser would otherwise type them into the chat buffer. const mouseEnabled = parsed.mouse ?? config.tui.mouse; const mouseSource = makeMouseSource(); - const mouseStdin = createMouseStdin(process.stdin, mouseSource.emit); let mouseTracking: MouseTrackingController | null = mouseEnabled ? enableMouseTracking({ stdout: process.stdout }) : null; + // A shift-modified press only reaches us on terminals that refuse to + // bypass reporting for selection (Apple Terminal). Treat it as "I was + // trying to select" and hand the terminal back its drag for a moment, + // rather than making the operator find `/mouse off`. See + // `selection-passthrough.ts` for why in-app selection is not the + // answer here. + const selection = createSelectionPassthrough({ + tracking: () => mouseTracking, + notify: (text) => bus.emit({ type: "system_message", text }), + }); + const mouseStdin = createMouseStdin(process.stdin, (event) => { + if (selection.observe(event)) return; + mouseSource.emit(event); + }); const setMouseEnabled = (next: boolean | null): void => { if (next === null) { bus.emit({ @@ -244,6 +258,9 @@ export async function tuiCommand(args: string[]): Promise { if (next) { mouseTracking = enableMouseTracking({ stdout: process.stdout }); } else { + // Drop any pending selection window first: it would otherwise fire + // a resume against a controller the operator just switched off. + selection.dispose(); mouseTracking?.disable(); mouseTracking = null; } @@ -256,7 +273,8 @@ export async function tuiCommand(args: string[]): Promise { bus.emit({ type: "system_message", text: next - ? "mouse support on — click panels, rows and the prompt; wheel scrolls" + ? "mouse support on — click panels, rows and the prompt; wheel scrolls; " + + "[copy] under a message copies it; Shift+drag still selects text" : "mouse support off — the terminal's own text selection is back", }); }; @@ -513,6 +531,9 @@ export async function tuiCommand(args: string[]): Promise { process.off("SIGTERM", onSignal); process.off("SIGHUP", onSignal); try { + // Before `disable()`: a live selection window would keep a timer + // pinned and could re-enable reporting after teardown. + selection.dispose(); mouseTracking?.disable(); mouseStdin.dispose(); altScreen.restore(); From c6a62c1ba2524ac7c4e808a29072cfebb66c911c Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 19:32:07 +0300 Subject: [PATCH 49/57] fix(tui): make Enter in the /run overlay actually apply the mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/tui/app-key-bindings.ts | 4 +- src/tui/components/run-mode-picker.test.tsx | 40 +++++++++++ src/tui/components/run-mode-picker.tsx | 70 +++++++++++++++++++ src/tui/run-mode/run-mode-actions.ts | 13 ++-- .../run-mode/run-mode-key-bindings.test.ts | 45 ++++++++---- src/tui/run-mode/run-mode-key-bindings.ts | 24 +++++-- src/tui/run-mode/run-mode-orchestrator.ts | 2 + src/tui/run-mode/run-mode-panel-state.ts | 12 ++++ src/tui/run-mode/run-mode-reducer.test.ts | 10 --- src/tui/run-mode/run-mode-reducer.ts | 6 +- 10 files changed, 187 insertions(+), 39 deletions(-) diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 55149323..02fbc04f 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -116,7 +116,9 @@ export function handleAppKey( // 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 })) return true; + if (handleRunModePickerKey(input, key, { state, dispatch, callbacks })) { + return true; + } if (state.pendingApproval) { return handleApprovalKey(input, key, state.pendingApproval, ctx); } diff --git a/src/tui/components/run-mode-picker.test.tsx b/src/tui/components/run-mode-picker.test.tsx index dfd24718..92a25d61 100644 --- a/src/tui/components/run-mode-picker.test.tsx +++ b/src/tui/components/run-mode-picker.test.tsx @@ -78,4 +78,44 @@ describe("RunModePicker", () => { 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. + */ + describe("the two legs", () => { + it("names the provider filling each leg", () => { + const frame = frameOf( + open({ + cloudProviderId: "aimlapi", + cloudLabel: "gpt-5", + localProviderId: "local-llama", + localLabel: "qwen-3.5-4b", + }), + ); + expect(frame).toContain("cloud leg"); + expect(frame).toContain("aimlapi · gpt-5"); + expect(frame).toContain("local leg"); + expect(frame).toContain("local-llama · qwen-3.5-4b"); + }); + + it("says a missing cloud leg is missing, and how to fix it", () => { + const frame = frameOf( + open({ cloudProviderId: null, localProviderId: "local-llama" }), + ); + expect(frame).toContain("cloud leg"); + expect(frame).toContain("press n to add one"); + }); + + it("does not repeat the id when no model name resolved", () => { + const frame = frameOf( + open({ cloudProviderId: "openrouter", cloudLabel: "openrouter" }), + ); + expect(frame).toContain("openrouter"); + expect(frame).not.toContain("openrouter · openrouter"); + }); + }); }); diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx index e886ceed..45c23fbd 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -36,6 +36,14 @@ const MODE_BLURBS: Record = { * mouse layer uses for lists, because applying a mode swaps providers. * The dial is the exception: it is a slider, and clicking a slider at a * position means "put it here", so one click sets the share. + * + * The leg rows report, they do not edit. Pinning a leg writes + * `llm.runMode.cloudProvider` / `localProvider`, and the only wire this + * screen has to the orchestrator that owns config writes is + * `onRunModeChangeRequested(mode, cloudShare?)` — which has no room for + * a provider id. Widening it means editing `TuiAppCallbacks` in + * `tui-app.tsx`. Until then the pins stay a config-file setting, and + * this screen at least says which providers are in force. */ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | null { const picker = panel.picker; @@ -70,6 +78,7 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul ? describeCloudShare(picker.draftCloudShare) : "the dial only applies to Fusion"} + {panel.degradedMessage ? ( {panel.degradedMessage} ) : null} @@ -81,6 +90,67 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul ); } +/** + * The two legs a mode runs on, named. + * + * Every mode on this screen is a statement about a PAIR of providers — + * Fusion runs both at once — and the overlay used to name neither. With + * more than one cloud provider configured that is not a cosmetic gap: + * the cloud leg is `llm.runMode.cloudProvider`, or failing that the + * first non-`llama-server` entry in `llm.providers`, which is not + * necessarily the one the operator was last using. "Fusion" with no + * further information does not say which account is about to be billed. + * + * Read-only for now, and deliberately so — see the note on + * `RunModePicker`. Changing a leg means writing `llm.runMode`, and this + * screen has exactly one wire to the orchestrator that can do that. + */ +function LegRows({ panel }: { panel: RunModePanelState }): ReactElement { + return ( + <> + + + + ); +} + +function LegRow({ + name, + providerId, + model, + missingHint, +}: { + name: string; + providerId: string | null; + model: string | null; + missingHint: string; +}): ReactElement { + return ( + + {" "} + {name}{" "} + {providerId ? ( + + {providerId} + {model && model !== providerId ? ` · ${model}` : ""} + + ) : ( + {missingHint} + )} + + ); +} + /** * On a fresh install two of the three modes cannot be entered at all, * and this overlay was where you found that out and then had nowhere to diff --git a/src/tui/run-mode/run-mode-actions.ts b/src/tui/run-mode/run-mode-actions.ts index eca55462..6f64c9a8 100644 --- a/src/tui/run-mode/run-mode-actions.ts +++ b/src/tui/run-mode/run-mode-actions.ts @@ -4,10 +4,12 @@ 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. * - * `run_mode_change_requested` is intentionally a REQUEST, not a state - * change: only the orchestrator may write config or move the active - * provider, and the mirror updates when it reports back via - * `run_mode_synced`. + * 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 = | { @@ -17,11 +19,12 @@ export type RunModeAction = 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_requested"; mode: RunModeName; cloudShare?: number } | { type: "run_mode_change_started" } | { type: "run_mode_change_settled"; error?: string } | { type: "run_mode_picker_opened" } diff --git a/src/tui/run-mode/run-mode-key-bindings.test.ts b/src/tui/run-mode/run-mode-key-bindings.test.ts index 9e4bf099..52c8518d 100644 --- a/src/tui/run-mode/run-mode-key-bindings.test.ts +++ b/src/tui/run-mode/run-mode-key-bindings.test.ts @@ -31,11 +31,13 @@ function withPicker(): TuiState { 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 }; + return { handled, dispatch, onRunModeChangeRequested }; } describe("handleRunModePickerKey", () => { @@ -47,24 +49,39 @@ describe("handleRunModePickerKey", () => { }); it("closes on Esc without requesting a change", () => { - const { handled, dispatch } = press(withPicker(), "", { escape: true }); + const { handled, dispatch, onRunModeChangeRequested } = press( + withPicker(), + "", + { escape: true }, + ); expect(handled).toBe(true); expect(dispatch).toHaveBeenCalledWith({ type: "run_mode_picker_closed" }); - expect(dispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "run_mode_change_requested" }), - ); + expect(onRunModeChangeRequested).not.toHaveBeenCalled(); }); - it("applies the draft on Enter and closes", () => { - const { dispatch } = press(withPicker(), "", { return: true }); - expect(dispatch).toHaveBeenNthCalledWith(1, { - type: "run_mode_change_requested", - mode: "local", - cloudShare: 40, - }); - expect(dispatch).toHaveBeenNthCalledWith(2, { - type: "run_mode_picker_closed", + /** + * 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", () => { diff --git a/src/tui/run-mode/run-mode-key-bindings.ts b/src/tui/run-mode/run-mode-key-bindings.ts index 3ae4a9a4..6bb6cb98 100644 --- a/src/tui/run-mode/run-mode-key-bindings.ts +++ b/src/tui/run-mode/run-mode-key-bindings.ts @@ -1,4 +1,5 @@ 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 { createProvidersWizardState } from "../providers/providers-wizard-state.js"; @@ -12,6 +13,18 @@ import { 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; + }; } /** @@ -57,11 +70,12 @@ export function handleRunModePickerKey( return true; } if (key.return) { - dispatch({ - type: "run_mode_change_requested", - mode: picker.draftMode, - cloudShare: picker.draftCloudShare, - }); + // 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; } diff --git a/src/tui/run-mode/run-mode-orchestrator.ts b/src/tui/run-mode/run-mode-orchestrator.ts index 93fbe2b0..696319af 100644 --- a/src/tui/run-mode/run-mode-orchestrator.ts +++ b/src/tui/run-mode/run-mode-orchestrator.ts @@ -34,6 +34,8 @@ export class RunModeOrchestrator { 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 diff --git a/src/tui/run-mode/run-mode-panel-state.ts b/src/tui/run-mode/run-mode-panel-state.ts index 75874d99..811ceb9e 100644 --- a/src/tui/run-mode/run-mode-panel-state.ts +++ b/src/tui/run-mode/run-mode-panel-state.ts @@ -18,6 +18,16 @@ export interface RunModePanelState { /** 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 @@ -54,6 +64,8 @@ export function createInitialRunModePanelState(): RunModePanelState { cloudShare: 40, localLabel: null, cloudLabel: null, + localProviderId: null, + cloudProviderId: null, cloudProviderMissing: false, localProviderMissing: false, degradedMessage: null, diff --git a/src/tui/run-mode/run-mode-reducer.test.ts b/src/tui/run-mode/run-mode-reducer.test.ts index b4e75c82..a1b81038 100644 --- a/src/tui/run-mode/run-mode-reducer.test.ts +++ b/src/tui/run-mode/run-mode-reducer.test.ts @@ -105,16 +105,6 @@ describe("reduceRunModeAction", () => { expect(state.runModePanel.cloudShare).toBe(40); }); - it("does not move the mirror on a change REQUEST", () => { - // Only the orchestrator may change the mode; the mirror follows the - // `run_mode_synced` it emits afterwards. - const state = apply(base(), [ - synced, - { type: "run_mode_change_requested", mode: "local" }, - ]); - expect(state.runModePanel.effective).toBe("fusion"); - }); - it("tracks busy and surfaces a settle error", () => { const busy = apply(base(), [{ type: "run_mode_change_started" }]); expect(busy.runModePanel.busy).toBe(true); diff --git a/src/tui/run-mode/run-mode-reducer.ts b/src/tui/run-mode/run-mode-reducer.ts index aadad01e..b6cfc6e2 100644 --- a/src/tui/run-mode/run-mode-reducer.ts +++ b/src/tui/run-mode/run-mode-reducer.ts @@ -33,6 +33,8 @@ function reducePanel( cloudShare: action.cloudShare, localLabel: action.localLabel, cloudLabel: action.cloudLabel, + localProviderId: action.localProviderId, + cloudProviderId: action.cloudProviderId, cloudProviderMissing: action.cloudProviderMissing, localProviderMissing: action.localProviderMissing, degradedMessage: action.degradedMessage, @@ -107,9 +109,5 @@ function reducePanel( if (!panel.picker) return panel; return { ...panel, picker: { ...panel.picker, digitBuffer: "" } }; } - // A request is handled by the orchestrator; the mirror only moves - // on the `run_mode_synced` that follows. - case "run_mode_change_requested": - return panel; } } From 08ab36f4de3fa5649f0559e072370776bfa86aac Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 21:09:40 +0300 Subject: [PATCH 50/57] feat(tui): rail lockup, composer and menu follow the second round of hand testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven changes asked for after looking at the running app. Rail: - The breadcrumb row is gone. The run-mode strip and the sub-tab strip already say which surface you are on. - '+ New session' sits at the head of the session list, because that is the list it adds to and /new was the only way to reach it. - The menu button moves to the foot, where an application parks the control you reach for occasionally rather than look at. - The mark drops to 6x4 with the wordmark and version BESIDE it. Stacked, branding spent six rows before the first useful line. Composer: - The file button is gone. It appended a prose marker nothing resolved, which is a button that only looks like a feature. - The newline gesture is advertised as ctrl+j, and this is the real fix for 'newline does not work': shift+enter cannot be told apart from enter unless the app turns on the kitty keyboard protocol or modifyOtherKeys, so a bare terminal sent plain CR and submitted. Ctrl+J is the literal LF byte — every terminal can send it with no negotiation. Shift/alt+enter still work where a terminal can express them. - The Local/Cloud/Fusion strip moves below the input: it describes the message you are about to send. Menu: - Clicking away from it closes it, and the wheel walks the list. Both ride on the root element's own rect at the modal layer — a backdrop rendered inside the popup could not cover the rail, and one sized by percentage inside the pane never took a press. - 'Toggle debug pane' moves to Help. The go group is where you are going; the debug pane is a diagnostic you switch on. Theme: - Assistant prose on github-dark is the default foreground, not GitHub's green. It is the bulk of what is on screen, and a saturated colour on every reply reads as status — which the markers and tool cards already carry. --- src/tui/components/hotkey-hint.tsx | 4 +- src/tui/components/logo.tsx | 11 ++ src/tui/components/multi-line-editor.tsx | 20 ++-- src/tui/components/prompt-meta-bar.test.tsx | 32 ------ src/tui/components/prompt-meta-bar.tsx | 30 ------ src/tui/components/prompt-shell.test.tsx | 6 +- src/tui/components/prompt-shell.tsx | 3 +- src/tui/components/sidebar.tsx | 106 +++++++++++++------- src/tui/escape-chat-editor.test.tsx | 6 +- src/tui/escape-import-tab.test.tsx | 9 +- src/tui/escape-observe-tabs.test.tsx | 6 +- src/tui/menu/menu-behaviour.test.ts | 12 ++- src/tui/menu/menu-popup.tsx | 3 + src/tui/menu/menu-registry.ts | 24 ++--- src/tui/mouse/mouse-app.test.tsx | 14 +++ src/tui/theme/github-themes.test.ts | 6 +- src/tui/theme/theme-palettes.ts | 2 +- src/tui/tui-app.test.tsx | 28 +++--- src/tui/tui-app.tsx | 61 ++++++----- 19 files changed, 208 insertions(+), 175 deletions(-) diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 4e0d1aac..9f73174e 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -28,7 +28,7 @@ interface HotkeyChip { readonly label: string; /** * What a click on this chip does. Only chips with one unambiguous - * meaning get one — "shift+enter newline" or "↑↓ select" describe a + * meaning get one — "ctrl+j newline" or "↑↓ select" describe a * gesture, not a command, so they stay plain text rather than * pretending to be buttons. */ @@ -219,7 +219,7 @@ function resolveChips( // menu now lists Local / Cloud / Fusion outright. return [ { key: "enter", label: "send" }, - { key: "shift+enter", label: "newline" }, + { key: "ctrl+j", label: "newline" }, // Tab only reaches the rail when there is one; below its width // threshold the chip would name a surface that is not on screen. ...(sidebarVisible diff --git a/src/tui/components/logo.tsx b/src/tui/components/logo.tsx index a1aae8d3..a71abe02 100644 --- a/src/tui/components/logo.tsx +++ b/src/tui/components/logo.tsx @@ -84,6 +84,17 @@ export const LOGO_ART: Readonly> = { mini: rasteriseMark(toInkMask(FULL_ART), { columns: 7, rows: 4 }), }; +/** + * The rail's own mark: smaller than `mini`, because on the rail it sits + * beside the wordmark rather than above it and has to leave room for the + * text. 6x4 is the floor at which the silhouette still reads — 6x3 and + * 5x3 collapse the arms into a blob. + */ +export const RAIL_MARK: readonly string[] = rasteriseMark( + toInkMask(FULL_ART), + { columns: 6, rows: 4 }, +); + export const WORDMARK_ROWS: readonly string[] = [ "▄▀█ ▀█▀ █▀█ █▀▄▀█ █ █▀▀ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀", "█▀█ █ █▄█ █ ▀ █ █ █▄▄ █▀█ █▄█ ██▄ █ ▀█ █ ", diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index 753f9bc1..b1f9e281 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -56,12 +56,20 @@ export interface MultiLineEditorProps { * * Key handling: * - Enter submits the trimmed buffer (and emits empty-submit as no-op) - * - Shift+Enter inserts a newline. Alt/Meta+Enter and Ctrl+J do the - * same, deliberately: plenty of terminals cannot report Shift with - * Return at all (it needs modifyOtherKeys or the kitty protocol), and - * a multi-line editor that some terminals cannot reach is worse than - * one advertised gesture plus quiet fallbacks. The hint strip names - * Shift+Enter because that is what an operator expects to try. + * - Ctrl+J inserts a newline, and it is what the hint strip names. + * Ctrl+J is the literal LF byte, so every terminal can send it with + * no negotiation at all. + * + * Shift+Enter and Alt+Enter also insert one *where the terminal can + * express them*, which is the catch that made "newline does not + * work" a real report: a bare terminal sends plain CR for + * Shift+Enter, indistinguishable from Enter, so it submits. Telling + * them apart needs the kitty keyboard protocol or modifyOtherKeys, + * which this app does not turn on — doing so changes how every key + * arrives, which is not a trade to make for one gesture. + * + * A trailing backslash before Enter also forces a newline, and works + * everywhere for the same reason Ctrl+J does. * - Backslash at end-of-line before Enter also forces a newline * - Up/Down trigger `onHistoryPrev` / `onHistoryNext` when the cursor * is at the top/bottom of the buffer diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx index 1f1781c4..77fc4a40 100644 --- a/src/tui/components/prompt-meta-bar.test.tsx +++ b/src/tui/components/prompt-meta-bar.test.tsx @@ -6,7 +6,6 @@ import type { TuiMouseEvent } from "../mouse/mouse-event.js"; import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; import type { TuiAppCallbacks } from "../tui-app.js"; import type { TuiState } from "../tui-state.js"; -import { appendFileReference } from "./prompt-meta-bar.js"; import { PromptShell } from "./prompt-shell.js"; function strip(value: string): string { @@ -73,21 +72,6 @@ async function mountWithMouse(node: ReactElement): Promise<{ return { registry, frame: () => lastFrame() ?? "", unmount }; } -describe("appendFileReference", () => { - it("seeds an empty draft", () => { - expect(appendFileReference("")).toBe("file: "); - }); - - it("separates the marker from existing prose", () => { - expect(appendFileReference("summarise")).toBe("summarise file: "); - }); - - it("does not double the space when the draft already ends in one", () => { - expect(appendFileReference("summarise ")).toBe("summarise file: "); - expect(appendFileReference("summarise\n")).toBe("summarise\nfile: "); - }); -}); - describe("composer buttons", () => { it("submits the live buffer when Send is clicked", async () => { const sent: string[] = []; @@ -138,21 +122,6 @@ describe("composer buttons", () => { unmount(); }); - it("appends the file marker when the file button is clicked", async () => { - const changed: string[] = []; - const { registry, frame, unmount } = await mountWithMouse( - changed.push(value)} - onSubmit={() => {}} - />, - ); - const { x, y } = locate(frame(), "+ file"); - expect(registry.dispatch(click(x, y))).toBe(true); - expect(changed).toEqual(["explain file: "]); - unmount(); - }); it("ignores a right-button press on Send", async () => { const sent: string[] = []; @@ -177,7 +146,6 @@ describe("composer buttons", () => { {}} onSubmit={() => {}} />, ); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("+ file"); expect(frame).toContain("send"); unmount(); }); diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx index c0218d50..48b68ea1 100644 --- a/src/tui/components/prompt-meta-bar.tsx +++ b/src/tui/components/prompt-meta-bar.tsx @@ -40,31 +40,9 @@ export interface PromptMetaBarProps { /** Whether Send has something to send; drives the primary/ghost look. */ canSend: boolean; onSend: () => void; - onAttachFile: () => void; -} - -/** - * Marker the file button drops into the draft. It is prose, not syntax: - * nothing in this codebase resolves it, and pretending otherwise would - * be a lie dressed as a feature. The agent reads files by path with - * `os.fs.read`, so a message that says `file: src/x.ts` gets the file - * read — the button's only job is to say that affordance exists and put - * the caret where the path goes. - */ -const FILE_REFERENCE_MARKER = "file: "; - -/** - * Append the file-reference marker to `value`, inserting a separating - * space only when the draft does not already end in whitespace. - */ -export function appendFileReference(value: string): string { - if (value.length === 0) return FILE_REFERENCE_MARKER; - if (/\s$/.test(value)) return `${value}${FILE_REFERENCE_MARKER}`; - return `${value} ${FILE_REFERENCE_MARKER}`; } /** Labels carry their own padding so the chip's ground reads as a button. */ -const ATTACH_LABEL = " + file "; const SEND_LABEL = " send → "; const MODEL_LABEL_MAX_LEN = 32; @@ -76,7 +54,6 @@ export function PromptMetaBar({ rightSlot, canSend, onSend, - onAttachFile, }: PromptMetaBarProps): ReactElement { return ( ) : null} - - - diff --git a/src/tui/components/prompt-shell.test.tsx b/src/tui/components/prompt-shell.test.tsx index 1bd1ecff..5efaa1d3 100644 --- a/src/tui/components/prompt-shell.test.tsx +++ b/src/tui/components/prompt-shell.test.tsx @@ -29,7 +29,7 @@ describe("PromptShell", () => { unmount(); }); - it("shows both buttons", () => { + it("shows the send button", () => { const { lastFrame, unmount } = render( { />, ); const frame = strip(lastFrame() ?? ""); - expect(frame).toContain("+ file"); expect(frame).toContain("send"); unmount(); }); @@ -78,7 +77,7 @@ describe("PromptShell", () => { * thing allowed to give up columns — a clipped button reads as a * rendering bug, a clipped model name reads as a long model name. */ - it("keeps both buttons whole in a 56-column chat column", () => { + it("keeps the send button whole in a 56-column chat column", () => { const { lastFrame, unmount } = render( // A column, like the chat surface: the composer takes the // column's full width rather than its own intrinsic one. @@ -103,7 +102,6 @@ describe("PromptShell", () => { expect(line.length).toBeLessThanOrEqual(56); } const bar = lines[2] ?? ""; - expect(bar).toContain(" + file "); expect(bar).toContain(" send → "); unmount(); }); diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx index a5e1c5bf..905143df 100644 --- a/src/tui/components/prompt-shell.tsx +++ b/src/tui/components/prompt-shell.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js"; import { theme } from "../theme/theme.js"; import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js"; -import { appendFileReference, PromptMetaBar } from "./prompt-meta-bar.js"; +import { PromptMetaBar } from "./prompt-meta-bar.js"; /** * The composer: a framed input field with a toolbar under it. @@ -121,7 +121,6 @@ export function PromptShell(props: PromptShellProps): ReactElement { // place for slash-command handling and the busy-mode queue to // drift out of sync. onSend={() => onSubmit(value)} - onAttachFile={() => onChange(appendFileReference(value))} /> diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index b33c0916..41bbdb20 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -11,7 +11,7 @@ import type { TaskSummaryRow } from "../tasks/tasks-panel-state.js"; import { theme } from "../theme/theme.js"; import type { SessionPickerEntry } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; -import { LOGO_ART } from "./logo.js"; +import { RAIL_MARK } from "./logo.js"; export type SidebarSection = "sessions" | "tasks"; @@ -28,8 +28,6 @@ export interface SidebarProps { focused: boolean; /** Short session id, shown under the wordmark. */ sessionId?: string | null; - /** Where the operator is, e.g. `Manage › Tasks`. */ - location?: string; /** * Row budget for each pane, normally derived from the terminal height * by `computeSidebarRowBudget` in `../layout.ts`. The defaults keep @@ -91,7 +89,6 @@ export function Sidebar(props: SidebarProps): ReactElement { activeSection, focused, sessionId = null, - location, maxSessionRows = DEFAULT_MAX_SESSION_ROWS, maxTaskRows = DEFAULT_MAX_TASK_ROWS, } = props; @@ -128,10 +125,9 @@ export function Sidebar(props: SidebarProps): ReactElement { paddingX={1} > - - {location ? : null} + + {/* + The menu sits at the foot of the rail, the way an application + parks its account or settings control: it is the thing you reach + for occasionally, and the lists above it are what you look at. + The spacer pushes it down however tall the terminal is. + */} + ); } @@ -199,7 +202,15 @@ function RailBlank(): ReactElement { return ; } -/** Mark, wordmark, version, session id. */ +/** + * Mark, wordmark, version — the mark on the left with the text beside + * it, the way a product lockup is normally set. Stacked, it spent six of + * the rail's rows on branding before the first useful line. + * + * The session id keeps its own full-width row underneath: it is the one + * piece here that can be long, and squeezing it into the column beside a + * six-column mark would truncate it to nothing. + */ function RailBrand({ inner, sessionId, @@ -207,26 +218,68 @@ function RailBrand({ inner: number; sessionId: string | null; }): ReactElement { - const art = LOGO_ART.mini; + const art = RAIL_MARK; + const textWidth = Math.max(0, inner - MARK_COLUMNS - 1); return ( - {art.map((row, idx) => ( - - {row} + + + {art.map((row, idx) => ( + + {row} + + ))} + + + {/* Blank rows centre the two text lines against the four-row mark. */} + + + {clip("atomic-agent", textWidth)} + + + {clip(`v${getAppVersion()}`, textWidth)} + + + + {sessionId ? ( + + {shortenId(sessionId)} - ))} - - {"atomic-agent"} - - - {`v${getAppVersion()}${sessionId ? ` · ${shortenId(sessionId)}` : ""}`} - - + ) : null} ); } +/** Width of {@link RAIL_MARK}, kept beside it so the lockup can measure. */ +const MARK_COLUMNS = 6; + +/** + * Starts a fresh thread. It sits at the head of the session list because + * that is the list it adds to — and because `/new` was the only way to + * reach it, which is not a thing a first-time operator knows. + */ +function NewSessionButton({ inner }: { inner: number }): ReactElement { + const mouse = useMouseCommands(); + const label = ( + + {" + New session"} + + ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onSessionNewRequested?.(); + return true; + }} + > + {label} + + ); +} + /** * The one control on the rail. `ctrl+p` opens the same menu; this is * what makes it reachable without knowing that, which was the whole @@ -254,21 +307,6 @@ function MenuButton({ inner }: { inner: number }): ReactElement { ); } -/** Where you are — the breadcrumb the removed top bar used to carry. */ -function RailLocation({ - inner, - label, -}: { - inner: number; - label: string; -}): ReactElement { - return ( - - {label} - - ); -} - function shortenId(value: string): string { if (value.length <= 8) return value; return `${value.slice(0, 8)}…`; diff --git a/src/tui/escape-chat-editor.test.tsx b/src/tui/escape-chat-editor.test.tsx index 0d6a2101..d112d1ab 100644 --- a/src/tui/escape-chat-editor.test.tsx +++ b/src/tui/escape-chat-editor.test.tsx @@ -95,9 +95,9 @@ describe("Esc in the chat editor", () => { stdin.write(ESC); await settle(); - // The breadcrumb lives on the left rail now — the top bar is gone — - // so "on the Run screen" is a rail line reading exactly `Run`. - expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); + // The rail dropped its breadcrumb row, so "on the Run screen" is now + // the run-mode strip, which only renders on the chat surface. + expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local"); expect(counts.quit).toBe(0); stdin.write(ESC); diff --git a/src/tui/escape-import-tab.test.tsx b/src/tui/escape-import-tab.test.tsx index 5ee0c626..7caedfb7 100644 --- a/src/tui/escape-import-tab.test.tsx +++ b/src/tui/escape-import-tab.test.tsx @@ -49,7 +49,8 @@ describe("Esc on the Import tab", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "import" }); await settle(); - expect(strip(lastFrame() ?? "")).toContain("Manage ▸"); + expect(strip(lastFrame() ?? "")).toContain("\u25b8 Import"); + stdin.write(ESC); await settle(); @@ -57,9 +58,9 @@ describe("Esc on the Import tab", () => { // The configure-mode handler ends in a catch-all `return true` that // swallows stray letters; before the fix it swallowed Esc too, so the // operator was stuck on the tab with no "back" gesture at all. - // The breadcrumb lives on the left rail now — the top bar is gone — - // so "on the Run screen" is a rail line reading exactly `Run`. - expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); + // The rail dropped its breadcrumb row, so "on the Run screen" is now + // the run-mode strip, which only renders on the chat surface. + expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local"); expect(quit).toBe(0); unmount(); }); diff --git a/src/tui/escape-observe-tabs.test.tsx b/src/tui/escape-observe-tabs.test.tsx index 71b35ff6..dabacc80 100644 --- a/src/tui/escape-observe-tabs.test.tsx +++ b/src/tui/escape-observe-tabs.test.tsx @@ -57,7 +57,9 @@ describe("Esc on the Observe tabs", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab }); await settle(); - expect(strip(lastFrame() ?? "")).toContain("Observe ▸"); + // The Observe sub-tab strip is what says we are on a panel; which + // tab is marked varies across the cases this loop drives. + expect(strip(lastFrame() ?? "")).toContain("Reasoning"); stdin.write(ESC); await settle(); @@ -67,7 +69,7 @@ describe("Esc on the Observe tabs", () => { // reached the still-focused chat editor and quit the process. expect(counts.quit).toBe(0); expect(counts.abort).toBe(0); - expect(strip(lastFrame() ?? "")).toMatch(/^\s{0,3}Run\s/m); + expect(strip(lastFrame() ?? "")).toContain("\u25b8 Local"); unmount(); }); } diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts index 9581cd16..f3ea2e80 100644 --- a/src/tui/menu/menu-behaviour.test.ts +++ b/src/tui/menu/menu-behaviour.test.ts @@ -38,13 +38,18 @@ describe("menu rows", () => { const rows = selectMenuRows(open()); const headers = rows.flatMap((r) => (r.kind === "header" ? [r.label] : [])); expect(headers).toEqual(["Go", "Session", "Model", "Run", "Setup", "Help"]); + // `go` is where you are going. The debug pane is a diagnostic you + // switch on, so it lives under Help. const go = rows.filter((r) => r.kind === "item" && r.node.group === "go"); expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([ "Run", - "Toggle debug pane", "Observe", "Manage", ]); + const help = rows.filter((r) => r.kind === "item" && r.node.group === "help"); + expect(help.map((r) => (r.kind === "item" ? r.node.label : ""))).toContain( + "Toggle debug pane", + ); }); it("lists a submenu's children and titles the popup with a breadcrumb", () => { @@ -94,8 +99,9 @@ describe("menu keys", () => { }); it("opens a submenu with the right arrow and leaves it with the left", () => { - const atManage = open({ menuCursor: 2 }); - expect(drive(atManage, "", { rightArrow: true }).actions).toEqual([ + // Cursor 1 is Observe now that the debug toggle moved to Help. + const atObserve = open({ menuCursor: 1 }); + expect(drive(atObserve, "", { rightArrow: true }).actions).toEqual([ { type: "menu_path_set", path: "go.observe" }, { type: "menu_cursor_set", cursor: 0 }, ]); diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5b6261ce..7975bc88 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -199,6 +199,9 @@ function MenuItem({ { + // Let a wheel notch fall through to the backdrop, which owns + // scrolling for the whole popup. + if (hit.event.kind === "wheel") return false; if (!isPrimaryPress(hit.event)) return false; // One click acts, the way a menu item does everywhere else. The // rest of the mouse layer selects first and acts on the second diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index c247723e..554631bc 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -117,18 +117,6 @@ export const MENU: readonly MenuNode[] = [ }, section: "run", }, - { - kind: "action", - id: "go.debug", - label: "Toggle debug pane", - group: "go", - slash: { - name: "debug", - description: - "toggle debug pane (feed / logs / world …)", - rank: 7, - }, - }, { kind: "submenu", id: "go.observe", @@ -576,6 +564,18 @@ export const MENU: readonly MenuNode[] = [ rank: 2, }, }, + { + kind: "action", + id: "help.debug", + label: "Toggle debug pane", + group: "help", + slash: { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + rank: 7, + }, + }, { kind: "action", id: "help.dump", diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx index cad01034..091ff517 100644 --- a/src/tui/mouse/mouse-app.test.tsx +++ b/src/tui/mouse/mouse-app.test.tsx @@ -192,6 +192,13 @@ describe("TuiApp mouse", () => { ); // One click acts on a menu row — the menu is the one surface where // the two-step select-then-activate rule would be the surprise. + // The debug toggle is filed under Help, which sits below the fold on + // a fresh menu — search for it first, the way an operator would. + app.stdin.write("debug"); + await waitUntil( + () => app.frame().includes("Toggle debug pane"), + "the searched menu row", + ); await clickUntil( app.mouse, () => locate(app.frame(), "Toggle debug pane"), @@ -211,6 +218,13 @@ describe("TuiApp mouse", () => { () => app.frame().includes("GO"), "click on the rail's Menu button", ); + // The debug toggle is filed under Help, which sits below the fold on + // a fresh menu — search for it first, the way an operator would. + app.stdin.write("debug"); + await waitUntil( + () => app.frame().includes("Toggle debug pane"), + "the searched menu row", + ); await clickUntil( app.mouse, () => locate(app.frame(), "Toggle debug pane"), diff --git a/src/tui/theme/github-themes.test.ts b/src/tui/theme/github-themes.test.ts index 31fe5e37..d48c7f2c 100644 --- a/src/tui/theme/github-themes.test.ts +++ b/src/tui/theme/github-themes.test.ts @@ -30,7 +30,11 @@ describe("github themes", () => { expect(c.user).toBe("#4493f8"); expect(c.tool).toBe("#4493f8"); expect(c.info).toBe("#4493f8"); - expect(c.assistant).toBe("#3fb950"); + // The assistant's prose is deliberately NOT GitHub's green. It is + // the bulk of what is on screen, and a saturated colour on every + // reply reads as a status signal — which the markers, tool cards and + // `toolOk` below already carry. Default foreground instead. + expect(c.assistant).toBe("#e6edf3"); expect(c.toolOk).toBe("#3fb950"); expect(c.success).toBe("#3fb950"); expect(c.reasoning).toBe("#ab7df8"); diff --git a/src/tui/theme/theme-palettes.ts b/src/tui/theme/theme-palettes.ts index d1e6455e..fe08f1ab 100644 --- a/src/tui/theme/theme-palettes.ts +++ b/src/tui/theme/theme-palettes.ts @@ -26,7 +26,7 @@ import type { TuiColors } from "./theme.js"; // muted=fgColor-muted, border=borderColor-default. export const GITHUB_DARK_COLORS: TuiColors = { user: "#4493f8", - assistant: "#3fb950", + assistant: "#e6edf3", system: "#9198a1", reasoning: "#ab7df8", tool: "#4493f8", diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 92457b4d..7b951153 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -43,7 +43,7 @@ describe("TuiApp (smoke)", () => { expect(text).toContain("atomic-agent"); // The status bar shows where you are, not a menu of where you could go — // the three-section pill row moved into the ctrl+p menu. - expect(text).toContain("Run"); + expect(text).toContain("\u25b8 Local"); // #172 turned the section pills into a breadcrumb: the status bar // names where you *are*, and the ctrl+p menu is where you go. expect(text).not.toContain("Observe"); @@ -84,7 +84,7 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Observe"); + expect(text).toContain("Reasoning"); expect(text).toContain("Feed"); expect(text).toContain("Logs"); // Manage-only tabs should not be in the Observe sub-tab strip. @@ -103,7 +103,7 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Manage"); + expect(text).toContain("Skills"); expect(text).toContain("Tasks"); expect(text).toContain("Skills"); expect(text).toContain("Telegram"); @@ -153,7 +153,7 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("Run"); + expect(before).toContain("\u25b8 Local"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); @@ -161,12 +161,12 @@ describe("TuiApp (smoke)", () => { if (before.includes("SESSIONS")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("Run"); - expect(after).not.toContain("Observe \u25b8"); + expect(after).toContain("\u25b8 Local"); + expect(after).not.toContain("\u25b8 Feed"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("Observe \u25b8 Feed"); + expect(after).toContain("\u25b8 Feed"); } unmount(); }); @@ -180,7 +180,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Observe \u25b8 Feed"); + expect(text).toContain("\u25b8 Feed"); unmount(); }); @@ -199,7 +199,9 @@ describe("TuiApp (smoke)", () => { // one of the two drifted assertions #170 called out as the reason a // single menu registry exists. #172 changed the surface they // describe, so this is the commit that owes them a fix. - expect(text).toContain("Manage \u25b8"); + // The Manage sub-tab strip is on screen (the breadcrumb that used to + // say so went with the top bar), and the marker sits on Privacy. + expect(text).toContain("Skills"); expect(text).toContain("▸ Privacy"); unmount(); }); @@ -349,12 +351,12 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); - expect(strip(lastFrame() ?? "")).toContain("Manage \u25b8"); + expect(strip(lastFrame() ?? "")).toContain("Skills"); stdin.write("\u001b"); await new Promise((r) => setTimeout(r, 60)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Run"); + expect(text).toContain("\u25b8 Local"); expect(text).not.toContain("Manage \u25b8"); unmount(); }); @@ -403,7 +405,7 @@ describe("TuiApp (smoke)", () => { stdin.write("t"); await new Promise((r) => setTimeout(r, 30)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Manage"); + expect(text).toContain("Skills"); expect(text).toContain("Tasks"); // Ink delivers every key to every useInput, child first — so the editor // sees the chord letter too. If the leader did not disable it, a stray @@ -425,7 +427,7 @@ describe("TuiApp (smoke)", () => { await new Promise((r) => setTimeout(r, 20)); const text = strip(lastFrame() ?? ""); expect(text).not.toContain("esc close"); - expect(text).toContain("Run"); + expect(text).toContain("\u25b8 Local"); unmount(); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 02129213..7dc7b99d 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -41,8 +41,6 @@ import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { SlashPalette } from "./components/slash-palette.js"; import { RunModeBar } from "./components/run-mode-bar.js"; import { StatusBar } from "./components/status-bar.js"; -import { menuPlaceByTab } from "./menu/menu-registry.js"; -import { getCurrentSection } from "./section.js"; import { RunModePicker } from "./components/run-mode-picker.js"; import type { RunModeName } from "../config/index.js"; import { TasksCancelModal } from "./components/tasks-cancel-modal.js"; @@ -85,6 +83,7 @@ import { handleProvidersTabKey } from "./providers/providers-key-bindings.js"; import { handleTelegramTabKey } from "./telegram/telegram-key-bindings.js"; import { handlePrivacyTabKey } from "./privacy/privacy-key-bindings.js"; import { MouseProvider } from "./mouse/mouse-context.js"; +import { isPrimaryPress } from "./mouse/mouse-event.js"; import { MOUSE_LAYER_BASE, MOUSE_LAYER_MODAL, @@ -428,24 +427,6 @@ const PROMPT_PLACEHOLDERS: readonly string[] = [ "Inspect a file, run a search, draft a fix…", ]; -const SECTION_LABELS: Record = { - run: "Run", - observe: "Observe", - manage: "Manage", -}; - -/** - * `Manage › Tasks` — the one thing the ctrl+p menu cannot tell you, - * because you have to open it to read it. Lived in the top bar until the - * rail took over the app chrome. - */ -function describeLocation(state: TuiState): string { - const section = SECTION_LABELS[getCurrentSection(state)] ?? "Run"; - if (state.uiMode !== "debug") return section; - const tab = menuPlaceByTab(state.activeTab)?.label; - return tab ? `${section} ${theme.glyphs.chevronRight} ${tab}` : section; -} - export function TuiApp({ session, bus, @@ -691,6 +672,34 @@ export function TuiApp({ // TuiApp renders the provider, so it cannot consume the context hook // itself — it registers on the registry it owns. The handler is read // through a ref so the subscription survives every re-render. + // Clicking away from the menu closes it, and the wheel walks it — the + // two gestures every windowed UI gives a modal. Registered on the root + // element at the modal layer: while the menu is open TuiApp raises the + // registry floor there, so base-layer targets (rail rows, the prompt, + // panels) are out of the running and only this and the popup's own + // rows compete. Hit testing picks the smallest box, so a click inside + // the popup still lands on the row it hit. + useEffect( + () => + registry.register({ + ref: contentMouseRef, + layer: MOUSE_LAYER_MODAL, + handler: (hit) => { + if (!stateRef.current.menuOpen) return false; + if (hit.event.kind === "wheel" && hit.event.wheel) { + dispatch({ + type: "menu_cursor_moved", + delta: hit.event.wheel === "up" ? -1 : 1, + }); + return true; + } + if (!isPrimaryPress(hit.event)) return false; + dispatch({ type: "menu_closed" }); + return true; + }, + }), + [registry], + ); const wheelHandlerRef = useRef(wheelHandler); wheelHandlerRef.current = wheelHandler; useEffect( @@ -944,6 +953,7 @@ export function TuiApp({ ref={contentMouseRef} {...(rootHeight ? { height: rootHeight } : {})} > + {/* Below the rail's width threshold there is no rail, and the top bar it replaced is gone — which left a narrow terminal with no @@ -966,7 +976,6 @@ export function TuiApp({ sessionsCursor={state.sidebarCursor} currentSessionId={state.session.sessionId} sessionId={state.session.sessionId} - location={describeLocation(state)} tasks={selectSidebarTasks(state.tasksPanel.rows)} tasksCursor={state.sidebarTasksCursor} activeSection={state.sidebarSection} @@ -979,11 +988,6 @@ export function TuiApp({ overflow="hidden" paddingLeft={2} > - {state.uiMode === "chat" ? ( - - - - ) : null} + {state.uiMode === "chat" ? ( + + + + ) : null} Date: Wed, 19 Aug 2026 21:54:11 +0300 Subject: [PATCH 51/57] feat(tui): [try again] re-runs a user message through the Enter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every finalised bubble had `[copy]` and nothing that acts on the message. Re-running a command meant retyping it or walking the input history. `[try again]` sits beside `[copy]` on **user** messages and re-submits that message's source through `handleEditorSubmit`, the function Enter calls, so a re-run inherits the operator's routing instead of growing a second submit path: idle starts a turn, and while a turn is running `tui.whileBusySubmit` (Ctrl+T) steers or queues it. Assistant and system bubbles keep `[copy]` only — their text is not a command anyone gave the agent. Two things the obvious implementation gets wrong: - Every landing on that path blanks `inputValue` (`startNewRun`, `message_queued`, `message_steered`), so a re-run over a half-written draft ate it. The draft is snapshotted before the submit and written back after. - A terminal reports a double-click as two presses, and a turn is not free. The `[sent]` badge doubles as the guard: clicks are ignored while it is up, and it also answers the case where the click has no visible effect at all — a steered message is not rendered until the loop applies it. Both buttons share one footer row, so `estimateMessageHeight` still charges one row per message whatever the role; the row-sharing is pinned by a test because Ink 7 paints an over-tall frame's later lines over its earlier ones instead of clipping. The badge timer both buttons need is now `useTransientStatus` — the `[copy]` tests pass untouched, which is what proves the extraction is behaviour-preserving. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 + src/tui/components/chat-copy-button.tsx | 37 +-- src/tui/components/chat-log.test.tsx | 29 +++ src/tui/components/chat-log.tsx | 26 +- src/tui/components/chat-message-height.ts | 16 +- .../components/chat-try-again-button.test.tsx | 237 ++++++++++++++++++ src/tui/components/chat-try-again-button.tsx | 141 +++++++++++ src/tui/hooks/use-transient-status.ts | 53 ++++ 8 files changed, 494 insertions(+), 47 deletions(-) create mode 100644 src/tui/components/chat-try-again-button.test.tsx create mode 100644 src/tui/components/chat-try-again-button.tsx create mode 100644 src/tui/hooks/use-transient-status.ts diff --git a/AGENTS.md b/AGENTS.md index 35641985..de5f9475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,6 +159,8 @@ The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse **Interaction contract.** First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow key (`synthetic-key.ts`), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (`rowColToCursor`, clamped to the line length). +**Re-running a message.** Every finalised **user** bubble carries `[try again]` beside `[copy]` (`components/chat-try-again-button.tsx`). It re-submits that message's source through `handleEditorSubmit` — the function Enter calls — so a re-run inherits the operator's routing rather than growing a second submit path: idle starts a turn, and while a turn is running `tui.whileBusySubmit` (Ctrl+T) decides between steering and queueing. Assistant and system bubbles do not get one; their text is not a command anyone gave the agent, and "have another go at the same question" is a different feature (it would have to drop the last turn, not append one). The composer draft is snapshotted before the submit and written back after it, because every landing on that path blanks `inputValue` (`startNewRun`, `message_queued`, `message_steered`). The label flips to `[sent]` for 2s and swallows clicks while it is up — the badge is the double-click guard, since a terminal reports a double-click as two presses and a turn is not free. Both buttons share one footer row, so `estimateMessageHeight` still charges one row per message whatever the role. + **The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v38, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys. ## Selecting and copying text diff --git a/src/tui/components/chat-copy-button.tsx b/src/tui/components/chat-copy-button.tsx index b5e0b851..208ccbb9 100644 --- a/src/tui/components/chat-copy-button.tsx +++ b/src/tui/components/chat-copy-button.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; -import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; +import { useCallback, type ReactElement } from "react"; import { useClipboard } from "../clipboard/clipboard-context.js"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { theme } from "../theme/theme.js"; @@ -52,42 +53,12 @@ export function ChatCopyButton({ }: ChatCopyButtonProps): ReactElement { const clipboard = useClipboard(); const mouse = useMouseCommands(); - const [status, setStatus] = useState("idle"); - // Both refs exist to survive re-renders, which happen constantly here: - // every streamed token repaints the whole chat log. A timer id in - // component state would be replaced mid-flight and the old timeout - // would fire against a stale closure, stranding the label. - const timerRef = useRef(null); - const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = null; - }; - }, []); - - const flash = useCallback( - (next: CopyStatus) => { - if (!mountedRef.current) return; - setStatus(next); - // Restart rather than stack: a second click while `copied!` is up - // must extend the badge, not leave an older timeout to clear it - // early and blink the label back mid-feedback. - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - timerRef.current = null; - if (mountedRef.current) setStatus("idle"); - }, revertAfterMs); - }, - [revertAfterMs], - ); + const [status, flash] = useTransientStatus("idle", revertAfterMs); const copy = useCallback(() => { // Fire-and-forget: the click handler runs outside React's render // pass and the clipboard write can outlive the frame. `flash` is the - // only thing that touches state, and it checks `mountedRef` first. + // only thing that touches state, and it no-ops after unmount. void clipboard .copy(text) .then((ok) => flash(ok ? "copied" : "failed")) diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx index aebef8fd..7dfcd6e2 100644 --- a/src/tui/components/chat-log.test.tsx +++ b/src/tui/components/chat-log.test.tsx @@ -61,6 +61,35 @@ describe("ChatLog", () => { expect(text).toContain("hi there"); }); + it("offers [try again] beside [copy] on a user message, sharing one row", () => { + const state: TuiState = { + ...createInitialTuiState(BASE_SESSION), + messages: [{ id: "m1", role: "user", text: "list the files", timestamp: 1 }], + }; + const { lastFrame } = render(); + const text = strip(lastFrame() ?? ""); + const footer = text.split("\n").find((line) => line.includes("[copy]")); + // Same row, not merely present: `estimateMessageHeight` charges one + // footer row per message, and Ink 7 paints an over-tall frame's later + // lines over its earlier ones instead of clipping it. + expect(footer).toContain("[try again]"); + }); + + it("keeps [try again] off assistant and system messages", () => { + const state: TuiState = { + ...createInitialTuiState(BASE_SESSION), + messages: [ + { id: "m1", role: "assistant", text: "hi there", toolSteps: 0, timestamp: 1 }, + { id: "m2", role: "system", text: "turn finished", timestamp: 2 }, + ], + }; + const { lastFrame } = render(); + const text = strip(lastFrame() ?? ""); + // Both still copyable; neither carries a command to re-run. + expect(text.split("[copy]").length - 1).toBe(2); + expect(text).not.toContain("[try again]"); + }); + it("hides the `reply` tool card so it does not duplicate the assistant bubble", () => { const state: TuiState = { ...createInitialTuiState(BASE_SESSION), diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index eb367c77..3cbdd5fb 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -11,6 +11,7 @@ import { estimateMessageHeight, estimateStreamingTailHeight, } from "./chat-message-height.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; import { ReasoningBubble } from "./reasoning-bubble.js"; import { SplashBanner } from "./splash-banner.js"; import { SystemBubble } from "./system-bubble.js"; @@ -167,14 +168,20 @@ interface FinalisedMessageProps { } /** - * One finalised message plus its copy affordance. + * One finalised message plus its footer of affordances. * - * The button hangs below the bubble rather than inside it so the bubble - * components stay purely presentational, and so the copied text is the - * message's own `text` — the raw source, before markdown rendering, - * borders and wrapping. It is attached only to **finalised** messages: - * the streaming tail is by definition half a message, and a copy taken - * mid-stream would silently truncate. + * The buttons hang below the bubble rather than inside it so the bubble + * components stay purely presentational, and so the text they act on is + * the message's own `text` — the raw source, before markdown rendering, + * borders and wrapping. They are attached only to **finalised** + * messages: the streaming tail is by definition half a message, and a + * copy taken mid-stream would silently truncate. + * + * `[try again]` joins `[copy]` on user messages only — the roles differ + * in whether re-sending their text means anything, and the argument is + * written out in `chat-try-again-button.tsx`. Both buttons share one + * row, so the footer costs the same single row for every role and + * `estimateMessageHeight` stays role-blind. */ function FinalisedMessage({ message, @@ -184,7 +191,10 @@ function FinalisedMessage({ return ( - + + + + ); } diff --git a/src/tui/components/chat-message-height.ts b/src/tui/components/chat-message-height.ts index ec9849ec..e837a9c0 100644 --- a/src/tui/components/chat-message-height.ts +++ b/src/tui/components/chat-message-height.ts @@ -26,12 +26,16 @@ const REASONING_BUBBLE_OVERHEAD_ROWS = 5; // marginTop + paddingTop + 1-line hea const TOOL_CARD_BASE_ROWS = 2; const ASSISTANT_FOOTER_ROWS = 1; /** - * `FinalisedMessage` hangs a `[copy]` button under every finalised - * bubble, whatever the role. One row, unconditional — the streaming tail - * has no button, which is why this is charged here and not in - * `estimateStreamingTailHeight`. + * `FinalisedMessage` hangs a button footer under every finalised bubble, + * whatever the role: `[copy]` everywhere, `[try again]` beside it on + * user messages. The two share a row, so this stays one row and + * unconditional — the day a role earns a second footer line this + * estimate has to learn about roles, and an under-count is not cosmetic: + * Ink 7 paints an over-tall frame's later lines over its earlier ones + * instead of clipping. The streaming tail has no footer at all, which is + * why the row is charged here and not in `estimateStreamingTailHeight`. */ -const COPY_BUTTON_ROWS = 1; +const MESSAGE_FOOTER_ROWS = 1; function bodyLines(text: string): number { if (text.length === 0) return 1; @@ -40,7 +44,7 @@ function bodyLines(text: string): number { export function estimateMessageHeight(message: ChatMessage): number { const bodyRows = bodyLines(message.text); - let total = bodyRows + BUBBLE_OVERHEAD_ROWS + COPY_BUTTON_ROWS; + let total = bodyRows + BUBBLE_OVERHEAD_ROWS + MESSAGE_FOOTER_ROWS; if (message.role === "assistant") { if (message.reasoningBlocks && message.reasoningBlocks.length > 0) { total += REASONING_BUBBLE_OVERHEAD_ROWS + 1; diff --git a/src/tui/components/chat-try-again-button.test.tsx b/src/tui/components/chat-try-again-button.test.tsx new file mode 100644 index 00000000..3462644a --- /dev/null +++ b/src/tui/components/chat-try-again-button.test.tsx @@ -0,0 +1,237 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import type { ReactElement, ReactNode } from "react"; +import { reduceTuiState } from "../agent-event-reducer.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { MouseProvider } from "../mouse/mouse-context.js"; +import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import { createInitialTuiState, type TuiSessionInfo, type TuiState } from "../tui-state.js"; +import { ChatTryAgainButton } from "./chat-try-again-button.js"; + +const SESSION: TuiSessionInfo = { + sessionId: "again", + workingDir: "/tmp/again", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +function strip(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +/** Screen cell of `needle` — the position a terminal reports for a click. */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Ink commits frames on a throttle and the effect that registers a click + * target runs after the frame the label first appears in, so nothing + * here sleeps a fixed interval — it polls. Same reason + * `chat-copy-button.test.tsx` and `mouse-app.test.tsx` re-send clicks. + */ +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +interface Harness { + frame: () => string; + /** Clicks the label until the registry actually owns those cells. */ + clickUntil: (needle: string, landed: () => boolean) => Promise; + clickOnce: (needle: string) => void; + state: () => TuiState; + actions: TuiAction[]; + submitted: string[]; + steered: string[]; + unmount: () => void; +} + +function mount( + children: ReactNode, + { withMouse = true, initial }: { withMouse?: boolean; initial?: TuiState } = {}, +): Harness { + const registry = new MouseTargetRegistry(); + // A real reducer behind the provider: the point of these tests is what + // the submit path does to `TuiState`, and a stub dispatch would assert + // only that the component called something. + let state = initial ?? createInitialTuiState(SESSION); + const actions: TuiAction[] = []; + const dispatch = (action: TuiAction): void => { + actions.push(action); + state = reduceTuiState(state, action); + }; + const submitted: string[] = []; + const steered: string[] = []; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: (text) => submitted.push(text), + onMessageSteered: (text) => steered.push(text), + }; + const tree: ReactElement = withMouse ? ( + state} + > + {children} + + ) : ( + <>{children} + ); + const { lastFrame, unmount } = render(tree); + const frame = (): string => strip(lastFrame() ?? ""); + const clickOnce = (needle: string): void => { + const at = locate(frame(), needle); + registry.dispatch(click(at.x, at.y)); + }; + return { + frame, + clickOnce, + clickUntil: async (needle, landed) => { + await waitUntil(() => frame().includes(needle), `the ${needle} label`); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (landed()) return; + clickOnce(needle); + await delay(25); + } + throw new Error(`click never took effect on ${needle}`); + }, + state: () => state, + actions, + submitted, + steered, + unmount, + }; +} + +describe("ChatTryAgainButton", () => { + it("renders the quiet idle label", () => { + const app = mount(); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("still renders without a mouse provider", () => { + const app = mount(, { withMouse: false }); + expect(app.frame()).toContain("[try again]"); + app.unmount(); + }); + + it("re-sends the message through the normal submit path", async () => { + const app = mount(); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["list the files"]); + // `message_submitted` is what Enter dispatches — the re-run starts a + // real turn rather than poking the orchestrator behind the reducer. + expect(app.actions.map((a) => a.type)).toContain("message_submitted"); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + app.unmount(); + }); + + it("keeps an unsent draft in the composer", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + inputValue: "half-written thought", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.submitted).toEqual(["run that again"]); + // Submitting blanks `inputValue` (`startNewRun`); the draft is put + // back afterwards, so the re-run costs a turn and not the operator's + // half-typed message. + expect(app.state().inputValue).toBe("half-written thought"); + app.unmount(); + }); + + it("steers into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "steer", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.steered.length > 0); + expect(app.steered).toEqual(["try that again"]); + // Not a second turn: the routing is `handleEditorSubmit`'s, not ours. + expect(app.submitted).toEqual([]); + expect(app.state().status).toBe("running"); + app.unmount(); + }); + + it("queues into the running turn when that is what Enter would do", async () => { + const initial: TuiState = { + ...createInitialTuiState(SESSION), + status: "running", + whileBusyMode: "queue", + }; + const app = mount(, { initial }); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(app.steered).toEqual([]); + expect(app.state().queuedMessages).toEqual(["and again"]); + app.unmount(); + }); + + it("ignores the second press of a double-click, then re-arms", async () => { + const app = mount( + , + ); + // The first send starts a turn, so the second one steers into it — + // count both landings, since which one fires is the submit path's + // decision and this test is about how many times it was asked. + const sends = (): number => app.submitted.length + app.steered.length; + await app.clickUntil("[try again]", () => sends() > 0); + await waitUntil(() => app.frame().includes("[sent]"), "the sent badge"); + // A terminal reports a double-click as two presses; a turn is not + // free, so the badge window swallows the second one. + app.clickOnce("[sent]"); + await delay(50); + expect(sends()).toBe(1); + // The guard is a window, not a latch. + await waitUntil( + () => app.frame().includes("[try again]"), + "the label re-arming", + ); + await app.clickUntil("[try again]", () => sends() > 1); + expect(app.submitted).toEqual(["expensive turn"]); + expect(app.steered).toEqual(["expensive turn"]); + app.unmount(); + }); +}); diff --git a/src/tui/components/chat-try-again-button.tsx b/src/tui/components/chat-try-again-button.tsx new file mode 100644 index 00000000..20855b96 --- /dev/null +++ b/src/tui/components/chat-try-again-button.tsx @@ -0,0 +1,141 @@ +import { Box, Text } from "ink"; +import { type ReactElement } from "react"; +import { useTransientStatus } from "../hooks/use-transient-status.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { + MouseTarget, + useMouseCommands, + type MouseContextValue, +} from "../mouse/mouse-context.js"; +import { handleEditorSubmit } from "../submit-handler.js"; +import { theme } from "../theme/theme.js"; + +interface ChatTryAgainButtonProps { + /** The message source, resent verbatim — byte for byte what was sent before. */ + readonly text: string; + /** How long `sent` stays up before the label reverts. */ + readonly revertAfterMs?: number; +} + +/** Idle / just-resent. The badge is also the double-click guard. */ +type TryAgainStatus = "idle" | "sent"; + +const DEFAULT_REVERT_MS = 2_000; + +const LABELS: Readonly> = { + idle: "[try again]", + sent: "[sent]", +}; + +/** + * Re-run `text` exactly as if it had been typed into the composer and + * submitted with Enter. + * + * **One submit path.** Everything goes through `handleEditorSubmit`, the + * function Enter calls, so a re-run inherits whatever routing the + * operator has configured instead of inventing a third behaviour: idle + * starts a turn; while a turn is running `tui.whileBusySubmit` (Ctrl+T) + * decides between steering the text into the turn in flight and parking + * it in the queue. The same rule covers the odd cases for free — a + * message that happens to read as a slash command runs as one, because + * that is what typing it would do, and a second interpretation of the + * same text is exactly how two submit paths drift apart. + * + * **The composer draft survives.** Every landing that path dispatches + * blanks `inputValue` — `startNewRun`, `message_queued` and + * `message_steered` all do — which would silently eat a half-written + * message the operator had not sent yet. The draft is snapshotted before + * the submit and written back after it, so a re-run costs a turn and + * nothing else. Restoring the buffer alone is enough: a draft that would + * also need slash-palette state restored cannot reach this handler at + * all, because `TuiApp` raises the mouse floor to `MOUSE_LAYER_MODAL` + * while the palette is open and this button sits on the base layer. + */ +export function resubmitChatMessage( + text: string, + mouse: MouseContextValue, +): void { + // Read state at click time, not render time: the handler fires outside + // React's render pass and the turn may have started or finished since + // the frame that painted the button. + const state = mouse.getState(); + const draft = state.inputValue; + handleEditorSubmit(text, state, mouse.dispatch, mouse.callbacks); + if (draft.length > 0) { + mouse.dispatch({ type: "input_changed", value: draft }); + } +} + +/** + * The per-message "run that again" affordance, beside `[copy]`. + * + * **Only user messages get one**, which is `chat-log.tsx`'s call to + * make, not this component's — but the reasoning belongs next to the + * code it explains. A user message is a command someone gave the agent, + * so re-running it is a real intent: the model wandered off, a file + * changed, a tool was down. An assistant message is the agent's own + * prose; sending it back would open a turn whose prompt is the previous + * answer, which is not "try again" in any sense an operator means. A + * system message is TUI runtime output — queue listings, turn-failed + * lines — and re-sending one as a prompt is worse than nonsense. Asking + * the model to have another go at the *same* question is a different + * feature (it has to drop the last turn, not append one) and it is not + * this button. + * + * **Why a badge when the click already changes the screen.** Often it + * does not. A steered message is not rendered until the loop applies it + * at the next step boundary (`steer_applied`), which can be seconds + * away, so a click with no feedback reads as a dead button and gets + * clicked again. `[sent]` closes that gap and doubles as the guard: + * clicks are ignored while it is up, so the double-click a terminal + * reports as two presses cannot open two turns. + * + * **Without a mouse provider** (component tests, `--no-mouse`) it still + * renders, exactly like `[copy]` — a legible hint that the affordance is + * there when the mouse is on — but registers no target. + */ +export function ChatTryAgainButton({ + text, + revertAfterMs = DEFAULT_REVERT_MS, +}: ChatTryAgainButtonProps): ReactElement { + const mouse = useMouseCommands(); + const [status, flash] = useTransientStatus( + "idle", + revertAfterMs, + ); + + const label = ( + + {LABELS[status]} + + ); + + // One space off `[copy]`, on the same row: the footer stays a single + // line whatever the role, so `estimateMessageHeight` does not have to + // branch — and an under-counted row is not a cosmetic bug in Ink 7, + // which paints an over-tall frame's later lines over its earlier ones + // rather than clipping. + return ( + + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + // Claim the press either way — the click landed on this + // button, and letting it fall through would hand it to the + // viewport wheel target behind the chat log. + if (status !== "idle") return true; + resubmitChatMessage(text, mouse); + flash("sent"); + return true; + }} + > + {label} + + ) : ( + label + )} + + ); +} diff --git a/src/tui/hooks/use-transient-status.ts b/src/tui/hooks/use-transient-status.ts new file mode 100644 index 00000000..2019f29e --- /dev/null +++ b/src/tui/hooks/use-transient-status.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * A status value that falls back to `idle` on its own after + * `revertAfterMs` — the "label flips for two seconds, then reverts" + * pattern the chat-log buttons are built on. + * + * The bookkeeping is fussier than it looks, which is why both buttons + * share it rather than each carrying a copy: + * + * - **The timer id lives in a ref.** The chat log repaints on every + * streamed token, so an id kept in component state is replaced + * mid-flight and the old timeout fires against a stale closure, + * stranding the label on its badge. + * - **A re-flash restarts the window** instead of stacking a second + * timeout behind it — otherwise the older timeout clears the badge + * early and the label blinks mid-feedback. + * - **Unmount clears it.** A message can scroll out of the ring buffer + * while its badge is still up. + */ +export function useTransientStatus( + idle: T, + revertAfterMs: number, +): [T, (next: T) => void] { + const [status, setStatus] = useState(idle); + const timerRef = useRef(null); + const mountedRef = useRef(true); + // Read through a ref so `flash` never has to be re-created when the + // caller passes a fresh object/array as the idle value. + const idleRef = useRef(idle); + idleRef.current = idle; + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, []); + const flash = useCallback( + (next: T) => { + if (!mountedRef.current) return; + setStatus(next); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (mountedRef.current) setStatus(idleRef.current); + }, revertAfterMs); + }, + [revertAfterMs], + ); + return [status, flash]; +} From cc533bf71b07a5dda00903ac3ae039ce756556cd Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 22:01:58 +0300 Subject: [PATCH 52/57] feat(tui): a run-mode switch moves the composer's model, or opens the setup it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were wrong when switching Local / Cloud / Fusion from the strip. The composer's model name never moved. The switch itself worked — config written, registry swapped, strip repainted — but the meta row reads the PROVIDERS mirror (`selectPromptLlmMeta` → `providersPanel.rows`), and nothing republishes that mirror after a mode switch. It went on naming the leg you had just switched away from until some unrelated event refreshed it. A successful `setMode` now emits `providers_refresh_requested` on the bus after the swap; emitting rather than dispatching is the point, since the bus is bridged into the reducer one way. Cloud and Fusion also refused with a sentence and no way forward, and Local was worse than that: `resolveRunMode` does not degrade a `local` request with no llama-server, so the orchestrator wrote `runMode.mode: "local"` while leaving a cloud provider active — a stored mode that resolves to something else on the next read — and said nothing at all. A missing leg is now checked directly and routes to the screen that can fill it, per leg: the cloud wizard for a missing provider, the Local pane for a missing llama-server. The `n` key and the overlay's clickable row follow the highlighted mode through the same code. Fusion is the one mode whose route is a pair, and the fusion rule puts the cloud leg in `activeTextProvider` — so following the active row alone printed the same single model for Cloud and Fusion. The prompt meta now reports the resolved pair when `effective` is fusion, with each half given its own share of the label budget so neither is truncated away. `activeTextProvider` stays authoritative throughout: `effective` is only ever fusion when that provider IS the cloud leg. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 + src/tui/components/prompt-meta-bar.test.tsx | 36 +++ src/tui/components/prompt-meta-bar.tsx | 28 +- src/tui/components/run-mode-picker.tsx | 35 ++- src/tui/llm-panel/llm-panel-selectors.test.ts | 82 ++++++ src/tui/llm-panel/llm-panel-selectors.ts | 25 ++ src/tui/run-mode/index.ts | 8 + .../run-mode/run-mode-key-bindings.test.ts | 53 ++++ src/tui/run-mode/run-mode-key-bindings.ts | 36 +-- .../run-mode/run-mode-orchestrator.test.ts | 246 ++++++++++++++++++ src/tui/run-mode/run-mode-orchestrator.ts | 62 ++++- src/tui/run-mode/run-mode-setup.test.ts | 101 +++++++ src/tui/run-mode/run-mode-setup.ts | 114 ++++++++ 13 files changed, 797 insertions(+), 33 deletions(-) create mode 100644 src/tui/run-mode/run-mode-orchestrator.test.ts create mode 100644 src/tui/run-mode/run-mode-setup.test.ts create mode 100644 src/tui/run-mode/run-mode-setup.ts diff --git a/AGENTS.md b/AGENTS.md index 35641985..89289cbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1880,6 +1880,10 @@ Note it is **not** `DebugPane`'s `SubTabBar` — that component only renders in **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. +**A switch republishes the providers mirror.** Everything that names the *model* — the composer's meta row above all — reads `state.providersPanel.rows`, not this panel's mirror, and a mode switch is the one path that moves the active provider without going through `ProvidersOrchestrator`. So a successful `setMode` emits `providers_refresh_requested` *on the bus* once the swap has landed. Emitting rather than dispatching is load-bearing: the bus is bridged into the reducer one way, so a dispatched request reaches the reducer (which ignores it) and never the orchestrator that rebuilds the rows. + +**An unconfigured mode routes to the screen that fixes it** ([src/tui/run-mode/run-mode-setup.ts](src/tui/run-mode/run-mode-setup.ts)), rather than only refusing. Per leg, because the two legs are repaired in different places: a missing cloud provider opens Manage → LLM → Cloud with the add-provider wizard, a missing llama-server opens Manage → LLM → Local (the backend/model/daemon checklist — no wizard, which would only hide it). Fusion fixes its cloud leg first. One implementation serves all three entry points — the `n` key, the overlay's clickable row, and the switch itself — so the gestures cannot drift apart. Note this also closed a silent hole: `resolveRunMode` does not degrade a `local` request that has no local leg, so the orchestrator used to write `runMode.mode: "local"` while leaving a cloud provider active, with no swap and no message. A missing leg is now checked directly, ahead of the resolver's degradation. + #### 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). diff --git a/src/tui/components/prompt-meta-bar.test.tsx b/src/tui/components/prompt-meta-bar.test.tsx index 77fc4a40..24cd8ba5 100644 --- a/src/tui/components/prompt-meta-bar.test.tsx +++ b/src/tui/components/prompt-meta-bar.test.tsx @@ -150,3 +150,39 @@ describe("composer buttons", () => { unmount(); }); }); + +describe("the model label", () => { + const renderModel = (model: string): string => { + const { lastFrame, unmount } = render( + {}} + onSubmit={() => {}} + />, + ); + const frame = strip(lastFrame() ?? ""); + unmount(); + return frame; + }; + + /** + * Fusion names both legs. Spending the whole budget left-to-right ate + * the local half outright — "vendor/some-very-long-name ⇄ q…" — which + * hides the model that actually executes most of the steps. + */ + it("keeps both fusion legs identifiable", () => { + const frame = renderModel( + "vendor/some-very-long-cloud-model ⇄ qwen3-4b-instruct-q4.gguf", + ); + expect(frame).toContain("vendor/some-v…"); + expect(frame).toContain("qwen3-4b-inst…"); + }); + + it("still trims a single long name the way it always did", () => { + expect(renderModel("vendor/an-extremely-long-single-model-name")).toContain( + "vendor/an-extremely-long-single…", + ); + }); +}); diff --git a/src/tui/components/prompt-meta-bar.tsx b/src/tui/components/prompt-meta-bar.tsx index 48b68ea1..c5e24ee8 100644 --- a/src/tui/components/prompt-meta-bar.tsx +++ b/src/tui/components/prompt-meta-bar.tsx @@ -47,6 +47,14 @@ const SEND_LABEL = " send → "; const MODEL_LABEL_MAX_LEN = 32; +/** + * Separator `runModeModelSummary` puts between the two fusion legs. + * Matched here rather than imported as a run-mode concept: this file + * only needs to know that a label can be a pair, so that it can spend + * its budget on both halves instead of on the first one. + */ +const PAIR_SEPARATOR = " ⇄ "; + export function PromptMetaBar({ leftSlot, model, @@ -190,7 +198,21 @@ function MetaLeft({ leftSlot, model, provider }: MetaLeftProps): ReactElement { } function formatModel(model: string): string { - const stripped = model.replace(/\.gguf$/i, ""); - if (stripped.length <= MODEL_LABEL_MAX_LEN) return stripped; - return `${stripped.slice(0, MODEL_LABEL_MAX_LEN - 1)}…`; + // Fusion names both legs. Truncating the joined string would eat the + // local half whole and leave "anthropic/claude-sonnet-4.5 ⇄ q…", which + // says less than either name alone would: the reader can no longer + // tell which local model is executing. Each side gets half the budget + // so both stay identifiable at the width the row already had. + const [cloud, local] = model.split(PAIR_SEPARATOR); + if (cloud !== undefined && local !== undefined) { + const half = Math.floor((MODEL_LABEL_MAX_LEN - PAIR_SEPARATOR.length) / 2); + return `${shorten(cloud, half)}${PAIR_SEPARATOR}${shorten(local, half)}`; + } + return shorten(model, MODEL_LABEL_MAX_LEN); +} + +function shorten(label: string, max: number): string { + const stripped = label.replace(/\.gguf$/i, ""); + if (stripped.length <= max) return stripped; + return `${stripped.slice(0, max - 1)}…`; } diff --git a/src/tui/components/run-mode-picker.tsx b/src/tui/components/run-mode-picker.tsx index 45c23fbd..2b9cc7f5 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -4,7 +4,11 @@ import type { ReactElement } from "react"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; -import { openProviderSetupFromRunMode } from "../run-mode/run-mode-key-bindings.js"; +import { + openRunModeSetup, + runModeSetupOffer, + type RunModeSetupTarget, +} from "../run-mode/run-mode-setup.js"; import { theme } from "../theme/theme.js"; import { RUN_MODES, RUN_MODE_LABELS } from "../run-mode/run-mode-nav.js"; import { @@ -82,7 +86,12 @@ export function RunModePicker({ panel }: RunModePickerProps): ReactElement | nul {panel.degradedMessage ? ( {panel.degradedMessage} ) : null} - {panel.cloudProviderMissing ? : null} + {/* + The row follows the cursor, not the config: highlight Local on a + machine with no llama-server and the thing to set up is the local + runtime, not another cloud key. + */} + ↑↓ mode · ←→ share (shift ±25) · digits set · enter apply · esc cancel @@ -151,17 +160,33 @@ function LegRow({ ); } +const SETUP_LABELS: Record = { + "cloud-provider": "Set up a cloud provider…", + "local-runtime": "Set up the local llama-server…", +}; + /** * On a fresh install two of the three modes cannot be entered at all, * and this overlay was where you found that out and then had nowhere to * go. The fix belongs on the screen that raises the problem. + * + * `null` renders an empty row rather than nothing, because this overlay + * has to hold its height: Ink 7 paints an over-tall frame's later lines + * over its earlier ones, and a row that appears and disappears as the + * cursor moves between modes would make the box breathe under the chat + * surface it floats over. */ -function SetUpProviderRow(): ReactElement { +function SetUpLegRow({ + target, +}: { + target: RunModeSetupTarget | null; +}): ReactElement { const mouse = useMouseCommands(); + if (!target) return ; const label = ( {" "} - {theme.glyphs.chevronRight} Set up a cloud provider…{" "} + {theme.glyphs.chevronRight} {SETUP_LABELS[target]}{" "} (n) ); @@ -171,7 +196,7 @@ function SetUpProviderRow(): ReactElement { layer={MOUSE_LAYER_MODAL} onMouse={(hit) => { if (!isPrimaryPress(hit.event)) return false; - openProviderSetupFromRunMode(mouse.dispatch); + openRunModeSetup(mouse.dispatch, target); return true; }} > diff --git a/src/tui/llm-panel/llm-panel-selectors.test.ts b/src/tui/llm-panel/llm-panel-selectors.test.ts index 3cd70c08..f8452f02 100644 --- a/src/tui/llm-panel/llm-panel-selectors.test.ts +++ b/src/tui/llm-panel/llm-panel-selectors.test.ts @@ -131,6 +131,88 @@ describe("llm-panel selectors", () => { cloudLabel: "cloud", }); }); + + /** + * Cloud and Fusion share an active provider — the fusion rule requires + * the cloud leg to be the active one — so following `isActiveText` + * alone printed the same single model for both. Switching Cloud → + * Fusion moved nothing in the composer, and the local executor that + * runs most of a fusion turn was never named at all. + */ + it("names both legs in the prompt when fusion is in force", () => { + const base = createInitialTuiState(fakeSession()); + const state = { + ...base, + runModePanel: { + ...base.runModePanel, + effective: "fusion" as const, + cloudProviderId: "openrouter", + localProviderId: "local-llama", + cloudLabel: "openai/gpt-4o-mini", + localLabel: "qwen3-4b", + }, + providersPanel: { + ...base.providersPanel, + rows: [ + { + id: "openrouter", + kind: "openrouter", + isActiveText: true, + isActiveEmbedding: false, + hasApiKey: true, + chatModel: "openai/gpt-4o-mini", + embeddingModel: null, + }, + ], + }, + }; + + expect(selectPromptLlmMeta(state)).toEqual({ + model: "openai/gpt-4o-mini ⇄ qwen3-4b", + provider: "openrouter", + usesLocalHealth: false, + cloudLabel: "fusion", + }); + }); + + /** + * `activeTextProvider` stays authoritative: an operator who moved the + * active provider by hand drops out of fusion, `effective` reports + * `cloud`, and the composer must follow the resolution rather than the + * stored mode. + */ + it("falls back to the active provider when fusion is only stored", () => { + const base = createInitialTuiState(fakeSession()); + const state = { + ...base, + runModePanel: { + ...base.runModePanel, + effective: "cloud" as const, + stored: "fusion" as const, + cloudProviderId: "openrouter", + localProviderId: "local-llama", + cloudLabel: "openai/gpt-4o-mini", + localLabel: "qwen3-4b", + }, + providersPanel: { + ...base.providersPanel, + rows: [ + { + id: "openrouter", + kind: "openrouter", + isActiveText: true, + isActiveEmbedding: false, + hasApiKey: true, + chatModel: "openai/gpt-4o-mini", + embeddingModel: null, + }, + ], + }, + }; + + expect(selectPromptLlmMeta(state).model).toBe("openai/gpt-4o-mini"); + expect(selectPromptLlmMeta(state).cloudLabel).toBe("cloud"); + }); }); function localDef(id: LocalModelDef["id"]): LocalModelDef { diff --git a/src/tui/llm-panel/llm-panel-selectors.ts b/src/tui/llm-panel/llm-panel-selectors.ts index 752f95c2..ce1bdf77 100644 --- a/src/tui/llm-panel/llm-panel-selectors.ts +++ b/src/tui/llm-panel/llm-panel-selectors.ts @@ -1,5 +1,6 @@ import type { EmbeddingModelRow, LocalModelRow } from "../local-models/local-models-panel-state.js"; import type { ProviderRow } from "../providers/providers-panel-state.js"; +import { runModeModelSummary } from "../run-mode/run-mode-selectors.js"; import type { TuiState } from "../tui-state.js"; import type { LlmPanelMode } from "./llm-panel-state.js"; import { @@ -171,8 +172,32 @@ export function selectLlmActiveRouteSummary( }; } +/** + * What the composer's meta row names as the route for the next turn. + * + * Fusion is read off the run-mode mirror rather than the active provider + * because it is the one mode whose route is a PAIR. The fusion rule puts + * the cloud leg in `activeTextProvider`, so following the active row + * alone would print the same single cloud model for Cloud and for + * Fusion — the composer would not move at all on a switch between them, + * and it would never name the local executor that runs most of the + * steps. `activeTextProvider` stays authoritative: `effective` is only + * ever `fusion` when that provider IS the cloud leg, so this reports the + * resolution, it does not compete with it. + */ export function selectPromptLlmMeta(state: TuiState): PromptLlmMeta { const active = state.providersPanel.rows.find((row) => row.isActiveText) ?? null; + const runMode = state.runModePanel; + const fusionPair = + runMode.effective === "fusion" ? runModeModelSummary(runMode) : null; + if (fusionPair) { + return { + model: fusionPair, + provider: runMode.cloudProviderId, + usesLocalHealth: false, + cloudLabel: "fusion", + }; + } if (active && active.kind !== "llama-server") { return { model: active.chatModel, diff --git a/src/tui/run-mode/index.ts b/src/tui/run-mode/index.ts index 6565dabe..38db91e2 100644 --- a/src/tui/run-mode/index.ts +++ b/src/tui/run-mode/index.ts @@ -24,3 +24,11 @@ export { } from "./run-mode-selectors.js"; export { handleRunModePickerKey } from "./run-mode-key-bindings.js"; export { RunModeOrchestrator } from "./run-mode-orchestrator.js"; +export { + describeRunModeSetup, + openRunModeSetup, + runModeSetupOffer, + runModeSetupTarget, + type RunModeLegAvailability, + type RunModeSetupTarget, +} from "./run-mode-setup.js"; diff --git a/src/tui/run-mode/run-mode-key-bindings.test.ts b/src/tui/run-mode/run-mode-key-bindings.test.ts index 52c8518d..2eb52650 100644 --- a/src/tui/run-mode/run-mode-key-bindings.test.ts +++ b/src/tui/run-mode/run-mode-key-bindings.test.ts @@ -29,6 +29,26 @@ function withPicker(): TuiState { ]); } +/** The mirror the orchestrator pushes in, with one leg reported absent. */ +function withMissingLeg(leg: "cloud" | "local"): TuiState { + return apply(createInitialTuiState(fakeSession()), [ + { + type: "run_mode_synced", + effective: "local", + stored: "local", + cloudShare: 40, + localLabel: leg === "local" ? null : "qwen3-4b", + cloudLabel: leg === "cloud" ? null : "vendor/big", + localProviderId: leg === "local" ? null : "local-llama", + cloudProviderId: leg === "cloud" ? null : "openrouter", + cloudProviderMissing: leg === "cloud", + localProviderMissing: leg === "local", + degradedMessage: null, + }, + { type: "run_mode_picker_opened" }, + ]); +} + function press(state: TuiState, input: string, key: Partial = {}) { const dispatch = vi.fn(); const onRunModeChangeRequested = vi.fn(); @@ -131,6 +151,39 @@ describe("handleRunModePickerKey", () => { }); }); + /** + * `n` used to open the add-a-cloud-provider wizard whatever the cursor + * was on. With Local highlighted on a machine that has no llama-server + * that is the wrong screen entirely — no number of cloud keys makes a + * local executor appear — so the key follows the highlighted mode. + */ + it("routes n to the leg the highlighted mode is missing", () => { + const onLocal = withMissingLeg("local"); + const { dispatch } = press(onLocal, "n"); + expect(dispatch).toHaveBeenCalledWith({ type: "llm_mode_set", mode: "local" }); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "providers_wizard_opened" }), + ); + }); + + it("routes n to the cloud wizard when Cloud is the mode with no leg", () => { + const onCloud = apply(withMissingLeg("cloud"), [ + { type: "run_mode_picker_cursor_set", cursor: 1 }, + ]); + const { dispatch } = press(onCloud, "n"); + expect(dispatch).toHaveBeenCalledWith({ type: "llm_mode_set", mode: "cloud" }); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "providers_wizard_opened" }), + ); + }); + + it("leaves the chat surface for the LLM tab either way", () => { + const { dispatch } = press(withMissingLeg("local"), "n"); + expect(dispatch).toHaveBeenCalledWith({ type: "run_mode_picker_closed" }); + expect(dispatch).toHaveBeenCalledWith({ type: "ui_mode_set", mode: "debug" }); + expect(dispatch).toHaveBeenCalledWith({ type: "tab_changed", tab: "llm" }); + }); + 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. diff --git a/src/tui/run-mode/run-mode-key-bindings.ts b/src/tui/run-mode/run-mode-key-bindings.ts index 6bb6cb98..18ca0ec4 100644 --- a/src/tui/run-mode/run-mode-key-bindings.ts +++ b/src/tui/run-mode/run-mode-key-bindings.ts @@ -2,13 +2,13 @@ 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 { createProvidersWizardState } from "../providers/providers-wizard-state.js"; import { clampCloudShare, CLOUD_SHARE_COARSE_STEP, CLOUD_SHARE_STEP, RUN_MODES, } from "./run-mode-nav.js"; +import { openRunModeSetup, runModeSetupOffer } from "./run-mode-setup.js"; export interface RunModePickerKeyContext { state: TuiState; @@ -44,13 +44,16 @@ export interface RunModePickerKeyContext { * - `←`/`→` — dial ±5 (shift: ±25). * - digits — type a dial value directly. * - `Enter` — apply the highlighted mode + dial. - * - `n` — set up the provider this mode needs. + * - `n` — set up the leg the HIGHLIGHTED mode is missing. * - `Esc` — close, reverting to what was in force. * * `n` exists because the overlay was a dead end on a fresh install: * Cloud and Fusion both refuse without a cloud provider, and the only * cure was to leave, find Manage -> LLM, and add one. The screen that * tells you what is missing is the screen that should be able to fix it. + * It follows the cursor rather than always opening the cloud wizard, + * because with Local highlighted the missing leg is a llama-server and + * the cloud wizard cannot supply one. */ export function handleRunModePickerKey( input: string, @@ -66,7 +69,15 @@ export function handleRunModePickerKey( return true; } if (input === "n" || input === "N") { - openProviderSetupFromRunMode(dispatch); + // Nothing missing still opens the cloud wizard: `n` is advertised on + // the overlay, and a key that silently does nothing on a healthy + // config reads as broken. Adding a second cloud provider is the only + // sensible thing "set up" can mean there. + openRunModeSetup( + dispatch, + runModeSetupOffer(picker.draftMode, ctx.state.runModePanel) ?? + "cloud-provider", + ); return true; } if (key.return) { @@ -113,22 +124,3 @@ export function handleRunModePickerKey( // Anything else is swallowed while the picker owns the keyboard. return true; } - - -/** - * Leave the overlay and land on the provider wizard with the Cloud pane - * selected — the one place a cloud provider can be added. Exported so - * the mouse layer runs exactly this, not a second copy of it. - */ -export function openProviderSetupFromRunMode( - dispatch: (action: TuiAction) => void, -): void { - dispatch({ type: "run_mode_picker_closed" }); - dispatch({ type: "ui_mode_set", mode: "debug" }); - dispatch({ type: "tab_changed", tab: "llm" }); - dispatch({ type: "llm_mode_set", mode: "cloud" }); - dispatch({ - type: "providers_wizard_opened", - wizard: createProvidersWizardState("add"), - }); -} diff --git a/src/tui/run-mode/run-mode-orchestrator.test.ts b/src/tui/run-mode/run-mode-orchestrator.test.ts new file mode 100644 index 00000000..1c89757a --- /dev/null +++ b/src/tui/run-mode/run-mode-orchestrator.test.ts @@ -0,0 +1,246 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetConfigCache } from "../../config/index.js"; +import type { AgentRuntime } from "../../runtime/bootstrap.js"; +import type { TuiAction } from "../tui-action.js"; +import { RunModeOrchestrator } from "./run-mode-orchestrator.js"; + +const STATE_DIR_ENV = "ATOMIC_AGENT_STATE_DIR"; + +/** Shared bus: the orchestrator emits, the test reads back what it said. */ +function makeBus() { + const listeners = new Set<(action: TuiAction) => void>(); + const emitted: TuiAction[] = []; + return { + emitted, + types: () => emitted.map((a) => a.type), + subscribe(listener: (action: TuiAction) => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit(action: TuiAction) { + emitted.push(action); + for (const listener of [...listeners]) listener(action); + }, + }; +} + +/** + * Only `providerRegistry.setActive` is reachable from `setMode`, and the + * ids it is called with are the assertion — a mode switch that swaps the + * wrong leg is the failure this stub exists to catch. + */ +function makeRuntime() { + const activated: string[] = []; + return { + activated, + runtime: { + providerRegistry: { + setActive: async (id: string) => { + activated.push(id); + }, + }, + } as unknown as AgentRuntime, + }; +} + +const LOCAL_LEG = { + id: "local-llama", + kind: "llama-server", + url: "http://127.0.0.1:8080", + model: "qwen3-4b.gguf", +}; +const CLOUD_LEG = { + id: "openrouter", + kind: "openrouter", + defaultChatModel: "vendor/big", +}; + +function seed( + stateDir: string, + llm: { + activeTextProvider: string; + providers: unknown[]; + runMode?: unknown; + }, +): void { + writeFileSync( + join(stateDir, "config.json"), + JSON.stringify({ + llm: { + activeEmbeddingProvider: llm.activeTextProvider, + toolTransport: "auto", + ...llm, + }, + }), + "utf8", + ); + resetConfigCache(); +} + +function onDisk(stateDir: string): { + activeTextProvider?: string; + runMode?: { mode?: string }; +} { + return ( + JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")).llm ?? {} + ); +} + +describe("RunModeOrchestrator.setMode", () => { + let stateDir: string; + let original: string | undefined; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "run-mode-orch-")); + mkdirSync(stateDir, { recursive: true }); + original = process.env[STATE_DIR_ENV]; + process.env[STATE_DIR_ENV] = stateDir; + resetConfigCache(); + }); + + afterEach(() => { + if (original === undefined) delete process.env[STATE_DIR_ENV]; + else process.env[STATE_DIR_ENV] = original; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + /** + * The composer's model name comes from the PROVIDERS mirror, not from + * this panel's, and a mode switch is the one thing that moves the + * active provider without going through the providers orchestrator. + * Without this republish the swap really happened — config written, + * registry moved, strip repainted — and the composer went on naming + * the model of the leg the operator had just switched away from. + */ + it("republishes the providers mirror once the swap has landed", async () => { + seed(stateDir, { + activeTextProvider: "local-llama", + providers: [LOCAL_LEG, CLOUD_LEG], + runMode: { mode: "local" }, + }); + const bus = makeBus(); + const { runtime, activated } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("cloud"); + + expect(activated).toEqual(["openrouter"]); + expect(bus.types()).toContain("providers_refresh_requested"); + // After the swap, never before: a mirror rebuilt while the registry + // still points at the old leg would republish the stale row set. + expect(bus.types().indexOf("providers_refresh_requested")).toBeGreaterThan( + bus.types().indexOf("run_mode_change_settled"), + ); + }); + + it("does not republish when the switch never happened", async () => { + seed(stateDir, { + activeTextProvider: "local-llama", + providers: [LOCAL_LEG], + runMode: { mode: "local" }, + }); + const bus = makeBus(); + const { runtime } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("cloud"); + expect(bus.types()).not.toContain("providers_refresh_requested"); + }); + + it("takes an unconfigured Cloud to the add-a-provider wizard", async () => { + seed(stateDir, { + activeTextProvider: "local-llama", + providers: [LOCAL_LEG], + runMode: { mode: "local" }, + }); + const bus = makeBus(); + const { runtime } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("cloud"); + + expect(bus.types()).toEqual( + expect.arrayContaining([ + "ui_mode_set", + "tab_changed", + "llm_mode_set", + "providers_wizard_opened", + ]), + ); + expect(bus.emitted).toContainEqual({ type: "llm_mode_set", mode: "cloud" }); + }); + + /** + * "For this specific type of model": a missing llama-server is not + * fixed by the cloud wizard, so Local lands on the Local pane, which + * is where the backend, the model and the daemon are. + */ + it("takes an unconfigured Local to the local pane, not the cloud wizard", async () => { + seed(stateDir, { + activeTextProvider: "openrouter", + providers: [CLOUD_LEG], + runMode: { mode: "cloud" }, + }); + const bus = makeBus(); + const { runtime } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("local"); + + expect(bus.emitted).toContainEqual({ type: "llm_mode_set", mode: "local" }); + expect(bus.types()).not.toContain("providers_wizard_opened"); + }); + + /** + * The silent hole this closes: `resolveRunMode` does not degrade a + * `local` request with no local leg, so the old code wrote + * `runMode.mode = "local"` while leaving a cloud provider active — + * a stored mode that resolves to something else on the very next + * read, with no swap and not one word to the operator. + */ + it("refuses to store a Local it cannot run, and says so", async () => { + seed(stateDir, { + activeTextProvider: "openrouter", + providers: [CLOUD_LEG], + runMode: { mode: "cloud" }, + }); + const bus = makeBus(); + const { runtime, activated } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("local"); + + expect(onDisk(stateDir).runMode?.mode).toBe("cloud"); + expect(onDisk(stateDir).activeTextProvider).toBe("openrouter"); + expect(activated).toEqual([]); + const settled = bus.emitted.find((a) => a.type === "run_mode_change_settled"); + expect(settled).toMatchObject({ error: expect.stringContaining("llama-server") }); + }); + + it("takes an unconfigured Fusion to whichever leg is missing", async () => { + seed(stateDir, { + activeTextProvider: "openrouter", + providers: [CLOUD_LEG], + runMode: { mode: "cloud" }, + }); + const bus = makeBus(); + const { runtime } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("fusion"); + expect(bus.emitted).toContainEqual({ type: "llm_mode_set", mode: "local" }); + }); + + it("still applies a mode both legs can support", async () => { + seed(stateDir, { + activeTextProvider: "local-llama", + providers: [LOCAL_LEG, CLOUD_LEG], + runMode: { mode: "local" }, + }); + const bus = makeBus(); + const { runtime, activated } = makeRuntime(); + await new RunModeOrchestrator(runtime, bus).setMode("fusion", 65); + + // The fusion rule pins the CLOUD leg as primary; the dial rides along + // in the same write. + expect(activated).toEqual(["openrouter"]); + expect(onDisk(stateDir).runMode).toMatchObject({ + mode: "fusion", + fusion: { cloudShare: 65 }, + }); + expect(bus.types()).not.toContain("providers_wizard_opened"); + }); +}); diff --git a/src/tui/run-mode/run-mode-orchestrator.ts b/src/tui/run-mode/run-mode-orchestrator.ts index 696319af..0edcc0bf 100644 --- a/src/tui/run-mode/run-mode-orchestrator.ts +++ b/src/tui/run-mode/run-mode-orchestrator.ts @@ -7,6 +7,12 @@ 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"; +import { + describeRunModeSetup, + openRunModeSetup, + runModeSetupTarget, + type RunModeSetupTarget, +} from "./run-mode-setup.js"; /** * Only TUI module that writes `llm.runMode` or moves the active text @@ -53,6 +59,12 @@ export class RunModeOrchestrator { * 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. + * + * When what the mode lacks is a whole leg, the sentence is no longer + * the entire response: the operator is taken to the screen that can + * supply it. Picking a mode is a statement of intent, and answering + * intent with a refusal and no route is what made Cloud unreachable + * on a fresh install without knowing where Manage → LLM lives. */ async setMode(mode: RunModeName, cloudShare?: number): Promise { this.bus.emit({ type: "run_mode_change_started" }); @@ -61,8 +73,14 @@ export class RunModeOrchestrator { if (target.blocked) { this.bus.emit({ type: "run_mode_change_settled", - error: target.blocked, + error: target.blocked.message, }); + if (target.blocked.setup) { + openRunModeSetup( + (action) => this.bus.emit(action), + target.blocked.setup, + ); + } this.refresh(); return; } @@ -73,6 +91,16 @@ export class RunModeOrchestrator { }); await this.runtime.providerRegistry.setActive(target.primaryProviderId); this.bus.emit({ type: "run_mode_change_settled" }); + // The active provider just moved, and everything that names the + // model — the composer's meta row above all — reads it off the + // providers mirror, not off this panel's. Nothing else republishes + // that mirror after a mode switch, so the composer went on naming + // the previous leg's model until some unrelated event refreshed + // it. Emitting rather than dispatching is load-bearing: the bus is + // bridged into the reducer one way, so a dispatched request would + // reach the reducer (which ignores it) and never the providers + // orchestrator that actually rebuilds the rows. + this.bus.emit({ type: "providers_refresh_requested" }); } catch (err) { this.bus.emit({ type: "run_mode_change_settled", @@ -88,12 +116,36 @@ export class RunModeOrchestrator { * 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. + * + * A missing leg is checked first and separately from the degradation + * the resolver reports, because the two do not line up. `resolveRunMode` + * degrades `cloud` and `fusion` when the cloud provider is absent, but + * a `local` request with no llama-server provider is not a degradation + * at all — it resolves quietly to whatever is active, which used to let + * this method write `runMode.mode: "local"` while leaving a cloud + * provider active. That is precisely the file-disagrees-with-the-strip + * state the comment above promises never to write, and it happened in + * silence: no swap, no sentence, nothing. */ private resolveTarget( mode: RunModeName, cloudShare?: number, - ): { primaryProviderId: string; blocked?: string } { + ): { + primaryProviderId: string; + blocked?: { message: string; setup?: RunModeSetupTarget }; + } { const live = resolveLlmConfig(getConfig()); + const current = resolveRunMode(live); + const setup = runModeSetupTarget(mode, { + cloudProviderMissing: current.cloudProviderId === null, + localProviderMissing: current.localProviderId === null, + }); + if (setup) { + return { + primaryProviderId: current.primaryProviderId, + blocked: { message: describeRunModeSetup(setup), setup }, + }; + } const hypothetical = resolveRunMode({ ...live, runMode: { @@ -107,10 +159,14 @@ export class RunModeOrchestrator { // fusion rule ("cloud leg must be primary") can be satisfied. activeTextProvider: this.legFor(mode, live.activeTextProvider), }); + // Both legs exist and the mode still will not hold. Nothing shipped + // reaches here today — every degradation that flips `effective` is a + // missing leg, caught above — so this stays a report with no route, + // which is the right shape for a cause we cannot name a screen for. if (hypothetical.degraded && hypothetical.effective !== mode) { return { primaryProviderId: hypothetical.primaryProviderId, - blocked: describeRunModeDegradation(hypothetical.degraded), + blocked: { message: describeRunModeDegradation(hypothetical.degraded) }, }; } return { primaryProviderId: hypothetical.primaryProviderId }; diff --git a/src/tui/run-mode/run-mode-setup.test.ts b/src/tui/run-mode/run-mode-setup.test.ts new file mode 100644 index 00000000..b50718d3 --- /dev/null +++ b/src/tui/run-mode/run-mode-setup.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + openRunModeSetup, + runModeSetupOffer, + runModeSetupTarget, + type RunModeLegAvailability, +} from "./run-mode-setup.js"; + +const BOTH: RunModeLegAvailability = { + cloudProviderMissing: false, + localProviderMissing: false, +}; +const NO_CLOUD: RunModeLegAvailability = { + cloudProviderMissing: true, + localProviderMissing: false, +}; +const NO_LOCAL: RunModeLegAvailability = { + cloudProviderMissing: false, + localProviderMissing: true, +}; +const NEITHER: RunModeLegAvailability = { + cloudProviderMissing: true, + localProviderMissing: true, +}; + +describe("runModeSetupTarget", () => { + it("clears every mode when both legs are configured", () => { + for (const mode of ["local", "cloud", "fusion"] as const) { + expect(runModeSetupTarget(mode, BOTH)).toBeNull(); + } + }); + + it("does not send Local to the cloud wizard", () => { + // The whole point of routing per mode: a missing cloud key has + // nothing to do with whether Local can run. + expect(runModeSetupTarget("local", NO_CLOUD)).toBeNull(); + expect(runModeSetupTarget("local", NO_LOCAL)).toBe("local-runtime"); + }); + + it("sends Cloud to the cloud wizard and nowhere else", () => { + expect(runModeSetupTarget("cloud", NO_CLOUD)).toBe("cloud-provider"); + expect(runModeSetupTarget("cloud", NO_LOCAL)).toBeNull(); + }); + + it("fixes fusion's cloud leg first, because without it fusion cannot run at all", () => { + expect(runModeSetupTarget("fusion", NEITHER)).toBe("cloud-provider"); + expect(runModeSetupTarget("fusion", NO_LOCAL)).toBe("local-runtime"); + }); +}); + +describe("runModeSetupOffer", () => { + it("keeps a fresh install's way out visible from the Local row", () => { + // The overlay opens with the cursor on the mode in force, which on a + // fresh install is Local — the one mode that works. A strict answer + // would hide the offer exactly where the dead end used to be. + expect(runModeSetupTarget("local", NO_CLOUD)).toBeNull(); + expect(runModeSetupOffer("local", NO_CLOUD)).toBe("cloud-provider"); + }); + + it("still prefers the highlighted mode's own missing leg", () => { + expect(runModeSetupOffer("local", NEITHER)).toBe("local-runtime"); + }); + + it("offers nothing when there is nothing to fix", () => { + expect(runModeSetupOffer("fusion", BOTH)).toBeNull(); + }); +}); + +describe("openRunModeSetup", () => { + const dispatched = (target: "cloud-provider" | "local-runtime") => { + const dispatch = vi.fn(); + openRunModeSetup(dispatch, target); + return dispatch.mock.calls.map(([action]) => action); + }; + + it("closes the overlay and lands on the LLM tab for either leg", () => { + for (const target of ["cloud-provider", "local-runtime"] as const) { + expect(dispatched(target).slice(0, 3)).toEqual([ + { type: "run_mode_picker_closed" }, + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "llm" }, + ]); + } + }); + + it("opens the add-provider wizard only for the cloud leg", () => { + expect(dispatched("cloud-provider")).toContainEqual( + expect.objectContaining({ type: "providers_wizard_opened" }), + ); + // The Local pane IS the checklist; a wizard on top of it would hide + // the backend/model/daemon rows the operator came for. + expect(dispatched("local-runtime")).not.toContainEqual( + expect.objectContaining({ type: "providers_wizard_opened" }), + ); + expect(dispatched("local-runtime")).toContainEqual({ + type: "llm_mode_set", + mode: "local", + }); + }); +}); diff --git a/src/tui/run-mode/run-mode-setup.ts b/src/tui/run-mode/run-mode-setup.ts new file mode 100644 index 00000000..d202dc04 --- /dev/null +++ b/src/tui/run-mode/run-mode-setup.ts @@ -0,0 +1,114 @@ +import type { RunModeName } from "../../config/llm-run-mode-config.js"; +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; +import type { TuiAction } from "../tui-action.js"; + +/** + * The leg a run mode cannot start without, named by the screen that + * fixes it rather than by the config key it is missing. + * + * Two targets, not one, because the two legs are repaired in completely + * different places: a cloud provider is an entry in `llm.providers` and + * is added by the provider wizard, while the local leg is a llama-server + * — a backend binary, a downloaded model and a running daemon — which is + * the Local pane's whole subject. Sending "Local is not set up" to the + * add-a-cloud-provider wizard would be a worse dead end than the refusal + * it replaced. + */ +export type RunModeSetupTarget = "cloud-provider" | "local-runtime"; + +/** Which legs `resolveRunMode` could not fill, as the panel mirrors them. */ +export interface RunModeLegAvailability { + cloudProviderMissing: boolean; + localProviderMissing: boolean; +} + +/** + * What the operator has to configure before `mode` can run, or `null` + * when the mode is ready to go. + * + * Fusion needs both legs and reports the cloud one first: without a + * cloud orchestrator fusion cannot run at all, whereas a missing local + * executor only makes it cloud-only. Fixing the fatal half first is also + * the order the degradation sentences already use. + */ +export function runModeSetupTarget( + mode: RunModeName, + legs: RunModeLegAvailability, +): RunModeSetupTarget | null { + if (mode === "local") return legs.localProviderMissing ? "local-runtime" : null; + if (legs.cloudProviderMissing) return "cloud-provider"; + if (mode === "fusion" && legs.localProviderMissing) return "local-runtime"; + return null; +} + +/** + * The setup offer a run-mode surface should put in front of the + * operator, given which mode the cursor is on. + * + * Wider than `runModeSetupTarget` on purpose. That one answers "can this + * mode run", which is the only question a switch has to ask. A screen + * has a second job: on a fresh install the cursor opens on Local — the + * one mode that works — and a strictly-scoped offer would vanish exactly + * where the dead end used to be. So the highlighted mode's own missing + * leg wins, and failing that any missing leg keeps the way out visible. + */ +export function runModeSetupOffer( + mode: RunModeName, + legs: RunModeLegAvailability, +): RunModeSetupTarget | null { + const forMode = runModeSetupTarget(mode, legs); + if (forMode) return forMode; + if (legs.cloudProviderMissing) return "cloud-provider"; + return legs.localProviderMissing ? "local-runtime" : null; +} + +/** + * Why the switch did not take, phrased for someone who is about to be + * moved to the screen that fixes it. + * + * Deliberately not `describeRunModeDegradation`: that sentence ends by + * telling the reader where to go ("Add one in Manage → LLM → Cloud"), + * which is now stale advice — they are already being taken there. The + * resolver's wording stays untouched for the CLI and HTTP surfaces, + * which still only report. + */ +export function describeRunModeSetup(target: RunModeSetupTarget): string { + return target === "cloud-provider" + ? "No cloud provider is configured yet — opening Manage → LLM → Cloud so you can add one." + : "No llama-server is configured yet — opening Manage → LLM → Local so you can set one up."; +} + +/** + * Leave whatever run-mode surface asked and land on the screen that can + * fill the missing leg. + * + * One implementation for all three entry points (the `n` key, the + * overlay's clickable row, and a mode switch that turned out to be + * unconfigured) so the three gestures cannot drift into landing in + * different places. + * + * `dispatch` here is genuinely a dispatch when the caller is a key + * handler or the mouse layer, and `bus.emit` when the caller is the + * orchestrator — the bus is bridged into the reducer, so an emitted + * action arrives exactly as a dispatched one does. + */ +export function openRunModeSetup( + dispatch: (action: TuiAction) => void, + target: RunModeSetupTarget, +): void { + dispatch({ type: "run_mode_picker_closed" }); + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: "llm" }); + if (target === "local-runtime") { + // The Local pane is already a setup checklist — backend, model, + // daemon — so landing on it is the whole navigation. There is no + // wizard to open, and opening one would hide the checklist. + dispatch({ type: "llm_mode_set", mode: "local" }); + return; + } + dispatch({ type: "llm_mode_set", mode: "cloud" }); + dispatch({ + type: "providers_wizard_opened", + wizard: createProvidersWizardState("add"), + }); +} From 1d77df47140bf38b98e4ff5f75a0789483ce9424 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 22:20:18 +0300 Subject: [PATCH 53/57] fix(tui): /model arrives focused, and its rows take a click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the operator hit on the first /model of a session. The pane flip was deferred, the focus that rode with it was not. `/model` dispatches `llm_mode_set_to_active_route` and then `llm_cloud_filter_focus_set` back to back. The first can only resolve the pane once `providersPanel.rows` exist; before that it parks the request in `syncModeToActiveRoute` and leaves the mode at its `local` default. The second saw a non-cloud pane and dropped the request on the floor. The refresh that finally resolved the route restored the pane but not the focus, so `/model` arrived with the filter row unfocused (typing fired panel hotkeys instead of filtering) and the cursor still on a provider row above the model section — where the first arrow press is spent climbing into the list while the counter, which clamps, does not move. One keystroke silently eaten, and after that everything works: exactly the report. The request now waits with the mode flip and is applied by the same refresh. The rows were registered below the floor they had to clear. A focused filter is a text-entry surface, so `isPanelModalOpen` counts the pane as a modal and TuiApp raises the mouse registry's floor to `MOUSE_LAYER_MODAL` — which silently disqualified the panel-layer targets on the model rows at exactly the moment the list was open. Same gap in the reopenable picker modal and in the wizard's pick lists, which had no click targets at all. All three now register at the modal layer and keep the app's rule: first click selects, a second click on the selected row runs the row's own Enter path. Swept the rest: session picker, theme picker, slash palette and the approval modal were already wired correctly and are unchanged. Co-Authored-By: Claude Opus 5 --- src/tui/components/llm-mode-rows.tsx | 16 + src/tui/components/llm-panel-modals.tsx | 37 ++- src/tui/components/providers-wizard.tsx | 29 ++ src/tui/components/wizard-pick-list.tsx | 34 +- src/tui/llm-panel/llm-panel-reducer.test.ts | 56 ++++ src/tui/llm-panel/llm-panel-reducer.ts | 70 +++-- src/tui/llm-panel/llm-panel-state.ts | 10 + src/tui/providers/model-picker-click.test.tsx | 291 ++++++++++++++++++ src/tui/providers/picker-reopen-app.test.tsx | 79 ++++- src/tui/providers/providers-reducer.ts | 20 +- 10 files changed, 607 insertions(+), 35 deletions(-) create mode 100644 src/tui/providers/model-picker-click.test.tsx diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index 4a21cecd..d0a06221 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -5,6 +5,11 @@ import { activeCursor, selectLlmPanelRows, type LlmPanelRow } from "../llm-panel import { classifyRamFit, classifyVramFit } from "../local-models/local-models-panel-state.js"; import { computeRowWindow } from "../row-window.js"; import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { + MOUSE_LAYER_MODAL, + MOUSE_LAYER_PANEL, +} from "../mouse/mouse-registry.js"; +import { isPanelModalOpen } from "../app-key-bindings.js"; import { handleLlmPanelKey } from "../llm-panel/llm-panel-key-bindings.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; @@ -354,6 +359,17 @@ function Row({ row, state }: { row: LlmPanelRow; state: TuiState }): ReactElemen // what garbles adjacent rows and drags rendering on a narrow window. return ( mouse.dispatch({ type: "llm_cursor_set", cursor: idx }) diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx index 97bdc004..652d97ea 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -3,7 +3,12 @@ import type { ReactElement, ReactNode } from "react"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { ProvidersWizard } from "./providers-wizard.js"; -import { parseExternalUrl } from "../llm-panel/llm-panel-modal-key-bindings.js"; +import { + handleLlmModalKey, + parseExternalUrl, +} from "../llm-panel/llm-panel-modal-key-bindings.js"; +import { MouseListRow, pressEnter } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { filteredPickerModels } from "../providers/providers-panel-state.js"; /** Upper bound for the picker's list window (roomy terminals). */ @@ -180,15 +185,31 @@ export function LlmPanelModals({ // the fixed-height note above. truncate-end keeps a long id // from wrapping into a second line and changing the height. return ( - + mouse.dispatch({ + type: "providers_chat_model_picker_cursor_set", + cursor: idx, + }) + } + onActivate={pressEnter(handleLlmModalKey)} > - {selected ? "› " : " "} - {id} - {isCurrent ? current : null} - + + {selected ? "› " : " "} + {id} + {isCurrent ? current : null} + + ); })} {blankRows(blanks)} diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index 75cf9b2f..0947b6a9 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -36,6 +36,31 @@ import type { ProvidersWizardState, } from "../providers/providers-wizard-state.js"; import { renderPickList } from "./wizard-pick-list.js"; +import type { MouseContextValue } from "../mouse/mouse-context.js"; +import { pressEnter } from "../mouse/mouse-list-row.js"; +import { handleLlmModalKey } from "../llm-panel/llm-panel-modal-key-bindings.js"; + +/** + * Click wiring shared by every pick list the wizard shows, so the four + * screens cannot drift apart. A click on an unselected row only moves + * the wizard's own cursor; a click on the selected row is replayed as + * Enter through `handleLlmModalKey`, the same entry point the keyboard + * uses, so whatever Enter advances or saves today a second click does + * too. + */ +const PICK_LIST_MOUSE = { + onRowSelect: (index: number, mouse: MouseContextValue): void => { + const wizard = mouse.getState().providersPanel.wizard; + // The wizard can close between the paint and the click; there is + // nothing to move a cursor in then. + if (!wizard) return; + mouse.dispatch({ + type: "providers_wizard_updated", + wizard: { ...wizard, cursor: index }, + }); + }, + onRowActivate: pressEnter(handleLlmModalKey), +} as const; const KIND_LABELS: Record = { "claude-cli": @@ -209,6 +234,7 @@ function CompatChatModelStep(props: { w.submitting, ), ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + ...PICK_LIST_MOUSE, error: w.error, }); } @@ -297,6 +323,7 @@ function CatalogChatModelStep(props: { moveHint: "j/k move", actionsHint: listActionsHint(actionsHint, w.submitting), ...(props.maxRows === undefined ? {} : { maxRows: props.maxRows }), + ...PICK_LIST_MOUSE, error: w.error, }); } @@ -327,6 +354,7 @@ export function ProvidersWizard(props: { moveHint: "j/k move", actionsHint: "Enter pick · Esc cancel", ...maxRows, + ...PICK_LIST_MOUSE, error: w.error, }); } @@ -395,6 +423,7 @@ export function ProvidersWizard(props: { w.submitting, ), ...maxRows, + ...PICK_LIST_MOUSE, error: w.error, }); } diff --git a/src/tui/components/wizard-pick-list.tsx b/src/tui/components/wizard-pick-list.tsx index 65ce7536..b748a71f 100644 --- a/src/tui/components/wizard-pick-list.tsx +++ b/src/tui/components/wizard-pick-list.tsx @@ -1,5 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import type { MouseContextValue } from "../mouse/mouse-context.js"; +import { MouseListRow } from "../mouse/mouse-list-row.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { theme } from "../theme/theme.js"; /** @@ -92,6 +95,15 @@ export function renderPickList(props: { * keypress that did nothing — the whole of report #3. */ error?: string | null; + /** + * Move the cursor to the clicked row. Omit to leave the list + * keyboard-only: the local-models wizard renders in its own Ink tree + * with no mouse context, and a list without this renders exactly as + * it did before. + */ + onRowSelect?: (index: number, mouse: MouseContextValue) => void; + /** Run the row that already holds the cursor — the click's "Enter". */ + onRowActivate?: (mouse: MouseContextValue) => void; }): ReactElement { const total = props.options.length; const clamped = Math.min(Math.max(props.cursor, 0), Math.max(0, total - 1)); @@ -118,7 +130,7 @@ export function renderPickList(props: { {visible.map((opt, i) => { const index = start + i; const mark = index === clamped ? ">" : " "; - return ( + const row = ( ); + const select = props.onRowSelect; + if (!select) return row; + return ( + // The wizard is a modal — `TuiApp` raises the registry floor + // while it owns the keyboard, so its rows have to live at the + // modal layer or the click is dropped. Two-step, like every + // other cursor list here: these are 300-row catalogs, one text + // row looks much like the next, and the last screen's Enter is + // the save that writes the provider and runs the live key + // check. Selecting first is what makes a mis-click free. + select(index, mouse)} + {...(props.onRowActivate ? { onActivate: props.onRowActivate } : {})} + > + {row} + + ); })} {errors.map((line, i) => ( { expect(refreshed.llmPanel.mode).toBe("cloud"); expect(refreshed.llmPanel.syncModeToActiveRoute).toBe(false); }); + + it("applies the /model filter focus deferred by an unresolved route", () => { + // The first /model of a session: provider rows have not landed yet, + // so `llm_mode_set_to_active_route` cannot say which pane this is. + const base = createInitialTuiState(fakeSession()); + const opened = MODEL_COMMAND_ACTIONS.reduce(reduceTuiState, base); + + expect(opened.llmPanel.syncModeToActiveRoute).toBe(true); + expect(opened.llmPanel.cloudModelFilterFocused).toBe(false); + + const refreshed = reduceTuiState(opened, { + type: "providers_refresh", + rows: [ + providerRow("local-llama", "llama-server", false), + providerRow("openrouter", "openrouter", true), + ], + }); + + expect(refreshed.llmPanel.mode).toBe("cloud"); + expect(refreshed.llmPanel.cloudModelFilterFocused).toBe(true); + // One cloud provider row precedes the model section (the llama-server + // entry is not listed on this pane), and the cursor has to land + // inside the section: parked at 0 it sits on that provider row, and + // the first ↑/↓ is spent climbing in — which reads as a swallowed + // keypress because the counter clamps to the first model either way. + expect(refreshed.llmPanel.cloudCursor).toBe(1); + }); + + it("drops the deferred /model filter focus when the route is local", () => { + const base = createInitialTuiState(fakeSession()); + const opened = MODEL_COMMAND_ACTIONS.reduce(reduceTuiState, base); + + const refreshed = reduceTuiState(opened, { + type: "providers_refresh", + rows: [ + providerRow("local-llama", "llama-server", true), + providerRow("openrouter", "openrouter", false), + ], + }); + + expect(refreshed.llmPanel.mode).toBe("local"); + expect(refreshed.llmPanel.cloudModelFilterFocused).toBe(false); + expect(refreshed.llmPanel.pendingCloudFilterFocus).toBe(false); + }); }); +/** + * What `/model` dispatches, in order (see `dispatchModelsSub`). The + * `providers_inline_models_ensure_requested` action is intercepted by + * `submit-handler` and never reaches the reducer, so it is not here. + */ +const MODEL_COMMAND_ACTIONS = [ + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "llm" }, + { type: "llm_mode_set_to_active_route" }, + { type: "llm_cloud_filter_focus_set", focused: true }, +] as const; + function providerRow(id: string, kind: string, isActiveText: boolean) { return { id, diff --git a/src/tui/llm-panel/llm-panel-reducer.ts b/src/tui/llm-panel/llm-panel-reducer.ts index f42d8c08..498fdcab 100644 --- a/src/tui/llm-panel/llm-panel-reducer.ts +++ b/src/tui/llm-panel/llm-panel-reducer.ts @@ -75,30 +75,31 @@ export function reduceLlmPanelAction( if (!action.focused) { return { ...state, - llmPanel: { ...panel, cloudModelFilterFocused: false }, + llmPanel: { + ...panel, + cloudModelFilterFocused: false, + pendingCloudFilterFocus: false, + }, }; } - // Focus only applies to the Cloud pane: /model on a local route - // dispatches this right after `llm_mode_set_to_active_route` - // resolved to `local`, and there the tab switch is the whole - // effect. (`f` flips the pane to cloud explicitly before this.) - if (panel.mode !== "cloud") return state; - // Focusing the filter drops the cursor into the model section so - // ↑/↓ and Enter act on models immediately. The section lists the - // current model first; when the cursor is already inside the - // section, leave it where the operator put it. - const section = selectCloudModelSection(state); - const sectionEnd = section.sectionStart + section.filtered.length - 1; - const cursorInSection = - panel.cloudCursor >= section.sectionStart && - panel.cloudCursor <= sectionEnd; + if (panel.mode === "cloud") return focusCloudModelFilter(state); + // The pane is not Cloud. Two very different reasons for that, and + // they must not be treated alike: + // + // - the route resolved to `local`, so /model legitimately landed + // on the Local pane and the tab switch is the whole effect + // (`f` flips the pane to cloud explicitly before this); + // - the route has not resolved yet, because `providersPanel.rows` + // was still empty when `llm_mode_set_to_active_route` ran one + // action earlier — the first /model of a session. Dropping the + // request there left the operator on a Cloud pane whose filter + // was not focused and whose cursor still pointed at a provider + // row above the list, so the first ↑/↓ was spent climbing into + // the section and looked like a swallowed keypress. + if (!panel.syncModeToActiveRoute) return state; return { ...state, - llmPanel: { - ...panel, - cloudModelFilterFocused: true, - cloudCursor: cursorInSection ? panel.cloudCursor : section.sectionStart, - }, + llmPanel: { ...panel, pendingCloudFilterFocus: true }, }; } case "llm_cloud_filter_set": { @@ -122,6 +123,35 @@ export function reduceLlmPanelAction( } } +/** + * Give the `filter:` row of the inline Cloud list the keyboard and drop + * the cursor into the model section, so ↑/↓ and Enter act on models + * immediately. The section lists the current model first; when the + * cursor is already inside the section, leave it where the operator put + * it. + * + * Shared with `providers_refresh` (see `providers-reducer.ts`): the + * route that /model asks for can only be resolved once provider rows + * exist, and this is the half of the request that has to wait for them. + */ +export function focusCloudModelFilter(state: TuiState): TuiState { + const panel = state.llmPanel; + const section = selectCloudModelSection(state); + const sectionEnd = section.sectionStart + section.filtered.length - 1; + const cursorInSection = + panel.cloudCursor >= section.sectionStart && + panel.cloudCursor <= sectionEnd; + return { + ...state, + llmPanel: { + ...panel, + cloudModelFilterFocused: true, + pendingCloudFilterFocus: false, + cloudCursor: cursorInSection ? panel.cloudCursor : section.sectionStart, + }, + }; +} + /** Step `delta` panes from `mode`, wrapping at both ends. */ export function nextMode(mode: LlmPanelMode, delta: number): LlmPanelMode { const at = LLM_PANEL_MODES.indexOf(mode); diff --git a/src/tui/llm-panel/llm-panel-state.ts b/src/tui/llm-panel/llm-panel-state.ts index 6561d53a..45788479 100644 --- a/src/tui/llm-panel/llm-panel-state.ts +++ b/src/tui/llm-panel/llm-panel-state.ts @@ -46,6 +46,15 @@ export interface LlmPanelState { * `f` or `/model`. */ cloudModelFilterFocused: boolean; + /** + * `/model` asked for the filter row while `syncModeToActiveRoute` was + * still waiting on provider rows, so the pane was not yet known to be + * Cloud and the focus could not be applied. The refresh that resolves + * the route applies it — see `focusCloudModelFilter`. Without this the + * request was simply dropped and `/model` landed on an unfocused pane + * with the cursor still sitting on a provider row above the list. + */ + pendingCloudFilterFocus: boolean; } export function createInitialLlmPanelState(): LlmPanelState { @@ -60,6 +69,7 @@ export function createInitialLlmPanelState(): LlmPanelState { externalUrlDraft: null, cloudModelFilter: "", cloudModelFilterFocused: false, + pendingCloudFilterFocus: false, }; } diff --git a/src/tui/providers/model-picker-click.test.tsx b/src/tui/providers/model-picker-click.test.tsx new file mode 100644 index 00000000..f7a703cd --- /dev/null +++ b/src/tui/providers/model-picker-click.test.tsx @@ -0,0 +1,291 @@ +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AgentRuntime } from "../../runtime/bootstrap.js"; +import type { AtomicAgentConfig } from "../../config/index.js"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import { fakeSession } from "../test-fixtures.js"; +import { makeMouseSource, type MouseSourceEmitter } from "../mouse/mouse-source.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { ProvidersOrchestrator } from "./providers-orchestrator.js"; + +/** + * The `/model` list under the mouse, driven through the real registry. + * + * `/model` is not a floating window but it *behaves* as a modal: the + * focused `filter:` row owns every printable key, so `isPanelModalOpen` + * reports true and `TuiApp` raises the mouse registry's floor to + * `MOUSE_LAYER_MODAL`. Rows registered at the panel layer are dropped + * without a trace at exactly that moment, which is why this needs an + * app-level test: the component renders its `MouseTarget` either way, + * and only the live registry can tell you the click went nowhere. + */ + +vi.mock("../../config/index.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, getConfig: () => currentConfig }; +}); + +let currentConfig: AtomicAgentConfig; + +function configWithNous(baseUrl: string): AtomicAgentConfig { + return { + llm: { + activeTextProvider: "nous", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto", + providers: [ + { + id: "nous", + kind: "openai-compatible", + baseUrl, + apiKey: "sk-nous-test", + model: "nous/bytedance", + defaultChatModel: "nous/bytedance", + }, + ], + }, + } as AtomicAgentConfig; +} + +const SESSION = fakeSession({ workingDir: "/tmp/model-click" }); + +function strip(value: string): string { + return value + .replace(/\[[0-9;]*m/g, "") + .replace(/\]8;;[^]*/g, ""); +} + +/** Cell of `needle` in the frame — the cell a terminal would report. */ +function locate(frame: string, needle: string): { x: number; y: number } { + for (const [y, line] of frame.split("\n").entries()) { + const x = line.indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Ink commits on its own throttle and React registers the click targets + * in an effect after that commit, so a freshly painted row is not + * clickable for a frame or two. Re-send the click until it lands, the + * way an operator would — see `mouse-app.test.tsx` for the same pattern. + */ +async function clickUntil( + mouse: MouseSourceEmitter, + point: () => { x: number; y: number }, + settled: () => boolean, + what: string, +): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + const { x, y } = point(); + mouse.emit(click(x, y)); + await delay(50); + if (settled()) return; + } + throw new Error(`click never took effect: ${what}`); +} + +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +function mountApp() { + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const orchestrator = new ProvidersOrchestrator({} as AgentRuntime, bus); + const onProvidersSelectChatModel = vi.fn(); + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: () => {}, + onMessageSubmitted: () => {}, + onProvidersTabRefresh: () => { + orchestrator.refresh(); + void orchestrator.ensureInlineModels(null); + }, + onProvidersSelectChatModel, + onProvidersInlineModelsEnsureRequested: (providerId) => + void orchestrator.ensureInlineModels(providerId), + }; + const { lastFrame, stdin, unmount } = render( + , + ); + return { + frame: () => strip(lastFrame() ?? ""), + bus, + mouse, + stdin, + unmount, + onProvidersSelectChatModel, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("/model list clicks", () => { + it("selects on the first click and switches the model on the second", async () => { + currentConfig = configWithNous("https://click.nous.example"); + const models = Array.from({ length: 6 }, (_, i) => `vendor/model-${i}`); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: models.map((id) => ({ id })) }), + })), + ); + + const app = mountApp(); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + app.stdin.write("/model"); + await delay(50); + app.stdin.write("\r"); + await waitUntil( + () => app.frame().includes("vendor/model-1"), + "the inline model list", + ); + // Cursor starts on the current model, which the section lists first. + expect(app.frame()).toContain("(1/7)"); + + // Two-step, not one: Enter on a model row repoints the chat route at + // it (and on the Local pane the same row starts a multi-GB + // download), so a stray click must not be able to do that. The first + // click only moves the cursor. + await clickUntil( + app.mouse, + () => locate(app.frame(), "vendor/model-1"), + () => app.frame().includes("(3/7)"), + "first click on the vendor/model-1 row", + ); + expect(app.onProvidersSelectChatModel).not.toHaveBeenCalled(); + + // Second click on the row that now holds the cursor commits it, + // through the same handler Enter uses. + await clickUntil( + app.mouse, + () => locate(app.frame(), "vendor/model-1"), + () => app.onProvidersSelectChatModel.mock.calls.length > 0, + "second click on the vendor/model-1 row", + ); + expect(app.onProvidersSelectChatModel).toHaveBeenCalledWith( + "nous", + "vendor/model-1", + ); + + app.unmount(); + }); + + it("drives the reopenable picker modal from the mouse too", async () => { + // The modal picker is no longer what `/model` opens, but it is still + // the surface for flows outside the Cloud pane, and a modal that + // ignores the mouse is exactly the gap this sweep is closing. + currentConfig = configWithNous("https://modal.nous.example"); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ data: [] }) }))); + + const app = mountApp(); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + app.bus.emit({ type: "ui_mode_set", mode: "debug" }); + app.bus.emit({ type: "tab_changed", tab: "llm" }); + app.bus.emit({ + type: "providers_chat_model_picker_opened", + providerId: "nous", + currentModelId: "nous/bytedance", + generation: 1, + }); + app.bus.emit({ + type: "providers_chat_model_picker_loaded", + generation: 1, + models: ["alpha/one", "beta/two", "gamma/three"], + }); + await waitUntil( + () => app.frame().includes("beta/two"), + "the modal picker", + ); + + await clickUntil( + app.mouse, + () => locate(app.frame(), "beta/two"), + () => app.frame().includes("(2/3)"), + "first click on the beta/two row", + ); + expect(app.onProvidersSelectChatModel).not.toHaveBeenCalled(); + + await clickUntil( + app.mouse, + () => locate(app.frame(), "beta/two"), + () => app.onProvidersSelectChatModel.mock.calls.length > 0, + "second click on the beta/two row", + ); + expect(app.onProvidersSelectChatModel).toHaveBeenCalledWith( + "nous", + "beta/two", + ); + + app.unmount(); + }); + + it("drives the provider wizard's pick list from the mouse", async () => { + currentConfig = configWithNous("https://wizard.nous.example"); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ data: [] }) }))); + + const app = mountApp(); + await waitUntil(() => app.frame().includes("llama.cpp"), "the Run screen"); + app.bus.emit({ type: "ui_mode_set", mode: "debug" }); + app.bus.emit({ type: "tab_changed", tab: "llm" }); + await waitUntil(() => app.frame().includes("▸ LLM"), "the LLM tab"); + // `n` is the panel's own "add provider" hotkey, so the wizard opens + // the way an operator opens it. + app.stdin.write("n"); + await waitUntil( + () => app.frame().includes("LLM provider"), + "the add-provider wizard", + ); + expect(app.frame()).toContain("(1/"); + + await clickUntil( + app.mouse, + () => locate(app.frame(), "OpenRouter (cloud"), + () => app.frame().includes("(3/"), + "first click on the OpenRouter kind row", + ); + // Still on the kind list: one click never advances a wizard step. + expect(app.frame()).toContain("LLM provider"); + + await clickUntil( + app.mouse, + () => locate(app.frame(), "OpenRouter (cloud"), + () => !app.frame().includes("LLM provider"), + "second click on the OpenRouter kind row", + ); + expect(app.frame()).not.toContain("LLM provider"); + + app.unmount(); + }); +}); diff --git a/src/tui/providers/picker-reopen-app.test.tsx b/src/tui/providers/picker-reopen-app.test.tsx index b85396d7..7bf6c3b9 100644 --- a/src/tui/providers/picker-reopen-app.test.tsx +++ b/src/tui/providers/picker-reopen-app.test.tsx @@ -57,18 +57,31 @@ function strip(value: string): string { const tick = () => new Promise((r) => setTimeout(r, 25)); -function makeApp() { +/** What a terminal sends for ↓ — spelled out so an editor cannot eat it. */ +const DOWN_ARROW = "\u001B[B"; + +/** + * `holdRows: true` keeps the provider refresh bottled up until the test + * calls `releaseRows()`. That is the shape of a cold TUI: the rows come + * from the orchestrator, and until they land nothing can say whether the + * active route is cloud or local. + */ +function makeApp(options: { holdRows?: boolean } = {}) { const bus = makeTuiEventBus(); const orchestrator = new ProvidersOrchestrator({} as AgentRuntime, bus); const onProvidersSelectChatModel = vi.fn(); + let rowsAllowed = !options.holdRows; + const refresh = (): void => { + orchestrator.refresh(); + void orchestrator.ensureInlineModels(null); + }; const callbacks: TuiAppCallbacks = { onApprovalDecision: () => {}, onAbort: () => {}, onQuit: () => {}, onMessageSubmitted: () => {}, onProvidersTabRefresh: () => { - orchestrator.refresh(); - void orchestrator.ensureInlineModels(null); + if (rowsAllowed) refresh(); }, onProvidersSelectChatModel, onProvidersInlineModelsEnsureRequested: (providerId) => @@ -77,7 +90,19 @@ function makeApp() { const rendered = render( , ); - return { ...rendered, onProvidersSelectChatModel }; + return { + ...rendered, + onProvidersSelectChatModel, + releaseRows: () => { + rowsAllowed = true; + refresh(); + }, + }; +} + +/** `(3/21)` → `3/21`; `` when the list is not on screen. */ +function modelCounter(frame: string): string { + return frame.match(/↑\/↓ move \(([^)]*)\)/)?.[1] ?? ""; } afterEach(() => { @@ -191,4 +216,50 @@ describe("full-app inline model list (ink render, real orchestrator)", () => { unmount(); }); + + it("the first ↓ after a cold /model moves the cursor", async () => { + currentConfig = configWithNous("https://app-cold.nous.example"); + const models = Array.from({ length: 20 }, (_, i) => `vendor/model-${i}`); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: models.map((id) => ({ id })) }), + })), + ); + + const { lastFrame, stdin, unmount, releaseRows } = makeApp({ + holdRows: true, + }); + await tick(); + stdin.write("/model"); + await tick(); + stdin.write("\r"); + await tick(); + // The rows /model needed to resolve its pane only land now. + releaseRows(); + await tick(); + await tick(); + + const opened = strip(lastFrame() ?? ""); + expect(opened).toContain("Cloud text models"); + // The deferred half of the request has to arrive with the rows: + // without it the pane is Cloud but the filter row never took the + // keyboard, so typing fires panel hotkeys instead of filtering. + expect(opened).toContain("type to filter"); + expect(modelCounter(opened)).toBe("1/21"); + + stdin.write(DOWN_ARROW); + await tick(); + // The bug this pins: the cursor was parked on the provider row above + // the section, so this press only climbed into the list and the + // counter stayed at 1/21 — one keystroke silently eaten. + expect(modelCounter(strip(lastFrame() ?? ""))).toBe("2/21"); + + stdin.write(DOWN_ARROW); + await tick(); + expect(modelCounter(strip(lastFrame() ?? ""))).toBe("3/21"); + + unmount(); + }); }); diff --git a/src/tui/providers/providers-reducer.ts b/src/tui/providers/providers-reducer.ts index cc035907..5d2d834a 100644 --- a/src/tui/providers/providers-reducer.ts +++ b/src/tui/providers/providers-reducer.ts @@ -1,5 +1,8 @@ import type { TuiState } from "../tui-state.js"; -import { resolveModeFromActiveRoute } from "../llm-panel/llm-panel-reducer.js"; +import { + focusCloudModelFilter, + resolveModeFromActiveRoute, +} from "../llm-panel/llm-panel-reducer.js"; import { isProvidersAction } from "./providers-actions.js"; import { createInitialProvidersPanelState } from "./providers-panel-state.js"; @@ -28,7 +31,7 @@ export function reduceProvidersPanel( if (!state.llmPanel.syncModeToActiveRoute) return nextState; const mode = resolveModeFromActiveRoute(nextState); if (!mode) return nextState; - return { + const synced: TuiState = { ...nextState, llmPanel: { ...nextState.llmPanel, @@ -36,6 +39,19 @@ export function reduceProvidersPanel( syncModeToActiveRoute: false, }, }; + // These rows are what `/model` was waiting for. It asked for the + // Cloud pane's filter row before the route was known, so apply + // that half here — with the rows in hand the model section has a + // real start index to drop the cursor onto. A route that turned + // out to be local drops the request instead: there is no filter + // row on that pane. + if (mode !== "cloud" || !synced.llmPanel.pendingCloudFilterFocus) { + return { + ...synced, + llmPanel: { ...synced.llmPanel, pendingCloudFilterFocus: false }, + }; + } + return focusCloudModelFilter(synced); } case "providers_cursor_down": if (panel.rows.length === 0) return state; From 68b844823070d8a1b1cbb072aa6e912c6efa42ec Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 22:25:35 +0300 Subject: [PATCH 54/57] feat(tui): esc on the Run screen opens the operator menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esc reached the bottom of its ladder and did nothing: an idle Run screen with an empty buffer swallowed the press. It now opens the operator menu — the same surface ctrl+p opens — which is the natural end of a key that already means "cancel / back one level" everywhere else: once there is nothing to back out of, "get me out of here" becomes "show me where I can go". Additive only. Every prior claimant keeps the key, and the order is written down once in `escapeHasNothingToCancel`: an open overlay (approval, update offer, run-mode dial, slash palette, session/theme picker), a focused sidebar, an open debug panel, a scrolled-back transcript, a running turn, a half-typed draft. `escapeOpensMenu` adds the one rung only the binding cares about — an already-open menu, which Esc closes — so the press cannot toggle the popup it just opened. The hint strip reads the shared predicate rather than a hardcoded word, so the chat row says `esc menu` or `esc cancel` depending on what the key will actually do. It deliberately ignores the menu being open: the row describes the surface behind the popup, and rewriting it would move the frame the popup is built to leave alone. Co-Authored-By: Claude Opus 5 --- src/tui/app-key-bindings.test.ts | 88 ++++++- src/tui/app-key-bindings.ts | 60 ++++- src/tui/components/hotkey-hint.test.tsx | 34 +++ src/tui/components/hotkey-hint.tsx | 34 ++- src/tui/components/multi-line-editor.tsx | 13 +- src/tui/escape-opens-menu.test.tsx | 297 +++++++++++++++++++++++ src/tui/tui-app.tsx | 17 +- 7 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 src/tui/escape-opens-menu.test.tsx diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index 67f06d0c..10efe7ca 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -1,8 +1,17 @@ import { describe, it, expect, vi } from "vitest"; import type { Key } from "ink"; -import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; -import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; +import { + escapeHasNothingToCancel, + escapeOpensMenu, + handleAppKey, + handlePanelEscape, +} from "./app-key-bindings.js"; +import { + createInitialTuiState, + type TuiSessionInfo, + type TuiState, +} from "./tui-state.js"; import type { ApprovalRequest } from "../approval/approval-gate.js"; function pendingRequest( @@ -753,3 +762,78 @@ describe("Ctrl+T — Enter-while-busy mode", () => { }); }); }); + +/** + * The Esc ladder, rung by rung. Each case is a state in which the + * operator pressing Esc means something specific and *not* "show me the + * menu" — the whole risk of giving the key a new meaning is that one of + * these quietly loses it. + */ +describe("escapeOpensMenu", () => { + function runScreen(patch: Partial = {}): TuiState { + return { ...createInitialTuiState(stubSession()), uiMode: "chat", ...patch }; + } + + it("opens on an idle, empty, unscrolled Run screen", () => { + expect(escapeOpensMenu(runScreen())).toBe(true); + }); + + it("declines while the menu is already up, so Esc cannot toggle it", () => { + expect(escapeOpensMenu(runScreen({ menuOpen: true }))).toBe(false); + // ...but the surface underneath is still an idle Run screen, which is + // what the hint strip reads and why the two predicates are separate. + expect(escapeHasNothingToCancel(runScreen({ menuOpen: true }))).toBe(true); + }); + + it("declines with a draft in the buffer — Esc clears it first", () => { + expect(escapeOpensMenu(runScreen({ inputValue: "half a thought" }))).toBe( + false, + ); + }); + + it("declines while a turn is running — Esc is the abort", () => { + expect(escapeOpensMenu(runScreen({ status: "running" }))).toBe(false); + }); + + it("declines while the transcript is scrolled back — Esc snaps it down", () => { + expect(escapeOpensMenu(runScreen({ chatScrollOffset: 4 }))).toBe(false); + }); + + it("declines under every overlay that owns Esc in its own layer", () => { + const overlays: Partial[] = [ + { slashPaletteOpen: true }, + { sessionPickerOpen: true }, + { themePickerOpen: true }, + { pendingApproval: pendingRequest() }, + { updatePrompt: { current: "0.3.0", latest: "0.4.0" } }, + { updateStatus: "done" }, + ]; + for (const overlay of overlays) { + expect(escapeOpensMenu(runScreen(overlay))).toBe(false); + } + }); + + it("declines while the run-mode dial is up", () => { + const base = createInitialTuiState(stubSession()); + const state = runScreen({ + runModePanel: { + ...base.runModePanel, + picker: { + cursor: 0, + draftMode: "local", + draftCloudShare: 0, + digitBuffer: "", + }, + }, + }); + expect(escapeOpensMenu(state)).toBe(false); + }); + + it("declines with the sidebar focused — Esc hands focus back first", () => { + expect(escapeOpensMenu(runScreen({ chatFocus: "sidebar" }))).toBe(false); + }); + + it("declines inside a debug panel — Esc is the way home to Run", () => { + expect(escapeOpensMenu(runScreen({ uiMode: "debug" }))).toBe(false); + }); +}); diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 02fbc04f..fe6eae53 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -20,7 +20,7 @@ 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"; +import { canAcceptMessage, type TuiState } from "./tui-state.js"; /** * Number of **terminal rows** a single PageUp / PageDown keypress @@ -435,6 +435,64 @@ export function handlePanelEscape( return true; } +/** + * True when a press of Esc on the Run screen would find nothing to + * cancel — the state in which it opens the operator menu instead. + * + * Esc is the key an operator presses when they want *out* of whatever + * they are in, and four PRs went into making it mean exactly "cancel / + * back one level" everywhere (it used to quit the agent on the first + * press, unannounced). Opening the menu is the natural bottom of that + * ladder: once there is nothing left to back out of, "get me out of + * here" becomes "show me where I can go". It is additive — every rung + * above it keeps the key. + * + * The rungs, in the order Esc already resolves them, each of which is a + * cancel the operator meant and must not be swapped for a menu: + * + * 1. an open overlay — approval prompt, update offer, run-mode dial, + * slash palette, session picker, theme picker — closes. These own + * Esc in their own layer; the dial and the approval prompt also + * take focus off the editor, which is the only way to keep a key + * out of the prompt at all. + * 2. a focused sidebar hands focus back to the editor. + * 3. a debug panel goes home to Run. + * 4. a transcript scrolled up snaps back to the latest reply. + * 5. a running turn aborts. + * 6. a half-typed draft is cleared. + * + * Only when all six decline is the operator pressing Esc against an + * idle, empty, unscrolled Run screen — a press that used to do nothing + * at all, which is the whole reason it is free to mean something now. + * + * Says nothing about the menu already being open; that is + * {@link escapeOpensMenu}'s business. The hint strip wants this one: it + * describes the chat surface, which does not stop being an idle Run + * screen just because a popup is floating over it. + */ +export function escapeHasNothingToCancel(state: TuiState): boolean { + if (state.pendingApproval) return false; + if (state.updatePrompt || state.updateStatus === "done") return false; + if (state.runModePanel.picker !== null) return false; + if (state.slashPaletteOpen) return false; + if (state.sessionPickerOpen || state.themePickerOpen) return false; + if (state.chatFocus !== "editor") return false; + if (state.uiMode !== "chat") return false; + if (state.chatScrollOffset > 0) return false; + if (!canAcceptMessage(state)) return false; + return state.inputValue.length === 0; +} + +/** + * The binding itself: {@link escapeHasNothingToCancel} plus the one rung + * that only the key cares about — an open menu, which Esc closes + * (`handleMenuKey`). Without this the same press would close and reopen + * the popup and Esc would look inert. + */ +export function escapeOpensMenu(state: TuiState): boolean { + return !state.menuOpen && escapeHasNothingToCancel(state); +} + function shouldTreatArrowAsChatScroll( input: string, key: Key, diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 961e03fd..9a170711 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -117,6 +117,40 @@ describe("HotkeyHint scroll key spelling per platform", () => { }); }); +describe("HotkeyHint esc chip", () => { + it("names the menu when that is what Esc will actually do", () => { + const out = renderHint(chatState()); + expect(out).toMatch(/esc\]\s*menu/); + }); + + it("names the cancel while there is a draft to cancel", () => { + // The whole point of the chip is that Esc changed meaning; a fixed + // label would be a lie in one state or the other. + const out = renderHint(chatState({ inputValue: "half a thought" })); + expect(out).toMatch(/esc\]\s*cancel/); + expect(out).not.toMatch(/esc\]\s*menu/); + }); + + it("names the cancel while the transcript is scrolled back", () => { + const out = renderHint(chatState({ chatScrollOffset: 4 })); + expect(out).toMatch(/esc\]\s*cancel/); + }); + + it("does not flinch when a popup floats over the chat", () => { + // The row describes the surface behind the menu, not the menu — and a + // strip that rewrites itself when a floating window opens would make + // the frame below the popup move, which is exactly what the popup is + // built not to do. + const out = renderHint(chatState({ menuOpen: true })); + expect(out).toMatch(/esc\]\s*menu/); + }); + + it("leaves the abort chip alone while a turn is running", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).toMatch(/esc\]\s*abort/); + }); +}); + describe("HotkeyHint queue affordances", () => { it("advertises what Enter does now that the editor stays live mid-run", () => { const steering = renderHint(chatState({ status: "running" })); diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 9f73174e..eda66a6c 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -1,6 +1,10 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { applyNavSlot, decideApproval } from "../app-key-bindings.js"; +import { + applyNavSlot, + decideApproval, + escapeHasNothingToCancel, +} from "../app-key-bindings.js"; import { MouseTarget, useMouseCommands, @@ -217,6 +221,7 @@ function resolveChips( // (cycle run mode) stays unadvertised for the same reason ctrl+b was — // the mode strip above the chat is its visible entry point, and the // menu now lists Local / Cloud / Fusion outright. + const escapeMenu = escapeHasNothingToCancel(state); return [ { key: "enter", label: "send" }, { key: "ctrl+j", label: "newline" }, @@ -234,12 +239,27 @@ function resolveChips( : []), { key: SCROLL_KEY, label: "scroll" }, { key: "ctrl+p", label: "menu", onClick: openOperatorMenu }, - // Esc means something here — it clears the draft — and nothing said - // so. It is the one key an operator reaches for expecting "get me - // out of this", and leaving it off the strip is how it ended up - // feeling like the quit key. (The running branch above already - // advertises it as `abort`.) - { key: "esc", label: "clear" }, + // Esc means something here and nothing said so. It is the one key an + // operator reaches for expecting "get me out of this", and leaving it + // off the strip is how it ended up feeling like the quit key. (The + // running branch above already advertises it as `abort`.) + // + // What it means now depends on what is left to cancel, so the chip has + // to follow the state instead of naming one fixed action: `cancel` + // covers the two things Esc still backs out of on this surface — a + // half-typed draft and a transcript scrolled up — and once neither is + // there it opens the menu. It shares its body with the binding, so the + // label cannot claim one thing while the key does another. + // + // Deliberately blind to the menu already being open (`escapeOpensMenu` + // is not): this whole row describes the chat surface *behind* any + // popup — `enter send` and `tab sidebar` are no truer while the menu + // has the keyboard — and the popup carries its own `esc close` footer. + // Flipping this one word when a floating surface opens would also make + // the strip a moving part of a frame that is supposed to sit still. + escapeMenu + ? { key: "esc", label: "menu", onClick: openOperatorMenu } + : { key: "esc", label: "cancel" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/components/multi-line-editor.tsx b/src/tui/components/multi-line-editor.tsx index b1f9e281..fe395e4b 100644 --- a/src/tui/components/multi-line-editor.tsx +++ b/src/tui/components/multi-line-editor.tsx @@ -21,7 +21,18 @@ export interface MultiLineEditorProps { disabled?: boolean; onChange: (value: string) => void; onSubmit: (value: string) => void; - /** Esc pressed while editor has focus. */ + /** + * Esc pressed while the editor has focus. + * + * The editor reports the press and decides nothing — not even "clear the + * buffer", which it could do locally. Esc is a whole-app ladder (cancel + * an overlay → leave the sidebar → back out of a panel → snap the + * transcript down → abort the turn → clear the draft → open the menu), + * and only the parent can see the rungs. A local shortcut here would be + * a second opinion on the same keystroke, which is exactly how Esc came + * to quit the agent in the first place. See `escapeOpensMenu` in + * `app-key-bindings.ts` for the order and the reasoning behind it. + */ onEscape?: () => void; /** Ctrl+C while editor has focus (overrides the default ignore for Ctrl+C). */ onInterrupt?: () => void; diff --git a/src/tui/escape-opens-menu.test.tsx b/src/tui/escape-opens-menu.test.tsx new file mode 100644 index 00000000..c9442225 --- /dev/null +++ b/src/tui/escape-opens-menu.test.tsx @@ -0,0 +1,297 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "./tui-app.js"; +import type { TuiSessionInfo } from "./tui-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp/smoke", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** + * Ink holds a lone Esc byte for `pendingInputFlushDelayMilliseconds` + * (20ms) to disambiguate it from a longer escape sequence, so every + * assertion waits past that flush window before reading the frame. + */ +const ESC = String.fromCharCode(27); +const TAB = "\t"; +const FLUSH_MS = 80; + +/** + * The menu popup's footer. Asserting on the word "Menu" would not do — + * the rail carries a `menu / ctrl+p` affordance whether the popup is up + * or not, so that assertion is already true before the key is pressed. + */ +const MENU_FOOTER = "↑↓ move"; + +const BEL = String.fromCharCode(7); +// Built from `ESC` rather than written as literals: a raw control byte in +// a regex source is exactly what it looks like — a stray escape sequence +// — and every frame is full of SGR runs and OSC-8 hyperlinks that a +// `.toContain` would otherwise trip over. +const SGR = new RegExp(ESC + "\\[[0-9;]*m", "g"); +const OSC8 = new RegExp(ESC + "\\]8;;[^" + BEL + "]*" + BEL, "g"); + +const strip = (value: string): string => + value.replace(SGR, "").replace(OSC8, ""); + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Wait for Ink to finish reacting to whatever was just written. + * + * A flat sleep is not good enough here and the failure mode is nasty: the + * splash screen is expensive to lay out, so under load a repaint can land + * hundreds of milliseconds late, and a test that reads too early asserts + * against the *previous* frame — which for a "did nothing happen?" case + * passes for the wrong reason. So: clear Ink's Esc-disambiguation window + * first, then wait until the frame has stopped moving. + */ +async function settleOn(read: () => string): Promise { + await sleep(FLUSH_MS); + let quiet = 0; + let previous = read(); + for (let i = 0; i < 25; i++) { + await sleep(30); + const current = read(); + if (current === previous) { + if (++quiet === 3) return; + continue; + } + quiet = 0; + previous = current; + } +} + +/** + * Poll until `predicate` holds. Used where the frame never goes quiet — + * a running turn animates its waiting phrase forever, so `settleOn` would + * burn its whole budget and still read a half-updated screen. + */ +async function waitUntil(predicate: () => boolean): Promise { + await sleep(FLUSH_MS); + for (let i = 0; i < 80; i++) { + if (predicate()) return; + await sleep(25); + } +} + +function trackingCallbacks(counts: { + quit: number; + abort: number; +}): TuiAppCallbacks { + return { + onApprovalDecision: () => {}, + onAbort: () => { + counts.abort++; + }, + onQuit: () => { + counts.quit++; + }, + onMessageSubmitted: () => {}, + }; +} + +function mount() { + const counts = { quit: 0, abort: 0 }; + const bus = makeTuiEventBus(); + const rendered = render( + , + ); + const frame = (): string => strip(rendered.lastFrame() ?? ""); + const menuIsOpen = (): boolean => frame().includes(MENU_FOOTER); + const settle = (): Promise => settleOn(frame); + return { ...rendered, bus, counts, frame, menuIsOpen, settle, waitUntil }; +} + +/** + * Esc reaching the bottom of its ladder opens the operator menu. Every + * case below is one rung of that ladder holding, or the bottom being + * reached. The rung that is *not* here is the scrolled-transcript one: + * this harness starts on the splash screen, whose content measures zero + * rows, so `ChatLog` self-corrects any scroll offset back to zero within + * a frame or two and the state cannot be held open long enough to press + * a key into. `escapeOpensMenu` covers that rung directly in + * `app-key-bindings.test.ts`. + */ +describe("Esc opens the operator menu", () => { + it("opens the menu on an idle, empty Run screen", async () => { + const app = mount(); + await app.settle(); + expect(app.menuIsOpen()).toBe(false); + + app.stdin.write(ESC); + await app.settle(); + + expect(app.menuIsOpen()).toBe(true); + expect(app.counts.quit).toBe(0); + expect(app.counts.abort).toBe(0); + app.unmount(); + }); + + it("closes on the next Esc rather than toggling on the opening press", async () => { + // Ink hands the same keypress to every live `useInput`, so the press + // that opens the menu also reaches `handleAppKey`. If that layer saw + // the fresh `menuOpen` it would close the popup in the same frame and + // Esc would look inert. + const app = mount(); + await app.settle(); + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(true); + + app.stdin.write(ESC); + await app.settle(); + + expect(app.menuIsOpen()).toBe(false); + app.unmount(); + }); + + it("lets a draft keep Esc, and opens the menu on the press after", async () => { + const app = mount(); + await app.settle(); + app.stdin.write("half typed"); + await app.settle(); + expect(app.frame()).toContain("half typed"); + + app.stdin.write(ESC); + await app.settle(); + expect(app.frame()).not.toContain("half typed"); + expect(app.menuIsOpen()).toBe(false); + + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(true); + app.unmount(); + }); + + it("lets the slash palette keep Esc", async () => { + const app = mount(); + await app.settle(); + app.stdin.write("/"); + await app.settle(); + // The palette's own hint row. The command names themselves are no + // good as a marker — the splash screen lists half of them as tips. + expect(app.frame()).toContain("tab/enter"); + + app.stdin.write(ESC); + await app.settle(); + expect(app.frame()).not.toContain("tab/enter"); + expect(app.menuIsOpen()).toBe(false); + + // Closing the palette leaves the `/` it was completing in the buffer, + // so the draft rung is next and it takes a third press to reach the + // menu. Two rungs, two presses — the point is that neither is skipped. + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(false); + + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(true); + app.unmount(); + }); + + it("lets the run-mode dial keep Esc", async () => { + const app = mount(); + await app.settle(); + app.bus.emit({ type: "run_mode_picker_opened" }); + await app.settle(); + expect(app.frame()).toContain("Run mode"); + + app.stdin.write(ESC); + await app.settle(); + + expect(app.frame()).not.toContain("Run mode"); + expect(app.menuIsOpen()).toBe(false); + app.unmount(); + }); + + it("lets a focused sidebar keep Esc", async () => { + const app = mount(); + await app.settle(); + app.stdin.write(TAB); + await app.settle(); + // The rail's own hint row — proof focus really moved, so the Esc + // below is being declined by the sidebar rather than by nothing. + expect(app.frame()).toContain("back to editor"); + + app.stdin.write(ESC); + await app.settle(); + + expect(app.frame()).not.toContain("back to editor"); + expect(app.menuIsOpen()).toBe(false); + app.unmount(); + }); + + it("lets a running turn keep Esc for the abort", async () => { + const app = mount(); + await app.settle(); + app.bus.emitAgentEvent({ type: "turn_started", turnIndex: 0 }); + // The running screen animates its waiting phrase, so wait on the + // hint strip flipping to the running row rather than on a quiet frame. + await app.waitUntil(() => app.frame().includes("ctrl+t")); + expect(app.frame()).toContain("ctrl+t"); + + app.stdin.write(ESC); + await app.waitUntil(() => app.counts.abort > 0); + + expect(app.counts.abort).toBeGreaterThan(0); + expect(app.menuIsOpen()).toBe(false); + app.unmount(); + }); + + it("lets an open panel keep Esc for the way home to Run", async () => { + const app = mount(); + await app.settle(); + app.bus.emit({ type: "ui_mode_set", mode: "debug" }); + app.bus.emit({ type: "tab_changed", tab: "skills" }); + await app.settle(); + + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(false); + // Back on Run — the run-mode strip only renders on the chat surface. + expect(app.frame()).toContain("▸ Local"); + + app.stdin.write(ESC); + await app.settle(); + expect(app.menuIsOpen()).toBe(true); + app.unmount(); + }); + + it("still never quits, however many times Esc is pressed", async () => { + const app = mount(); + await app.settle(); + for (let i = 0; i < 6; i++) { + app.stdin.write(ESC); + await app.settle(); + } + expect(app.counts.quit).toBe(0); + app.unmount(); + }); + + it("keeps a stray key out of the prompt once Esc has opened the menu", async () => { + // The menu takes focus off the editor — the only thing that stops a + // key reaching it — so what the operator types next drives the menu's + // search box and never lands in the message they were not writing. + const app = mount(); + await app.settle(); + app.stdin.write(ESC); + await app.settle(); + + app.stdin.write("z"); + await app.settle(); + + expect(app.menuIsOpen()).toBe(true); + expect(app.frame()).toContain("Menu ❯ z"); + app.unmount(); + }); +}); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 7dc7b99d..af43fff3 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -12,6 +12,7 @@ import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { WhileBusySubmitMode } from "../config/index.js"; import type { TuiAction } from "./tui-action.js"; import { + escapeOpensMenu, handleAppKey, handlePanelEscape, isPanelModalOpen, @@ -816,11 +817,25 @@ export function TuiApp({ dispatch({ type: "ui_mode_set", mode: "chat" }); return; } + // Bottom of the ladder: nothing left to cancel, so Esc opens the + // operator menu instead of dying quietly. `escapeOpensMenu` re-checks + // every prior claimant rather than trusting the branches above to have + // returned — it is shared with the hint strip, and a predicate that + // only held "when called from here" would let the strip and the key + // drift apart. Dispatching from the editor's own Esc handler is safe: + // Ink delivers the same press to `handleAppKey` a moment later, but + // with the pre-dispatch `state`, so `handleMenuKey` does not see + // `menuOpen` yet and cannot close the menu on the keystroke that + // opened it. + if (escapeOpensMenu(state)) { + dispatch({ type: "menu_opened" }); + return; + } // Esc never quits. Everywhere else in the TUI it means cancel / back // one level, so a single unannounced press killing the agent — and // the half-typed message with it — was a trap: no hint strip ever // advertised it, while Ctrl+C deliberately asks twice. Quitting stays - // on Ctrl+C twice and `/quit`; Esc just clears the draft. + // on Ctrl+C twice and `/quit`; Esc clears the draft. if (canAcceptMessage(state)) { if (state.inputValue.length > 0) { dispatch({ type: "input_changed", value: "" }); From 32e9d3792c40cc6a86081eb9b2fb94ed1d4f7295 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 23:39:59 +0300 Subject: [PATCH 55/57] fix(llm): stop concatenating a streamed tool-call name into itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a live session on AI/ML API serving anthropic/claude-sonnet-5: Turn failed [tool]: tool not registered in this agent: replyreplyreplyreplyreplyreplyreplyreplyreplyreplyreply... The model had done nothing wrong. The OpenAI streaming shape sends a tool call's function name ONCE, in the first delta for a given index, and streams only arguments after that — so the consumer appended every name fragment it saw. Gateways that re-send the whole name in every chunk, as AI/ML API fronting Anthropic does, therefore built `reply` up into one long repetition, which then missed the registry and failed the turn with the tool the model actually asked for appearing nowhere in the message. A repeat of what we already hold is dropped; a genuine continuation is still appended, so a gateway that really does split a name across chunks keeps assembling. The two are told apart by whether the accumulated name already ends with the incoming fragment, which is the only signal the stream gives. This is why it fired "all the time" rather than occasionally: it needs only a provider that repeats the name and a turn long enough to stream in more than one chunk. Local llama-server drives the grammar transport and never took this path, which is why it went unseen until a cloud provider was configured. --- .../openai/openai-stream-consumer.test.ts | 109 ++++++++++++++++++ .../provider/openai/openai-stream-consumer.ts | 30 ++++- 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/llm/provider/openai/openai-stream-consumer.test.ts diff --git a/src/llm/provider/openai/openai-stream-consumer.test.ts b/src/llm/provider/openai/openai-stream-consumer.test.ts new file mode 100644 index 00000000..11bb71cd --- /dev/null +++ b/src/llm/provider/openai/openai-stream-consumer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { createOpenAiStreamConsumer } from "./openai-stream-consumer.js"; +import type { StreamFinalResult } from "../completion-types.js"; + +/** An SSE body carrying one `data:` line per event, then `[DONE]`. */ +function sseBody(events: readonly unknown[]): ReadableStream { + const encoder = new TextEncoder(); + const chunks = [ + ...events.map((e) => `data: ${JSON.stringify(e)}\n\n`), + "data: [DONE]\n\n", + ]; + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +function toolCallDelta( + index: number, + fn: { name?: string; arguments?: string }, + extra: Record = {}, +): unknown { + return { + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: fn, ...extra }] }, + }, + ], + }; +} + +async function drain( + body: ReadableStream, +): Promise { + const consumer = createOpenAiStreamConsumer("none"); + const it = consumer.consume(body, new AbortController().signal); + let last: IteratorResult; + do { + last = (await it.next()) as IteratorResult; + } while (!last.done); + return last.value; +} + +describe("openai stream consumer — tool call names", () => { + it("keeps the name whole when the gateway repeats it in every chunk", async () => { + // AI/ML API fronting Anthropic does exactly this. Concatenating turned + // `reply` into `replyreplyreplyreplyreply`, and the agent then refused + // its own protocol tool with "tool not registered in this agent". + const result = await drain( + sseBody([ + toolCallDelta(0, { name: "reply", arguments: '{"text"' }, { + id: "call_1", + type: "function", + }), + toolCallDelta(0, { name: "reply", arguments: ':"hi' }), + toolCallDelta(0, { name: "reply", arguments: ' there"}' }), + { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] }, + ]), + ); + expect(result.toolCalls?.[0]?.function.name).toBe("reply"); + expect(result.toolCalls?.[0]?.function.arguments).toBe( + '{"text":"hi there"}', + ); + }); + + it("takes the spec shape, where the name arrives once", async () => { + const result = await drain( + sseBody([ + toolCallDelta(0, { name: "os.fs.read", arguments: "{}" }, { + id: "call_1", + type: "function", + }), + { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] }, + ]), + ); + expect(result.toolCalls?.[0]?.function.name).toBe("os.fs.read"); + }); + + it("still assembles a name that is genuinely split across chunks", async () => { + const result = await drain( + sseBody([ + toolCallDelta(0, { name: "os.fs" }, { id: "c", type: "function" }), + toolCallDelta(0, { name: ".read", arguments: "{}" }), + { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] }, + ]), + ); + expect(result.toolCalls?.[0]?.function.name).toBe("os.fs.read"); + }); + + it("keeps parallel calls apart when both repeat their names", async () => { + const result = await drain( + sseBody([ + toolCallDelta(0, { name: "os.fs.read", arguments: "{}" }, { id: "a" }), + toolCallDelta(1, { name: "os.shell.run", arguments: "{}" }, { id: "b" }), + toolCallDelta(0, { name: "os.fs.read" }), + toolCallDelta(1, { name: "os.shell.run" }), + { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] }, + ]), + ); + expect(result.toolCalls?.map((c) => c.function.name)).toEqual([ + "os.fs.read", + "os.shell.run", + ]); + }); +}); diff --git a/src/llm/provider/openai/openai-stream-consumer.ts b/src/llm/provider/openai/openai-stream-consumer.ts index a866e3c6..9d138ad7 100644 --- a/src/llm/provider/openai/openai-stream-consumer.ts +++ b/src/llm/provider/openai/openai-stream-consumer.ts @@ -120,7 +120,9 @@ function applyToolCallDeltas( }; if (delta.id) current.id = delta.id; if (delta.type) current.type = delta.type; - if (delta.function?.name) current.function.name += delta.function.name; + if (delta.function?.name) { + appendToolName(current.function, delta.function.name); + } if (delta.function?.arguments) { current.function.arguments += delta.function.arguments; } @@ -128,6 +130,32 @@ function applyToolCallDeltas( } } +/** + * Accumulate a tool call's function name across stream deltas. + * + * The OpenAI streaming shape sends the name **once**, in the first delta + * for a given `index`, and streams only `arguments` after that. Plenty of + * gateways do not: AI/ML API fronting Anthropic repeats the whole name in + * every chunk of the call. Blind concatenation turned a five-chunk `reply` + * into `replyreplyreplyreplyreply`, which then failed the registry lookup + * as `tool not registered in this agent` — the model had done nothing + * wrong, and the transcript showed the tool it actually asked for nowhere. + * + * A repeat of what we already hold is dropped; a genuine continuation is + * appended, so a gateway that really does split a name across chunks still + * assembles. The two cases are told apart by whether the accumulated name + * already ends with the incoming fragment, which is the only signal the + * stream gives us. + */ +function appendToolName(fn: { name: string }, fragment: string): void { + if (!fn.name) { + fn.name = fragment; + return; + } + if (fn.name === fragment || fn.name.endsWith(fragment)) return; + fn.name += fragment; +} + function buildFinalResult(args: { content: string; reasoningContent: string; From f23e8a6f13535ec83704a67ce7ca0d895f92f6ff Mon Sep 17 00:00:00 2001 From: Valerii Date: Thu, 20 Aug 2026 01:03:13 +0300 Subject: [PATCH 56/57] fix(llm): stop the tool-name de-dup from truncating a real name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous commit: the second clause of if (fn.name === fragment || fn.name.endsWith(fragment)) return; drops a genuine fragment whenever it happens to be a suffix of what has already accumulated. For the gateway that splits a name across chunks — the case that commit explicitly set out to keep supporting — `os.proc.kil` + `l` stays `os.proc.kil`, misses the registry and fails the turn with exactly the "tool not registered in this agent" error the fix was for. Enumerating two-way splits of this repo's real tool names, eight are lossy: os.proc.kill, os.fs.diff, os.git.diff, browser.scroll, the three memory.*.recall tools and app — every name ending in a doubled letter, split before its final character. The signal cannot separate a repeat from a continuation even in principle (["search", "search"] is both a repeated `search` and a split `searchsearch`), and the equality clause already covers the reported shape, where the whole name repeats in every chunk. The endsWith clause only ever loses characters, so it goes; the doc comment above the function said the two cases are told apart by it, and now says plainly that only a whole-name repeat is detectable. The four tests that shipped with the previous commit all passed because none exercised a lossy split (the relevant one uses os.fs + .read, which happens to be safe). Adds the doubled-final-letter case. Co-Authored-By: Claude Opus 5 --- .../openai/openai-stream-consumer.test.ts | 16 ++++++++++++++++ .../provider/openai/openai-stream-consumer.ts | 19 +++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/llm/provider/openai/openai-stream-consumer.test.ts b/src/llm/provider/openai/openai-stream-consumer.test.ts index 11bb71cd..01add17e 100644 --- a/src/llm/provider/openai/openai-stream-consumer.test.ts +++ b/src/llm/provider/openai/openai-stream-consumer.test.ts @@ -91,6 +91,22 @@ describe("openai stream consumer — tool call names", () => { expect(result.toolCalls?.[0]?.function.name).toBe("os.fs.read"); }); + it("assembles a split that ends in the name's doubled last letter", async () => { + // `os.proc.kil` + `l`. The fragment is a suffix of what we already + // hold, so dropping partial repeats left `os.proc.kil` — a name no + // registry has. Every tool whose name ends in a doubled letter + // (`os.proc.kill`, `browser.scroll`, `memory.*.recall`) splits this + // way, so the suffix signal is loss, not de-duplication. + const result = await drain( + sseBody([ + toolCallDelta(0, { name: "os.proc.kil" }, { id: "c", type: "function" }), + toolCallDelta(0, { name: "l", arguments: "{}" }), + { choices: [{ index: 0, finish_reason: "tool_calls", delta: {} }] }, + ]), + ); + expect(result.toolCalls?.[0]?.function.name).toBe("os.proc.kill"); + }); + it("keeps parallel calls apart when both repeat their names", async () => { const result = await drain( sseBody([ diff --git a/src/llm/provider/openai/openai-stream-consumer.ts b/src/llm/provider/openai/openai-stream-consumer.ts index 9d138ad7..4a4d2398 100644 --- a/src/llm/provider/openai/openai-stream-consumer.ts +++ b/src/llm/provider/openai/openai-stream-consumer.ts @@ -141,18 +141,25 @@ function applyToolCallDeltas( * as `tool not registered in this agent` — the model had done nothing * wrong, and the transcript showed the tool it actually asked for nowhere. * - * A repeat of what we already hold is dropped; a genuine continuation is - * appended, so a gateway that really does split a name across chunks still - * assembles. The two cases are told apart by whether the accumulated name - * already ends with the incoming fragment, which is the only signal the - * stream gives us. + * Only one case is detectable: a fragment equal to the whole name we + * already hold is that repeat, and is dropped. Everything else is a + * continuation and is appended, so a gateway that really does split a + * name across chunks still assembles. + * + * A *partial* repeat cannot be separated from a continuation even in + * principle — `["search", "search"]` is both a repeated `search` and a + * split `searchsearch` — and guessing by suffix costs real tool names: + * every name ending in a doubled letter (`os.proc.kill`, `browser.scroll`, + * `os.git.diff`, the `memory.*.recall` trio) split before its last + * character would lose that character and fail the same registry lookup + * this function exists to keep working. */ function appendToolName(fn: { name: string }, fragment: string): void { if (!fn.name) { fn.name = fragment; return; } - if (fn.name === fragment || fn.name.endsWith(fragment)) return; + if (fn.name === fragment) return; fn.name += fragment; } From faabd9a54533981208b4912b4e1c7e9f4692e2cb Mon Sep 17 00:00:00 2001 From: Valerii Date: Thu, 20 Aug 2026 01:03:26 +0300 Subject: [PATCH 57/57] fix(config): actually preserve the top-level keys a newer build wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment justifying the permissive newer-version read claimed `writeUserConfigFileSync` "preserves unknown top-level keys rather than dropping them". It did not: the write was JSON.stringify over a UserConfigFile that parseUserConfigFile rebuilds field by field from a fixed key list, with no passthrough. So a newer build's config survived being read — and ensureUserConfigFileSync correctly declined to rewrite it — but the operator's first `config set`, or any other persist caller, deleted the unknown key and stamped `version` back down. The version-skew loop that made an installed older build die on every command was postponed to the first setting change, not prevented. Makes the code match the comment. - parseUserConfigFile collects the top-level keys it has no parser for and hangs them on the object it returns under a new symbol, UNKNOWN_USER_CONFIG_KEYS. A symbol, not a field: no JSON key can collide with it, JSON.stringify skips it (so a preserved key is written once, by the merge below, never twice), and the `{ ...prev, tui: … }` drafts in every persist-* helper carry it for free — object spread copies own enumerable symbol properties. The collector unions the literal keys with any already on the carrier, so the read → merge → validate → write round trip those helpers perform does not lose them on the second validation. - The known-key set is derived from USER_CONFIG_DEFAULTS plus the two input-only keys (`llm`, and the legacy `telemetry` alias folded into `tracing`), so a newly added block registers itself. - writeUserConfigFileSync merges the carrier AND the unknown keys still in the file on disk back into the payload, and leaves a newer on-disk `version` standing instead of stamping it down. Both sources are needed: the carrier covers the persist-* helpers, the disk read covers the whole-payload replacements (`config set`, PATCH /api/config) whose payload never saw those keys. A key this build owns always wins, so a preserved key can never shadow a parsed value. Tests: the carrier survives a parse → spread → parse round trip and is absent when the file is fully understood; on disk, a v43 file with a foreign top-level key keeps both the key and its version through a persist-* rewrite and through a whole-file replacement that never saw them. Co-Authored-By: Claude Opus 5 --- src/config/config-file.test.ts | 70 +++++++++++++++++++++++++++++ src/config/config-file.ts | 53 +++++++++++++++++++++- src/config/config-schema.test.ts | 27 ++++++++++++ src/config/config-schema.ts | 76 ++++++++++++++++++++++++++++++-- 4 files changed, 221 insertions(+), 5 deletions(-) diff --git a/src/config/config-file.test.ts b/src/config/config-file.test.ts index ed3d03a5..ed1b6a75 100644 --- a/src/config/config-file.test.ts +++ b/src/config/config-file.test.ts @@ -11,6 +11,7 @@ import { } from "./config-file.js"; import { ConfigValidationError, + parseUserConfigFile, USER_CONFIG_DEFAULTS, USER_CONFIG_VERSION, } from "./config-schema.js"; @@ -594,6 +595,75 @@ describe("a config written by a newer build", () => { expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true }); }); + it("keeps its keys and its version when an older build persists a setting", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }), + "utf8", + ); + // Exactly what every `persist-*` helper does: read → merge → + // validate → write. Before the passthrough this is where the newer + // build's keys died and `version` was stamped back down — the loop + // the permissive read prevents was only postponed to here. + const prev = ensureUserConfigFileSync(path); + writeUserConfigFileSync( + path, + parseUserConfigFile({ ...prev, log: { level: "debug" } }), + ); + + const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true }); + expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5); + expect((onDisk.log as { level: string }).level).toBe("debug"); + }); + + it("keeps its keys through a whole-file replacement that never saw them", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }), + "utf8", + ); + // `config set` and `PATCH /api/config` validate an operator payload + // and replace the file with it. That payload never held the newer + // build's keys, so the write path is all that stands between them + // and deletion. + writeUserConfigFileSync( + path, + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + log: { level: "warn" }, + }), + ); + + const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true }); + expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5); + expect((onDisk.log as { level: string }).level).toBe("warn"); + // A key this build owns is still the payload's to set: preserving + // foreign keys never shadows a parsed value. + expect((onDisk.localModels as { url: string }).url).toBe( + USER_CONFIG_DEFAULTS.localModels.url, + ); + }); + it("still refuses a version older than the oldest supported one", () => { const dir = mkdtempSync(join(tmpdir(), "atomic-old-config-")); const path = join(dir, "config.json"); diff --git a/src/config/config-file.ts b/src/config/config-file.ts index 633ecd60..3af48e76 100644 --- a/src/config/config-file.ts +++ b/src/config/config-file.ts @@ -8,6 +8,7 @@ import { import { dirname, join } from "node:path"; import { + collectUnknownUserConfigKeys, ConfigValidationError, ENV_DEFAULTS, parseUserConfigFile, @@ -60,15 +61,65 @@ export function readUserConfigFileSync(path: string): UserConfigFile | null { /** * Atomically write the user config file: tmp file + rename. Creates * the parent directory as needed. + * + * Top-level keys this build has no parser for are written back rather + * than dropped, and a `version` already on disk that is *newer* than + * this build's is left standing. That is the other half of the + * permissive read in `parseUserConfigFile`: without it a shared + * `config.json` survives being read by an older build and then loses the + * newer build's keys on the first `config set`. + * + * The keys come from two places, and both are needed. The carrier + * `parseUserConfigFile` leaves on the object covers the read → merge → + * validate → write helpers (`persist-*`, `models`, telegram settings), + * which spread the file they just parsed. The file on disk covers the + * whole-payload replacements — `config set`, `PATCH /api/config` — whose + * payload never saw those keys at all. A key this build owns always + * wins, so a preserved key can never shadow a parsed value. */ export function writeUserConfigFileSync(path: string, data: UserConfigFile): void { mkdirSync(dirname(path), { recursive: true }); - const payload = JSON.stringify(data, null, 2) + "\n"; + const payload = JSON.stringify(withForeignKeys(path, data), null, 2) + "\n"; const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, payload, "utf8"); renameSync(tmp, path); } +/** + * Merge what this build does not own — unknown top-level keys, and a + * newer `version` — back into the payload about to be written. The file + * on disk is read best-effort: unreadable or malformed content carries + * nothing forward, which is the status quo, and it is about to be + * replaced anyway. Where the carrier and the file hold the same foreign + * key the file wins; it is the fresher copy of a value neither this + * build nor the caller owns. + */ +function withForeignKeys( + path: string, + data: UserConfigFile, +): Record { + let onDisk: unknown = null; + try { + if (existsSync(path)) onDisk = JSON.parse(readFileSync(path, "utf8")); + } catch { + onDisk = null; + } + const preserved = { + ...collectUnknownUserConfigKeys(data), + ...collectUnknownUserConfigKeys(onDisk), + }; + const out: Record = { ...data }; + for (const [key, value] of Object.entries(preserved)) { + if (key in out) continue; + out[key] = value; + } + const diskVersion = readVersionField(onDisk); + if (diskVersion !== null && diskVersion > data.version) { + out.version = diskVersion; + } + return out; +} + /** * Ensure the user config file exists and is at the current schema * version. diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index d7715efb..df1187b3 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ConfigValidationError, + UNKNOWN_USER_CONFIG_KEYS, USER_CONFIG_DEFAULTS, USER_CONFIG_VERSION, parseUserConfigFile, @@ -97,6 +98,32 @@ describe("parseUserConfigFile", () => { expect(parsed.localModels.url).toBe("http://127.0.0.1:9999"); }); + it("carries top-level keys it cannot parse, through a persist round trip", () => { + // The newer build's keys have to survive more than the read: every + // `persist-*` helper spreads the parsed file into a draft and hands + // it straight back here, so the carrier has to be picked up again on + // input or the second validation drops what the first one saved. + const parsed = parseUserConfigFile({ + version: 99, + somethingFromTheFuture: { enabled: true }, + log: { level: "warn" }, + }); + expect(parsed[UNKNOWN_USER_CONFIG_KEYS]).toEqual({ + somethingFromTheFuture: { enabled: true }, + }); + + const reparsed = parseUserConfigFile({ ...parsed, log: { level: "debug" } }); + expect(reparsed[UNKNOWN_USER_CONFIG_KEYS]).toEqual({ + somethingFromTheFuture: { enabled: true }, + }); + expect(reparsed.log.level).toBe("debug"); + }); + + it("leaves the carrier off a file it fully understands", () => { + const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); + expect(parsed[UNKNOWN_USER_CONFIG_KEYS]).toBeUndefined(); + }); + it("still rejects a non-integer version", () => { expect(() => parseUserConfigFile({ version: "38" })).toThrow( ConfigValidationError, diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 4e60bbcf..1ca6d23e 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -911,6 +911,23 @@ export interface UserManagedEmbeddingLlmConfig { url: string; } +/** + * Carrier for top-level keys a *newer* build wrote and this build has no + * parser for. `parseUserConfigFile` hangs them on the object it returns + * and `writeUserConfigFileSync` puts them back into the file, so an + * older build reading a shared `config.json` no longer deletes the newer + * build's settings the first time it persists anything. + * + * A symbol rather than a field, deliberately: no JSON key can collide + * with it, `JSON.stringify` skips it (so a preserved key is written once, + * by the merge in `writeUserConfigFileSync`, never twice), and every + * `{ ...prev, tui: { … } }` draft in the `persist-*` helpers carries it + * along for free — object spread copies own enumerable symbol properties. + */ +export const UNKNOWN_USER_CONFIG_KEYS: unique symbol = Symbol.for( + "atomic-agent.userConfig.unknownKeys", +); + /** * User-facing keys that live in `/config.json`. The file * format is versioned; bump `USER_CONFIG_VERSION` on breaking schema @@ -918,6 +935,11 @@ export interface UserManagedEmbeddingLlmConfig { */ export interface UserConfigFile { version: typeof USER_CONFIG_VERSION; + /** + * Present only when the parsed file carried top-level keys this build + * does not know. See {@link UNKNOWN_USER_CONFIG_KEYS}. + */ + readonly [UNKNOWN_USER_CONFIG_KEYS]?: Readonly>; localModels: { url: string; mode: LocalLlmMode; @@ -2678,17 +2700,56 @@ export function parseMcpServers( return out; } +/** + * Top-level keys this build's parser consumes. Derived from + * `USER_CONFIG_DEFAULTS` so a newly added block registers itself, plus + * the two input-only keys that have no default entry: the optional `llm` + * block, and the legacy `telemetry` alias that is read here and written + * back as `tracing`. Everything outside this set was written by another + * build and is preserved verbatim instead of parsed. + */ +const KNOWN_USER_CONFIG_KEYS: ReadonlySet = new Set([ + ...Object.keys(USER_CONFIG_DEFAULTS), + "llm", + "telemetry", +]); + +/** + * The top-level keys of `raw` this build has no parser for, unioned with + * any already carried on it under {@link UNKNOWN_USER_CONFIG_KEYS}. The + * union is what makes a `persist-*` round trip lossless: those helpers + * spread an already-parsed file (whose unknown keys live only on the + * carrier) and hand the draft back to `parseUserConfigFile`. Literal keys + * win over carried ones — they are what the file being read says now. + */ +export function collectUnknownUserConfigKeys( + raw: unknown, +): Record { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {}; + const obj = raw as Record & { + [UNKNOWN_USER_CONFIG_KEYS]?: Readonly>; + }; + const out: Record = { ...(obj[UNKNOWN_USER_CONFIG_KEYS] ?? {}) }; + for (const [key, value] of Object.entries(obj)) { + if (KNOWN_USER_CONFIG_KEYS.has(key)) continue; + out[key] = value; + } + return out; +} + /** * Validate and normalise a raw JSON payload into a `UserConfigFile`. * Missing sub-keys are filled with defaults — this lets us add new - * fields without breaking existing installations. Unknown top-level - * keys are preserved silently (forward compat). + * fields without breaking existing installations. Top-level keys this + * build has no parser for are carried on the returned object under + * {@link UNKNOWN_USER_CONFIG_KEYS} (forward compat). */ export function parseUserConfigFile(raw: unknown): UserConfigFile { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { throw new ConfigValidationError("", "expected JSON object"); } const obj = raw as Record; + const unknownKeys = collectUnknownUserConfigKeys(obj); const version = obj.version ?? USER_CONFIG_VERSION; if (typeof version !== "number" || !Number.isInteger(version)) { throw new ConfigValidationError( @@ -2701,8 +2762,12 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { // Every bump this schema has ever taken is additive: new keys arrive // with defaults and the parser reads field by field, so a file written // by a newer build parses correctly here — the keys this build does not - // know are simply not read, and `writeUserConfigFileSync` preserves - // unknown top-level keys rather than dropping them. + // know are not parsed but are carried on the returned object (see + // `UNKNOWN_USER_CONFIG_KEYS`), and `writeUserConfigFileSync` puts them + // back into the file rather than dropping them. Without that, reading + // would be safe but the operator's first `config set` would delete the + // newer build's keys and stamp `version` back down — the loop this + // permissiveness exists to prevent, merely postponed. // // Refusing was actively harmful. Two builds share one `config.json`, // so the moment the newer one wrote its version the older one died on @@ -3521,6 +3586,9 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { servers: parseMcpServers(mcp.servers, "mcp.servers"), }, ...(llmBlock !== undefined ? { llm: llmBlock } : {}), + ...(Object.keys(unknownKeys).length > 0 + ? { [UNKNOWN_USER_CONFIG_KEYS]: unknownKeys } + : {}), }; }