Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/tui/app-key-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions src/tui/app-key-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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" &&
Expand Down
2 changes: 1 addition & 1 deletion src/tui/components/debug-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/tui/components/hotkey-hint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<HotkeyHint state={chatState()} menuLeaderArmed />,
);
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(
<HotkeyHint state={chatState({ pendingApproval: fakeApproval() })} menuLeaderArmed />,
);
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;

Expand Down
39 changes: 31 additions & 8 deletions src/tui/components/hotkey-hint.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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";

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 {
Expand All @@ -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 (
<Box flexShrink={0}>
{chips.map((chip, idx) => (
Expand All @@ -50,14 +57,29 @@ 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" },
{ key: "n", label: "deny" },
{ 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" },
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
57 changes: 28 additions & 29 deletions src/tui/components/status-bar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand All @@ -37,7 +40,7 @@ export function StatusBar({ state }: StatusBarProps): ReactElement {
</Text>
<Text color={theme.colors.muted}> v{getAppVersion()}</Text>
<Sep />
<SectionPills active={section} />
<Breadcrumb state={state} section={section} />
<SessionTag sessionId={state.session.sessionId} />
</Box>
);
Expand All @@ -49,30 +52,26 @@ const SECTION_LABELS: Record<TuiSection, string> = {
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 (
<Text>
{SECTION_ORDER.map((id, idx) => {
const isActive = id === active;
return (
<Text key={id}>
<Text
color={isActive ? theme.colors.accentSoft : theme.colors.muted}
bold={isActive}
>
{isActive ? `${theme.glyphs.chevronRight} ` : " "}
{SECTION_LABELS[id]}
</Text>
{idx < SECTION_ORDER.length - 1 ? (
<Text color={theme.colors.muted}>
{" "}
{theme.glyphs.dotSeparator}
{" "}
</Text>
) : null}
</Text>
);
})}
<Text color={theme.colors.accentSoft} bold>
{SECTION_LABELS[section]}
</Text>
{tabLabel ? (
<Text color={theme.colors.muted}>
{" "}
{theme.glyphs.chevronRight} <Text>{tabLabel}</Text>
</Text>
) : null}
</Text>
);
}
Expand Down
Loading