From 2100e7ab54460f28b67c3bb07855eef8f04eb9ec Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 3 Sep 2026 14:11:47 +0700 Subject: [PATCH] feat(tui): implement interactive menu system for model and config selection --- src/interfaces/tui/action.ts | 92 +++++++++++++- src/interfaces/tui/command.ts | 30 ++--- src/interfaces/tui/controller.ts | 93 ++++++++++---- src/interfaces/tui/menu.ts | 154 +++++++++++++++++++++++ src/interfaces/tui/render.smoke.test.tsx | 27 ++++ src/interfaces/tui/state.ts | 51 +++++++- src/interfaces/tui/tui.test.ts | 87 +++++++++---- src/interfaces/tui/ui.tsx | 71 ++++++++++- 8 files changed, 532 insertions(+), 73 deletions(-) create mode 100644 src/interfaces/tui/menu.ts diff --git a/src/interfaces/tui/action.ts b/src/interfaces/tui/action.ts index 65c64b2..e4ee5d1 100644 --- a/src/interfaces/tui/action.ts +++ b/src/interfaces/tui/action.ts @@ -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 diff --git a/src/interfaces/tui/command.ts b/src/interfaces/tui/command.ts index d0a95bb..0ed5228 100644 --- a/src/interfaces/tui/command.ts +++ b/src/interfaces/tui/command.ts @@ -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": diff --git a/src/interfaces/tui/controller.ts b/src/interfaces/tui/controller.ts index 657e8ad..185d54c 100644 --- a/src/interfaces/tui/controller.ts +++ b/src/interfaces/tui/controller.ts @@ -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; diff --git a/src/interfaces/tui/menu.ts b/src/interfaces/tui/menu.ts new file mode 100644 index 0000000..cfbd191 --- /dev/null +++ b/src/interfaces/tui/menu.ts @@ -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" }]; +} \ No newline at end of file diff --git a/src/interfaces/tui/render.smoke.test.tsx b/src/interfaces/tui/render.smoke.test.tsx index 34a295d..6512987 100644 --- a/src/interfaces/tui/render.smoke.test.tsx +++ b/src/interfaces/tui/render.smoke.test.tsx @@ -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"); + }); }); \ No newline at end of file diff --git a/src/interfaces/tui/state.ts b/src/interfaces/tui/state.ts index 0f86716..33b7506 100644 --- a/src/interfaces/tui/state.ts +++ b/src/interfaces/tui/state.ts @@ -35,11 +35,10 @@ export const COMMANDS: string[] = [ "/help", "/quit", "/clear", - "/edit", - "/mcp add", + "/edit ", + "/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 Open file for inline editing - Ctrl+S Save changes (not yet wired) Esc Dismiss editor ─── Legend ─── diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 753eb3e..28d7205 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -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[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", () => { diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx index e4351e7..62f8fd7 100644 --- a/src/interfaces/tui/ui.tsx +++ b/src/interfaces/tui/ui.tsx @@ -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 ? ( + + ) : overlayActive ? ( ) : ( @@ -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 ( + + {/* Quick filter bar at the top */} + {filter ? ` filter: ${frame.filter} ` : " type to filter…"} + {matched.map(({ it, i }) => { + const isSel = i === frame.selected; + const detail = detailOf?.[i] ?? ""; + return ( + + + {isSel ? {`${"▸ "}${it}`} : ` ${it}`} + + {detail && ( + + {detail} + + )} + + ); + })} + {matched.length === 0 && ( + no matches — type less or press Backspace. + )} + + {` ${frame.hint ?? ""}`} + + {frame.items.length > 10 && ( + + {` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`} + + )} + + ); +} + /* ── Status bar ───────────────────────────────────────────────────── */ function StatusBar(props: {