From 46b49cc661c52b230933f454098fce610db16980 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 07:13:49 +0300 Subject: [PATCH 1/5] =?UTF-8?q?refactor(tui):=20one=20menu=20registry=20?= =?UTF-8?q?=E2=80=94=20the=20slash=20palette=20becomes=20a=20projection=20?= =?UTF-8?q?of=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke). --- src/tui/commands/slash-commands.ts | 118 +----- src/tui/menu/menu-registry.test.ts | 265 +++++++++++++ src/tui/menu/menu-registry.ts | 580 +++++++++++++++++++++++++++++ 3 files changed, 862 insertions(+), 101 deletions(-) create mode 100644 src/tui/menu/menu-registry.test.ts create mode 100644 src/tui/menu/menu-registry.ts diff --git a/src/tui/commands/slash-commands.ts b/src/tui/commands/slash-commands.ts index 779235c2..5b0fb080 100644 --- a/src/tui/commands/slash-commands.ts +++ b/src/tui/commands/slash-commands.ts @@ -1,5 +1,7 @@ import fuzzysort from "fuzzysort"; +import { toSlashCommands } from "../menu/menu-registry.js"; + export interface SlashCommandDef { /** Canonical command name (without leading `/`). */ readonly name: string; @@ -10,108 +12,22 @@ export interface SlashCommandDef { } /** - * Atomic-agent's slash command registry. Intentionally small: the - * handler-side dispatch in `slash-command-handler.ts` knows how to - * action each name. Additions live here so the palette + parser stay - * in sync by construction. + * Atomic-agent's slash command registry — a **projection** of the + * operator menu (`src/tui/menu/menu-registry.ts`), not a list of its + * own. Every command is one menu node carrying a `slash` field, so the + * palette and the menu cannot describe the same command differently. + * + * Order is the historical palette order, carried on `MenuSlash.rank`: + * an empty query lists the registry as-is, and fuzzy-search ties break + * by index, so both are user-visible. + * + * To add a command, add the node to `MENU`. The handler-side dispatch in + * `slash-command-handler.ts` still knows how to action each name. */ -export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ - { - name: "dump", - description: - "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", - }, - { name: "help", description: "list available slash commands" }, - { - name: "tools", - description: - "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", - }, - { - name: "theme", - description: - "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", - }, - { name: "clear", description: "clear chat transcript (keeps session)" }, - { name: "abort", description: "abort the running turn" }, - { name: "quit", description: "exit atomic-agent", aliases: ["exit"] }, - { name: "debug", description: "toggle debug pane (feed / logs / world …)" }, - { name: "chat", description: "return to single-view chat mode", aliases: ["run"] }, - { - name: "observe", - description: - "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", - }, - { - name: "manage", - description: - "switch to the Manage section (tasks / skills / LLM / telegram)", - }, - { name: "feed", description: "jump to the Observe → Feed tab" }, - { name: "logs", description: "jump to the Observe → Logs tab" }, - { name: "reasoning", description: "jump to the Observe → Reasoning tab" }, - { name: "world", description: "jump to the Observe → World tab" }, - { name: "expand", description: "expand every tool card in the chat log" }, - { name: "collapse", description: "collapse every tool card in the chat log" }, - { name: "session", description: "show current session id" }, - { name: "sessions", description: "open session picker to switch threads" }, - { name: "new", description: "start a fresh session (keeps warm runtime)" }, - { - name: "skills", - description: - "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", - }, - { - name: "skill", - description: - "skill subcommand: `/skill enable ` | `/skill disable `", - }, - { - name: "memory", - description: - "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", - }, - { - name: "llm", - description: - "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", - }, - { - name: "mcp", - description: - "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", - }, - { - name: "model", - description: - "open chat model picker · subcommands: pull | use | status | ", - aliases: ["models", "local"], - }, - { name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)" }, - { - name: "task", - description: - "task subcommand: `/task new` | `/task cancel ` | `/task run `", - }, - { - name: "telegram", - description: - "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", - }, - { - name: "import", - description: "open the Import tab (one-shot Hermes -> atomic-agent migration)", - }, - { - name: "privacy", - description: - "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", - }, - { - name: "analytics", - description: "toggle anonymous analytics: `/analytics on|off|status`", - }, -]; +export const SLASH_COMMANDS: readonly SlashCommandDef[] = toSlashCommands().map( + ({ name, description, aliases }) => + aliases ? { name, description, aliases } : { name, description }, +); /** * Filter the registry by a slash query (the characters typed after `/`). diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts new file mode 100644 index 00000000..7d523d05 --- /dev/null +++ b/src/tui/menu/menu-registry.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; + +import { SLASH_COMMANDS } from "../commands/slash-commands.js"; +import { + MENU, + MENU_GROUP_ORDER, + menuChildren, + menuNodeByChord, + menuNodeById, + menuRoots, +} from "./menu-registry.js"; + +/** + * The slash palette exactly as it shipped in v0.2.2, before the registry + * refactor. `SLASH_COMMANDS` is now derived from `MENU`; this snapshot is + * what makes "no visible change" a claim the suite can check rather than + * a promise in a PR description. + */ +const V0_2_2_SLASH_COMMANDS = [ + { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + }, + { + name: "help", + description: + "list available slash commands", + }, + { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + }, + { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + }, + { + name: "clear", + description: + "clear chat transcript (keeps session)", + }, + { + name: "abort", + description: + "abort the running turn", + }, + { + name: "quit", + description: + "exit atomic-agent", + aliases: ["exit"], + }, + { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + }, + { + name: "chat", + description: + "return to single-view chat mode", + aliases: ["run"], + }, + { + name: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + }, + { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + }, + { + name: "feed", + description: + "jump to the Observe → Feed tab", + }, + { + name: "logs", + description: + "jump to the Observe → Logs tab", + }, + { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + }, + { + name: "world", + description: + "jump to the Observe → World tab", + }, + { + name: "expand", + description: + "expand every tool card in the chat log", + }, + { + name: "collapse", + description: + "collapse every tool card in the chat log", + }, + { + name: "session", + description: + "show current session id", + }, + { + name: "sessions", + description: + "open session picker to switch threads", + }, + { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + }, + { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + }, + { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + }, + { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + }, + { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + }, + { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + }, + { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + }, + { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + }, + { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + }, + { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + }, + { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + }, + { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + }, + { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + }, +]; + +describe("menu registry", () => { + it("derives the v0.2.2 slash palette unchanged — same commands, same order", () => { + expect(SLASH_COMMANDS).toEqual(V0_2_2_SLASH_COMMANDS); + }); + + it("gives every node a unique id", () => { + const ids = MENU.map((node) => node.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("never lets two nodes claim the same ctrl+g chord", () => { + const chords = MENU.flatMap((node) => (node.chord ? [node.chord] : [])); + expect(chords.length).toBeGreaterThan(0); + expect(new Set(chords).size).toBe(chords.length); + }); + + it("never lets two nodes claim the same slash name or alias", () => { + const names = MENU.flatMap((node) => + node.slash ? [node.slash.name, ...(node.slash.aliases ?? [])] : [], + ); + expect(new Set(names).size).toBe(names.length); + }); + + it("gives every slash command a distinct palette rank", () => { + const ranks = MENU.flatMap((node) => (node.slash ? [node.slash.rank] : [])); + expect(new Set(ranks).size).toBe(ranks.length); + }); + + it("points every parent at a real submenu", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent, `${node.id} -> ${node.parent}`).not.toBeNull(); + expect(parent?.kind).toBe("submenu"); + } + }); + + it("keeps the tree exactly one level deep", () => { + for (const node of MENU) { + if (node.parent === undefined) continue; + const parent = menuNodeById(node.parent); + expect(parent?.parent).toBeUndefined(); + } + }); + + it("leaves no submenu empty", () => { + for (const node of MENU) { + if (node.kind !== "submenu") continue; + expect(menuChildren(node.id).length, node.id).toBeGreaterThan(0); + } + }); + + it("puts every node in a group the menu knows how to render", () => { + for (const node of MENU) { + expect(MENU_GROUP_ORDER).toContain(node.group); + } + }); + + it("resolves places by chord", () => { + expect(menuNodeByChord("t")?.id).toBe("go.manage.tasks"); + expect(menuNodeByChord("p")?.id).toBe("go.manage.privacy"); + expect(menuNodeByChord("§")).toBeNull(); + }); + + it("lists Observe and Manage as the browsable destinations under Go", () => { + const roots = menuRoots("go").map((node) => node.id); + expect(roots).toContain("go.observe"); + expect(roots).toContain("go.manage"); + expect(roots).not.toContain("go.manage.tasks"); + expect(menuChildren("go.manage").map((n) => n.label)).toEqual([ + "Tasks", + "Skills", + "Memory", + "MCP", + "LLM", + "Telegram", + "Import", + "Privacy", + ]); + }); +}); diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts new file mode 100644 index 00000000..0e390280 --- /dev/null +++ b/src/tui/menu/menu-registry.ts @@ -0,0 +1,580 @@ +import type { TuiSection } from "../section.js"; +import type { TuiTab } from "../tui-state.js"; + +/** + * Top-level grouping of the operator menu. `go` holds destinations and + * deliberately mirrors the product's own Run / Observe / Manage split + * rather than inventing a second taxonomy for the same rooms; the rest + * are verbs grouped by *what they act on* — the thread, the model, the + * turn in flight, the configuration — which is the only grouping that + * stays true as entries are added. + */ +export type MenuGroup = + | "go" + | "session" + | "model" + | "run" + | "setup" + | "help"; + +/** Display order of the groups in the menu. */ +export const MENU_GROUP_ORDER: readonly MenuGroup[] = [ + "go", + "session", + "model", + "run", + "setup", + "help", +]; + +export const MENU_GROUP_LABELS: Record = { + go: "Go", + session: "Session", + model: "Model", + run: "Run", + setup: "Setup", + help: "Help", +}; + +/** + * The slash command a node is also reachable as. A node that carries one + * is *activated* by running that command, so the menu never grows a + * second dispatch path alongside `slash-command-handler.ts`. + */ +export interface MenuSlash { + readonly name: string; + readonly description: string; + readonly aliases?: readonly string[]; + /** + * Position in the slash palette listing. Kept explicit because the + * palette order is user-visible (empty query lists the registry in + * order, and ties in a fuzzy search break by index) and is not the + * same as the menu's own order. + */ + readonly rank: number; +} + +interface MenuNodeBase { + /** Stable identifier, e.g. `go.manage.tasks`. Never shown to the operator. */ + readonly id: string; + readonly label: string; + readonly group: MenuGroup; + /** + * Single key pressed after the `ctrl+g` leader. Unique across the whole + * registry — `menu-registry.test.ts` fails the build if two nodes claim + * the same one. + */ + readonly chord?: string; + readonly slash?: MenuSlash; + /** Parent submenu id, for nodes one level down. */ + readonly parent?: string; +} + +/** A destination: a section, or a tab inside one. */ +export interface MenuPlaceNode extends MenuNodeBase { + readonly kind: "place"; + readonly section: TuiSection; + readonly tab?: TuiTab; +} + +/** A one-level-deep grouping of places. The tree never goes deeper. */ +export interface MenuSubmenuNode extends MenuNodeBase { + readonly kind: "submenu"; +} + +/** A verb. Activating it runs `slash.name` through the existing handler. */ +export interface MenuActionNode extends MenuNodeBase { + readonly kind: "action"; +} + +export type MenuNode = MenuPlaceNode | MenuSubmenuNode | MenuActionNode; + +/** + * The single source of truth for the operator menu, the slash palette and + * the `ctrl+g` chord table. Everything that used to be a hand-kept + * parallel list is now a projection of this array — see + * `toSlashCommands()` below and `slash-commands.ts`. + */ +export const MENU: readonly MenuNode[] = [ + { + kind: "place", + id: "go.run", + label: "Run", + group: "go", + chord: "r", + slash: { + name: "chat", + description: + "return to single-view chat mode", + aliases: ["run"], + rank: 8, + }, + section: "run", + }, + { + kind: "action", + id: "go.debug", + label: "Toggle debug pane", + group: "go", + slash: { + name: "debug", + description: + "toggle debug pane (feed / logs / world …)", + rank: 7, + }, + }, + { + kind: "submenu", + id: "go.observe", + label: "Observe", + group: "go", + slash: { + name: "observe", + description: + "switch to the Observe section (feed / world / reasoning / logs / llm-logs)", + rank: 9, + }, + }, + { + kind: "place", + id: "go.observe.feed", + label: "Feed", + group: "go", + chord: "f", + slash: { + name: "feed", + description: + "jump to the Observe → Feed tab", + rank: 11, + }, + section: "observe", + tab: "feed", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.world", + label: "World", + group: "go", + chord: "w", + slash: { + name: "world", + description: + "jump to the Observe → World tab", + rank: 14, + }, + section: "observe", + tab: "world", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.reasoning", + label: "Reasoning", + group: "go", + chord: "e", + slash: { + name: "reasoning", + description: + "jump to the Observe → Reasoning tab", + rank: 13, + }, + section: "observe", + tab: "reasoning", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.logs", + label: "Logs", + group: "go", + chord: "o", + slash: { + name: "logs", + description: + "jump to the Observe → Logs tab", + rank: 12, + }, + section: "observe", + tab: "logs", + parent: "go.observe", + }, + { + kind: "place", + id: "go.observe.llm-logs", + label: "LLM logs", + group: "go", + chord: "L", + section: "observe", + tab: "llm-logs", + parent: "go.observe", + }, + { + kind: "submenu", + id: "go.manage", + label: "Manage", + group: "go", + slash: { + name: "manage", + description: + "switch to the Manage section (tasks / skills / LLM / telegram)", + rank: 10, + }, + }, + { + kind: "place", + id: "go.manage.tasks", + label: "Tasks", + group: "go", + chord: "t", + slash: { + name: "tasks", + description: + "jump to the Tasks tab (Option 4 cron + ingress UI)", + rank: 26, + }, + section: "manage", + tab: "tasks", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.skills", + label: "Skills", + group: "go", + chord: "s", + slash: { + name: "skills", + description: + "jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat", + rank: 20, + }, + section: "manage", + tab: "skills", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.memory", + label: "Memory", + group: "go", + chord: "m", + slash: { + name: "memory", + description: + "open Memory tab (profile, notes, lessons, …) · subcommand: `/memory dump` for profile in chat", + rank: 22, + }, + section: "manage", + tab: "memory", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.mcp", + label: "MCP", + group: "go", + chord: "c", + slash: { + name: "mcp", + description: + "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", + rank: 24, + }, + section: "manage", + tab: "mcp", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.llm", + label: "LLM", + group: "go", + chord: "l", + slash: { + name: "llm", + description: + "open LLM Local/Cloud/External panel · `/llm provider ` switch text provider", + rank: 23, + }, + section: "manage", + tab: "llm", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.telegram", + label: "Telegram", + group: "go", + chord: "g", + slash: { + name: "telegram", + description: + "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", + rank: 28, + }, + section: "manage", + tab: "telegram", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.import", + label: "Import", + group: "go", + chord: "i", + slash: { + name: "import", + description: + "open the Import tab (one-shot Hermes -> atomic-agent migration)", + rank: 29, + }, + section: "manage", + tab: "import", + parent: "go.manage", + }, + { + kind: "place", + id: "go.manage.privacy", + label: "Privacy", + group: "go", + chord: "p", + slash: { + name: "privacy", + description: + "open the Privacy tab (analytics opt-out + approval level) · subcommands: `/privacy analytics on|off` | `/privacy level 1..5` | `/privacy approve on|off`", + rank: 30, + }, + section: "manage", + tab: "privacy", + parent: "go.manage", + }, + { + kind: "action", + id: "session.new", + label: "New session", + group: "session", + chord: "n", + slash: { + name: "new", + description: + "start a fresh session (keeps warm runtime)", + rank: 19, + }, + }, + { + kind: "action", + id: "session.switch", + label: "Switch session…", + group: "session", + chord: "u", + slash: { + name: "sessions", + description: + "open session picker to switch threads", + rank: 18, + }, + }, + { + kind: "action", + id: "session.clear", + label: "Clear transcript", + group: "session", + slash: { + name: "clear", + description: + "clear chat transcript (keeps session)", + rank: 4, + }, + }, + { + kind: "action", + id: "session.id", + label: "Show session id", + group: "session", + slash: { + name: "session", + description: + "show current session id", + rank: 17, + }, + }, + { + kind: "action", + id: "model.chat", + label: "Switch chat model…", + group: "model", + chord: "k", + slash: { + name: "model", + description: + "open chat model picker · subcommands: pull | use | status | ", + aliases: ["models", "local"], + rank: 25, + }, + }, + { + kind: "action", + id: "run.abort", + label: "Abort turn", + group: "run", + chord: "a", + slash: { + name: "abort", + description: + "abort the running turn", + rank: 5, + }, + }, + { + kind: "action", + id: "run.expand", + label: "Expand all tool cards", + group: "run", + slash: { + name: "expand", + description: + "expand every tool card in the chat log", + rank: 15, + }, + }, + { + kind: "action", + id: "run.collapse", + label: "Collapse all tool cards", + group: "run", + slash: { + name: "collapse", + description: + "collapse every tool card in the chat log", + rank: 16, + }, + }, + { + kind: "action", + id: "setup.theme", + label: "Theme…", + group: "setup", + chord: "h", + slash: { + name: "theme", + description: + "switch the UI theme: `/theme ` | `/theme list` (github, catppuccin, dracula, nord, …)", + rank: 3, + }, + }, + { + kind: "action", + id: "setup.analytics", + label: "Analytics", + group: "setup", + slash: { + name: "analytics", + description: + "toggle anonymous analytics: `/analytics on|off|status`", + rank: 31, + }, + }, + { + kind: "action", + id: "setup.skill", + label: "Enable or disable a skill…", + group: "setup", + slash: { + name: "skill", + description: + "skill subcommand: `/skill enable ` | `/skill disable `", + rank: 21, + }, + }, + { + kind: "action", + id: "setup.task", + label: "Create, cancel or run a task…", + group: "setup", + slash: { + name: "task", + description: + "task subcommand: `/task new` | `/task cancel ` | `/task run `", + rank: 27, + }, + }, + { + kind: "action", + id: "help.commands", + label: "Commands", + group: "help", + slash: { + name: "help", + description: + "list available slash commands", + rank: 1, + }, + }, + { + kind: "action", + id: "help.tools", + label: "List built-in tools", + group: "help", + slash: { + name: "tools", + description: + "list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools `", + rank: 2, + }, + }, + { + kind: "action", + id: "help.dump", + label: "Write debug bundle", + group: "help", + chord: "d", + slash: { + name: "dump", + description: + "write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug", + rank: 0, + }, + }, + { + kind: "action", + id: "help.quit", + label: "Quit", + group: "help", + chord: "q", + slash: { + name: "quit", + description: + "exit atomic-agent", + aliases: ["exit"], + rank: 6, + }, + }, +]; + +/** Every node that is also a slash command, in palette order. */ +export function toSlashCommands(): readonly MenuSlash[] { + return MENU.flatMap((node) => (node.slash ? [node.slash] : [])).sort( + (a, b) => a.rank - b.rank, + ); +} + +/** Children of a submenu, in registry order. */ +export function menuChildren(parentId: string): readonly MenuNode[] { + return MENU.filter((node) => node.parent === parentId); +} + +/** Top-level nodes of a group — submenu children are excluded. */ +export function menuRoots(group: MenuGroup): readonly MenuNode[] { + return MENU.filter((node) => node.group === group && node.parent === undefined); +} + +/** Resolve a node by id. */ +export function menuNodeById(id: string): MenuNode | null { + return MENU.find((node) => node.id === id) ?? null; +} + +/** Resolve the node bound to a `ctrl+g` chord key. */ +export function menuNodeByChord(key: string): MenuNode | null { + return MENU.find((node) => node.chord === key) ?? null; +} From ac5e2877f70cdbe7d3aaf051d38936566bc22e4b Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 07:33:04 +0300 Subject: [PATCH 2/5] feat(tui): ctrl+p operator menu, and ctrl+g chords to jump straight there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point. --- src/tui/app-key-bindings.ts | 33 ++++++ src/tui/components/hotkey-hint.tsx | 11 +- src/tui/menu/menu-behaviour.test.ts | 150 +++++++++++++++++++++++++ src/tui/menu/menu-keys.ts | 157 ++++++++++++++++++++++++++ src/tui/menu/menu-popup.tsx | 144 ++++++++++++++++++++++++ src/tui/menu/menu-selectors.ts | 166 ++++++++++++++++++++++++++++ src/tui/reduce-ui-actions.ts | 32 ++++++ src/tui/theme/theme.ts | 58 ++++++++++ src/tui/tui-action.ts | 6 + src/tui/tui-app.test.tsx | 70 ++++++++++++ src/tui/tui-app.tsx | 40 ++++++- src/tui/tui-state.ts | 15 +++ 12 files changed, 876 insertions(+), 6 deletions(-) create mode 100644 src/tui/menu/menu-behaviour.test.ts create mode 100644 src/tui/menu/menu-keys.ts create mode 100644 src/tui/menu/menu-popup.tsx create mode 100644 src/tui/menu/menu-selectors.ts diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 61d88729..77870064 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -6,6 +6,13 @@ import { type ApprovalRequest, } from "../approval/approval-gate.js"; import { formatApprovalCategory } from "../approval/approval-level.js"; +import { + handleMenuKey, + isMenuLeaderKey, + isMenuOpenKey, + resolveLeaderChord, +} from "./menu/menu-keys.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { cycleNavSlot, type NavSlot } from "./section.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import type { TuiAction } from "./tui-action.js"; @@ -72,6 +79,11 @@ export interface AppKeyContext { * the sidebar steals plain Tab. */ sidebarVisible: boolean; + /** True while a `ctrl+g` leader is waiting for its chord key. */ + menuLeaderArmed: boolean; + setMenuLeaderArmed: (armed: boolean) => void; + /** Navigate to a place, or run an action's slash command. */ + activateMenuNode: (node: MenuNode) => void; } /** @@ -102,6 +114,27 @@ export function handleAppKey( if (state.updatePrompt && handleUpdateKey(input, key, ctx)) { return true; } + // The menu and its leader sit above every panel guard on purpose: they are + // the way out of a panel, so a panel must never be able to swallow them. + if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) { + return true; + } + if (ctx.menuLeaderArmed) { + ctx.setMenuLeaderArmed(false); + const node = resolveLeaderChord(input, key); + if (node) ctx.activateMenuNode(node); + // An unclaimed chord is swallowed rather than passed on: a mistyped + // leader must not leak a letter into the prompt or fire a panel hotkey. + return true; + } + if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) { + ctx.setMenuLeaderArmed(true); + return true; + } + if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) { + dispatch({ type: "menu_opened" }); + return true; + } if ( ctx.sidebarVisible && state.uiMode === "chat" && diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 03f093ea..dbb0f5d2 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -85,7 +85,7 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { { key: "tab", label: "next panel" }, { key: "shift+tab", label: "prev panel" }, { key: "esc", label: "back to Run" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", @@ -104,15 +104,16 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { }, ]; } - // Six chips is the cap for one row on narrow terminals. The scroll - // hint replaces ctrl+b: Observe stays reachable via /observe, while - // scrolling had no visible entry point at all. + // Six chips is the cap for one row on narrow terminals. `ctrl+p` takes + // the slot `/` used to hold: the menu contains every slash command as + // well as every destination, so advertising the superset costs nothing + // and `/` keeps working for anyone who already reaches for it. return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, { key: "tab", label: "sidebar" }, { key: SCROLL_KEY, label: "scroll" }, - { key: "/", label: "commands" }, + { key: "ctrl+p", label: "menu" }, { key: "ctrl+c", label: ctrlCArmed ? "press again to quit" : "quit", diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts new file mode 100644 index 00000000..9581cd16 --- /dev/null +++ b/src/tui/menu/menu-behaviour.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { handleMenuKey, resolveLeaderChord } from "./menu-keys.js"; +import type { MenuNode } from "./menu-registry.js"; +import { + selectMenuItems, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import type { TuiAction } from "../tui-action.js"; +import { createInitialTuiState } from "../tui-state.js"; +import type { TuiState } from "../tui-state.js"; +import { fakeSession } from "../test-fixtures.js"; + +const KEY = { + upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, + pageDown: false, pageUp: false, return: false, escape: false, ctrl: false, + shift: false, tab: false, backspace: false, delete: false, meta: false, +} as const; + +function open(patch: Partial = {}): TuiState { + return { ...createInitialTuiState(fakeSession()), menuOpen: true, ...patch }; +} + +function drive(state: TuiState, input: string, key: Partial) { + const actions: TuiAction[] = []; + const activated: MenuNode[] = []; + const handled = handleMenuKey(input, { ...KEY, ...key } as never, { + state, + dispatch: (a) => actions.push(a), + activate: (n) => activated.push(n), + }); + return { handled, actions, activated }; +} + +describe("menu rows", () => { + it("shows group headings and the two submenus at the root", () => { + const rows = selectMenuRows(open()); + const headers = rows.flatMap((r) => (r.kind === "header" ? [r.label] : [])); + expect(headers).toEqual(["Go", "Session", "Model", "Run", "Setup", "Help"]); + const go = rows.filter((r) => r.kind === "item" && r.node.group === "go"); + expect(go.map((r) => (r.kind === "item" ? r.node.label : ""))).toEqual([ + "Run", + "Toggle debug pane", + "Observe", + "Manage", + ]); + }); + + it("lists a submenu's children and titles the popup with a breadcrumb", () => { + const state = open({ menuPath: "go.manage" }); + expect(selectMenuTitle(state)).toContain("Manage"); + const labels = selectMenuItems(state).map((r) => r.node.label); + expect(labels).toEqual([ + "Tasks", "Skills", "Memory", "MCP", "LLM", "Telegram", "Import", "Privacy", + ]); + }); + + it("flattens the tree when searching and keeps a breadcrumb on each hit", () => { + const state = open({ menuQuery: "privacy" }); + const items = selectMenuItems(state); + const privacy = items.find((r) => r.node.id === "go.manage.privacy"); + expect(privacy).toBeDefined(); + expect(privacy?.crumb).toBe("Manage"); + expect(items.some((r) => r.node.kind === "submenu")).toBe(false); + }); + + it("searching from inside a submenu still reaches the whole registry", () => { + const state = open({ menuPath: "go.manage", menuQuery: "feed" }); + const ids = selectMenuItems(state).map((r) => r.node.id); + expect(ids).toContain("go.observe.feed"); + }); + + it("carries live counts onto destinations", () => { + const base = createInitialTuiState(fakeSession()); + const state = open({ + tasksPanel: { ...base.tasksPanel, rows: [{}, {}] as never }, + menuPath: "go.manage", + }); + const tasks = selectMenuItems(state).find((r) => r.node.id === "go.manage.tasks"); + expect(tasks?.status).toBe("2 tasks"); + }); +}); + +describe("menu keys", () => { + it("moves the cursor with the arrows only, so letters stay available for search", () => { + expect(drive(open(), "", { downArrow: true }).actions).toEqual([ + { type: "menu_cursor_moved", delta: 1 }, + ]); + expect(drive(open(), "j", {}).actions).toEqual([ + { type: "menu_query_changed", query: "j" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("opens a submenu with the right arrow and leaves it with the left", () => { + const atManage = open({ menuCursor: 2 }); + expect(drive(atManage, "", { rightArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: "go.observe" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + const inside = open({ menuPath: "go.manage" }); + expect(drive(inside, "", { leftArrow: true }).actions).toEqual([ + { type: "menu_path_set", path: null }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("closes before activating, so the menu is never left over a new screen", () => { + const state = open({ menuPath: "go.manage" }); + const { actions, activated } = drive(state, "", { return: true }); + expect(actions).toEqual([{ type: "menu_closed" }]); + expect(activated.map((n) => n.id)).toEqual(["go.manage.tasks"]); + }); + + it("takes a whole burst into the search box, so a paste is not swallowed", () => { + expect(drive(open(), "privacy", {}).actions).toEqual([ + { type: "menu_query_changed", query: "privacy" }, + { type: "menu_cursor_set", cursor: 0 }, + ]); + }); + + it("keeps escape-sequence fragments out of the query", () => { + const arrow = String.fromCharCode(27) + "[A"; + expect(drive(open(), arrow, {}).actions).toEqual([]); + }); + + it("swallows every key while open so no panel below can act on it", () => { + for (const [input, key] of [["x", {}], ["", { tab: true }], ["", { pageUp: true }]] as const) { + expect(drive(open(), input, key).handled).toBe(true); + } + }); + + it("declines every key when closed", () => { + const closed = { ...createInitialTuiState(fakeSession()), menuOpen: false }; + expect(drive(closed, "x", {}).handled).toBe(false); + }); +}); + +describe("leader chords", () => { + it("resolves a place from the key pressed after ctrl+g", () => { + expect(resolveLeaderChord("t", KEY as never)?.id).toBe("go.manage.tasks"); + expect(resolveLeaderChord("f", KEY as never)?.id).toBe("go.observe.feed"); + }); + + it("resolves nothing for an unclaimed key or an escape", () => { + expect(resolveLeaderChord("z", KEY as never)).toBeNull(); + expect(resolveLeaderChord("", { ...KEY, escape: true } as never)).toBeNull(); + }); +}); diff --git a/src/tui/menu/menu-keys.ts b/src/tui/menu/menu-keys.ts new file mode 100644 index 00000000..22db0efe --- /dev/null +++ b/src/tui/menu/menu-keys.ts @@ -0,0 +1,157 @@ +import type { Key } from "ink"; + +import type { TuiAction } from "../tui-action.js"; +import type { TuiState } from "../tui-state.js"; +import { menuNodeByChord, type MenuNode } from "./menu-registry.js"; +import { clampMenuCursor, selectMenuSelection } from "./menu-selectors.js"; + +/** + * Prefix for direct jumps: `ctrl+g` then a single key. A leader is what + * keeps the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) + * usable — the chord namespace is disjoint from both those letters and from + * ordinary typing, so nothing had to be renamed to make room for it. + * + * `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix + * and is intercepted by some terminals. + */ +export const MENU_LEADER_LABEL = "ctrl+g"; + +export interface MenuKeyContext { + state: TuiState; + dispatch: (action: TuiAction) => void; + /** Run the node — navigate to a place, or run an action's slash command. */ + activate: (node: MenuNode) => void; +} + +/** True when the keypress opens the menu. */ +export function isMenuOpenKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "p"; +} + +/** True when the keypress arms the `ctrl+g` leader. */ +export function isMenuLeaderKey(input: string, key: Key): boolean { + return key.ctrl && !key.meta && !key.shift && input === "g"; +} + +/** + * Resolve the key pressed after the leader. Returns the node to activate, + * or `null` when nothing claims that key — an unknown chord is swallowed + * rather than falling through, so a mistyped leader can never land a stray + * letter in the prompt or fire a panel hotkey. + */ +export function resolveLeaderChord(input: string, key: Key): MenuNode | null { + if (key.escape || input.length === 0) return null; + return menuNodeByChord(input); +} + +/** + * Key layer for the open menu. Runs before every other handler, and claims + * every printable key — the search box owns typing, which is why the list is + * navigated with arrows only and never with `j`/`k`. + * + * Returns `true` when the key was consumed. + */ +export function handleMenuKey( + input: string, + key: Key, + ctx: MenuKeyContext, +): boolean { + const { state, dispatch } = ctx; + if (!state.menuOpen) return false; + + if (key.escape) { + dispatch({ type: "menu_closed" }); + return true; + } + if (key.downArrow) { + dispatch({ type: "menu_cursor_moved", delta: 1 }); + return true; + } + if (key.upArrow) { + dispatch({ type: "menu_cursor_moved", delta: -1 }); + return true; + } + + const searching = state.menuQuery.trim().length > 0; + const selection = selectMenuSelection(state); + + if (key.rightArrow) { + if (!searching && selection?.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + } + return true; + } + if (key.leftArrow) { + if (!searching && state.menuPath !== null) { + dispatch({ type: "menu_path_set", path: null }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); + } + return true; + } + if (key.return) { + if (!selection) return true; + if (selection.node.kind === "submenu") { + enterSubmenu(dispatch, selection.node.id); + return true; + } + dispatch({ type: "menu_closed" }); + ctx.activate(selection.node); + return true; + } + if (key.backspace || key.delete) { + if (state.menuQuery.length > 0) { + setQuery(dispatch, state.menuQuery.slice(0, -1)); + } + return true; + } + if (isPrintable(input, key)) { + setQuery(dispatch, state.menuQuery + input); + return true; + } + // Anything else (Tab, page keys, stray control bytes) is swallowed so the + // panel layer underneath cannot act on a key aimed at the menu. + return true; +} + +/** Clamp helper shared with the reducer so cursor moves stay in range. */ +export function nextMenuCursor(state: TuiState, delta: number): number { + return clampMenuCursor(state, state.menuCursor + delta); +} + +function enterSubmenu( + dispatch: (action: TuiAction) => void, + id: string, +): void { + dispatch({ type: "menu_path_set", path: id }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Typing flattens the tree: a query ranks across the whole registry, so any + * submenu the operator had walked into is dropped at the same time. + */ +function setQuery( + dispatch: (action: TuiAction) => void, + query: string, +): void { + dispatch({ type: "menu_query_changed", query }); + dispatch({ type: "menu_cursor_set", cursor: 0 }); +} + +/** + * Printable text destined for the search box. + * + * Accepts a whole burst, not just one character: a paste arrives as a single + * input event, and so does fast typing under a slow render. Control bytes and + * escape-sequence fragments are rejected per code point so a stray arrow can + * never end up inside the query. + */ +function isPrintable(input: string, key: Key): boolean { + if (input.length === 0) return false; + if (key.ctrl || key.meta || key.tab || key.return || key.escape) return false; + for (const char of input) { + const code = char.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx new file mode 100644 index 00000000..5e1b0696 --- /dev/null +++ b/src/tui/menu/menu-popup.tsx @@ -0,0 +1,144 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; + +import { chromeTheme } from "../theme/theme.js"; +import type { TuiState } from "../tui-state.js"; +import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import { + clampMenuCursor, + selectMenuRows, + selectMenuTitle, +} from "./menu-selectors.js"; +import { MENU_LEADER_LABEL } from "./menu-keys.js"; + +/** Rows of list body. Keeps the popup shorter than a short terminal. */ +const MAX_ROWS = 16; + +interface MenuPopupProps { + state: TuiState; +} + +/** + * The operator menu: one key (`ctrl+p`) to every destination and every verb. + * + * Rendered directly above the prompt on the same left rail rather than as a + * full-screen takeover, so it reads as belonging to the input you were + * already typing in. The app behind it is dimmed by `setBackdropDimmed` + * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores + * that flag, so the menu stays at full contrast against a faded backdrop. + * + * Pure presentation: every key is handled by `handleMenuKey`. + */ +export function MenuPopup({ state }: MenuPopupProps): ReactElement { + const rows = selectMenuRows(state); + const cursor = clampMenuCursor(state, state.menuCursor); + const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); + const cursorRowIdx = itemIndexes[cursor] ?? -1; + const start = windowStart(rows, cursorRowIdx); + const visible = rows.slice(start, start + MAX_ROWS); + const hiddenAfter = Math.max(0, rows.length - start - visible.length); + + return ( + + + + {selectMenuTitle(state)} + + + {" "} + {chromeTheme.glyphs.promptCaret} {state.menuQuery} + {"█"} + + + {start > 0 ? ( + {"↑"} {start} above + ) : null} + {visible.map((row, idx) => + row.kind === "header" ? ( + + {row.label.toUpperCase()} + + ) : ( + + ), + )} + {hiddenAfter > 0 ? ( + + {"↓"} {hiddenAfter} below + + ) : null} + {rows.length === 0 ? ( + nothing matches + ) : null} + {footer(state)} + + ); +} + +function MenuItem({ + row, + selected, +}: { + row: MenuItemRow; + selected: boolean; +}): ReactElement { + const { node } = row; + const isSubmenu = node.kind === "submenu"; + const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + return ( + + + + {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} + {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} + + + + {detail} + + {node.chord ? ( + + + {MENU_LEADER_LABEL} {node.chord} + + + ) : null} + + ); +} + +/** + * Footer names exactly the moves that are legal right now — `←` only appears + * once there is a level to go back to. + */ +function footer(state: TuiState): string { + const parts = [`${"↑↓"} move`]; + if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { + parts.push(`${"←"} back`); + } + if (state.menuQuery.trim().length === 0 && state.menuPath === null) { + parts.push(`${"→"} open`); + } + parts.push("enter go", "type to search", "esc close"); + return parts.join(" "); +} + +/** Scroll window that keeps the cursor row visible. */ +function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { + if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; + if (cursorRowIdx < MAX_ROWS) return 0; + return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +} diff --git a/src/tui/menu/menu-selectors.ts b/src/tui/menu/menu-selectors.ts new file mode 100644 index 00000000..c502dcde --- /dev/null +++ b/src/tui/menu/menu-selectors.ts @@ -0,0 +1,166 @@ +import fuzzysort from "fuzzysort"; + +import { + MENU, + MENU_GROUP_LABELS, + MENU_GROUP_ORDER, + menuChildren, + menuNodeById, + menuRoots, + type MenuNode, +} from "./menu-registry.js"; +import type { TuiState } from "../tui-state.js"; + +/** A group heading. Rendered, never selectable. */ +export interface MenuHeaderRow { + readonly kind: "header"; + readonly label: string; +} + +/** A selectable entry. */ +export interface MenuItemRow { + readonly kind: "item"; + readonly node: MenuNode; + /** Live state for a destination, e.g. `3 scheduled`. Empty when unknown. */ + readonly status: string; + /** Where the node lives, shown only while searching flattens the tree. */ + readonly crumb: string; +} + +export type MenuRow = MenuHeaderRow | MenuItemRow; + +/** + * Rows the menu should render for the current state. + * + * Three modes, and the rule that decides between them is the whole design: + * **hierarchy to browse, flat to search.** With a query, every node in the + * registry competes on one ranked list and the tree is irrelevant; without + * one, the operator walks groups and submenus. + */ +export function selectMenuRows(state: TuiState): readonly MenuRow[] { + const query = state.menuQuery.trim(); + if (query.length > 0) return searchRows(state, query); + if (state.menuPath !== null) return submenuRows(state, state.menuPath); + return rootRows(state); +} + +/** Only the selectable rows, in render order — the cursor indexes these. */ +export function selectMenuItems(state: TuiState): readonly MenuItemRow[] { + return selectMenuRows(state).flatMap((row) => + row.kind === "item" ? [row] : [], + ); +} + +/** The row under the cursor, or `null` when the list is empty. */ +export function selectMenuSelection(state: TuiState): MenuItemRow | null { + const items = selectMenuItems(state); + if (items.length === 0) return null; + return items[clampMenuCursor(state, state.menuCursor)] ?? null; +} + +/** Clamp a cursor into the current item list. */ +export function clampMenuCursor(state: TuiState, cursor: number): number { + const max = selectMenuItems(state).length - 1; + if (max < 0) return 0; + return Math.max(0, Math.min(cursor, max)); +} + +/** Title shown in the popup border — `Menu` or `Menu › Manage`. */ +export function selectMenuTitle(state: TuiState): string { + if (state.menuPath === null || state.menuQuery.trim().length > 0) { + return "Menu"; + } + const parent = menuNodeById(state.menuPath); + return parent ? `Menu ${String.fromCodePoint(0x203a)} ${parent.label}` : "Menu"; +} + +function rootRows(state: TuiState): readonly MenuRow[] { + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const nodes = menuRoots(group); + if (nodes.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const node of nodes) { + rows.push(itemRow(state, node, "")); + } + } + return rows; +} + +function submenuRows(state: TuiState, parentId: string): readonly MenuRow[] { + return menuChildren(parentId).map((node) => itemRow(state, node, "")); +} + +function searchRows(state: TuiState, query: string): readonly MenuRow[] { + // Submenus are excluded: "open the Manage submenu" is a browsing move, and + // a search that already found `Privacy` should offer Privacy, not the + // folder it happens to sit in. + const candidates = MENU.filter((node) => node.kind !== "submenu"); + const scored = candidates + .map((node, idx) => { + const haystacks = [node.label, node.slash?.name ?? "", crumbFor(node)]; + const best = Math.max( + ...haystacks.map( + (h) => (h ? (fuzzysort.single(query, h)?.score ?? -Infinity) : -Infinity), + ), + ); + return { node, score: best, idx }; + }) + .filter(({ score }) => score > -Infinity) + .sort((a, b) => b.score - a.score || a.idx - b.idx); + + const rows: MenuRow[] = []; + for (const group of MENU_GROUP_ORDER) { + const hits = scored.filter(({ node }) => node.group === group); + if (hits.length === 0) continue; + rows.push({ kind: "header", label: MENU_GROUP_LABELS[group] }); + for (const { node } of hits) { + rows.push(itemRow(state, node, crumbFor(node))); + } + } + return rows; +} + +function crumbFor(node: MenuNode): string { + if (node.parent === undefined) return ""; + return menuNodeById(node.parent)?.label ?? ""; +} + +function itemRow(state: TuiState, node: MenuNode, crumb: string): MenuItemRow { + return { kind: "item", node, status: statusFor(state, node), crumb }; +} + +/** + * Live one-liner for a destination. Deliberately reads the same state slices + * the sub-tab strip already counts (`debug-pane.tsx`), so opening the menu + * costs a few array lengths and never a refresh. + */ +function statusFor(state: TuiState, node: MenuNode): string { + switch (node.id) { + case "go.manage.tasks": + return countLabel(state.tasksPanel.rows.length, "task"); + case "go.manage.skills": + return countLabel(state.skillsPanel.rows.length, "skill"); + case "go.manage.memory": + return countLabel(state.memoryPanel.rows.length, "note"); + case "go.manage.mcp": + return countLabel(state.mcpPanel.rows.length, "server"); + case "go.observe.feed": + return countLabel(state.feed.length, "event"); + case "go.observe.reasoning": + return countLabel(state.reasoning.length, "entry"); + case "go.observe.logs": + return countLabel(state.logs.length, "line"); + case "go.run": + return countLabel(state.messages.length, "message"); + case "session.switch": + return countLabel(state.recentSessions.length, "recent"); + default: + return ""; + } +} + +function countLabel(count: number, noun: string): string { + if (count === 0) return ""; + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 3f5769ea..bffecadd 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -1,3 +1,4 @@ +import { clampMenuCursor } from "./menu/menu-selectors.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { selectSidebarTasks } from "./sidebar-tasks-selector.js"; import { THEME_NAMES } from "./theme/theme.js"; @@ -62,6 +63,37 @@ export function reduceUiAction( for (const card of state.streamingToolCards) next[card.id] = action.expanded; return { ...state, toolsExpandedById: next }; } + case "menu_opened": + // Always reopen at the root with an empty query: a menu that resumes + // where it was last left makes the same keypress mean different + // things on different days. + return { + ...state, + menuOpen: true, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_closed": + return { + ...state, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, + }; + case "menu_query_changed": + // A query flattens the tree, so any open submenu is dropped with it. + return { ...state, menuQuery: action.query, menuPath: null }; + case "menu_path_set": + return { ...state, menuPath: action.path }; + case "menu_cursor_set": + return { ...state, menuCursor: clampMenuCursor(state, action.cursor) }; + case "menu_cursor_moved": + return { + ...state, + menuCursor: clampMenuCursor(state, state.menuCursor + action.delta), + }; case "slash_palette_opened": return { ...state, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 7578c3fa..bf3e9356 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -209,12 +209,70 @@ export function getActiveThemeName(): ThemeName { return "github-dark"; } +/** + * Backdrop dimming. While the operator menu is open the whole app behind it + * fades, so the popup reads as the foreground rather than as one more panel + * competing with the chat log. + * + * Implemented here rather than by threading a `dimmed` prop through every + * component because {@link theme} is already a read-at-render proxy — the + * same machinery that makes `/theme` live-preview repaint the whole UI. One + * flag flips every colour; the menu itself reads {@link chromeTheme}, which + * ignores the flag, so it stays at full contrast. + * + * Every colour collapses to the active theme's `muted`: a real terminal has + * no alpha channel, so "faded" has to mean "one low-contrast tone" rather + * than "the same colours, weaker". + */ +let backdropDimmed = false; +let dimmedColorsFor: TuiColors | null = null; +let dimmedColorsCache: TuiColors | null = null; + +export function setBackdropDimmed(next: boolean): void { + backdropDimmed = next; +} + +export function isBackdropDimmed(): boolean { + return backdropDimmed; +} + +function dimColors(colors: TuiColors): TuiColors { + if (dimmedColorsFor === colors && dimmedColorsCache) return dimmedColorsCache; + const flat = Object.fromEntries( + Object.keys(colors).map((key) => [key, colors.muted]), + ) as unknown as TuiColors; + dimmedColorsFor = colors; + dimmedColorsCache = flat; + return flat; +} + /** * The themed palette consumed across the TUI. A `Proxy` that always forwards * to the current {@link activeTheme}, so `theme.colors.X` reflects the active * theme at read time even after a `setActiveTheme` swap. */ export const theme: TuiTheme = new Proxy({} as TuiTheme, { + get(_target, prop: string | symbol): unknown { + if (prop === "colors" && backdropDimmed) return dimColors(activeTheme.colors); + return activeTheme[prop as keyof TuiTheme]; + }, + has(_target, prop: string | symbol): boolean { + return prop in activeTheme; + }, + ownKeys(): ArrayLike { + return Reflect.ownKeys(activeTheme); + }, + getOwnPropertyDescriptor(_target, prop: string | symbol) { + return Reflect.getOwnPropertyDescriptor(activeTheme, prop); + }, +}); + +/** + * The palette for chrome that must stay legible while the backdrop is dimmed — + * i.e. the operator menu. Identical to {@link theme} except that it ignores + * {@link setBackdropDimmed}. + */ +export const chromeTheme: TuiTheme = new Proxy({} as TuiTheme, { get(_target, prop: string | symbol): unknown { return activeTheme[prop as keyof TuiTheme]; }, diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index b44d0158..36b08735 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -93,6 +93,12 @@ export type TuiAction = | { type: "slash_palette_queried"; query: string } /** Close the slash palette without committing a selection. */ | { type: "slash_palette_closed" } + | { type: "menu_opened" } + | { type: "menu_closed" } + | { type: "menu_query_changed"; query: string } + | { type: "menu_cursor_moved"; delta: number } + | { type: "menu_cursor_set"; cursor: number } + | { type: "menu_path_set"; path: string | null } /** Move the highlight in the open slash palette by delta rows. */ | { type: "slash_palette_cursor_moved"; delta: 1 | -1 } /** Reset the slash palette highlight to a specific row. */ diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 480215dc..9ceb8390 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -344,4 +344,74 @@ describe("TuiApp (smoke)", () => { expect(text).not.toContain("▸ Manage"); unmount(); }); + + it("ctrl+p opens the operator menu over the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Menu"); + expect(text).toContain("GO"); + expect(text).toContain("Manage"); + expect(text).toContain("esc close"); + unmount(); + }); + + it("typing in the menu searches instead of reaching the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + stdin.write("privacy"); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Privacy"); + // The query lives in the menu, never in the editor buffer underneath. + expect(text).not.toContain("> privacy"); + unmount(); + }); + + it("ctrl+g then a chord jumps straight to a panel, and the chord letter never reaches the prompt", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(7)); + await new Promise((r) => setTimeout(r, 30)); + stdin.write("t"); + await new Promise((r) => setTimeout(r, 30)); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Manage"); + expect(text).toContain("Tasks"); + // Ink delivers every key to every useInput, child first — so the editor + // sees the chord letter too. If the leader did not disable it, a stray + // "t" would be sitting in the prompt right now. + expect(text).not.toMatch(/[>\u276f]\s+t\s*$/m); + unmount(); + }); + + it("esc closes the menu and leaves the screen it was opened over", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 20)); + expect(strip(lastFrame() ?? "")).toContain("esc close"); + stdin.write(String.fromCharCode(27)); + await new Promise((r) => setTimeout(r, 20)); + const text = strip(lastFrame() ?? ""); + expect(text).not.toContain("esc close"); + expect(text).toContain("Run"); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d97ddca1..f814d35b 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,8 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { MenuPopup } from "./menu/menu-popup.js"; +import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; import { ChatLog } from "./components/chat-log.js"; import { DebugPane } from "./components/debug-pane.js"; @@ -22,6 +24,7 @@ import { ThemePicker } from "./components/theme-picker.js"; import { isThemeName, setActiveTheme, + setBackdropDimmed, theme, THEME_NAMES, THEMES, @@ -37,7 +40,7 @@ import { UpdateRestartPrompt } from "./components/update-restart-prompt.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; import { filterSlashCommands } from "./commands/slash-commands.js"; import { slashPrefix } from "./commands/slash-command-parser.js"; -import { handleEditorSubmit } from "./submit-handler.js"; +import { handleEditorSubmit, runSlashCommand } from "./submit-handler.js"; import type { TaskCreateKind } from "./tasks/tasks-panel-state.js"; import type { TaskSchedule } from "../tasks/task-types.js"; import { @@ -380,6 +383,7 @@ export function TuiApp({ ); const app = useApp(); const [ctrlCArmed, setCtrlCArmed] = useState(false); + const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); useEffect(() => bus.subscribe(dispatch), [bus]); @@ -485,6 +489,8 @@ export function TuiApp({ state.uiMode === "chat" && terminalSize.columns >= SIDEBAR_MIN_COLUMNS; const sidebarFocused = sidebarVisible && state.chatFocus === "sidebar"; const editorFocus = + !state.menuOpen && + !menuLeaderArmed && !state.pendingApproval && // The update offer claims y / n / Esc; keep the editor unfocused so // those keystrokes never leak into the input buffer. The post-update @@ -522,6 +528,26 @@ export function TuiApp({ } }, [sidebarVisible, state.chatFocus]); + const activateMenuNode = useCallback( + (node: MenuNode) => { + // A node that carries a slash name is *run as that command*, so the + // menu never grows a second dispatch path beside the slash handler. + if (node.slash) { + runSlashCommand(`/${node.slash.name}`, state, dispatch, callbacks); + return; + } + if (node.kind === "place") { + if (node.tab) { + dispatch({ type: "ui_mode_set", mode: "debug" }); + dispatch({ type: "tab_changed", tab: node.tab }); + } else { + dispatch({ type: "ui_mode_set", mode: "chat" }); + } + } + }, + [state, callbacks], + ); + useInput((input, key) => { const appHandled = handleAppKey(input, key, { state, @@ -530,6 +556,9 @@ export function TuiApp({ ctrlCArmed, setCtrlCArmed, sidebarVisible, + menuLeaderArmed, + setMenuLeaderArmed, + activateMenuNode, }); if (appHandled) return; // While the slash-command palette is open, let the (now-focused) @@ -686,6 +715,10 @@ export function TuiApp({ // the smoke tests assert against an overlapped frame. In production // the alt-screen + `height={rows}` combo gives us the opencode-style // pinned-input-at-bottom UX. + // Render-phase on purpose: `theme` is a read-at-render proxy, and children + // render after this body runs, so the flag is already correct for them. + setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; const promptLlm = selectPromptLlmMeta(state); @@ -749,6 +782,11 @@ export function TuiApp({ ) : null} + {state.menuOpen ? ( + + + + ) : null} {state.sessionPickerOpen ? ( >; /** Is the session picker overlay visible? */ @@ -489,6 +500,10 @@ export function createInitialTuiState( slashPaletteOpen: false, slashQuery: "", slashPaletteCursor: 0, + menuOpen: false, + menuPath: null, + menuQuery: "", + menuCursor: 0, toolsExpandedById: {}, sessionPickerOpen: false, sessionPickerList: [], From 60320f2624e5c67e8872462063092743c9868890 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 19 Aug 2026 10:45:29 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(tui):=20make=20the=20menu=20a=20real=20?= =?UTF-8?q?overlay=20=E2=80=94=20it=20floats,=20nothing=20reflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen. --- src/tui/components/debug-pane.tsx | 2 +- src/tui/menu/menu-popup.tsx | 199 +++++++++++++++++++----------- src/tui/tui-app.test.tsx | 19 +++ src/tui/tui-app.tsx | 25 +++- 4 files changed, 169 insertions(+), 76 deletions(-) diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index 0946055c..27d0fdf7 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -138,7 +138,7 @@ function buildManageTabs(state: TuiState): SubTab[] { * terminal — it overlaps/garbles earlier lines instead (verified) — so * the per-tab budget must subtract this accurately and err generous. */ -const APP_CHROME_ROWS = 9; +export const APP_CHROME_ROWS = 9; /** * Height consumed INSIDE the debug pane above the active tab: the * `SubTabBar` (1 row) + the `DebugDiagnosticsLine`. The diagnostics line diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5e1b0696..5deff17a 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { chromeTheme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; -import type { MenuItemRow, MenuRow } from "./menu-selectors.js"; +import type { MenuItemRow } from "./menu-selectors.js"; import { clampMenuCursor, selectMenuRows, @@ -11,134 +11,195 @@ import { } from "./menu-selectors.js"; import { MENU_LEADER_LABEL } from "./menu-keys.js"; -/** Rows of list body. Keeps the popup shorter than a short terminal. */ -const MAX_ROWS = 16; +/** Popup width, clamped to the terminal on narrow windows. */ +const PREFERRED_WIDTH = 64; +/** Rows of list body at most, before the window starts scrolling. */ +const MAX_BODY_ROWS = 16; +/** Border (2) + title row + footer row. */ +const CHROME_ROWS = 4; +/** Column reserved for the entry label. */ +const LABEL_WIDTH = 26; interface MenuPopupProps { state: TuiState; + /** Rows available in the pane the menu floats over. */ + availableRows: number; + /** Columns available in that pane. */ + availableColumns: number; } /** * The operator menu: one key (`ctrl+p`) to every destination and every verb. * - * Rendered directly above the prompt on the same left rail rather than as a - * full-screen takeover, so it reads as belonging to the input you were - * already typing in. The app behind it is dimmed by `setBackdropDimmed` - * (see `theme.ts`) — this component reads {@link chromeTheme}, which ignores - * that flag, so the menu stays at full contrast against a faded backdrop. + * Rendered as a true overlay — `position="absolute"` inside the content pane, + * so it floats **on top of** the chat log or the active panel instead of + * displacing them. Nothing below it reflows when the menu opens or closes. + * + * Terminals have no compositing and Ink has no z-index, so occlusion has to + * be earned: every interior line is padded to the popup's exact inner width, + * which paints spaces over whatever was underneath. That is also why the rows + * are laid out as fixed-width columns rather than with `flexGrow` — a flexed + * row stops at its content and lets the background show through. + * + * A background colour would do the same job in one line, but only by picking + * a colour, and the TUI ships eleven themes across light and dark grounds. + * Spaces are theme-agnostic. + * + * The app behind is dimmed by `setBackdropDimmed` (see `theme.ts`); this + * component reads {@link chromeTheme}, which ignores that flag, so the menu + * stays at full contrast against a faded backdrop. * * Pure presentation: every key is handled by `handleMenuKey`. */ -export function MenuPopup({ state }: MenuPopupProps): ReactElement { +export function MenuPopup({ + state, + availableRows, + availableColumns, +}: MenuPopupProps): ReactElement { + const width = Math.max(28, Math.min(PREFERRED_WIDTH, availableColumns - 2)); + // Interior columns between the two border columns. Ink's own `paddingX` + // is NOT painted by our rows — it leaves real gaps the backdrop shows + // through — so the one-column gutter is baked into every string instead. + const inner = width - 2; + const rows = selectMenuRows(state); const cursor = clampMenuCursor(state, state.menuCursor); const itemIndexes = rows.flatMap((row, idx) => (row.kind === "item" ? [idx] : [])); const cursorRowIdx = itemIndexes[cursor] ?? -1; - const start = windowStart(rows, cursorRowIdx); - const visible = rows.slice(start, start + MAX_ROWS); + + const bodyRows = Math.max( + 3, + Math.min(MAX_BODY_ROWS, availableRows - CHROME_ROWS), + ); + const start = windowStart(rows.length, cursorRowIdx, bodyRows); + const visible = rows.slice(start, start + bodyRows); const hiddenAfter = Math.max(0, rows.length - start - visible.length); + // Anchor to the bottom of the pane so the menu sits just above the prompt, + // the way a dropdown hangs off the control that opened it. + const height = visible.length + CHROME_ROWS; + const offsetTop = Math.max(0, availableRows - height); + return ( - - - {selectMenuTitle(state)} - - - {" "} - {chromeTheme.glyphs.promptCaret} {state.menuQuery} - {"█"} - - - {start > 0 ? ( - {"↑"} {start} above - ) : null} + {visible.map((row, idx) => row.kind === "header" ? ( - {row.label.toUpperCase()} + {fit(` ${row.label.toUpperCase()}`, inner)} ) : ( ), )} - {hiddenAfter > 0 ? ( - - {"↓"} {hiddenAfter} below - - ) : null} {rows.length === 0 ? ( - nothing matches + {fit(" nothing matches", inner)} ) : null} - {footer(state)} + + {fit(` ${footer(state, hiddenAfter)}`, inner)} + + + ); +} + +function TitleRow({ + state, + inner, +}: { + state: TuiState; + inner: number; +}): ReactElement { + const title = selectMenuTitle(state); + const caret = `${chromeTheme.glyphs.promptCaret} ${state.menuQuery}`; + const left = fit(` ${title}`, Math.min(title.length + 3, inner)); + const rest = inner - left.length; + return ( + + + {left} + + {fit(caret, Math.max(0, rest))} ); } function MenuItem({ row, + inner, selected, }: { row: MenuItemRow; + inner: number; selected: boolean; }): ReactElement { const { node } = row; - const isSubmenu = node.kind === "submenu"; - const detail = [row.crumb, row.status].filter((part) => part.length > 0).join(" "); + const marker = selected ? chromeTheme.glyphs.chevronRight : " "; + const arrow = node.kind === "submenu" ? ` ${chromeTheme.glyphs.arrowRight}` : ""; + // Leading and trailing space are part of the row, not Box padding, so the + // whole line is opaque edge to edge. + const label = fit(` ${marker} ${node.label}${arrow}`, Math.min(LABEL_WIDTH, inner)); + const chordText = node.chord ? `${MENU_LEADER_LABEL} ${node.chord} ` : " "; + const chord = fit(chordText, Math.min(chordText.length, Math.max(0, inner - label.length))); + const detailWidth = Math.max(0, inner - label.length - chord.length); + const detail = fit( + [row.crumb, row.status].filter((part) => part.length > 0).join(" "), + detailWidth, + ); return ( - - - {selected ? chromeTheme.glyphs.chevronRight : " "} {node.label} - {isSubmenu ? ` ${chromeTheme.glyphs.arrowRight}` : ""} - - - - {detail} - - {node.chord ? ( - - - {MENU_LEADER_LABEL} {node.chord} - - - ) : null} + + {label} + + {detail} + {chord} ); } /** * Footer names exactly the moves that are legal right now — `←` only appears - * once there is a level to go back to. + * once there is a level to go back to, `→` only while one is reachable. */ -function footer(state: TuiState): string { - const parts = [`${"↑↓"} move`]; - if (state.menuQuery.trim().length === 0 && state.menuPath !== null) { - parts.push(`${"←"} back`); - } - if (state.menuQuery.trim().length === 0 && state.menuPath === null) { - parts.push(`${"→"} open`); - } - parts.push("enter go", "type to search", "esc close"); +function footer(state: TuiState, hiddenAfter: number): string { + const searching = state.menuQuery.trim().length > 0; + const parts = ["↑↓ move"]; + if (!searching && state.menuPath !== null) parts.push("← back"); + if (!searching && state.menuPath === null) parts.push("→ open"); + parts.push("enter go", "esc close"); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); return parts.join(" "); } +/** + * Pad or truncate to exactly `width` columns. Every interior line goes + * through this — it is what makes the popup opaque. + */ +function fit(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length > width) { + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; + } + return text.padEnd(width); +} + /** Scroll window that keeps the cursor row visible. */ -function windowStart(rows: readonly MenuRow[], cursorRowIdx: number): number { - if (rows.length <= MAX_ROWS || cursorRowIdx < 0) return 0; - if (cursorRowIdx < MAX_ROWS) return 0; - return Math.min(cursorRowIdx - MAX_ROWS + 1, rows.length - MAX_ROWS); +function windowStart(total: number, cursorRowIdx: number, size: number): number { + if (total <= size || cursorRowIdx < 0) return 0; + if (cursorRowIdx < size) return 0; + return Math.min(cursorRowIdx - size + 1, total - size); } diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 9ceb8390..58cd1819 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -414,4 +414,23 @@ describe("TuiApp (smoke)", () => { expect(text).toContain("Run"); unmount(); }); + + it("floats over the UI without moving it — the frame below is unchanged", async () => { + const bus = makeTuiEventBus(); + const { lastFrame, stdin, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 10)); + const before = strip(lastFrame() ?? "").split("\n"); + stdin.write(String.fromCharCode(16)); + await new Promise((r) => setTimeout(r, 25)); + const after = strip(lastFrame() ?? "").split("\n"); + + // A popup composites on top; it must not add rows or push the prompt and + // the hint strip down the way an inline panel would. + expect(after.length).toBe(before.length); + expect(after.at(-1)).toBe(before.at(-1)); + expect(after.some((line) => line.includes("Menu"))).toBe(true); + unmount(); + }); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index f814d35b..3a52f7b6 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -11,6 +11,7 @@ import { reduceTuiState } from "./agent-event-reducer.js"; import type { ApprovalGrantScope } from "../approval/approval-gate.js"; import type { TuiAction } from "./tui-action.js"; import { handleAppKey, handlePanelEscape } from "./app-key-bindings.js"; +import { APP_CHROME_ROWS } from "./components/debug-pane.js"; import { MenuPopup } from "./menu/menu-popup.js"; import type { MenuNode } from "./menu/menu-registry.js"; import { ApprovalModal } from "./approval-modal.js"; @@ -719,8 +720,12 @@ export function TuiApp({ // render after this body runs, so the flag is already correct for them. setBackdropDimmed(state.menuOpen); + const isTty = Boolean(process.stdout.isTTY); const rootHeight = isTty ? terminalSize.rows : undefined; + // Rows the content pane actually has, so the overlay can sit on its bottom + // edge and cap its own height. Same budget the debug pane already uses. + const menuPaneRows = Math.max(6, terminalSize.rows - APP_CHROME_ROWS); const promptLlm = selectPromptLlmMeta(state); // No local backend chosen yet ⇒ no local health to report. Without this the // splash screen of a fresh install announces that a server the user never @@ -758,7 +763,13 @@ export function TuiApp({ - + {state.uiMode === "chat" ? ( ) : ( @@ -776,17 +787,19 @@ export function TuiApp({ } /> )} + {state.menuOpen ? ( + + ) : null} {state.pendingApproval ? ( ) : null} - {state.menuOpen ? ( - - - - ) : null} {state.sessionPickerOpen ? ( Date: Wed, 19 Aug 2026 11:33:28 +0300 Subject: [PATCH 4/5] feat(tui): status bar shows where you are, not a menu of where to go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change. --- src/tui/components/status-bar.tsx | 57 +++++++++++++++---------------- src/tui/menu/menu-registry.ts | 8 +++++ src/tui/tui-app.test.tsx | 26 +++++++------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 33750987..c3609b53 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -1,11 +1,8 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { - getCurrentSection, - SECTION_ORDER, - type TuiSection, -} from "../section.js"; +import { getCurrentSection, type TuiSection } from "../section.js"; +import { menuPlaceByTab } from "../menu/menu-registry.js"; import { theme } from "../theme/theme.js"; import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; @@ -15,7 +12,13 @@ interface StatusBarProps { } /** - * One-row operator status bar. Replaces the legacy `header-line` + + * One-row operator status bar. Shows **where you are**, not where you could + * go: the three-section pill row was a menu, and the menu now lives behind + * `ctrl+p` where it can hold every destination instead of only the top three. + * What is left is a breadcrumb — `Manage › Tasks` — which is the one thing + * the popup cannot tell you, because you have to open it to read it. + * + * Replaces the legacy `header-line` + * `status-line` + `footer-line` trio: only signal that needs to be * visible at every glance stays on screen — current section and a * short session id when one exists. Verbose details (full cwd, llama @@ -37,7 +40,7 @@ export function StatusBar({ state }: StatusBarProps): ReactElement { v{getAppVersion()} - + ); @@ -49,30 +52,26 @@ const SECTION_LABELS: Record = { manage: "Manage", }; -function SectionPills({ active }: { active: TuiSection }): ReactElement { +function Breadcrumb({ + state, + section, +}: { + state: TuiState; + section: TuiSection; +}): ReactElement { + const tabLabel = + state.uiMode === "debug" ? menuPlaceByTab(state.activeTab)?.label : undefined; return ( - {SECTION_ORDER.map((id, idx) => { - const isActive = id === active; - return ( - - - {isActive ? `${theme.glyphs.chevronRight} ` : " "} - {SECTION_LABELS[id]} - - {idx < SECTION_ORDER.length - 1 ? ( - - {" "} - {theme.glyphs.dotSeparator} - {" "} - - ) : null} - - ); - })} + + {SECTION_LABELS[section]} + + {tabLabel ? ( + + {" "} + {theme.glyphs.chevronRight} {tabLabel} + + ) : null} ); } diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index 0e390280..7b518cfd 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -578,3 +578,11 @@ export function menuNodeById(id: string): MenuNode | null { export function menuNodeByChord(key: string): MenuNode | null { return MENU.find((node) => node.chord === key) ?? null; } + +/** The destination that owns a debug tab, for breadcrumbs and status text. */ +export function menuPlaceByTab(tab: TuiTab): MenuPlaceNode | null { + for (const node of MENU) { + if (node.kind === "place" && node.tab === tab) return node; + } + return null; +} diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 58cd1819..891c1d44 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -41,9 +41,11 @@ describe("TuiApp (smoke)", () => { ); const text = strip(lastFrame() ?? ""); expect(text).toContain("atomic-agent"); + // The status bar shows where you are, not a menu of where you could go — + // the three-section pill row moved into the ctrl+p menu. expect(text).toContain("Run"); - expect(text).toContain("Observe"); - expect(text).toContain("Manage"); + expect(text).not.toContain("Observe"); + expect(text).not.toContain("Manage"); expect(text).toContain("Local AI-First Agent"); expect(text).toContain("commands"); unmount(); @@ -146,20 +148,19 @@ describe("TuiApp (smoke)", () => { ); await new Promise((r) => setTimeout(r, 10)); const before = strip(lastFrame() ?? ""); - expect(before).toContain("▸ Run"); + expect(before).toContain("Run"); stdin.write("\t"); await new Promise((r) => setTimeout(r, 10)); const after = strip(lastFrame() ?? ""); if (before.includes("Sessions")) { // Sidebar visible: Tab lands focus on the rail and stays in // chat mode. Ctrl+B is the dedicated key for nav cycling. - expect(after).toContain("▸ Run"); - expect(after).not.toContain("▸ Observe"); + expect(after).toContain("Run"); + expect(after).not.toContain("Observe \u25b8"); } else { // Sidebar collapsed (narrow runner): Tab falls back to the nav // cycle and lands on Observe → Feed. - expect(after).toContain("▸ Observe"); - expect(after).toContain("▸ Feed"); + expect(after).toContain("Observe \u25b8 Feed"); } unmount(); }); @@ -173,8 +174,7 @@ describe("TuiApp (smoke)", () => { stdin.write("\u0002"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Observe"); - expect(text).toContain("▸ Feed"); + expect(text).toContain("Observe \u25b8 Feed"); unmount(); }); @@ -188,7 +188,7 @@ describe("TuiApp (smoke)", () => { await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). - expect(text).toContain("▸ Manage"); + expect(text).toContain("Manage \u25b8"); expect(text).toContain("▸ Telegram"); unmount(); }); @@ -335,13 +335,13 @@ describe("TuiApp (smoke)", () => { bus.emit({ type: "ui_mode_set", mode: "debug" }); bus.emit({ type: "tab_changed", tab: "tasks" }); await new Promise((r) => setTimeout(r, 10)); - expect(strip(lastFrame() ?? "")).toContain("▸ Manage"); + expect(strip(lastFrame() ?? "")).toContain("Manage \u25b8"); stdin.write("\u001b"); await new Promise((r) => setTimeout(r, 60)); const text = strip(lastFrame() ?? ""); - expect(text).toContain("▸ Run"); - expect(text).not.toContain("▸ Manage"); + expect(text).toContain("Run"); + expect(text).not.toContain("Manage \u25b8"); unmount(); }); From a6cab04769077d2e4dc13c2491fefb963e0f7fc7 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 17:27:53 +0300 Subject: [PATCH 5/5] fix(tui): centre the menu, and pay off the two drifted assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-testing feedback: the popup should read as a modal — the app fades and the menu appears in the middle of the window, the way a web app would do it — rather than hanging off the prompt like a dropdown. So it is centred in the content pane on both axes. Two details: - Centring measures the chat column, not the terminal. With the sidebar on screen the old width ran the popup under the rail and clipped its right border, which looked like a rendering bug. - Vertical centring uses the same pane rows the overlay already sizes itself against, so nothing else had to move. Also fixes the two assertions #170's body named as evidence that the TUI kept several hand-maintained parallel lists which had drifted, and deliberately left alone because "fixing them belongs with the commit that changes the behaviour they describe". This branch is that commit — it is what replaced the section pills with a breadcrumb: - Shift+Tab from Run wraps to the last Manage tab, which has been Privacy since MANAGE_TABS gained import and privacy — not Telegram. - The LLM panel test wanted "Active chat route", "Mode:" and "Press left/right to switch mode"; with no provider configured the panel shows its section headings and a compact footer instead. --- src/tui/menu/menu-popup.tsx | 12 +++++++++--- src/tui/tui-app.test.tsx | 17 ++++++++++++----- src/tui/tui-app.tsx | 4 +++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/tui/menu/menu-popup.tsx b/src/tui/menu/menu-popup.tsx index 5deff17a..2fc1ad7e 100644 --- a/src/tui/menu/menu-popup.tsx +++ b/src/tui/menu/menu-popup.tsx @@ -35,6 +35,11 @@ interface MenuPopupProps { * so it floats **on top of** the chat log or the active panel instead of * displacing them. Nothing below it reflows when the menu opens or closes. * + * It sits **centred** in that pane, both axes. A dropdown hanging off the + * prompt was the first shape, but this is not a dropdown: it is the app's + * one modal surface, and a modal belongs in the middle of the window with + * the app faded behind it — the same thing a web app would do. + * * Terminals have no compositing and Ink has no z-index, so occlusion has to * be earned: every interior line is padded to the popup's exact inner width, * which paints spaces over whatever was underneath. That is also why the rows @@ -75,15 +80,16 @@ export function MenuPopup({ const visible = rows.slice(start, start + bodyRows); const hiddenAfter = Math.max(0, rows.length - start - visible.length); - // Anchor to the bottom of the pane so the menu sits just above the prompt, - // the way a dropdown hangs off the control that opened it. + // Centred in the pane on both axes. const height = visible.length + CHROME_ROWS; - const offsetTop = Math.max(0, availableRows - height); + const offsetTop = Math.max(0, Math.floor((availableRows - height) / 2)); + const offsetLeft = Math.max(0, Math.floor((availableColumns - width) / 2)); return ( { stdin.write("\u001b[Z"); await new Promise((r) => setTimeout(r, 10)); const text = strip(lastFrame() ?? ""); - // Shift+Tab from Run wraps to the last Manage sub-tab (Telegram). + // Shift+Tab from Run wraps to the last Manage sub-tab. That was + // Telegram when this test was written; MANAGE_TABS has gained + // `import` and `privacy` since, and the literal was never updated — + // one of the two drifted assertions #170 called out as the reason a + // single menu registry exists, and left for whichever commit changed + // the surface they describe. This is that commit. expect(text).toContain("Manage \u25b8"); - expect(text).toContain("▸ Telegram"); + 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(); }); diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 6f0e2c8c..501dcc58 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -806,7 +806,9 @@ export function TuiApp({ ) : null}