From e6c3f0166928973593485024727ef83ecca4e903 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 07:33:04 +0300 Subject: [PATCH 1/4] feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. --- src/tui/app-key-bindings.ts | 33 ++++++ src/tui/components/hotkey-hint.tsx | 11 +- src/tui/menu/menu-behaviour.test.ts | 150 +++++++++++++++++++++++++ src/tui/menu/menu-keys.ts | 157 ++++++++++++++++++++++++++ src/tui/menu/menu-popup.tsx | 144 ++++++++++++++++++++++++ src/tui/menu/menu-selectors.ts | 166 ++++++++++++++++++++++++++++ src/tui/reduce-ui-actions.ts | 32 ++++++ src/tui/tui-action.ts | 6 + src/tui/tui-app.test.tsx | 70 ++++++++++++ src/tui/tui-app.tsx | 40 ++++++- src/tui/tui-state.ts | 15 +++ 11 files changed, 818 insertions(+), 6 deletions(-) create mode 100644 src/tui/menu/menu-behaviour.test.ts create mode 100644 src/tui/menu/menu-keys.ts create mode 100644 src/tui/menu/menu-popup.tsx create mode 100644 src/tui/menu/menu-selectors.ts diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..77870064 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -6,6 +6,13 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import { + handleMenuKey, + isMenuLeaderKey, + isMenuOpenKey, + resolveLeaderChord, +} from "./menu/menu-keys.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; @@ -72,6 +79,11 @@ export interface AppKeyContext { * the sidebar steals plain Tab. */ sidebarVisible: boolean; + /** True while a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed: boolean; + setMenuLeaderArmed: (armed: boolean) => void; + /** Navigate to a place, or run an action's slash command. */ + activateMenuNode: (node: MenuNode) => void; } /** @@ -102,6 +114,27 @@ export function handleAppKey( if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { return true; } + // The menu and its leader sit above every panel guard on purpose: they are + // the way out of a panel, so a panel must never be able to swallow them. + if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) { + return true; + } + if (ctx.menuLeaderArmed) { + ctx.setMenuLeaderArmed(false); + const node = resolveLeaderChord(input, key); + if (node) ctx.activateMenuNode(node); + // An unclaimed chord is swallowed rather than passed on: a mistyped + // leader must not leak a letter into the prompt or fire a panel hotkey. + return true; + } + if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) { + ctx.setMenuLeaderArmed(true); + return true; + } + if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) { + dispatch({ type: "menu_opened" }); + return true; + } if ( ctx.sidebarVisible && state.uiMode === "chat" && diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..dbb0f5d2 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -85,7 +85,7 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { { key: "tab", label: "next panel" }, { key: "shift+tab", label: "prev panel" }, { key: "esc", label: "back to Run" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -104,15 +104,16 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { }, ]; } - // Six chips is the cap for one row on narrow terminals. The scroll - // hint replaces ctrl+b: Observe stays reachable via /observe, while - // scrolling had no visible entry point at all. + // Six chips is the cap for one row on narrow terminals. `ctrl+p` takes + // the slot `/` used to hold: the menu contains every slash command as + // well as every destination, so advertising the superset costs nothing + // and `/` keeps working for anyone who already reaches for it. return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, { key: "tab", label: "sidebar" }, { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts new file mode 100644 index 00000000..9581cd16 --- /dev/null +++ b/src/tui/menu/menu-behaviour.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { handleMenuKey, resolveLeaderChord } from "./menu-keys.js"; +import type { MenuNode } from "./menu-registry.js"; +import { + selectMenuItems, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import type { TuiAction } from "../tui-action.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; + +const KEY = { + upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, + pageDown: false, pageUp: false, return: false, escape: false, ctrl: false, + shift: false, tab: false, backspace: false, delete: false, meta: false, +} as const; + +function open(patch: Partial = {}): TuiState { + return { ...createInitialTuiState(fakeSession()), menuOpen: true, ...patch }; +} + +function drive(state: TuiState, input: string, key: Partial) { + const actions: TuiAction[] = []; + const activated: MenuNode[] = []; + const handled = handleMenuKey(input, { ...KEY, ...key } as never, { + state, + dispatch: (a) => actions.push(a), + activate: (n) => activated.push(n), + }); + return { handled, actions, activated }; +} + +describe("menu rows", () => { + it("shows group headings and the two submenus at the root", () => { + const rows = selectMenuRows(open()); + const headers = rows.flatMap((r) => (r.kind === "header" ? [r.label] : [])); + expect(headers).toEqual(["Go", "Session", "Model", "Run", "Setup", "Help"]); + const go = rows.filter((r) => r.kind === "item" && r.node.group === "go"); + expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([ + "Run", + "Toggle debug pane", + "Observe", + "Manage", + ]); + }); + + it("lists a submenu's children and titles the popup with a breadcrumb", () => { + const state = open({ menuPath: "go.manage" }); + expect(selectMenuTitle(state)).toContain("Manage"); + const labels = selectMenuItems(state).map((r) => r.node.label); + expect(labels).toEqual([ + "Tasks", "Skills", "Memory", "MCP", "LLM", "Telegram", "Import", "Privacy", + ]); + }); + + it("flattens the tree when searching and keeps a breadcrumb on each hit", () => { + const state = open({ menuQuery: "privacy" }); + const items = selectMenuItems(state); + const privacy = items.find((r) => r.node.id === "go.manage.privacy"); + expect(privacy).toBeDefined(); + expect(privacy?.crumb).toBe("Manage"); + expect(items.some((r) => r.node.kind === "submenu")).toBe(false); + }); + + it("searching from inside a submenu still reaches the whole registry", () => { + const state = open({ menuPath: "go.manage", menuQuery: "feed" }); + const ids = selectMenuItems(state).map((r) => r.node.id); + expect(ids).toContain("go.observe.feed"); + }); + + it("carries live counts onto destinations", () => { + const base = createInitialTuiState(fakeSession()); + const state = open({ + tasksPanel: { ...base.tasksPanel, rows: [{}, {}] as never }, + menuPath: "go.manage", + }); + const tasks = selectMenuItems(state).find((r) => r.node.id === "go.manage.tasks"); + expect(tasks?.status).toBe("2 tasks"); + }); +}); + +describe("menu keys", () => { + it("moves the cursor with the arrows only, so letters stay available for search", () => { + expect(drive(open(), "", { downArrow: true }).actions).toEqual([ + { type: "menu_cursor_moved", delta: 1 }, + ]); + expect(drive(open(), "j", {}).actions).toEqual([ + { type: "menu_query_changed", query: "j" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("opens a submenu with the right arrow and leaves it with the left", () => { + const atManage = open({ menuCursor: 2 }); + expect(drive(atManage, "", { rightArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: "go.observe" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + const inside = open({ menuPath: "go.manage" }); + expect(drive(inside, "", { leftArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: null }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("closes before activating, so the menu is never left over a new screen", () => { + const state = open({ menuPath: "go.manage" }); + const { actions, activated } = drive(state, "", { return: true }); + expect(actions).toEqual([{ type: "menu_closed" }]); + expect(activated.map((n) => n.id)).toEqual(["go.manage.tasks"]); + }); + + it("takes a whole burst into the search box, so a paste is not swallowed", () => { + expect(drive(open(), "privacy", {}).actions).toEqual([ + { type: "menu_query_changed", query: "privacy" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("keeps escape-sequence fragments out of the query", () => { + const arrow = String.fromCharCode(27) + "[A"; + expect(drive(open(), arrow, {}).actions).toEqual([]); + }); + + it("swallows every key while open so no panel below can act on it", () => { + for (const [input, key] of [["x", {}], ["", { tab: true }], ["", { pageUp: true }]] as const) { + expect(drive(open(), input, key).handled).toBe(true); + } + }); + + it("declines every key when closed", () => { + const closed = { ...createInitialTuiState(fakeSession()), menuOpen: false }; + expect(drive(closed, "x", {}).handled).toBe(false); + }); +}); + +describe("leader chords", () => { + it("resolves a place from the key pressed after ctrl+g", () => { + expect(resolveLeaderChord("t", KEY as never)?.id).toBe("go.manage.tasks"); + expect(resolveLeaderChord("f", KEY as never)?.id).toBe("go.observe.feed"); + }); + + it("resolves nothing for an unclaimed key or an escape", () => { + expect(resolveLeaderChord("z", KEY as never)).toBeNull(); + expect(resolveLeaderChord("", { ...KEY, escape: true } as never)).toBeNull(); + }); +}); diff --git a/src/tui/menu/menu-keys.ts b/src/tui/menu/menu-keys.ts new file mode 100644 index 00000000..22db0efe --- /dev/null +++ b/src/tui/menu/menu-keys.ts @@ -0,0 +1,157 @@ +import type { Key } from "ink"; + +import type { TuiAction } from "../tui-action.js"; +import type { TuiState } from "../tui-state.js"; +import { menuNodeByChord, type MenuNode } from "./menu-registry.js"; +import { clampMenuCursor, selectMenuSelection } from "./menu-selectors.js"; + +/** + * Prefix for direct jumps: `ctrl+g` then a single key. A leader is what + * keeps the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) + * usable — the chord namespace is disjoint from both those letters and from + * ordinary typing, so nothing had to be renamed to make room for it. + * + * `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix + * and is intercepted by some terminals. + */ +export const MENU_LEADER_LABEL = "ctrl+g"; + +export interface MenuKeyContext { + state: TuiState; + dispatch: (action: TuiAction) => void; + /** Run the node — navigate to a place, or run an action's slash command. */ + activate: (node: MenuNode) => void; +} + +/** True when the keypress opens the menu. */ +export function isMenuOpenKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "p"; +} + +/** True when the keypress arms the `ctrl+g` leader. */ +export function isMenuLeaderKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "g"; +} + +/** + * Resolve the key pressed after the leader. Returns the node to activate, + * or `null` when nothing claims that key — an unknown chord is swallowed + * rather than falling through, so a mistyped leader can never land a stray + * letter in the prompt or fire a panel hotkey. + */ +export function resolveLeaderChord(input: string, key: Key): MenuNode | null { + if (key.escape || input.length === 0) return null; + return menuNodeByChord(input); +} + +/** + * Key layer for the open menu. Runs before every other handler, and claims + * every printable key — the search box owns typing, which is why the list is + * navigated with arrows only and never with `j`/`k`. + * + * Returns `true` when the key was consumed. + */ +export function handleMenuKey( + input: string, + key: Key, + ctx: MenuKeyContext, +): boolean { + const { state, dispatch } = ctx; + if (!state.menuOpen) return false; + + if (key.escape) { + dispatch({ type: "menu_closed" }); + return true; + } + if (key.downArrow) { + dispatch({ type: "menu_cursor_moved", delta: 1 }); + return true; + } + if (key.upArrow) { + dispatch({ type: "menu_cursor_moved", delta: -1 }); + return true; + } + + const searching = state.menuQuery.trim().length > 0; + const selection = selectMenuSelection(state); + + if (key.rightArrow) { + if (!searching && selection?.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + } + return true; + } + if (key.leftArrow) { + if (!searching && state.menuPath !== null) { + dispatch({ type: "menu_path_set", path: null }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); + } + return true; + } + if (key.return) { + if (!selection) return true; + if (selection.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + return true; + } + dispatch({ type: "menu_closed" }); + ctx.activate(selection.node); + return true; + } + if (key.backspace || key.delete) { + if (state.menuQuery.length > 0) { + setQuery(dispatch, state.menuQuery.slice(0, -1)); + } + return true; + } + if (isPrintable(input, key)) { + setQuery(dispatch, state.menuQuery + input); + return true; + } + // Anything else (Tab, page keys, stray control bytes) is swallowed so the + // panel layer underneath cannot act on a key aimed at the menu. + return true; +} + +/** Clamp helper shared with the reducer so cursor moves stay in range. */ +export function nextMenuCursor(state: TuiState, delta: number): number { + return clampMenuCursor(state, state.menuCursor + delta); +} + +function enterSubmenu( + dispatch: (action: TuiAction) => void, + id: string, +): void { + dispatch({ type: "menu_path_set", path: id }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Typing flattens the tree: a query ranks across the whole registry, so any + * submenu the operator had walked into is dropped at the same time. + */ +function setQuery( + dispatch: (action: TuiAction) => void, + query: string, +): void { + dispatch({ type: "menu_query_changed", query }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Printable text destined for the search box. + * + * Accepts a whole burst, not just one character: a paste arrives as a single + * input event, and so does fast typing under a slow render. Control bytes and + * escape-sequence fragments are rejected per code point so a stray arrow can + * never end up inside the query. + */ +function isPrintable(input: string, key: Key): boolean { + if (input.length === 0) return false; + if (key.ctrl || key.meta || key.tab || key.return || key.escape) return false; + for (const char of input) { + const code = char.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx new file mode 100644 index 00000000..5e1b0696 --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,144 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { chromeTheme } from "../theme/theme.js"; +import type { TuiState } from "../tui-state.js"; +import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import { + clampMenuCursor, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import { MENU_LEADER_LABEL } from "./menu-keys.js"; + +/** Rows of list body. Keeps the popup shorter than a short terminal. */ +const MAX_ROWS = 16; + +interface MenuPopupProps { + state: TuiState; +} + +/** + * The operator menu: one key (`ctrl+p`) to every destination and every verb. + * + * Rendered directly above the prompt on the same left rail rather than as a + * full-screen takeover, so it reads as belonging to the input you were + * already typing in. The app behind it is dimmed by `setBackdropDimmed` + * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores + * that flag, so the menu stays at full contrast against a faded backdrop. + * + * Pure presentation: every key is handled by `handleMenuKey`. + */ +export function MenuPopup({ state }: MenuPopupProps): ReactElement { + const rows = selectMenuRows(state); + const cursor = clampMenuCursor(state, state.menuCursor); + const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); + const cursorRowIdx = itemIndexes[cursor] ?? -1; + const start = windowStart(rows, cursorRowIdx); + const visible = rows.slice(start, start + MAX_ROWS); + const hiddenAfter = Math.max(0, rows.length - start - visible.length); + + return ( + + + + {selectMenuTitle(state)} + + + {" "} + {chromeTheme.glyphs.promptCaret} {state.menuQuery} + {"█"} + + + {start > 0 ? ( + {"↑"} {start} above + ) : null} + {visible.map((row, idx) => + row.kind === "header" ? ( + + {row.label.toUpperCase()} + + ) : ( + + ), + )} + {hiddenAfter > 0 ? ( + + {"↓"} {hiddenAfter} below + + ) : null} + {rows.length === 0 ? ( + nothing matches + ) : null} + {footer(state)} + + ); +} + +function MenuItem({ + row, + selected, +}: { + row: MenuItemRow; + selected: boolean; +}): ReactElement { + const { node } = row; + const isSubmenu = node.kind === "submenu"; + const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + return ( + + + + {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} + {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} + + + + {detail} + + {node.chord ? ( + + + {MENU_LEADER_LABEL} {node.chord} + + + ) : null} + + ); +} + +/** + * Footer names exactly the moves that are legal right now — `←` only appears + * once there is a level to go back to. + */ +function footer(state: TuiState): string { + const parts = [`${"↑↓"} move`]; + if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { + parts.push(`${"←"} back`); + } + if (state.menuQuery.trim().length === 0 && state.menuPath === null) { + parts.push(`${"→"} open`); + } + parts.push("enter go", "type to search", "esc close"); + return parts.join(" "); +} + +/** Scroll window that keeps the cursor row visible. */ +function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { + if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; + if (cursorRowIdx < MAX_ROWS) return 0; + return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +} diff --git a/src/tui/menu/menu-selectors.ts b/src/tui/menu/menu-selectors.ts new file mode 100644 index 00000000..c502dcde --- /dev/null +++ b/src/tui/menu/menu-selectors.ts @@ -0,0 +1,166 @@ +import fuzzysort from "fuzzysort"; + +import { + MENU, + MENU_GROUP_LABELS, + MENU_GROUP_ORDER, + menuChildren, + menuNodeById, + menuRoots, + type MenuNode, +} from "./menu-registry.js"; +import type { TuiState } from "../tui-state.js"; + +/** A group heading. Rendered, never selectable. */ +export interface MenuHeaderRow { + readonly kind: "header"; + readonly label: string; +} + +/** A selectable entry. */ +export interface MenuItemRow { + readonly kind: "item"; + readonly node: MenuNode; + /** Live state for a destination, e.g. `3 scheduled`. Empty when unknown. */ + readonly status: string; + /** Where the node lives, shown only while searching flattens the tree. */ + readonly crumb: string; +} + +export type MenuRow = MenuHeaderRow | MenuItemRow; + +/** + * Rows the menu should render for the current state. + * + * Three modes, and the rule that decides between them is the whole design: + * **hierarchy to browse, flat to search.** With a query, every node in the + * registry competes on one ranked list and the tree is irrelevant; without + * one, the operator walks groups and submenus. + */ +export function selectMenuRows(state: TuiState): readonly MenuRow[] { + const query = state.menuQuery.trim(); + if (query.length > 0) return searchRows(state, query); + if (state.menuPath !== null) return submenuRows(state, state.menuPath); + return rootRows(state); +} + +/** Only the selectable rows, in render order — the cursor indexes these. */ +export function selectMenuItems(state: TuiState): readonly MenuItemRow[] { + return selectMenuRows(state).flatMap((row) => + row.kind === "item" ? [row] : [], + ); +} + +/** The row under the cursor, or `null` when the list is empty. */ +export function selectMenuSelection(state: TuiState): MenuItemRow | null { + const items = selectMenuItems(state); + if (items.length === 0) return null; + return items[clampMenuCursor(state, state.menuCursor)] ?? null; +} + +/** Clamp a cursor into the current item list. */ +export function clampMenuCursor(state: TuiState, cursor: number): number { + const max = selectMenuItems(state).length - 1; + if (max < 0) return 0; + return Math.max(0, Math.min(cursor, max)); +} + +/** Title shown in the popup border — `Menu` or `Menu › Manage`. */ +export function selectMenuTitle(state: TuiState): string { + if (state.menuPath === null || state.menuQuery.trim().length > 0) { + return "Menu"; + } + const parent = menuNodeById(state.menuPath); + return parent ? `Menu ${String.fromCodePoint(0x203a)} ${parent.label}` : "Menu"; +} + +function rootRows(state: TuiState): readonly MenuRow[] { + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const nodes = menuRoots(group); + if (nodes.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const node of nodes) { + rows.push(itemRow(state, node, "")); + } + } + return rows; +} + +function submenuRows(state: TuiState, parentId: string): readonly MenuRow[] { + return menuChildren(parentId).map((node) => itemRow(state, node, "")); +} + +function searchRows(state: TuiState, query: string): readonly MenuRow[] { + // Submenus are excluded: "open the Manage submenu" is a browsing move, and + // a search that already found `Privacy` should offer Privacy, not the + // folder it happens to sit in. + const candidates = MENU.filter((node) => node.kind !== "submenu"); + const scored = candidates + .map((node, idx) => { + const haystacks = [node.label, node.slash?.name ?? "", crumbFor(node)]; + const best = Math.max( + ...haystacks.map( + (h) => (h ? (fuzzysort.single(query, h)?.score ?? -Infinity) : -Infinity), + ), + ); + return { node, score: best, idx }; + }) + .filter(({ score }) => score > -Infinity) + .sort((a, b) => b.score - a.score || a.idx - b.idx); + + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const hits = scored.filter(({ node }) => node.group === group); + if (hits.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const { node } of hits) { + rows.push(itemRow(state, node, crumbFor(node))); + } + } + return rows; +} + +function crumbFor(node: MenuNode): string { + if (node.parent === undefined) return ""; + return menuNodeById(node.parent)?.label ?? ""; +} + +function itemRow(state: TuiState, node: MenuNode, crumb: string): MenuItemRow { + return { kind: "item", node, status: statusFor(state, node), crumb }; +} + +/** + * Live one-liner for a destination. Deliberately reads the same state slices + * the sub-tab strip already counts (`debug-pane.tsx`), so opening the menu + * costs a few array lengths and never a refresh. + */ +function statusFor(state: TuiState, node: MenuNode): string { + switch (node.id) { + case "go.manage.tasks": + return countLabel(state.tasksPanel.rows.length, "task"); + case "go.manage.skills": + return countLabel(state.skillsPanel.rows.length, "skill"); + case "go.manage.memory": + return countLabel(state.memoryPanel.rows.length, "note"); + case "go.manage.mcp": + return countLabel(state.mcpPanel.rows.length, "server"); + case "go.observe.feed": + return countLabel(state.feed.length, "event"); + case "go.observe.reasoning": + return countLabel(state.reasoning.length, "entry"); + case "go.observe.logs": + return countLabel(state.logs.length, "line"); + case "go.run": + return countLabel(state.messages.length, "message"); + case "session.switch": + return countLabel(state.recentSessions.length, "recent"); + default: + return ""; + } +} + +function countLabel(count: number, noun: string): string { + if (count === 0) return ""; + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..bffecadd 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -1,3 +1,4 @@ +import { clampMenuCursor } from "./menu/menu-selectors.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { THEME_NAMES } from "./theme/theme.js"; @@ -62,6 +63,37 @@ export function reduceUiAction( for (const card of state.streamingToolCards) next[card.id] = action.expanded; return { ...state, toolsExpandedById: next }; } + case "menu_opened": + // Always reopen at the root with an empty query: a menu that resumes + // where it was last left makes the same keypress mean different + // things on different days. + return { + ...state, + menuOpen: true, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_closed": + return { + ...state, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_query_changed": + // A query flattens the tree, so any open submenu is dropped with it. + return { ...state, menuQuery: action.query, menuPath: null }; + case "menu_path_set": + return { ...state, menuPath: action.path }; + case "menu_cursor_set": + return { ...state, menuCursor: clampMenuCursor(state, action.cursor) }; + case "menu_cursor_moved": + return { + ...state, + menuCursor: clampMenuCursor(state, state.menuCursor + action.delta), + }; case "slash_palette_opened": return { ...state, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..36b08735 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -93,6 +93,12 @@ export type TuiAction = | { type: "slash_palette_queried"; query: string } /** Close the slash palette without committing a selection. */ | { type: "slash_palette_closed" } + | { type: "menu_opened" } + | { type: "menu_closed" } + | { type: "menu_query_changed"; query: string } + | { type: "menu_cursor_moved"; delta: number } + | { type: "menu_cursor_set"; cursor: number } + | { type: "menu_path_set"; path: string | null } /** Move the highlight in the open slash palette by delta rows. */ | { type: "slash_palette_cursor_moved"; delta: 1 | -1 } /** Reset the slash palette highlight to a specific row. */ diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 12019148..3528145a 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -348,4 +348,74 @@ describe("TuiApp (smoke)", () => { expect(text).not.toContain("▸ Manage"); unmount(); }); + + it("ctrl+p opens the operator menu over the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Menu"); + expect(text).toContain("GO"); + expect(text).toContain("Manage"); + expect(text).toContain("esc close"); + unmount(); + }); + + it("typing in the menu searches instead of reaching the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + stdin.write("privacy"); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Privacy"); + // The query lives in the menu, never in the editor buffer underneath. + expect(text).not.toContain("> privacy"); + unmount(); + }); + + it("ctrl+g then a chord jumps straight to a panel, and the chord letter never reaches the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + await new Promise((r) => setTimeout(r, 30)); + stdin.write("t"); + await new Promise((r) => setTimeout(r, 30)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Manage"); + expect(text).toContain("Tasks"); + // Ink delivers every key to every useInput, child first — so the editor + // sees the chord letter too. If the leader did not disable it, a stray + // "t" would be sitting in the prompt right now. + expect(text).not.toMatch(/[>\u276f]\s+t\s*$/m); + unmount(); + }); + + it("esc closes the menu and leaves the screen it was opened over", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + expect(strip(lastFrame() ?? "")).toContain("esc close"); + stdin.write(String.fromCharCode(27)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).not.toContain("esc close"); + expect(text).toContain("Run"); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d61e520e..57c9842f 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,8 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { MenuPopup } from "./menu/menu-popup.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; @@ -22,6 +24,7 @@ import { ThemePicker } from "./components/theme-picker.js"; import { isThemeName, setActiveTheme, + setBackdropDimmed, theme, THEME_NAMES, THEMES, @@ -42,7 +45,7 @@ import { } from "./layout.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; -import { handleEditorSubmit } from "./submit-handler.js"; +import { handleEditorSubmit, runSlashCommand } from "./submit-handler.js"; import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { @@ -378,6 +381,7 @@ export function TuiApp({ ); const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); + const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); useEffect(() => bus.subscribe(dispatch), [bus]); @@ -491,6 +495,8 @@ export function TuiApp({ const sidebarRows = computeSidebarRowBudget(terminalSize.rows); const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = + !state.menuOpen && + !menuLeaderArmed && !state.pendingApproval && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update @@ -528,6 +534,26 @@ export function TuiApp({ } }, [sidebarVisible, state.chatFocus]); + const activateMenuNode = useCallback( + (node: MenuNode) => { + // A node that carries a slash name is *run as that command*, so the + // menu never grows a second dispatch path beside the slash handler. + if (node.slash) { + runSlashCommand(`/${node.slash.name}`, state, dispatch, callbacks); + return; + } + if (node.kind === "place") { + if (node.tab) { + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: node.tab }); + } else { + dispatch({ type: "ui_mode_set", mode: "chat" }); + } + } + }, + [state, callbacks], + ); + useInput((input, key) => { const appHandled = handleAppKey(input, key, { state, @@ -536,6 +562,9 @@ export function TuiApp({ ctrlCArmed, setCtrlCArmed, sidebarVisible, + menuLeaderArmed, + setMenuLeaderArmed, + activateMenuNode, }); if (appHandled) return; // While the slash-command palette is open, let the (now-focused) @@ -702,6 +731,10 @@ export function TuiApp({ // the smoke tests assert against an overlapped frame. In production // the alt-screen + `height={rows}` combo gives us the opencode-style // pinned-input-at-bottom UX. + // Render-phase on purpose: `theme` is a read-at-render proxy, and children + // render after this body runs, so the flag is already correct for them. + setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; const promptLlm = selectPromptLlmMeta(state); @@ -765,6 +798,11 @@ export function TuiApp({ ) : null} + {state.menuOpen ? ( + + + + ) : null} {state.sessionPickerOpen ? ( >; /** Is the session picker overlay visible? */ @@ -489,6 +500,10 @@ export function createInitialTuiState( slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, toolsExpandedById: {}, sessionPickerOpen: false, sessionPickerList: [], From ed4a3d60aa417c2face83fc67a6caa692a0dd3ea Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 10:45:29 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(tui):=20make=20the=20menu=20a=20real=20?= =?UTF-8?q?overlay=20=E2=80=94=20it=20floats,=20nothing=20reflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. --- src/tui/components/debug-pane.tsx | 2 +- src/tui/menu/menu-popup.tsx | 199 +++++++++++++++++++----------- src/tui/tui-app.test.tsx | 19 +++ src/tui/tui-app.tsx | 25 +++- 4 files changed, 169 insertions(+), 76 deletions(-) diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0946055c..27d0fdf7 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -138,7 +138,7 @@ function buildManageTabs(state: TuiState): SubTab[] { * terminal — it overlaps/garbles earlier lines instead (verified) — so * the per-tab budget must subtract this accurately and err generous. */ -const APP_CHROME_ROWS = 9; +export const APP_CHROME_ROWS = 9; /** * Height consumed INSIDE the debug pane above the active tab: the * `SubTabBar` (1 row) + the `DebugDiagnosticsLine`. The diagnostics line diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5e1b0696..5deff17a 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { chromeTheme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; -import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import type { MenuItemRow } from "./menu-selectors.js"; import { clampMenuCursor, selectMenuRows, @@ -11,134 +11,195 @@ import { } from "./menu-selectors.js"; import { MENU_LEADER_LABEL } from "./menu-keys.js"; -/** Rows of list body. Keeps the popup shorter than a short terminal. */ -const MAX_ROWS = 16; +/** Popup width, clamped to the terminal on narrow windows. */ +const PREFERRED_WIDTH = 64; +/** Rows of list body at most, before the window starts scrolling. */ +const MAX_BODY_ROWS = 16; +/** Border (2) + title row + footer row. */ +const CHROME_ROWS = 4; +/** Column reserved for the entry label. */ +const LABEL_WIDTH = 26; interface MenuPopupProps { state: TuiState; + /** Rows available in the pane the menu floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; } /** * The operator menu: one key (`ctrl+p`) to every destination and every verb. * - * Rendered directly above the prompt on the same left rail rather than as a - * full-screen takeover, so it reads as belonging to the input you were - * already typing in. The app behind it is dimmed by `setBackdropDimmed` - * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores - * that flag, so the menu stays at full contrast against a faded backdrop. + * Rendered as a true overlay — `position="absolute"` inside the content pane, + * so it floats **on top of** the chat log or the active panel instead of + * displacing them. Nothing below it reflows when the menu opens or closes. + * + * Terminals have no compositing and Ink has no z-index, so occlusion has to + * be earned: every interior line is padded to the popup's exact inner width, + * which paints spaces over whatever was underneath. That is also why the rows + * are laid out as fixed-width columns rather than with `flexGrow` — a flexed + * row stops at its content and lets the background show through. + * + * A background colour would do the same job in one line, but only by picking + * a colour, and the TUI ships eleven themes across light and dark grounds. + * Spaces are theme-agnostic. + * + * The app behind is dimmed by `setBackdropDimmed` (see `theme.ts`); this + * component reads {@link chromeTheme}, which ignores that flag, so the menu + * stays at full contrast against a faded backdrop. * * Pure presentation: every key is handled by `handleMenuKey`. */ -export function MenuPopup({ state }: MenuPopupProps): ReactElement { +export function MenuPopup({ + state, + availableRows, + availableColumns, +}: MenuPopupProps): ReactElement { + const width = Math.max(28, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + // Interior columns between the two border columns. Ink's own `paddingX` + // is NOT painted by our rows — it leaves real gaps the backdrop shows + // through — so the one-column gutter is baked into every string instead. + const inner = width - 2; + const rows = selectMenuRows(state); const cursor = clampMenuCursor(state, state.menuCursor); const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); const cursorRowIdx = itemIndexes[cursor] ?? -1; - const start = windowStart(rows, cursorRowIdx); - const visible = rows.slice(start, start + MAX_ROWS); + + const bodyRows = Math.max( + 3, + Math.min(MAX_BODY_ROWS, availableRows - CHROME_ROWS), + ); + const start = windowStart(rows.length, cursorRowIdx, bodyRows); + const visible = rows.slice(start, start + bodyRows); const hiddenAfter = Math.max(0, rows.length - start - visible.length); + // Anchor to the bottom of the pane so the menu sits just above the prompt, + // the way a dropdown hangs off the control that opened it. + const height = visible.length + CHROME_ROWS; + const offsetTop = Math.max(0, availableRows - height); + return ( - - - {selectMenuTitle(state)} - - - {" "} - {chromeTheme.glyphs.promptCaret} {state.menuQuery} - {"█"} - - - {start > 0 ? ( - {"↑"} {start} above - ) : null} + {visible.map((row, idx) => row.kind === "header" ? ( - {row.label.toUpperCase()} + {fit(` ${row.label.toUpperCase()}`, inner)} ) : ( ), )} - {hiddenAfter > 0 ? ( - - {"↓"} {hiddenAfter} below - - ) : null} {rows.length === 0 ? ( - nothing matches + {fit(" nothing matches", inner)} ) : null} - {footer(state)} + + {fit(` ${footer(state, hiddenAfter)}`, inner)} + + + ); +} + +function TitleRow({ + state, + inner, +}: { + state: TuiState; + inner: number; +}): ReactElement { + const title = selectMenuTitle(state); + const caret = `${chromeTheme.glyphs.promptCaret} ${state.menuQuery}`; + const left = fit(` ${title}`, Math.min(title.length + 3, inner)); + const rest = inner - left.length; + return ( + + + {left} + + {fit(caret, Math.max(0, rest))} ); } function MenuItem({ row, + inner, selected, }: { row: MenuItemRow; + inner: number; selected: boolean; }): ReactElement { const { node } = row; - const isSubmenu = node.kind === "submenu"; - const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + const marker = selected ? chromeTheme.glyphs.chevronRight : " "; + const arrow = node.kind === "submenu" ? ` ${chromeTheme.glyphs.arrowRight}` : ""; + // Leading and trailing space are part of the row, not Box padding, so the + // whole line is opaque edge to edge. + const label = fit(` ${marker} ${node.label}${arrow}`, Math.min(LABEL_WIDTH, inner)); + const chordText = node.chord ? `${MENU_LEADER_LABEL} ${node.chord} ` : " "; + const chord = fit(chordText, Math.min(chordText.length, Math.max(0, inner - label.length))); + const detailWidth = Math.max(0, inner - label.length - chord.length); + const detail = fit( + [row.crumb, row.status].filter((part) => part.length > 0).join(" "), + detailWidth, + ); return ( - - - {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} - {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} - - - - {detail} - - {node.chord ? ( - - - {MENU_LEADER_LABEL} {node.chord} - - - ) : null} + + {label} + + {detail} + {chord} ); } /** * Footer names exactly the moves that are legal right now — `←` only appears - * once there is a level to go back to. + * once there is a level to go back to, `→` only while one is reachable. */ -function footer(state: TuiState): string { - const parts = [`${"↑↓"} move`]; - if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { - parts.push(`${"←"} back`); - } - if (state.menuQuery.trim().length === 0 && state.menuPath === null) { - parts.push(`${"→"} open`); - } - parts.push("enter go", "type to search", "esc close"); +function footer(state: TuiState, hiddenAfter: number): string { + const searching = state.menuQuery.trim().length > 0; + const parts = ["↑↓ move"]; + if (!searching && state.menuPath !== null) parts.push("← back"); + if (!searching && state.menuPath === null) parts.push("→ open"); + parts.push("enter go", "esc close"); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); return parts.join(" "); } +/** + * Pad or truncate to exactly `width` columns. Every interior line goes + * through this — it is what makes the popup opaque. + */ +function fit(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text.padEnd(width); +} + /** Scroll window that keeps the cursor row visible. */ -function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { - if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; - if (cursorRowIdx < MAX_ROWS) return 0; - return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +function windowStart(total: number, cursorRowIdx: number, size: number): number { + if (total <= size || cursorRowIdx < 0) return 0; + if (cursorRowIdx < size) return 0; + return Math.min(cursorRowIdx - size + 1, total - size); } diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 3528145a..cccab8b2 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -418,4 +418,23 @@ describe("TuiApp (smoke)", () => { expect(text).toContain("Run"); unmount(); }); + + it("floats over the UI without moving it — the frame below is unchanged", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + const before = strip(lastFrame() ?? "").split("\n"); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 25)); + const after = strip(lastFrame() ?? "").split("\n"); + + // A popup composites on top; it must not add rows or push the prompt and + // the hint strip down the way an inline panel would. + expect(after.length).toBe(before.length); + expect(after.at(-1)).toBe(before.at(-1)); + expect(after.some((line) => line.includes("Menu"))).toBe(true); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 57c9842f..567162dc 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,7 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { APP_CHROME_ROWS } from "./components/debug-pane.js"; import { MenuPopup } from "./menu/menu-popup.js"; import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; @@ -735,8 +736,12 @@ export function TuiApp({ // render after this body runs, so the flag is already correct for them. setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; + // Rows the content pane actually has, so the overlay can sit on its bottom + // edge and cap its own height. Same budget the debug pane already uses. + const menuPaneRows = Math.max(6, terminalSize.rows - APP_CHROME_ROWS); const promptLlm = selectPromptLlmMeta(state); // No local backend chosen yet ⇒ no local health to report. Without this the // splash screen of a fresh install announces that a server the user never @@ -774,7 +779,13 @@ export function TuiApp({ - + {state.uiMode === "chat" ? ( ) : ( @@ -792,17 +803,19 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( ) : null} - {state.menuOpen ? ( - - - - ) : null} {state.sessionPickerOpen ? ( Date: Wed, 19 Aug 2026 11:33:28 +0300 Subject: [PATCH 3/4] feat(tui): status bar shows where you are, not a menu of where to go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. --- src/tui/components/status-bar.tsx | 57 +++++++++++++++---------------- src/tui/menu/menu-registry.ts | 8 +++++ src/tui/tui-app.test.tsx | 26 +++++++------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 33750987..c3609b53 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,11 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { - getCurrentSection, - SECTION_ORDER, - type TuiSection, -} from "../section.js"; +import { getCurrentSection, type TuiSection } from "../section.js"; +import { menuPlaceByTab } from "../menu/menu-registry.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; @@ -15,7 +12,13 @@ interface StatusBarProps { } /** - * One-row operator status bar. Replaces the legacy `header-line` + + * One-row operator status bar. Shows **where you are**, not where you could + * go: the three-section pill row was a menu, and the menu now lives behind + * `ctrl+p` where it can hold every destination instead of only the top three. + * What is left is a breadcrumb — `Manage › Tasks` — which is the one thing + * the popup cannot tell you, because you have to open it to read it. + * + * Replaces the legacy `header-line` + * `status-line` + `footer-line` trio: only signal that needs to be * visible at every glance stays on screen — current section and a * short session id when one exists. Verbose details (full cwd, llama @@ -37,7 +40,7 @@ export function StatusBar({ state }: StatusBarProps): ReactElement { v{getAppVersion()} - + ); @@ -49,30 +52,26 @@ const SECTION_LABELS: Record = { manage: "Manage", }; -function SectionPills({ active }: { active: TuiSection }): ReactElement { +function Breadcrumb({ + state, + section, +}: { + state: TuiState; + section: TuiSection; +}): ReactElement { + const tabLabel = + state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined; return ( - {SECTION_ORDER.map((id, idx) => { - const isActive = id === active; - return ( - - - {isActive ? `${theme.glyphs.chevronRight} ` : " "} - {SECTION_LABELS[id]} - - {idx < SECTION_ORDER.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + + {SECTION_LABELS[section]} + + {tabLabel ? ( + + {" "} + {theme.glyphs.chevronRight} {tabLabel} + + ) : null} ); } diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index 0e390280..7b518cfd 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -578,3 +578,11 @@ export function menuNodeById(id: string): MenuNode | null { export function menuNodeByChord(key: string): MenuNode | null { return MENU.find((node) => node.chord === key) ?? null; } + +/** The destination that owns a debug tab, for breadcrumbs and status text. */ +export function menuPlaceByTab(tab: TuiTab): MenuPlaceNode | null { + for (const node of MENU) { + if (node.kind === "place" && node.tab === tab) return node; + } + return null; +} diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index cccab8b2..b5a74dae 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -41,9 +41,11 @@ describe("TuiApp (smoke)", () => { ); const text = strip(lastFrame() ?? ""); expect(text).toContain("atomic-agent"); + // The status bar shows where you are, not a menu of where you could go — + // the three-section pill row moved into the ctrl+p menu. expect(text).toContain("Run"); - expect(text).toContain("Observe"); - expect(text).toContain("Manage"); + expect(text).not.toContain("Observe"); + expect(text).not.toContain("Manage"); // The splash mark scales with the window; ink-testing-library's // 100-column stdout reports no rows, so the fallback 80x24 surface // gets the compact mark rather than the wordmark + tagline. Assert @@ -150,20 +152,19 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("▸ Run"); + expect(before).toContain("Run"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); if (before.includes("Sessions")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("▸ Run"); - expect(after).not.toContain("▸ Observe"); + expect(after).toContain("Run"); + expect(after).not.toContain("Observe \u25b8"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("▸ Observe"); - expect(after).toContain("▸ Feed"); + expect(after).toContain("Observe \u25b8 Feed"); } unmount(); }); @@ -177,8 +178,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Observe"); - expect(text).toContain("▸ Feed"); + expect(text).toContain("Observe \u25b8 Feed"); unmount(); }); @@ -192,7 +192,7 @@ describe("TuiApp (smoke)", () => { await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). - expect(text).toContain("▸ Manage"); + expect(text).toContain("Manage \u25b8"); expect(text).toContain("▸ Telegram"); unmount(); }); @@ -339,13 +339,13 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); - expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + expect(strip(lastFrame() ?? "")).toContain("Manage \u25b8"); stdin.write("\u001b"); await new Promise((r) => setTimeout(r, 60)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Run"); - expect(text).not.toContain("▸ Manage"); + expect(text).toContain("Run"); + expect(text).not.toContain("Manage \u25b8"); unmount(); }); From 22c647c46eb29ef3d9096c881b73c886aa3d672f Mon Sep 17 00:00:00 2001 From: Valerii Date: Thu, 20 Aug 2026 01:15:26 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(tui):=20a=20held=20modifier=20is=20not?= =?UTF-8?q?=20a=20chord=20=E2=80=94=20ctrl+c=20stays=20reachable=20after?= =?UTF-8?q?=20ctrl+g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveLeaderChord` looked only at the character, and Ink spells Ctrl+C as input `"c"` with `key.ctrl`. So an operator who pressed ctrl+g, changed their mind and reached for Ctrl+C did not abort the turn — they landed on the MCP tab. Same class for ctrl+q (quit outright) and ctrl+l (the LLM tab, where ctrl+L is the conventional clear-screen). The resolver now refuses a modified key, which is the guard its two siblings in the file already apply (`isMenuOpenKey` / `isMenuLeaderKey` both insist on `key.ctrl && !key.meta && !key.shift`). Refusing is not enough on its own: the armed branch swallowed everything it could not resolve, so the key still never reached its real handler. Swallowing is right for a *bare* key — a mistyped leader must not leak a letter into the prompt — but a modified one was never aimed at the leader, so it now disarms and falls through to the bindings below. The leader also had no way to end other than a keystroke, unlike the Ctrl+C flag it is modelled on. Since `editorFocus` includes `!menuLeaderArmed`, a stray ctrl+g left the editor unfocused with nothing on screen saying so, and ate the next key — or, if that key happened to be `h`, opened the theme picker. It now disarms itself after the same 1.5 s window Ctrl+C uses, with the same timer-in-a-ref cleanup, and while it is pending the hint strip says so: `[ctrl+g] waiting for a chord · [ctrl+p] full menu · [esc] cancel`. The strip is where transient key state already lives (`ctrl+c → press again to quit`), and it needs no room in the breadcrumb the header just became. The integration test presses ctrl+g and then nothing at all, so only the timer can clear the indicator. It stops there rather than typing afterwards: Ink re-subscribes an editor's `useInput` in a passive effect one commit after the render that refocused it, so a keystroke fired at a loaded runner in that gap is genuinely lost — the same race already documented in `multi-line-editor`. Co-Authored-By: Claude Opus 5 --- src/tui/app-key-bindings.test.ts | 89 +++++++++++++++++++++++++ src/tui/app-key-bindings.ts | 14 ++-- src/tui/components/hotkey-hint.test.tsx | 27 ++++++++ src/tui/components/hotkey-hint.tsx | 28 +++++++- src/tui/menu/menu-behaviour.test.ts | 11 +++ src/tui/menu/menu-keys.ts | 8 +++ src/tui/tui-app.test.tsx | 47 +++++++++++++ src/tui/tui-app.tsx | 27 +++++++- 8 files changed, 243 insertions(+), 8 deletions(-) diff --git a/src/tui/app-key-bindings.test.ts b/src/tui/app-key-bindings.test.ts index 3152f3f3..d3ae7e9d 100644 --- a/src/tui/app-key-bindings.test.ts +++ b/src/tui/app-key-bindings.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Key } from "ink"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { createInitialTuiState, type TuiSessionInfo } from "./tui-state.js"; import type { ApprovalRequest } from "../approval/approval-gate.js"; @@ -546,6 +547,94 @@ describe("handleAppKey", () => { }); }); +describe("handleAppKey with the ctrl+g leader armed", () => { + function pressWhileArmed( + input: string, + key: Key, + state = createInitialTuiState(stubSession()), + ) { + const activated: MenuNode[] = []; + const dispatch = vi.fn(); + const setMenuLeaderArmed = vi.fn(); + const setCtrlCArmed = vi.fn(); + const onAbort = vi.fn(); + const onQuit = vi.fn(); + const handled = handleAppKey(input, key, { + state, + dispatch, + callbacks: { + onApprovalDecision: vi.fn(), + onAbort, + onQuit, + }, + ctrlCArmed: false, + setCtrlCArmed, + sidebarVisible: false, + menuLeaderArmed: true, + setMenuLeaderArmed, + activateMenuNode: (node) => activated.push(node), + }); + return { + handled, + activated, + dispatch, + setMenuLeaderArmed, + setCtrlCArmed, + onAbort, + onQuit, + }; + } + + it("a bare chord key activates its node", () => { + const run = pressWhileArmed("c", emptyKey()); + expect(run.activated.map((n) => n.id)).toEqual(["go.manage.mcp"]); + expect(run.handled).toBe(true); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + }); + + it("an unclaimed bare key is swallowed rather than leaked to the prompt", () => { + const run = pressWhileArmed("z", emptyKey()); + expect(run.activated).toEqual([]); + expect(run.handled).toBe(true); + }); + + it("Ctrl+C disarms and aborts the turn instead of jumping to the MCP tab", () => { + const state = createInitialTuiState(stubSession()); + state.status = "running"; + const run = pressWhileArmed("c", emptyKey({ ctrl: true }), state); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.setCtrlCArmed).toHaveBeenCalledWith(true); + expect(run.onAbort).toHaveBeenCalled(); + expect(run.dispatch).toHaveBeenCalledWith({ type: "abort_requested" }); + expect(run.handled).toBe(true); + }); + + it("Ctrl+Q disarms without quitting the app", () => { + const run = pressWhileArmed("q", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.onQuit).not.toHaveBeenCalled(); + expect(run.dispatch).not.toHaveBeenCalledWith({ type: "quit_requested" }); + // Nothing else binds ctrl+q, so the key falls through unclaimed — + // which is the point: the leader no longer stands in the way. + expect(run.handled).toBe(false); + }); + + it("Ctrl+L disarms and falls through instead of opening the LLM tab", () => { + const run = pressWhileArmed("l", emptyKey({ ctrl: true })); + expect(run.activated).toEqual([]); + expect(run.dispatch).not.toHaveBeenCalled(); + expect(run.handled).toBe(false); + }); + + it("Esc disarms and is swallowed, so it cancels the leader", () => { + const run = pressWhileArmed("", emptyKey({ escape: true })); + expect(run.activated).toEqual([]); + expect(run.setMenuLeaderArmed).toHaveBeenCalledWith(false); + expect(run.handled).toBe(true); + }); +}); + describe("handlePanelEscape", () => { it("sends an unclaimed Esc home to Run", () => { const dispatch = vi.fn(); diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 77870064..8edf6afe 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -122,10 +122,16 @@ export function handleAppKey( if (ctx.menuLeaderArmed) { ctx.setMenuLeaderArmed(false); const node = resolveLeaderChord(input, key); - if (node) ctx.activateMenuNode(node); - // An unclaimed chord is swallowed rather than passed on: a mistyped - // leader must not leak a letter into the prompt or fire a panel hotkey. - return true; + if (node) { + ctx.activateMenuNode(node); + return true; + } + // An unclaimed *bare* key is swallowed rather than passed on: a + // mistyped leader must not leak a letter into the prompt or fire a + // panel hotkey. A modified key was never a chord, though — it means + // the operator changed their mind — so it only disarms and then falls + // through to the bindings below, where `ctrl+c` still aborts the turn. + if (!key.ctrl && !key.meta) return true; } if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) { ctx.setMenuLeaderArmed(true); diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 8611ee66..0451e600 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -84,6 +84,33 @@ describe("HotkeyHint debug footer", () => { }); }); +describe("HotkeyHint pending ctrl+g leader", () => { + it("says the leader is waiting instead of showing the idle chips", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("ctrl+g"); + expect(out).toContain("waiting for a chord"); + expect(out).toContain("[esc]"); + expect(out).toContain("cancel"); + // The armed leader unfocuses the editor and eats the next key, so the + // strip must not keep advertising chips that no longer apply. + expect(out).not.toContain("send"); + }); + + it("keeps the approval footer, which outranks the leader on keys", () => { + const { lastFrame, unmount } = render( + , + ); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + expect(out).toContain("approve"); + expect(out).not.toContain("waiting for a chord"); + }); +}); + describe("HotkeyHint scroll key spelling per platform", () => { const realPlatform = process.platform; diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index dbb0f5d2..e741c9c3 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; +import { MENU_LEADER_LABEL } from "../menu/menu-keys.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; @@ -7,6 +8,8 @@ interface HotkeyHintProps { state: TuiState; /** Whether a Ctrl+C was recently pressed and is armed for exit. */ ctrlCArmed?: boolean; + /** Whether a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed?: boolean; } interface HotkeyChip { @@ -27,8 +30,12 @@ const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgd * to fit one terminal row and let slash commands take care of the long * tail. */ -export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement { - const chips = resolveChips(state, ctrlCArmed ?? false); +export function HotkeyHint({ + state, + ctrlCArmed, + menuLeaderArmed, +}: HotkeyHintProps): ReactElement { + const chips = resolveChips(state, ctrlCArmed ?? false, menuLeaderArmed ?? false); return ( {chips.map((chip, idx) => ( @@ -50,7 +57,11 @@ export function HotkeyHint({ state, ctrlCArmed }: HotkeyHintProps): ReactElement ); } -function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { +function resolveChips( + state: TuiState, + ctrlCArmed: boolean, + menuLeaderArmed: boolean, +): HotkeyChip[] { if (state.pendingApproval) { return [ { key: "y", label: "approve" }, @@ -58,6 +69,17 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { { key: "esc", label: "abort run" }, ]; } + // An armed leader owns the very next keystroke and unfocuses the editor + // while it waits, so it takes the whole strip: the row the operator is + // already looking at is where "the app is mid-gesture" belongs. Ordered + // to match key precedence — a pending approval still outranks it. + if (menuLeaderArmed) { + return [ + { key: MENU_LEADER_LABEL, label: "waiting for a chord" }, + { key: "ctrl+p", label: "full menu" }, + { key: "esc", label: "cancel" }, + ]; + } if (state.slashPaletteOpen) { return [ { key: "↑↓", label: "select" }, diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts index 9581cd16..14064a6f 100644 --- a/src/tui/menu/menu-behaviour.test.ts +++ b/src/tui/menu/menu-behaviour.test.ts @@ -147,4 +147,15 @@ describe("leader chords", () => { expect(resolveLeaderChord("z", KEY as never)).toBeNull(); expect(resolveLeaderChord("", { ...KEY, escape: true } as never)).toBeNull(); }); + + it("resolves nothing while a modifier is held, so ctrl+c stays reachable", () => { + // Ink reports Ctrl+C as input "c" with `key.ctrl` — the same letter the + // MCP tab claims as its chord. Reading it as a chord would navigate + // instead of aborting; ctrl+q would quit and ctrl+l would leave the + // conventional clear-screen unreachable. + for (const input of ["c", "q", "l", "t"]) { + expect(resolveLeaderChord(input, { ...KEY, ctrl: true } as never)).toBeNull(); + expect(resolveLeaderChord(input, { ...KEY, meta: true } as never)).toBeNull(); + } + }); }); diff --git a/src/tui/menu/menu-keys.ts b/src/tui/menu/menu-keys.ts index 22db0efe..de6bda9b 100644 --- a/src/tui/menu/menu-keys.ts +++ b/src/tui/menu/menu-keys.ts @@ -38,9 +38,17 @@ export function isMenuLeaderKey(input: string, key: Key): boolean { * or `null` when nothing claims that key — an unknown chord is swallowed * rather than falling through, so a mistyped leader can never land a stray * letter in the prompt or fire a panel hotkey. + * + * A chord is a *bare* key, same as the sibling predicates above insist on + * an unmodified `ctrl`. Ink reports `ctrl+c` as `input === "c"` with + * `key.ctrl`, so without this guard the armed leader would read the abort + * key as the MCP chord — and `ctrl+q` as quit, `ctrl+l` as the LLM tab. + * Held modifiers mean the operator changed their mind, not that they typed + * a chord. */ export function resolveLeaderChord(input: string, key: Key): MenuNode | null { if (key.escape || input.length === 0) return null; + if (key.ctrl || key.meta) return null; return menuNodeByChord(input); } diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index b5a74dae..30994713 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -33,6 +33,25 @@ function strip(value: string): string { .replace(/\u001b\]8;;[^\u0007]*\u0007/g, ""); } +/** + * Poll the rendered frame until it satisfies `match`, then return it. Ink + * renders on its own schedule and a loaded runner stretches it, so a fixed + * sleep is a coin flip for anything that also waits on a timer. + */ +async function waitForFrame( + lastFrame: () => string | undefined, + match: (text: string) => boolean, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + let text = strip(lastFrame() ?? ""); + while (!match(text) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 20)); + text = strip(lastFrame() ?? ""); + } + return text; +} + describe("TuiApp (smoke)", () => { it("renders the chat surface with the compact operator status bar", () => { const bus = makeTuiEventBus(); @@ -402,6 +421,34 @@ describe("TuiApp (smoke)", () => { unmount(); }); + it("an armed ctrl+g is visible in the hint strip and disarms itself when no chord follows", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + const armed = await waitForFrame(lastFrame, (t) => + t.includes("waiting for a chord"), + ); + expect(armed).toContain("waiting for a chord"); + + // Nothing follows the leader. It must disarm on its own — while it is + // armed the editor is unfocused and the next keystroke is swallowed. + // No key is pressed here on purpose: only the timer can end this state. + const idle = await waitForFrame( + lastFrame, + (t) => !t.includes("waiting for a chord"), + ); + expect(idle).not.toContain("waiting for a chord"); + // Idle chips are back, and no chord fired on the way out. Matched on the + // chip key, not its label: a narrow runner wraps the strip and can split + // "menu" off its own chip. + expect(idle).toContain("[ctrl+p]"); + expect(idle).not.toContain("Manage ▸"); + unmount(); + }); + it("esc closes the menu and leaves the screen it was opened over", async () => { const bus = makeTuiEventBus(); const { lastFrame, stdin, unmount } = render( diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 567162dc..10006df2 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -355,6 +355,13 @@ export interface TuiAppProps { const DEFAULT_MAX_VISIBLE_ROWS = 14; const CTRL_C_WINDOW_MS = 1500; +/** + * How long a `ctrl+g` leader waits for its chord before disarming itself. + * The same window as Ctrl+C on purpose — both are "you started a two-key + * gesture, finish it" timers, and an armed leader is not free to leave + * pending: it unfocuses the editor and eats the next keystroke. + */ +const MENU_LEADER_WINDOW_MS = CTRL_C_WINDOW_MS; /** * Rotating placeholder pool shown in the prompt's empty state. Phrasing @@ -384,6 +391,7 @@ export function TuiApp({ const [ctrlCArmed, setCtrlCArmed] = useState(false); const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); + const menuLeaderTimer = useRef(null); useEffect(() => bus.subscribe(dispatch), [bus]); @@ -464,6 +472,19 @@ export function TuiApp({ }; }, [ctrlCArmed]); + // A leader that is never followed by a chord must not stay armed: it + // holds the editor unfocused and swallows whatever is typed next. + useEffect(() => { + if (!menuLeaderArmed) return; + menuLeaderTimer.current = setTimeout( + () => setMenuLeaderArmed(false), + MENU_LEADER_WINDOW_MS, + ); + return () => { + if (menuLeaderTimer.current) clearTimeout(menuLeaderTimer.current); + }; + }, [menuLeaderArmed]); + const tasksTabActive = state.uiMode === "debug" && state.activeTab === "tasks"; const skillsTabActive = @@ -880,7 +901,11 @@ export function TuiApp({ onHistoryPrev={onHistoryPrev} onHistoryNext={onHistoryNext} /> - + {sidebarVisible ? (