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