Skip to content
Open
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
33 changes: 33 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 @@ -77,6 +84,11 @@ export interface AppKeyContext {
* the sidebar steals plain Tab.
*/
sidebarVisible: boolean;
/** True while a `ctrl+g` leader is waiting for its chord key. */
menuLeaderArmed: boolean;
setMenuLeaderArmed: (armed: boolean) => void;
/** Navigate to a place, or run an action's slash command. */
activateMenuNode: (node: MenuNode) => void;
}

/**
Expand Down Expand Up @@ -112,6 +124,27 @@ export function handleAppKey(
if (state.updatePrompt && handleUpdateKey(input, key, ctx)) {
return true;
}
// The menu and its leader sit above every panel guard on purpose: they are
// the way out of a panel, so a panel must never be able to swallow them.
if (handleMenuKey(input, key, { state, dispatch, activate: ctx.activateMenuNode })) {
return true;
}
if (ctx.menuLeaderArmed) {
ctx.setMenuLeaderArmed(false);
const node = resolveLeaderChord(input, key);
if (node) ctx.activateMenuNode(node);
// An unclaimed chord is swallowed rather than passed on: a mistyped
// leader must not leak a letter into the prompt or fire a panel hotkey.
return true;
}
if (!state.slashPaletteOpen && isMenuLeaderKey(input, key)) {
ctx.setMenuLeaderArmed(true);
return true;
}
if (!state.slashPaletteOpen && isMenuOpenKey(input, key)) {
dispatch({ type: "menu_opened" });
return true;
}
if (
ctx.sidebarVisible &&
state.uiMode === "chat" &&
Expand Down
123 changes: 17 additions & 106 deletions src/tui/commands/slash-commands.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,113 +12,22 @@ export interface SlashCommandDef {
}

/**
* Atomic-agent's slash command registry. Intentionally small: the
* handler-side dispatch in `slash-command-handler.ts` knows how to
* action each name. Additions live here so the palette + parser stay
* in sync by construction.
* Atomic-agent's slash command registry — a **projection** of the
* operator menu (`src/tui/menu/menu-registry.ts`), not a list of its
* own. Every command is one menu node carrying a `slash` field, so the
* palette and the menu cannot describe the same command differently.
*
* Order is the historical palette order, carried on `MenuSlash.rank`:
* an empty query lists the registry as-is, and fuzzy-search ties break
* by index, so both are user-visible.
*
* To add a command, add the node to `MENU`. The handler-side dispatch in
* `slash-command-handler.ts` still knows how to action each name.
*/
export const SLASH_COMMANDS: readonly SlashCommandDef[] = [
{
name: "dump",
description:
"write debug zip (TUI snapshot + recent trace NDJSON) to ~/Documents/atomic-agent-debug",
},
{ name: "help", description: "list available slash commands" },
{
name: "tools",
description:
"list built-in tools (fs, shell, browser, memory, vision): `/tools` | `/tools <query>`",
},
{
name: "theme",
description:
"switch the UI theme: `/theme <name>` | `/theme list` (github, catppuccin, dracula, nord, …)",
},
{ name: "clear", description: "clear chat transcript (keeps session)" },
{ name: "abort", description: "abort the running turn" },
{ name: "quit", description: "exit atomic-agent", aliases: ["exit"] },
{ name: "debug", description: "toggle debug pane (feed / logs / world …)" },
{ name: "chat", description: "return to single-view chat mode" },
{
name: "run",
description:
"run mode: `/run` (picker) | `/run local|cloud|fusion [0-100]` — fusion orchestrates on cloud, executes locally",
},
{
name: "observe",
description:
"switch to the Observe section (feed / world / reasoning / logs / llm-logs)",
},
{
name: "manage",
description:
"switch to the Manage section (tasks / skills / LLM / telegram)",
},
{ name: "feed", description: "jump to the Observe → Feed tab" },
{ name: "logs", description: "jump to the Observe → Logs tab" },
{ name: "reasoning", description: "jump to the Observe → Reasoning tab" },
{ name: "world", description: "jump to the Observe → World tab" },
{ name: "expand", description: "expand every tool card in the chat log" },
{ name: "collapse", description: "collapse every tool card in the chat log" },
{ name: "session", description: "show current session id" },
{ name: "sessions", description: "open session picker to switch threads" },
{ name: "new", description: "start a fresh session (keeps warm runtime)" },
{
name: "skills",
description:
"jump to the Skills tab · subcommand: `/skills dump` to print catalog in chat",
},
{
name: "skill",
description:
"skill subcommand: `/skill enable <name>` | `/skill disable <name>`",
},
{
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 <id>` switch text provider",
},
{
name: "mcp",
description:
"open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove <name>` opens delete-confirm",
},
{
name: "model",
description:
"open chat model picker · subcommands: pull <id> | use <id> | status | <base-url>",
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 <id>` | `/task run <id>`",
},
{
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 `/`).
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
17 changes: 9 additions & 8 deletions src/tui/components/hotkey-hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
{ key: "tab", label: "next panel" },
{ key: "shift+tab", label: "prev panel" },
{ key: "esc", label: "back to Run" },
{ key: "/", label: "commands" },
{ key: "ctrl+p", label: "menu" },
{
key: "ctrl+c",
label: ctrlCArmed ? "press again to quit" : "quit",
Expand All @@ -113,18 +113,19 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
},
];
}
// Six chips is the cap for one row on narrow terminals. The scroll
// hint replaces ctrl+b: Observe stays reachable via /observe, while
// scrolling had no visible entry point at all. ctrl+r (run mode) is
// unadvertised here for the same reason ctrl+b is — the mode strip
// above the chat is already the visible entry point, and `/run`
// reaches the picker from the palette.
// Six chips is the cap for one row on narrow terminals. `ctrl+p` takes
// the slot `/` used to hold: the menu contains every slash command as
// well as every destination, so advertising the superset costs nothing
// and `/` keeps working for anyone who already reaches for it. ctrl+r
// (cycle run mode) stays unadvertised for the same reason ctrl+b was —
// the mode strip above the chat is its visible entry point, and the
// menu now lists Local / Cloud / Fusion outright.
return [
{ key: "enter", label: "send" },
{ key: "alt+enter", label: "newline" },
{ key: "tab", label: "sidebar" },
{ key: SCROLL_KEY, label: "scroll" },
{ key: "/", label: "commands" },
{ key: "ctrl+p", label: "menu" },
{
key: "ctrl+c",
label: ctrlCArmed ? "press again to quit" : "quit",
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