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 61d88729..8edf6afe 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,33 @@ 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); + 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); + 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/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/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 03f093ea..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" }, @@ -85,7 +107,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 +126,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/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-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts new file mode 100644 index 00000000..14064a6f --- /dev/null +++ b/src/tui/menu/menu-behaviour.test.ts @@ -0,0 +1,161 @@ +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(); + }); + + 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 new file mode 100644 index 00000000..de6bda9b --- /dev/null +++ b/src/tui/menu/menu-keys.ts @@ -0,0 +1,165 @@ +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. + * + * 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); +} + +/** + * 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..5deff17a --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,205 @@ +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 } from "./menu-selectors.js"; +import { + clampMenuCursor, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import { MENU_LEADER_LABEL } from "./menu-keys.js"; + +/** 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 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, + 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 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 ( + + + {visible.map((row, idx) => + row.kind === "header" ? ( + + {fit(` ${row.label.toUpperCase()}`, inner)} + + ) : ( + + ), + )} + {rows.length === 0 ? ( + {fit(" nothing matches", inner)} + ) : null} + + {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 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 ( + + + {label} + + {detail} + {chord} + + ); +} + +/** + * Footer names exactly the moves that are legal right now — `←` only appears + * once there is a level to go back to, `→` only while one is reachable. + */ +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(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/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/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..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(); @@ -41,9 +60,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 +171,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 +197,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 +211,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 +358,130 @@ 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(); + }); + + 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("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( + , + ); + 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(); + }); + + 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 d61e520e..10006df2 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,9 @@ 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"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; @@ -22,6 +25,7 @@ import { ThemePicker } from "./components/theme-picker.js"; import { isThemeName, setActiveTheme, + setBackdropDimmed, theme, THEME_NAMES, THEMES, @@ -42,7 +46,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 { @@ -351,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 @@ -378,7 +389,9 @@ export function TuiApp({ ); const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); + const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); + const menuLeaderTimer = useRef(null); useEffect(() => bus.subscribe(dispatch), [bus]); @@ -459,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 = @@ -491,6 +517,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 +556,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 +584,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,8 +753,16 @@ 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; + // 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 @@ -741,7 +800,13 @@ export function TuiApp({ - + {state.uiMode === "chat" ? ( ) : ( @@ -759,6 +824,13 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( @@ -829,7 +901,11 @@ export function TuiApp({ onHistoryPrev={onHistoryPrev} onHistoryNext={onHistoryNext} /> - + {sidebarVisible ? ( >; /** 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: [],