diff --git a/AGENTS.md b/AGENTS.md index 4b938829..99ddc72b 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 | @@ -180,6 +196,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 f3a7224c..3521fd62 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, `/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). +**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 848ec16d..cbdde3ee 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 a146d807..5d946ac2 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -663,12 +663,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 @@ -1359,9 +1361,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 @@ -1425,7 +1434,9 @@ export interface UserConfigFile { // (`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. +// exists; the bump only records the schema change. The same bump adds +// `tui.mouse` (terminal mouse reporting, default `true`) — also +// defaulted in, so older files upgrade without a migration step. export const USER_CONFIG_VERSION = 38 as const; /** @@ -1782,6 +1793,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { }, tui: { theme: "auto", + mouse: true, }, analytics: { enabled: true, @@ -3436,6 +3448,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 60c3871b..a5f8a399 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 599fc071..5d4e5821 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -188,75 +188,7 @@ export function handleAppKey( return true; } } - const tasksTabBusy = - state.uiMode === "debug" && - state.activeTab === "tasks" && - (state.tasksPanel.mode === "create" || - state.tasksPanel.cancelConfirm !== null || - state.tasksPanel.searchOpen); - const skillsTabBusy = - state.uiMode === "debug" && - state.activeTab === "skills" && - (state.skillsPanel.mode === "detail" || - state.skillsPanel.mode === "hub" || - state.skillsPanel.installConfirm !== null || - state.skillsPanel.removeConfirm !== null); - const memoryTabBusy = - state.uiMode === "debug" && - state.activeTab === "memory" && - state.memoryPanel.mode === "detail"; - const localModelsTabBusy = - state.uiMode === "debug" && - state.activeTab === "models" && - (state.localModelsPanel.mode === "backendUpdate" || - state.localModelsPanel.removeConfirmId !== null); - // Telegram tab disables the editor outright (the panel owns letter - // hotkeys), so on entry Tab/Shift+Tab still cycle. The "busy" flag - // applies only when a modal is open and Tab/letters need to be - // captured by the modal layer instead of cycling away from it. - const telegramTabBusy = - state.uiMode === "debug" && - state.activeTab === "telegram" && - state.telegramPanel.mode !== "list"; - // MCP tab is "busy" while a modal is open: the add-server modal - // owns its own MultiLineEditor and the panel must keep capturing - // letter/Tab keys; the remove-confirm modal claims `y`/`n` and Esc - // so the global nav cycler cannot eat the confirmation keystrokes. - const mcpTabBusy = - state.uiMode === "debug" && - state.activeTab === "mcp" && - (state.mcpPanel.addModal !== null || state.mcpPanel.removeConfirm !== null); - const providersTabBusy = - state.uiMode === "debug" && - state.activeTab === "providers" && - (state.providersPanel.wizard !== null || - state.providersPanel.removeConfirm !== null); - const llmTabBusy = - state.uiMode === "debug" && - state.activeTab === "llm" && - (state.providersPanel.wizard !== null || - state.providersPanel.removeConfirm !== null || - state.localModelsPanel.mode === "backendUpdate" || - state.localModelsPanel.pull !== null || - state.localModelsPanel.removeConfirmId !== null || - state.localModelsPanel.embeddingRemoveConfirmId !== null || - state.localModelsPanel.embeddingOnboardingPrompt !== null || - state.providersPanel.chatModelPicker !== null || - state.llmPanel.externalUrlDraft !== null || - state.llmPanel.stopLocalDaemonsPrompt !== null || - // Focused inline model filter is a text-entry surface: Tab/Ctrl+B - // must not cycle the nav away mid-typing. - (state.llmPanel.mode === "cloud" && - state.llmPanel.cloudModelFilterFocused)); - const debugTabBusy = - tasksTabBusy || - skillsTabBusy || - memoryTabBusy || - localModelsTabBusy || - telegramTabBusy || - mcpTabBusy || - providersTabBusy || - llmTabBusy; + 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 / @@ -330,6 +262,125 @@ export function handleAppKey( return true; } return false; + // 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" && + (state.tasksPanel.mode === "create" || + state.tasksPanel.cancelConfirm !== null || + state.tasksPanel.searchOpen); + const skillsTabBusy = + state.uiMode === "debug" && + state.activeTab === "skills" && + (state.skillsPanel.mode === "detail" || + state.skillsPanel.mode === "hub" || + state.skillsPanel.installConfirm !== null || + state.skillsPanel.removeConfirm !== null); + const memoryTabBusy = + state.uiMode === "debug" && + state.activeTab === "memory" && + state.memoryPanel.mode === "detail"; + const localModelsTabBusy = + state.uiMode === "debug" && + state.activeTab === "models" && + (state.localModelsPanel.mode === "backendUpdate" || + state.localModelsPanel.removeConfirmId !== null); + // Telegram tab disables the editor outright (the panel owns letter + // hotkeys), so on entry Tab/Shift+Tab still cycle. The "busy" flag + // applies only when a modal is open and Tab/letters need to be + // captured by the modal layer instead of cycling away from it. + const telegramTabBusy = + state.uiMode === "debug" && + state.activeTab === "telegram" && + state.telegramPanel.mode !== "list"; + // MCP tab is "busy" while a modal is open: the add-server modal + // owns its own MultiLineEditor and the panel must keep capturing + // letter/Tab keys; the remove-confirm modal claims `y`/`n` and Esc + // so the global nav cycler cannot eat the confirmation keystrokes. + const mcpTabBusy = + state.uiMode === "debug" && + state.activeTab === "mcp" && + (state.mcpPanel.addModal !== null || state.mcpPanel.removeConfirm !== null); + const providersTabBusy = + state.uiMode === "debug" && + state.activeTab === "providers" && + (state.providersPanel.wizard !== null || + state.providersPanel.removeConfirm !== null); + const llmTabBusy = + state.uiMode === "debug" && + state.activeTab === "llm" && + (state.providersPanel.wizard !== null || + state.providersPanel.removeConfirm !== null || + state.localModelsPanel.mode === "backendUpdate" || + state.localModelsPanel.pull !== null || + state.localModelsPanel.removeConfirmId !== null || + state.localModelsPanel.embeddingRemoveConfirmId !== null || + state.localModelsPanel.embeddingOnboardingPrompt !== null || + state.providersPanel.chatModelPicker !== null || + state.llmPanel.externalUrlDraft !== null || + state.llmPanel.stopLocalDaemonsPrompt !== null || + // Focused inline model filter is a text-entry surface: Tab/Ctrl+B + // must not cycle the nav away mid-typing. + (state.llmPanel.mode === "cloud" && + state.llmPanel.cloudModelFilterFocused)); + return ( + tasksTabBusy || + skillsTabBusy || + memoryTabBusy || + localModelsTabBusy || + telegramTabBusy || + mcpTabBusy || + providersTabBusy || + llmTabBusy + ); } /** @@ -450,7 +501,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 { @@ -496,6 +552,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, @@ -504,57 +596,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 e394b859..1ae7ee4c 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -95,6 +95,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"; } /** @@ -148,6 +155,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": @@ -319,6 +328,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/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 27d0fdf7..0945b713 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 94033331..d1b49eee 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. */ +/** Click target for the `ctrl+p` chip — the operator menu. */ +function openOperatorMenu(mouse: MouseContextValue): void { + mouse.dispatch({ type: "menu_opened" }); +} + 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" }, ]; } @@ -91,10 +143,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: "ctrl+p", label: "menu" }, + { + 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: "ctrl+p", label: "menu", onClick: openOperatorMenu }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -123,9 +189,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: "ctrl+p", label: "menu" }, + { key: "ctrl+p", label: "menu", onClick: openOperatorMenu }, { 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/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..611232ca 100644 --- a/src/tui/components/run-mode-picker.tsx +++ b/src/tui/components/run-mode-picker.tsx @@ -1,9 +1,13 @@ 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 { 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 { + CLOUD_SHARE_BAR_WIDTH, describeCloudShare, formatCloudShareBar, } from "../run-mode/run-mode-selectors.js"; @@ -25,6 +29,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 +50,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 +78,93 @@ 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/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 c3609b53..162eaf50 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,6 +1,8 @@ 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 { getCurrentSection, type TuiSection } from "../section.js"; import { menuPlaceByTab } from "../menu/menu-registry.js"; import { theme } from "../theme/theme.js"; @@ -52,6 +54,19 @@ const SECTION_LABELS: Record = { manage: "Manage", }; +/** + * Where you are, as one line: `Manage › Tasks`. + * + * #172 retired the Run / Observe / Manage pill row — it was a menu, and + * the menu now lives behind `ctrl+p` where it can hold every destination + * instead of only the top three. The breadcrumb is what the popup cannot + * tell you, because you have to open it to read it. + * + * It stays clickable, though. Losing the pills would otherwise take away + * the only mouse route into navigation, so a click here opens the menu — + * the same thing `ctrl+p` does. Everything visible stays reachable with + * the mouse, which is the rule the mouse layer (#165) is built on. + */ function Breadcrumb({ state, section, @@ -59,9 +74,10 @@ function Breadcrumb({ state: TuiState; section: TuiSection; }): ReactElement { + const mouse = useMouseCommands(); const tabLabel = state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined; - return ( + const label = ( {SECTION_LABELS[section]} @@ -74,6 +90,18 @@ function Breadcrumb({ ) : null} ); + if (!mouse) return label; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.dispatch({ type: "menu_opened" }); + 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/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 2fc1ad7e..5b6261ce 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -1,8 +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 { 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"; import type { MenuItemRow } from "./menu-selectors.js"; import { clampMenuCursor, @@ -26,6 +30,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; } /** @@ -60,6 +70,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` @@ -107,6 +118,8 @@ export function MenuPopup({ row={row} inner={inner} selected={start + idx === cursorRowIdx} + itemIndex={itemIndexes.indexOf(start + idx)} + onActivate={onActivate} /> ), )} @@ -145,11 +158,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}` : ""; @@ -163,8 +182,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/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts index b6053880..3d2a13ee 100644 --- a/src/tui/menu/menu-registry.test.ts +++ b/src/tui/menu/menu-registry.test.ts @@ -11,7 +11,8 @@ import { } from "./menu-registry.js"; /** - * The slash palette exactly as `feat/run-mode-tui` (#163) declares it by hand. + * The slash palette exactly as `feat/run-mode-tui` (#163) declares it by + * hand, plus `/mouse` from the mouse layer (#165) this branch merges. * `SLASH_COMMANDS` is derived from `MENU` on this branch, so this is a * cross-check between two independent views of the command surface rather * than a snapshot of my own output: if the registry and #163 ever disagree @@ -33,6 +34,11 @@ const EXPECTED_SLASH_COMMANDS = [ 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/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index edf9b697..cddf80f1 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -603,6 +603,18 @@ export const MENU: readonly MenuNode[] = [ rank: 6, }, }, + { + kind: "action", + id: "setup.mouse", + label: "Mouse support", + group: "setup", + slash: { + name: "mouse", + description: + "mouse support on/off/status (off restores drag-to-select)", + rank: 2.5, + }, + }, ]; /** Every node that is also a slash command, in palette order. */ 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..fe84e4fd --- /dev/null +++ b/src/tui/mouse/mouse-app.test.tsx @@ -0,0 +1,373 @@ +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("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("llama.cpp"), "the Run screen"); + await clickUntil( + app.mouse, + () => locate(app.frame(), "Run"), + () => app.frame().includes("Menu"), + "click on the breadcrumb", + ); + 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("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(), "Toggle debug pane"), + () => app.frame().includes("▸ Feed"), + "click on a menu row", + ); + 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("llama.cpp"), "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("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 + // 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("llama.cpp"), "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(); + }); +}); + +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/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 bffecadd..233a4e71 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -45,6 +45,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 { @@ -155,6 +163,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, @@ -185,6 +200,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 @@ -199,6 +221,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/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/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 894ac020..1e389eb2 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -218,6 +218,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 1ec524ed..5fd1091f 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -116,6 +116,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 @@ -126,6 +128,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 @@ -179,8 +183,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 501dcc58..63f9f7ce 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 { APP_CHROME_ROWS } from "./components/debug-pane.js"; import { MenuPopup } from "./menu/menu-popup.js"; import type { MenuNode } from "./menu/menu-registry.js"; @@ -70,6 +74,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"; @@ -102,6 +115,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. */ @@ -349,6 +368,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; @@ -363,6 +388,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 @@ -383,6 +416,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), @@ -391,9 +425,24 @@ export function TuiApp({ const [ctrlCArmed, setCtrlCArmed] = useState(false); const [menuLeaderArmed, setMenuLeaderArmed] = 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]); @@ -559,6 +608,77 @@ export function TuiApp({ [state, callbacks], ); + /** + * 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, @@ -577,32 +697,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; @@ -763,9 +858,16 @@ export function TuiApp({ ) : null; return ( + @@ -809,6 +911,7 @@ export function TuiApp({ availableColumns={ terminalSize.columns - 4 - (sidebarVisible ? SIDEBAR_WIDTH : 0) } + onActivate={activateMenuNode} /> ) : null} @@ -902,6 +1005,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 75273c53..04ae8f6e 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, { @@ -388,10 +441,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, @@ -436,6 +491,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 {