feat(tui): implement interactive menu system for model and config selection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user