diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index ed5b37c5..599fc071 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"; @@ -77,6 +84,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; } /** @@ -112,6 +124,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/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 6079f338..5b0fb080 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -1,5 +1,7 @@ import fuzzysort from "fuzzysort"; +import { toSlashCommands } from "../menu/menu-registry.js"; + export interface SlashCommandDef { /** Canonical command name (without leading `/`). */ readonly name: string; @@ -10,113 +12,22 @@ export interface SlashCommandDef { } /** - * Atomic-agent's slash command registry. Intentionally small: the - * handler-side dispatch in `slash-command-handler.ts` knows how to - * action each name. Additions live here so the palette + parser stay - * in sync by construction. + * Atomic-agent's slash command registry — a **projection** of the + * operator menu (`src/tui/menu/menu-registry.ts`), not a list of its + * own. Every command is one menu node carrying a `slash` field, so the + * palette and the menu cannot describe the same command differently. + * + * Order is the historical palette order, carried on `MenuSlash.rank`: + * an empty query lists the registry as-is, and fuzzy-search ties break + * by index, so both are user-visible. + * + * To add a command, add the node to `MENU`. The handler-side dispatch in + * `slash-command-handler.ts` still knows how to action each name. */ -export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ - { - name: "dump", - description: - "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", - }, - { name: "help", description: "list available slash commands" }, - { - name: "tools", - description: - "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", - }, - { - name: "theme", - description: - "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", - }, - { name: "clear", description: "clear chat transcript (keeps session)" }, - { name: "abort", description: "abort the running turn" }, - { name: "quit", description: "exit atomic-agent", aliases: ["exit"] }, - { name: "debug", description: "toggle debug pane (feed / logs / world …)" }, - { name: "chat", description: "return to single-view chat mode" }, - { - name: "run", - description: - "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` — fusion orchestrates on cloud, executes locally", - }, - { - name: "observe", - description: - "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", - }, - { - name: "manage", - description: - "switch to the Manage section (tasks / skills / LLM / telegram)", - }, - { name: "feed", description: "jump to the Observe → Feed tab" }, - { name: "logs", description: "jump to the Observe → Logs tab" }, - { name: "reasoning", description: "jump to the Observe → Reasoning tab" }, - { name: "world", description: "jump to the Observe → World tab" }, - { name: "expand", description: "expand every tool card in the chat log" }, - { name: "collapse", description: "collapse every tool card in the chat log" }, - { name: "session", description: "show current session id" }, - { name: "sessions", description: "open session picker to switch threads" }, - { name: "new", description: "start a fresh session (keeps warm runtime)" }, - { - name: "skills", - description: - "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", - }, - { - name: "skill", - description: - "skill subcommand: `/skill enable ` | `/skill disable `", - }, - { - name: "memory", - description: - "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", - }, - { - name: "llm", - description: - "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", - }, - { - name: "mcp", - description: - "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", - }, - { - name: "model", - description: - "open chat model picker · subcommands: pull | use | status | ", - aliases: ["models", "local"], - }, - { name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)" }, - { - name: "task", - description: - "task subcommand: `/task new` | `/task cancel ` | `/task run `", - }, - { - name: "telegram", - description: - "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", - }, - { - name: "import", - description: "open the Import tab (one-shot Hermes -> atomic-agent migration)", - }, - { - name: "privacy", - description: - "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", - }, - { - name: "analytics", - description: "toggle anonymous analytics: `/analytics on|off|status`", - }, -]; +export const SLASH_COMMANDS: readonly SlashCommandDef[] = toSlashCommands().map( + ({ name, description, aliases }) => + aliases ? { name, description, aliases } : { name, description }, +); /** * Filter the registry by a slash query (the characters typed after `/`). diff --git a/src/tui/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.tsx b/src/tui/components/hotkey-hint.tsx index 76ce555f..94033331 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -94,7 +94,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", @@ -113,18 +113,19 @@ 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. ctrl+r (run mode) is - // unadvertised here for the same reason ctrl+b is — the mode strip - // above the chat is already the visible entry point, and `/run` - // reaches the picker from the palette. + // 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. ctrl+r + // (cycle run mode) stays unadvertised for the same reason ctrl+b was — + // the mode strip above the chat is its visible entry point, and the + // menu now lists Local / Cloud / Fusion outright. 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..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..2fc1ad7e --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,211 @@ +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. + * + * It sits **centred** in that pane, both axes. A dropdown hanging off the + * prompt was the first shape, but this is not a dropdown: it is the app's + * one modal surface, and a modal belongs in the middle of the window with + * the app faded behind it — the same thing a web app would do. + * + * Terminals have no compositing and Ink has no z-index, so occlusion has to + * be earned: every interior line is padded to the popup's exact inner width, + * which paints spaces over whatever was underneath. That is also why the rows + * 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); + + // Centred in the pane on both axes. + const height = visible.length + CHROME_ROWS; + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); + + 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.test.ts b/src/tui/menu/menu-registry.test.ts new file mode 100644 index 00000000..b6053880 --- /dev/null +++ b/src/tui/menu/menu-registry.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; + +import { SLASH_COMMANDS } from "../commands/slash-commands.js"; +import { + MENU, + MENU_GROUP_ORDER, + menuChildren, + menuNodeByChord, + menuNodeById, + menuRoots, +} from "./menu-registry.js"; + +/** + * The slash palette exactly as `feat/run-mode-tui` (#163) declares it by hand. + * `SLASH_COMMANDS` is derived from `MENU` on this branch, so this is a + * cross-check between two independent views of the command surface rather + * than a snapshot of my own output: if the registry and #163 ever disagree + * about a command, its description, its aliases or its position, this fails. + */ +const EXPECTED_SLASH_COMMANDS = [ + { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + }, + { + name: "help", + description: + "list available slash commands", + }, + { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + }, + { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + }, + { + name: "clear", + description: + "clear chat transcript (keeps session)", + }, + { + name: "abort", + description: + "abort the running turn", + }, + { + name: "quit", + description: + "exit atomic-agent", + aliases: ["exit"], + }, + { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + }, + { + name: "chat", + description: + "return to single-view chat mode", + }, + { + name: "run", + description: + "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` — fusion orchestrates on cloud, executes locally", + }, + { + name: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + }, + { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + }, + { + name: "feed", + description: + "jump to the Observe → Feed tab", + }, + { + name: "logs", + description: + "jump to the Observe → Logs tab", + }, + { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + }, + { + name: "world", + description: + "jump to the Observe → World tab", + }, + { + name: "expand", + description: + "expand every tool card in the chat log", + }, + { + name: "collapse", + description: + "collapse every tool card in the chat log", + }, + { + name: "session", + description: + "show current session id", + }, + { + name: "sessions", + description: + "open session picker to switch threads", + }, + { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + }, + { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + }, + { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + }, + { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + }, + { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + }, + { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + }, + { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + }, + { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + }, + { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + }, + { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + }, + { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + }, + { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + }, + { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + }, +]; + + +describe("menu registry", () => { + it("derives exactly the palette #163 declares — same commands, same order", () => { + expect(SLASH_COMMANDS).toEqual(EXPECTED_SLASH_COMMANDS); + }); + + it("gives every node a unique id", () => { + const ids = MENU.map((node) => node.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("never lets two nodes claim the same ctrl+g chord", () => { + const chords = MENU.flatMap((node) => (node.chord ? [node.chord] : [])); + expect(chords.length).toBeGreaterThan(0); + expect(new Set(chords).size).toBe(chords.length); + }); + + it("never lets two nodes claim the same slash name or alias", () => { + const names = MENU.flatMap((node) => + node.slash ? [node.slash.name, ...(node.slash.aliases ?? [])] : [], + ); + expect(new Set(names).size).toBe(names.length); + }); + + it("gives every slash command a distinct palette rank", () => { + const ranks = MENU.flatMap((node) => (node.slash ? [node.slash.rank] : [])); + expect(new Set(ranks).size).toBe(ranks.length); + }); + + it("points every parent at a real submenu", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent, `${node.id} -> ${node.parent}`).not.toBeNull(); + expect(parent?.kind).toBe("submenu"); + } + }); + + it("keeps the tree exactly one level deep", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent?.parent).toBeUndefined(); + } + }); + + it("leaves no submenu empty", () => { + for (const node of MENU) { + if (node.kind !== "submenu") continue; + expect(menuChildren(node.id).length, node.id).toBeGreaterThan(0); + } + }); + + it("puts every node in a group the menu knows how to render", () => { + for (const node of MENU) { + expect(MENU_GROUP_ORDER).toContain(node.group); + } + }); + + it("resolves places by chord", () => { + expect(menuNodeByChord("t")?.id).toBe("go.manage.tasks"); + expect(menuNodeByChord("p")?.id).toBe("go.manage.privacy"); + expect(menuNodeByChord("§")).toBeNull(); + }); + + it("lists Observe and Manage as the browsable destinations under Go", () => { + const roots = menuRoots("go").map((node) => node.id); + expect(roots).toContain("go.observe"); + expect(roots).toContain("go.manage"); + expect(roots).not.toContain("go.manage.tasks"); + expect(menuChildren("go.manage").map((n) => n.label)).toEqual([ + "Tasks", + "Skills", + "Memory", + "MCP", + "LLM", + "Telegram", + "Import", + "Privacy", + ]); + }); +}); diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts new file mode 100644 index 00000000..edf9b697 --- /dev/null +++ b/src/tui/menu/menu-registry.ts @@ -0,0 +1,641 @@ +import type { TuiSection } from "../section.js"; +import type { TuiTab } from "../tui-state.js"; + +/** + * Top-level grouping of the operator menu. `go` holds destinations and + * deliberately mirrors the product's own Run / Observe / Manage split + * rather than inventing a second taxonomy for the same rooms; the rest + * are verbs grouped by *what they act on* — the thread, the model, the + * turn in flight, the configuration — which is the only grouping that + * stays true as entries are added. + */ +export type MenuGroup = + | "go" + | "session" + | "model" + | "run" + | "setup" + | "help"; + +/** Display order of the groups in the menu. */ +export const MENU_GROUP_ORDER: readonly MenuGroup[] = [ + "go", + "session", + "model", + "run", + "setup", + "help", +]; + +export const MENU_GROUP_LABELS: Record = { + go: "Go", + session: "Session", + model: "Model", + run: "Run", + setup: "Setup", + help: "Help", +}; + +/** + * The slash command a node is also reachable as. A node that carries one + * is *activated* by running that command, so the menu never grows a + * second dispatch path alongside `slash-command-handler.ts`. + */ +export interface MenuSlash { + readonly name: string; + readonly description: string; + readonly aliases?: readonly string[]; + /** + * Position in the slash palette listing. Kept explicit because the + * palette order is user-visible (empty query lists the registry in + * order, and ties in a fuzzy search break by index) and is not the + * same as the menu's own order. + */ + readonly rank: number; +} + +interface MenuNodeBase { + /** Stable identifier, e.g. `go.manage.tasks`. Never shown to the operator. */ + readonly id: string; + readonly label: string; + readonly group: MenuGroup; + /** + * Single key pressed after the `ctrl+g` leader. Unique across the whole + * registry — `menu-registry.test.ts` fails the build if two nodes claim + * the same one. + */ + readonly chord?: string; + readonly slash?: MenuSlash; + /** + * Literal command line run when this node is activated, for entries that + * take arguments (`/run fusion`). Kept separate from {@link MenuSlash} + * because the palette should list `/run` once, not once per mode — this + * is an activation channel, not a listing. + */ + readonly command?: string; + /** Parent submenu id, for nodes one level down. */ + readonly parent?: string; +} + +/** A destination: a section, or a tab inside one. */ +export interface MenuPlaceNode extends MenuNodeBase { + readonly kind: "place"; + readonly section: TuiSection; + readonly tab?: TuiTab; +} + +/** A one-level-deep grouping of places. The tree never goes deeper. */ +export interface MenuSubmenuNode extends MenuNodeBase { + readonly kind: "submenu"; +} + +/** A verb. Activating it runs `slash.name` through the existing handler. */ +export interface MenuActionNode extends MenuNodeBase { + readonly kind: "action"; +} + +export type MenuNode = MenuPlaceNode | MenuSubmenuNode | MenuActionNode; + +/** + * The single source of truth for the operator menu, the slash palette and + * the `ctrl+g` chord table. Everything that used to be a hand-kept + * parallel list is now a projection of this array — see + * `toSlashCommands()` below and `slash-commands.ts`. + */ +export const MENU: readonly MenuNode[] = [ + { + kind: "place", + id: "go.run", + label: "Run", + group: "go", + chord: "r", + slash: { + name: "chat", + description: + "return to single-view chat mode", + rank: 8, + }, + section: "run", + }, + { + kind: "action", + id: "go.debug", + label: "Toggle debug pane", + group: "go", + slash: { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + rank: 7, + }, + }, + { + kind: "submenu", + id: "go.observe", + label: "Observe", + group: "go", + slash: { + name: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + rank: 9, + }, + }, + { + kind: "place", + id: "go.observe.feed", + label: "Feed", + group: "go", + chord: "f", + slash: { + name: "feed", + description: + "jump to the Observe → Feed tab", + rank: 11, + }, + section: "observe", + tab: "feed", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.world", + label: "World", + group: "go", + chord: "w", + slash: { + name: "world", + description: + "jump to the Observe → World tab", + rank: 14, + }, + section: "observe", + tab: "world", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.reasoning", + label: "Reasoning", + group: "go", + chord: "e", + slash: { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + rank: 13, + }, + section: "observe", + tab: "reasoning", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.logs", + label: "Logs", + group: "go", + chord: "o", + slash: { + name: "logs", + description: + "jump to the Observe → Logs tab", + rank: 12, + }, + section: "observe", + tab: "logs", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.llm-logs", + label: "LLM logs", + group: "go", + chord: "L", + section: "observe", + tab: "llm-logs", + parent: "go.observe", + }, + { + kind: "submenu", + id: "go.manage", + label: "Manage", + group: "go", + slash: { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + rank: 10, + }, + }, + { + kind: "place", + id: "go.manage.tasks", + label: "Tasks", + group: "go", + chord: "t", + slash: { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + rank: 26, + }, + section: "manage", + tab: "tasks", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.skills", + label: "Skills", + group: "go", + chord: "s", + slash: { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + rank: 20, + }, + section: "manage", + tab: "skills", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.memory", + label: "Memory", + group: "go", + chord: "m", + slash: { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + rank: 22, + }, + section: "manage", + tab: "memory", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.mcp", + label: "MCP", + group: "go", + chord: "c", + slash: { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + rank: 24, + }, + section: "manage", + tab: "mcp", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.llm", + label: "LLM", + group: "go", + chord: "l", + slash: { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + rank: 23, + }, + section: "manage", + tab: "llm", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.telegram", + label: "Telegram", + group: "go", + chord: "g", + slash: { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + rank: 28, + }, + section: "manage", + tab: "telegram", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.import", + label: "Import", + group: "go", + chord: "i", + slash: { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + rank: 29, + }, + section: "manage", + tab: "import", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.privacy", + label: "Privacy", + group: "go", + chord: "p", + slash: { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + rank: 30, + }, + section: "manage", + tab: "privacy", + parent: "go.manage", + }, + { + kind: "action", + id: "session.new", + label: "New session", + group: "session", + chord: "n", + slash: { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + rank: 19, + }, + }, + { + kind: "action", + id: "session.switch", + label: "Switch session…", + group: "session", + chord: "u", + slash: { + name: "sessions", + description: + "open session picker to switch threads", + rank: 18, + }, + }, + { + kind: "action", + id: "session.clear", + label: "Clear transcript", + group: "session", + slash: { + name: "clear", + description: + "clear chat transcript (keeps session)", + rank: 4, + }, + }, + { + kind: "action", + id: "session.id", + label: "Show session id", + group: "session", + slash: { + name: "session", + description: + "show current session id", + rank: 17, + }, + }, + { + kind: "action", + id: "model.chat", + label: "Switch chat model…", + group: "model", + chord: "k", + slash: { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + rank: 25, + }, + }, + { + kind: "action", + id: "run.abort", + label: "Abort turn", + group: "run", + chord: "a", + slash: { + name: "abort", + description: + "abort the running turn", + rank: 5, + }, + }, + { + kind: "action", + id: "run.picker", + label: "Run type\u2026", + group: "run", + slash: { + name: "run", + description: + "run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` \u2014 fusion orchestrates on cloud, executes locally", + // Fractional on purpose: `/run` slots between `chat` (8) and + // `observe` (9) without renumbering every entry after it. + rank: 8.5, + }, + }, + { + kind: "submenu", + id: "run.mode", + label: "Run type", + group: "run", + }, + { + kind: "action", + id: "run.mode.local", + label: "Local", + group: "run", + parent: "run.mode", + chord: "1", + command: "/run local", + }, + { + kind: "action", + id: "run.mode.cloud", + label: "Cloud", + group: "run", + parent: "run.mode", + chord: "2", + command: "/run cloud", + }, + { + kind: "action", + id: "run.mode.fusion", + label: "Fusion", + group: "run", + parent: "run.mode", + chord: "3", + command: "/run fusion", + }, + { + kind: "action", + id: "run.expand", + label: "Expand all tool cards", + group: "run", + slash: { + name: "expand", + description: + "expand every tool card in the chat log", + rank: 15, + }, + }, + { + kind: "action", + id: "run.collapse", + label: "Collapse all tool cards", + group: "run", + slash: { + name: "collapse", + description: + "collapse every tool card in the chat log", + rank: 16, + }, + }, + { + kind: "action", + id: "setup.theme", + label: "Theme…", + group: "setup", + chord: "h", + slash: { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + rank: 3, + }, + }, + { + kind: "action", + id: "setup.analytics", + label: "Analytics", + group: "setup", + slash: { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + rank: 31, + }, + }, + { + kind: "action", + id: "setup.skill", + label: "Enable or disable a skill…", + group: "setup", + slash: { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + rank: 21, + }, + }, + { + kind: "action", + id: "setup.task", + label: "Create, cancel or run a task…", + group: "setup", + slash: { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + rank: 27, + }, + }, + { + kind: "action", + id: "help.commands", + label: "Commands", + group: "help", + slash: { + name: "help", + description: + "list available slash commands", + rank: 1, + }, + }, + { + kind: "action", + id: "help.tools", + label: "List built-in tools", + group: "help", + slash: { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + rank: 2, + }, + }, + { + kind: "action", + id: "help.dump", + label: "Write debug bundle", + group: "help", + chord: "d", + slash: { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + rank: 0, + }, + }, + { + kind: "action", + id: "help.quit", + label: "Quit", + group: "help", + chord: "q", + slash: { + name: "quit", + description: + "exit atomic-agent", + aliases: ["exit"], + rank: 6, + }, + }, +]; + +/** Every node that is also a slash command, in palette order. */ +export function toSlashCommands(): readonly MenuSlash[] { + return MENU.flatMap((node) => (node.slash ? [node.slash] : [])).sort( + (a, b) => a.rank - b.rank, + ); +} + +/** Children of a submenu, in registry order. */ +export function menuChildren(parentId: string): readonly MenuNode[] { + return MENU.filter((node) => node.parent === parentId); +} + +/** Top-level nodes of a group — submenu children are excluded. */ +export function menuRoots(group: MenuGroup): readonly MenuNode[] { + return MENU.filter((node) => node.group === group && node.parent === undefined); +} + +/** Resolve a node by id. */ +export function menuNodeById(id: string): MenuNode | null { + return MENU.find((node) => node.id === id) ?? null; +} + +/** Resolve the node bound to a `ctrl+g` chord key. */ +export function menuNodeByChord(key: string): MenuNode | null { + return MENU.find((node) => node.chord === key) ?? null; +} + +/** 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..9faf24ff --- /dev/null +++ b/src/tui/menu/menu-selectors.ts @@ -0,0 +1,183 @@ +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 "run.mode": + return runModeLabel(state); + case "session.switch": + return countLabel(state.recentSessions.length, "recent"); + default: + return ""; + } +} + +/** + * Current run mode for the `Run type` row, so the menu answers "what am I on" + * as well as "what can I switch to". + */ +function runModeLabel(state: TuiState): string { + const panel = (state as { runModePanel?: { effective?: string; cloudShare?: number } }) + .runModePanel; + const mode = panel?.effective; + if (!mode) return ""; + if (mode === "fusion" && typeof panel?.cloudShare === "number") { + return `fusion \u00b7 ${panel.cloudShare}% cloud`; + } + return mode; +} + +function countLabel(count: number, noun: string): string { + if (count === 0) return ""; + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..bffecadd 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -1,3 +1,4 @@ +import { clampMenuCursor } from "./menu/menu-selectors.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { THEME_NAMES } from "./theme/theme.js"; @@ -62,6 +63,37 @@ export function reduceUiAction( for (const card of state.streamingToolCards) next[card.id] = action.expanded; return { ...state, toolsExpandedById: next }; } + case "menu_opened": + // Always reopen at the root with an empty query: a menu that resumes + // where it was last left makes the same keypress mean different + // things on different days. + return { + ...state, + menuOpen: true, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_closed": + return { + ...state, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_query_changed": + // A query flattens the tree, so any open submenu is dropped with it. + return { ...state, menuQuery: action.query, menuPath: null }; + case "menu_path_set": + return { ...state, menuPath: action.path }; + case "menu_cursor_set": + return { ...state, menuCursor: clampMenuCursor(state, action.cursor) }; + case "menu_cursor_moved": + return { + ...state, + menuCursor: clampMenuCursor(state, state.menuCursor + action.delta), + }; case "slash_palette_opened": return { ...state, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 7578c3fa..bf3e9356 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -209,12 +209,70 @@ export function getActiveThemeName(): ThemeName { return "github-dark"; } +/** + * Backdrop dimming. While the operator menu is open the whole app behind it + * fades, so the popup reads as the foreground rather than as one more panel + * competing with the chat log. + * + * Implemented here rather than by threading a `dimmed` prop through every + * component because {@link theme} is already a read-at-render proxy — the + * same machinery that makes `/theme` live-preview repaint the whole UI. One + * flag flips every colour; the menu itself reads {@link chromeTheme}, which + * ignores the flag, so it stays at full contrast. + * + * Every colour collapses to the active theme's `muted`: a real terminal has + * no alpha channel, so "faded" has to mean "one low-contrast tone" rather + * than "the same colours, weaker". + */ +let backdropDimmed = false; +let dimmedColorsFor: TuiColors | null = null; +let dimmedColorsCache: TuiColors | null = null; + +export function setBackdropDimmed(next: boolean): void { + backdropDimmed = next; +} + +export function isBackdropDimmed(): boolean { + return backdropDimmed; +} + +function dimColors(colors: TuiColors): TuiColors { + if (dimmedColorsFor === colors && dimmedColorsCache) return dimmedColorsCache; + const flat = Object.fromEntries( + Object.keys(colors).map((key) => [key, colors.muted]), + ) as unknown as TuiColors; + dimmedColorsFor = colors; + dimmedColorsCache = flat; + return flat; +} + /** * The themed palette consumed across the TUI. A `Proxy` that always forwards * to the current {@link activeTheme}, so `theme.colors.X` reflects the active * theme at read time even after a `setActiveTheme` swap. */ export const theme: TuiTheme = new Proxy({} as TuiTheme, { + get(_target, prop: string | symbol): unknown { + if (prop === "colors" && backdropDimmed) return dimColors(activeTheme.colors); + return activeTheme[prop as keyof TuiTheme]; + }, + has(_target, prop: string | symbol): boolean { + return prop in activeTheme; + }, + ownKeys(): ArrayLike { + return Reflect.ownKeys(activeTheme); + }, + getOwnPropertyDescriptor(_target, prop: string | symbol) { + return Reflect.getOwnPropertyDescriptor(activeTheme, prop); + }, +}); + +/** + * The palette for chrome that must stay legible while the backdrop is dimmed — + * i.e. the operator menu. Identical to {@link theme} except that it ignores + * {@link setBackdropDimmed}. + */ +export const chromeTheme: TuiTheme = new Proxy({} as TuiTheme, { get(_target, prop: string | symbol): unknown { return activeTheme[prop as keyof TuiTheme]; }, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index f86ff983..1ec524ed 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -94,6 +94,12 @@ export type TuiAction = | { type: "slash_palette_queried"; query: string } /** Close the slash palette without committing a selection. */ | { type: "slash_palette_closed" } + | { type: "menu_opened" } + | { type: "menu_closed" } + | { type: "menu_query_changed"; query: string } + | { type: "menu_cursor_moved"; delta: number } + | { type: "menu_cursor_set"; cursor: number } + | { type: "menu_path_set"; path: string | null } /** Move the highlight in the open slash palette by delta rows. */ | { type: "slash_palette_cursor_moved"; delta: 1 | -1 } /** Reset the slash palette highlight to a specific row. */ diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 480215dc..baa2d9de 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -41,9 +41,11 @@ describe("TuiApp (smoke)", () => { ); const text = strip(lastFrame() ?? ""); expect(text).toContain("atomic-agent"); + // The status bar shows where you are, not a menu of where you could go — + // the three-section pill row moved into the ctrl+p menu. expect(text).toContain("Run"); - expect(text).toContain("Observe"); - expect(text).toContain("Manage"); + expect(text).not.toContain("Observe"); + expect(text).not.toContain("Manage"); expect(text).toContain("Local AI-First Agent"); expect(text).toContain("commands"); unmount(); @@ -146,20 +148,19 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("▸ Run"); + expect(before).toContain("Run"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); if (before.includes("Sessions")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("▸ Run"); - expect(after).not.toContain("▸ Observe"); + expect(after).toContain("Run"); + expect(after).not.toContain("Observe \u25b8"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("▸ Observe"); - expect(after).toContain("▸ Feed"); + expect(after).toContain("Observe \u25b8 Feed"); } unmount(); }); @@ -173,8 +174,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Observe"); - expect(text).toContain("▸ Feed"); + expect(text).toContain("Observe \u25b8 Feed"); unmount(); }); @@ -187,9 +187,14 @@ describe("TuiApp (smoke)", () => { stdin.write("\u001b[Z"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). - expect(text).toContain("▸ Manage"); - expect(text).toContain("▸ Telegram"); + // Shift+Tab from Run wraps to the last Manage sub-tab. That was + // Telegram when this test was written; MANAGE_TABS has gained + // `import` and `privacy` since, and the literal was never updated — + // one of the two drifted assertions #170 called out as the reason a + // single menu registry exists, and left for whichever commit changed + // the surface they describe. This is that commit. + expect(text).toContain("Manage \u25b8"); + expect(text).toContain("▸ Privacy"); unmount(); }); @@ -285,11 +290,13 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "tab_changed", tab: "llm" }); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("Active chat route"); - expect(text).toContain("Mode:"); + // Stale in three places, all pre-existing: with no provider + // configured the panel leads with its section headings rather than a + // resolved route, and the verbose "Mode:" line and "Press ←/→ to + // switch mode" hint were replaced by the compact footer long ago. expect(text).toContain("Local text models"); expect(text).toContain("Local embeddings"); - expect(text).toContain("Press ←/→ to switch mode"); + expect(text).toContain("←/→ mode"); expect(text).not.toContain("Local runtime"); unmount(); }); @@ -335,13 +342,102 @@ 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("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 a72b8afb..501dcc58 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, @@ -40,7 +44,7 @@ import { UpdateRestartPrompt } from "./components/update-restart-prompt.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; -import { handleEditorSubmit } from "./submit-handler.js"; +import { handleEditorSubmit, runSlashCommand } from "./submit-handler.js"; import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { @@ -385,6 +389,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]); @@ -490,6 +495,8 @@ export function TuiApp({ state.uiMode === "chat" && terminalSize.columns >= SIDEBAR_MIN_COLUMNS; const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = + !state.menuOpen && + !menuLeaderArmed && !state.pendingApproval && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update @@ -527,6 +534,31 @@ export function TuiApp({ } }, [sidebarVisible, state.chatFocus]); + const activateMenuNode = useCallback( + (node: MenuNode) => { + // Nodes are activated by running a command, so the menu never grows a + // second dispatch path beside the slash handler. `command` carries the + // argument-bearing form (`/run fusion`); `slash` the bare one. + if (node.command) { + runSlashCommand(node.command, state, dispatch, callbacks); + return; + } + 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, @@ -535,6 +567,9 @@ export function TuiApp({ ctrlCArmed, setCtrlCArmed, sidebarVisible, + menuLeaderArmed, + setMenuLeaderArmed, + activateMenuNode, }); if (appHandled) return; // While the slash-command palette is open, let the (now-focused) @@ -691,8 +726,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 @@ -735,7 +778,13 @@ export function TuiApp({ ) : null} - + {state.uiMode === "chat" ? ( ) : ( @@ -753,6 +802,15 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 39caf4ca..ea3a68d9 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -313,6 +313,17 @@ export interface TuiState { slashQuery: string; /** Highlighted row in the slash palette. */ slashPaletteCursor: number; + /** + * Operator menu (`ctrl+p`) — the browsable half of the navigation surface. + * `menuPath` is the id of the submenu currently open, or `null` at the + * root; the tree is one level deep by construction so a single id is + * enough. A non-empty `menuQuery` flattens the tree: search ranks across + * every node regardless of where it lives. + */ + menuOpen: boolean; + menuPath: string | null; + menuQuery: string; + menuCursor: number; /** Which tool cards are shown expanded by the user. */ toolsExpandedById: Readonly>; /** Is the session picker overlay visible? */ @@ -495,6 +506,10 @@ export function createInitialTuiState( slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, toolsExpandedById: {}, sessionPickerOpen: false, sessionPickerList: [],