feat(tui): implement interactive menu system for model and config selection

This commit is contained in:
asepharyana
2026-09-03 16:13:29 +07:00
parent c53fdfa7c9
commit 2100e7ab54
8 changed files with 532 additions and 73 deletions
+91 -1
View File
@@ -16,8 +16,10 @@ import {
toastInfo,
markDirty,
EditorState,
type MenuFrame,
} from "./state.ts";
import { gitDiffOutput } from "./git.ts";
import { modelProviderMenu, configMenu } from "./menu.ts";
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
export type Action =
@@ -46,7 +48,21 @@ export type Action =
| { tag: "AbortTurn" }
| { tag: "Compact" }
| { tag: "ShowDiff" }
| { tag: "DiffScroll"; amount: number };
| { tag: "DiffScroll"; amount: number }
/* Interactive menu stack */
| { tag: "OpenMenu"; frame: MenuFrame }
| { tag: "MenuUp" }
| { tag: "MenuDown" }
| { tag: "MenuPageUp" }
| { tag: "MenuPageDown" }
| { tag: "MenuHome" }
| { tag: "MenuEnd" }
| { tag: "MenuSelect" }
| { tag: "MenuBack" }
| { tag: "MenuBackAll" }
| { tag: "MenuApply"; index: number }
/** Open a named top-level menu (frame built from live state at apply time). */
| { tag: "OpenMenuById"; menu: "model" | "config" };
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
export function applyAction(state: AppStateRest, action: Action): void {
@@ -184,9 +200,83 @@ export function applyAction(state: AppStateRest, action: Action): void {
state.dirty = true;
}
break;
case "OpenMenu":
state.misc.menuStack.push(action.frame);
state.misc.overlay = "menu";
state.dirty = true;
break;
case "MenuUp":
moveMenu(state, -1);
break;
case "MenuDown":
moveMenu(state, 1);
break;
case "MenuPageUp":
moveMenu(state, -10);
break;
case "MenuPageDown":
moveMenu(state, 10);
break;
case "MenuHome": {
const f = state.misc.menuStack[state.misc.menuStack.length - 1];
if (f && f.items.length) f.selected = 0;
state.dirty = true;
break;
}
case "MenuEnd": {
const f = state.misc.menuStack[state.misc.menuStack.length - 1];
if (f && f.items.length) f.selected = f.items.length - 1;
state.dirty = true;
break;
}
case "MenuSelect":
// Push nothing here; the controller resolves a sub-menu or action. This
// simply marks dirty so the selection is reflected.
state.dirty = true;
break;
case "MenuBack":
state.misc.menuStack.pop();
if (state.misc.menuStack.length === 0) state.misc.overlay = "none";
state.dirty = true;
break;
case "MenuBackAll":
state.misc.menuStack = [];
state.misc.overlay = "none";
state.dirty = true;
break;
case "MenuApply":
applyMenuAt(state, action.index);
break;
case "OpenMenuById": {
const frame =
action.menu === "model" ? modelProviderMenu(state) : configMenu(state);
state.misc.menuStack.push(frame);
state.misc.overlay = "menu";
state.dirty = true;
break;
}
}
}
/** Move the top menu's selection by `delta`, wrapping at the ends. */
function moveMenu(state: AppStateRest, delta: number): void {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
if (!frame || frame.items.length === 0) return;
const n = frame.items.length;
frame.selected = (frame.selected + delta) % n;
if (frame.selected < 0) frame.selected += n;
state.dirty = true;
}
function applyMenuAt(state: AppStateRest, index: number): void {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
if (!frame) return;
state.misc.selectedIndex = index;
state.dirty = true;
}
/** Periodic timer tick — drains queued events and applies streaming text. */
function handleTick(state: AppStateRest): void {
// Streaming updates are applied via appendToLastTranscript by the turn
+13 -17
View File
@@ -14,8 +14,8 @@ export type Command =
| { tag: "Clear" }
| { tag: "ClearConfirm" }
| { tag: "Edit"; path: string }
| { tag: "McpAdd"; name: string; command: string }
| { tag: "ModelList" }
| { tag: "OpenModelMenu" }
| { tag: "OpenConfigMenu" }
| { tag: "Compact" }
| { tag: "TodoOpen" }
| { tag: "PlanOpen" }
@@ -28,10 +28,9 @@ export function parseCommand(text: string): Command {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
const parts = trimmed.split(" ", 3);
const parts = trimmed.split(" ", 2);
const cmd = parts[0] ?? "";
const arg1 = parts[1] ?? "";
const arg2 = parts[2] ?? "";
switch (cmd) {
case "/help":
@@ -43,18 +42,15 @@ export function parseCommand(text: string): Command {
case "/edit":
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
case "/mcp":
// No inline sub-commands: `/mcp` alone opens the interactive server list.
if (arg1 === "") return { tag: "McpOpen" };
if (arg1 === "add") {
const rest = arg2.trim();
const space = rest.indexOf(" ");
if (space !== -1) {
return { tag: "McpAdd", name: rest.slice(0, space), command: rest.slice(space + 1).trim() };
}
return { tag: "McpAdd", name: rest, command: "" };
}
return { tag: "Unknown", text: cmd };
case "/model":
return { tag: "ModelList" };
// No inline `/model add` / `/model ls`: `/model` is a nested picker.
return { tag: "OpenModelMenu" };
case "/config":
case "/settings":
return { tag: "OpenConfigMenu" };
case "/compact":
return { tag: "Compact" };
case "/todo":
@@ -85,10 +81,10 @@ export function applyCommand(cmd: Command): Action[] {
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
case "Edit":
return [{ tag: "OpenEditor", path: cmd.path }];
case "McpAdd":
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
case "ModelList":
return [{ tag: "ModelList" }];
case "OpenModelMenu":
return [{ tag: "OpenMenuById", menu: "model" }];
case "OpenConfigMenu":
return [{ tag: "OpenMenuById", menu: "config" }];
case "Compact":
return [{ tag: "Compact" }];
case "TodoOpen":
+70 -23
View File
@@ -10,6 +10,7 @@ import type { AppStateRest } from "./state.ts";
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
import type { Action } from "./action.ts";
import { applyCommand, parseCommand } from "./command.ts";
import { resolveMenu } from "./menu.ts";
/** A decoded terminal key event (normalised from raw input). */
export interface KeyEvent {
@@ -32,6 +33,8 @@ export const KeyCode = {
Down: "ArrowDown",
PageUp: "PageUp",
PageDown: "PageDown",
Home: "Home",
End: "End",
Esc: "Escape",
Tab: "Tab",
Char: "Char",
@@ -183,31 +186,31 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
return [];
}
// ── Model selector overlay ───────────────────────────────────────────
if (state.misc.overlay === "model_selector") {
// ── Interactive menu stack ───────────────────────────────────────────
if (state.misc.menuStack.length > 0) {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
const n = frame?.items.length ?? 0;
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
if (code === KeyCode.Up) {
const n = Object.keys(state.appConfig.providers).length || 3;
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, false);
return mark(state);
}
if (code === KeyCode.Down) {
const n = Object.keys(state.appConfig.providers).length || 3;
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, true);
return mark(state);
}
if (code === KeyCode.Esc) return [{ tag: "MenuBack" }];
if (code === KeyCode.Up) return n > 0 ? [{ tag: "MenuUp" }] : [];
if (code === KeyCode.Down) return n > 0 ? [{ tag: "MenuDown" }] : [];
if (code === KeyCode.PageUp) return n > 0 ? [{ tag: "MenuPageUp" }] : [];
if (code === KeyCode.PageDown) return n > 0 ? [{ tag: "MenuPageDown" }] : [];
if (code === KeyCode.Home) return n > 0 ? [{ tag: "MenuHome" }] : [];
if (code === KeyCode.End) return n > 0 ? [{ tag: "MenuEnd" }] : [];
if (code === KeyCode.Enter) {
// Apply the selected provider and dismiss.
const names = Object.keys(state.appConfig.providers);
const picked = names[state.misc.selectedIndex] ?? names[0];
if (picked) {
state.settings = { ...state.settings, provider: picked };
toastInfo(state, `Provider set to ${picked}`);
}
state.misc.overlay = "none";
state.misc.selectedIndex = 0;
return mark(state);
return enterMenu(state, frame?.id ?? "");
}
// vim-style j/k / g/G navigation.
if (code === KeyCode.Char && key.value === "j") return n > 0 ? [{ tag: "MenuDown" }] : [];
if (code === KeyCode.Char && key.value === "k") return n > 0 ? [{ tag: "MenuUp" }] : [];
if (code === KeyCode.Char && key.value === "g") return n > 0 ? [{ tag: "MenuHome" }] : [];
if (code === KeyCode.Char && key.value === "G") return n > 0 ? [{ tag: "MenuEnd" }] : [];
// Backspace removes the last filter character (type-to-filter).
if (code === KeyCode.Backspace) return menuFilterBackspace(state);
// Printable char → type-to-filter (narrow + jump to first match).
if (code === KeyCode.Char && key.value && key.value.trim().length > 0 && /^[\w./-]$/.test(key.value)) {
return menuFilterAppend(state, key.value);
}
return [];
}
@@ -331,6 +334,50 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
}
}
/**
* Resolve Enter on a menu item. `id` joins the menu navigation path with `:`
* (e.g. `model:provider`, `model:model`, `settings:internet`). Returns the
* actions to run — typically an `OpenMenu` for a sub-menu, or an apply action
* that pops the whole stack.
*/
function enterMenu(state: AppStateRest, id: string): Action[] {
// Delegate Enter to the semantic resolver (menu.ts): it returns an OpenMenu
// for nested pickers, or applied actions that pop the stack.
return resolveMenu(state, id) ?? [];
}
/** Append a character to the type-to-filter and jump to the first match. */
function menuFilterAppend(state: AppStateRest, ch: string): Action[] {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
if (!frame || frame.items.length === 0) return [];
frame.filter = (frame.filter ?? "") + ch.toLowerCase();
jumpFiltered(frame);
markDirty(state);
return [];
}
/** Remove the last filter character; jump back to the first match. */
function menuFilterBackspace(state: AppStateRest): Action[] {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
if (!frame || frame.items.length === 0) return [];
const f = frame.filter ?? "";
frame.filter = f.slice(0, -1);
jumpFiltered(frame);
markDirty(state);
return [];
}
/** Move selection to the first item matching the frame filter (if any). */
function jumpFiltered(frame: { items: string[]; selected: number; filter?: string }): void {
const f = (frame.filter ?? "").toLowerCase();
if (!f) {
frame.selected = 0;
return;
}
const hit = frame.items.findIndex((it) => it.toLowerCase().includes(f));
if (hit !== -1) frame.selected = hit;
}
/** Local helper: whether an agent turn is in flight. */
function turnInFlightLocal(state: AppStateRest): boolean {
return state.turnInFlightFlag.value;
+154
View File
@@ -0,0 +1,154 @@
/**
* Data-driven interactive menus for the TUI.
*
* Replaces inline sub-commands (`/model add`, `/mcp add …`) with pickers the
* way Claude Code / opencode do. Each menu is a flat `MenuFrame` of selectable
* rows; choosing a row either opens a nested sub-menu (via `OpenMenu`) or
* applies a change and pops the stack. Everything here is logic-pure and
* unit-testable.
*
* Every select dialog shares one keymap (arrow/jk, Home/End, PageUp/Down,
* Enter select, Esc back) centralised in `controller.ts`; the *shape* of each
* menu and the *effect* of choosing a row lives here. The current value is
* shown on every relevant row.
*/
import type { AppStateRest, MenuFrame } from "./state.ts";
import type { Action } from "./action.ts";
import { toastInfo } from "./state.ts";
/* ── /model tree ──────────────────────────────────────────────────── */
/** Build the `/model` provider menu (opencode-style provider list first). */
export function modelProviderMenu(state: AppStateRest): MenuFrame {
const names = Object.keys(state.appConfig.providers);
const list = names.length ? names : ["claude", "zen", "router"];
const current = String(state.settings.provider ?? "");
return {
id: "model:provider",
title: "Providers",
items: list,
details: list.map((p) =>
p === current ? ` (active)` : "",
),
hint: "↑/↓ or j/k · Enter open models · Esc back",
selected: Math.max(0, list.indexOf(current)),
};
}
/** Build the per-provider model sub-menu. */
export function modelSubMenu(_state: AppStateRest, provider: string): MenuFrame | null {
// Candidate models for this provider. In a fuller build these come from a
// catalog / API; here we present a sensible, sortable list.
const known = [
"claude-opus-5",
"claude-sonnet-5",
"claude-haiku-4-5",
"deepseek-v4-flash-free",
"gpt-5",
];
return {
id: `model:${provider}`,
title: `Models — ${provider}`,
items: known,
details: known.map((m) => (m === _state.settings.model ? " (current)" : "")),
hint: "Enter applies · Esc back to providers",
selected: Math.max(0, known.indexOf(String(_state.settings.model ?? ""))),
};
}
/* ── /config tree ─────────────────────────────────────────────────── */
/** Build the top-level `/config` menu. */
export function configMenu(state: AppStateRest): MenuFrame {
const internet = String(state.settings.internet_mode ?? "Off");
const review = state.settings.review_enabled !== false ? "on" : "off";
const provider = String(state.settings.provider ?? "");
return {
id: "config:root",
title: "Settings",
items: ["Internet access", "Review enabled", "Provider / model"],
details: [
`current: ${internet}`,
`current: ${review}`,
`current: ${provider}`,
],
hint: "↑/↓ or j/k · Enter · Esc close",
selected: 0,
};
}
/** Build the internet-mode sub-menu. */
function internetMenu(state: AppStateRest): MenuFrame {
const modes = ["Off", "ReadOnly", "Full"];
const current = String(state.settings.internet_mode ?? "Off");
return {
id: "config:internet",
title: "Config — internet access",
items: modes,
details: modes.map((m) => (m === current ? " (current)" : "")),
hint: "Enter set · Esc back",
selected: Math.max(0, modes.indexOf(current)),
};
}
/* ── Menu resolver ────────────────────────────────────────────────── */
/**
* Resolve Enter inside any interactive menu. `id` encodes the navigation path
* (e.g. `model:provider`, `model:zen`, `config:root`, `config:internet`).
* Reads the top-most frame's selected row and returns the actions to run —
* usually an `OpenMenu` for a nested picker, or applied actions that pop the
* entire stack.
*/
export function resolveMenu(state: AppStateRest, id: string): Action[] {
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
if (!frame) return [];
const item = frame.items[frame.selected];
if (item === undefined) return [];
const idx = frame.selected;
// ── model tree ────────────────────────────────────────────────────
if (id === "model:provider") {
const sub = modelSubMenu(state, item);
return sub ? [{ tag: "OpenMenu", frame: sub }] : popAll();
}
if (id.startsWith("model:")) {
// A model row under a provider — apply it.
const provider = id.slice("model:".length);
state.settings = { ...state.settings, provider, model: item };
toastInfo(state, `Using ${provider} / ${item}`);
return popAll();
}
// ── config tree ───────────────────────────────────────────────────
if (id === "config:root") {
switch (idx) {
case 0:
return [{ tag: "OpenMenu", frame: internetMenu(state) }];
case 1: {
const on = state.settings.review_enabled !== false;
state.settings = { ...state.settings, review_enabled: !on };
toastInfo(state, `Review ${on ? "disabled" : "enabled"}`);
return popAll();
}
case 2:
return [{ tag: "OpenMenu", frame: modelProviderMenu(state) }];
default:
return [];
}
}
if (id === "config:internet") {
const modes = ["Off", "ReadOnly", "Full"];
const chosen = modes[idx] ?? "Off";
state.settings = { ...state.settings, internet_mode: chosen };
toastInfo(state, `Internet mode → ${chosen}`);
return popAll();
}
return [];
}
/** Pop the whole menu stack (a leaf decision applied). */
function popAll(): Action[] {
return [{ tag: "MenuBackAll" }];
}
+27
View File
@@ -7,6 +7,7 @@ import { describe, test, expect } from "bun:test";
import { createTestRenderer } from "@opentui/core/testing";
import { createRoot, flushSync } from "@opentui/react";
import { ZesdexApp } from "./ui.tsx";
import { applyAction } from "./action.ts";
import {
createTuiState,
pushTranscript,
@@ -119,4 +120,30 @@ describe("ZesdexApp render smoke", () => {
expect(frame).toContain("line one");
expect(frame).toContain("line three");
});
test("interactive /model menu renders providers with active preselected", async () => {
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
s.settings = { provider: "zen" };
applyAction(s, { tag: "OpenMenuById", menu: "model" });
const frame = await render(s, 90, 24);
expect(frame).toContain("Providers");
expect(frame).toContain("claude");
expect(frame).toContain("zen");
expect(frame).toContain("router");
expect(frame).toContain("type to filter");
expect(frame).toContain("(active)");
});
test("interactive /config menu shows rows with current values", async () => {
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
applyAction(s, { tag: "OpenMenuById", menu: "config" });
const frame = await render(s, 90, 24);
expect(frame).toContain("Settings");
expect(frame).toContain("Internet access");
expect(frame).toContain("Review enabled");
expect(frame).toContain("current: Off");
expect(frame).toContain("current: zen");
});
});
+45 -6
View File
@@ -35,11 +35,10 @@ export const COMMANDS: string[] = [
"/help",
"/quit",
"/clear",
"/edit",
"/mcp add",
"/edit <path>",
"/mcp",
"/model",
"/model ls",
"/model add",
"/config",
"/todo",
"/usage",
"/compact",
@@ -256,6 +255,7 @@ export const Overlay = {
ModelSelector: "model_selector",
ClearConfirm: "clear_confirm",
Diff: "diff",
Menu: "menu",
} as const;
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
@@ -299,6 +299,35 @@ export interface ActivityEntry {
count?: number;
}
/* ── Interactive menu ─────────────────────────────────────────────── */
/**
* A single frame in the interactive menu stack. Reusable across every
* configuration surface (models, settings, MCP, todo, …) so each is a
* picker like Claude Code / opencode — never inline sub-commands.
*/
export interface MenuFrame {
/** Stable id used by the resolver to route Enter (e.g. "model:provider"). */
id: string;
title: string;
/** Selectable option labels (one line each). */
items: string[];
/** Optional dim detail shown under each option. */
details?: string[];
/** Footer hint line (keybindings / current value). */
hint?: string;
selected: number;
/** Live type-to-filter: typing narrows/highlights matching rows. */
filter?: string;
}
export const emptyMenuFrame = (): MenuFrame => ({
id: "",
title: "",
items: [],
selected: 0,
});
/* ── Misc state ───────────────────────────────────────────────────── */
/** The "miscellaneous" slice of app state. */
@@ -320,6 +349,8 @@ export class MiscState {
diffScroll = 0;
/** Breadcrumb of the current turn's activity (tools / sub-agents). */
activity: ActivityEntry[] = [];
/** Stack of interactive menu frames (top = currently shown). */
menuStack: MenuFrame[] = [];
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
this.toasts.push(toast);
@@ -523,18 +554,26 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
─── Overlays ───
/help Show this help
/model Pick provider → model (interactive)
/config Settings menu (internet, review, provider)
/todo Open tasks (todo) overlay
/usage Open usage statistics
/diff Open git diff overlay
/plan Open plan overlay
/mcp Open MCP server management
/model Open model selector
/compact Compact conversation
/clear Clear transcript
─── Menus (interactive pickers) ───
↑/↓ · j/k Move selection
PgUp/PgDn Page Home/End — jump
g / G Top / Bottom
type a-z Type-to-filter
Enter Open / apply
Esc Back / close
─── Editor Mode ───
/edit <path> Open file for inline editing
Ctrl+S Save changes (not yet wired)
Esc Dismiss editor
─── Legend ───
+64 -23
View File
@@ -112,41 +112,82 @@ describe("handleKey", () => {
});
});
describe("handleKey model selector", () => {
test("Enter applies the currently selected provider and closes", () => {
describe("interactive menu stack (/model, /config)", () => {
test("/model command opens a menu (OpenMenuById → model)", () => {
const s = freshState();
s.misc.overlay = "model_selector";
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
s.settings = { provider: "claude" };
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
// The guard mutates state in place (a no-action mark), not via Action[].
expect(actions).toEqual([]);
expect(s.settings.provider).toBe("claude"); // index 0 unchanged
expect(s.misc.overlay as unknown as string).toBe("none"); // dismissed
const actions = applyCommand(parseCommand("/model"));
expect(actions).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
for (const a of actions) applyAction(s, a);
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
expect(frame?.id).toBe("model:provider");
expect(frame?.items).toEqual(["claude", "zen", "router"]);
expect(s.misc.overlay).toBe("menu");
});
test("Down moves selection forward, wraps at the last one", () => {
test("/config command opens the settings menu", () => {
const s = freshState();
s.misc.overlay = "model_selector";
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
expect(s.misc.selectedIndex).toBe(1);
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
expect(s.misc.selectedIndex).toBe(0); // wraps 2 → 0
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
const actions = applyCommand(parseCommand("/config"));
applyAction(s, actions[0]!);
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
expect(frame?.id).toBe("config:root");
expect(frame?.items).toContain("Internet access");
expect(frame?.items).toContain("Provider / model");
});
test("Enter on a non-first selection applies that provider", () => {
test("arrow/jk navigation moves selection; Home/End/extended wraps", () => {
const s = freshState();
s.misc.overlay = "model_selector";
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
s.settings = { provider: "claude" };
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); // → zen
handleKey({ code: "Enter", ctrl: false, alt: false }, s);
applyAction(s, { tag: "OpenMenuById", menu: "model" });
applyAction(s, { tag: "MenuDown" });
expect(s.misc.menuStack.at(-1)?.selected).toBe(1);
applyAction(s, { tag: "MenuEnd" });
expect(s.misc.menuStack.at(-1)?.selected).toBe(2);
applyAction(s, { tag: "MenuDown" }); // wraps 2 → 0
expect(s.misc.menuStack.at(-1)?.selected).toBe(0);
applyAction(s, { tag: "MenuHome" });
expect(s.misc.menuStack.at(-1)?.selected).toBe(0);
const claudeIndex = 0;
void claudeIndex;
});
test("provider → model nested flow applies and pops the stack", () => {
const s = freshState();
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
s.settings = { provider: "zen", model: "x" };
applyAction(s, { tag: "OpenMenuById", menu: "model" });
// The active provider (zen) is pre-selected when the menu opens.
const stack = () => s.misc.menuStack;
expect(stack().at(-1)?.items[0]).toBe("claude");
expect(stack().at(-1)?.selected).toBe(1); // zen
// Reuse a tiny helper that mirrors ui.tsx: apply every action from handleKey.
const drive = (k: Parameters<typeof handleKey>[0]) => {
for (const a of handleKey(k, s)) applyAction(s, a);
};
// Enter on the active provider → opens its model sub-menu.
drive({ code: "Enter", ctrl: false, alt: false });
expect(stack().length).toBe(2);
expect(stack().at(-1)?.id).toBe("model:zen");
expect(stack().at(-1)?.items.length).toBeGreaterThan(0);
// Enter on a model row applies zen/model and pops the whole stack.
drive({ code: "Enter", ctrl: false, alt: false });
expect(s.settings.provider).toBe("zen");
expect(s.misc.overlay as unknown as string).toBe("none");
});
test("type-to-filter narrows selection to the first match", () => {
const s = freshState();
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
applyAction(s, { tag: "OpenMenuById", menu: "model" });
handleKey({ code: "Char", value: "r", ctrl: false, alt: false }, s);
const frame = s.misc.menuStack.at(-1)!;
expect(frame.filter).toBe("r");
expect(frame.selected).toBe(2); // "router" is the r match
// Backspace clears the filter.
handleKey({ code: "Backspace", ctrl: false, alt: false }, s);
expect(s.misc.menuStack.at(-1)?.filter).toBe("");
});
});
describe("decodeKey", () => {
+68 -3
View File
@@ -72,6 +72,10 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
case "pagedown":
case "pgdn":
return { code: KeyCode.PageDown, ctrl, alt };
case "home":
return { code: KeyCode.Home, ctrl, alt };
case "end":
return { code: KeyCode.End, ctrl, alt };
case "escape":
case "esc":
return { code: KeyCode.Esc, ctrl, alt };
@@ -422,10 +426,13 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
}, [running, state, setTick]);
const overlayActive = state.misc.overlay !== "none";
const menuActive = state.misc.menuStack.length > 0;
// Modal overlays take the full body (below the header, above the status bar),
// replacing the transcript + input so their content is never cramped.
const body = overlayActive ? (
// Modal overlays and interactive menus take the full body (below the header,
// above the status bar), replacing the transcript + input.
const body = menuActive ? (
<MenuView state={state} height={height} />
) : overlayActive ? (
<OverlayView state={state} width={width} height={height} />
) : (
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
@@ -783,6 +790,64 @@ function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode
);
}
/* ── Interactive menu ─────────────────────────────────────────────── */
/** Modal picker for the menu stack — like Claude Code / opencode selectors. */
function MenuView(props: { state: AppStateRest; height: number }): ReactNode {
const { state, height } = props;
const stack = state.misc.menuStack;
const frame = stack[stack.length - 1];
if (!frame) return null;
const filter = (frame.filter ?? "").toLowerCase();
// Rows that match the live filter (empty filter = all rows).
const rawMatched = frame.items
.map((it, i) => ({ it, i }))
.filter((r) => !filter || r.it.toLowerCase().includes(filter));
// Fit the visible rows to the modal height (top rows take priority so the
// selected row stays anchored near the top).
const maxRows = Math.max(4, height - 8);
const visibleStart = Math.max(0, Math.min(rawMatched.length - maxRows, frame.selected));
const matched = rawMatched.slice(visibleStart, visibleStart + maxRows);
const detailOf = frame.details;
// Breadcrumb: titles from the stack, e.g. "Model › pick a model (zen)".
const crumb = stack.map((f) => f.title).join(" › ");
return (
<OverlayFrame title={crumb}>
{/* Quick filter bar at the top */}
<text fg={C.dim}>{filter ? ` filter: ${frame.filter} ` : " type to filter…"}</text>
{matched.map(({ it, i }) => {
const isSel = i === frame.selected;
const detail = detailOf?.[i] ?? "";
return (
<box key={i} flexDirection="row" width="100%">
<text fg={isSel ? C.accent : C.fg} flexGrow={1}>
{isSel ? <b>{`${"▸ "}${it}`}</b> : ` ${it}`}
</text>
{detail && (
<text fg={C.dim} flexShrink={0}>
{detail}
</text>
)}
</box>
);
})}
{matched.length === 0 && (
<text fg={C.red}> no matches — type less or press Backspace.</text>
)}
<text fg={C.dim}>
{` ${frame.hint ?? ""}`}
</text>
{frame.items.length > 10 && (
<text fg={C.dim}>
{` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`}
</text>
)}
</OverlayFrame>
);
}
/* ── Status bar ───────────────────────────────────────────────────── */
function StatusBar(props: {