feat(tui): implement interactive menu system for model and config selection
This commit is contained in:
@@ -16,8 +16,10 @@ import {
|
|||||||
toastInfo,
|
toastInfo,
|
||||||
markDirty,
|
markDirty,
|
||||||
EditorState,
|
EditorState,
|
||||||
|
type MenuFrame,
|
||||||
} from "./state.ts";
|
} from "./state.ts";
|
||||||
import { gitDiffOutput } from "./git.ts";
|
import { gitDiffOutput } from "./git.ts";
|
||||||
|
import { modelProviderMenu, configMenu } from "./menu.ts";
|
||||||
|
|
||||||
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -46,7 +48,21 @@ export type Action =
|
|||||||
| { tag: "AbortTurn" }
|
| { tag: "AbortTurn" }
|
||||||
| { tag: "Compact" }
|
| { tag: "Compact" }
|
||||||
| { tag: "ShowDiff" }
|
| { 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. */
|
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
|
||||||
export function applyAction(state: AppStateRest, action: Action): void {
|
export function applyAction(state: AppStateRest, action: Action): void {
|
||||||
@@ -184,9 +200,83 @@ export function applyAction(state: AppStateRest, action: Action): void {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
break;
|
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. */
|
/** Periodic timer tick — drains queued events and applies streaming text. */
|
||||||
function handleTick(state: AppStateRest): void {
|
function handleTick(state: AppStateRest): void {
|
||||||
// Streaming updates are applied via appendToLastTranscript by the turn
|
// Streaming updates are applied via appendToLastTranscript by the turn
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export type Command =
|
|||||||
| { tag: "Clear" }
|
| { tag: "Clear" }
|
||||||
| { tag: "ClearConfirm" }
|
| { tag: "ClearConfirm" }
|
||||||
| { tag: "Edit"; path: string }
|
| { tag: "Edit"; path: string }
|
||||||
| { tag: "McpAdd"; name: string; command: string }
|
| { tag: "OpenModelMenu" }
|
||||||
| { tag: "ModelList" }
|
| { tag: "OpenConfigMenu" }
|
||||||
| { tag: "Compact" }
|
| { tag: "Compact" }
|
||||||
| { tag: "TodoOpen" }
|
| { tag: "TodoOpen" }
|
||||||
| { tag: "PlanOpen" }
|
| { tag: "PlanOpen" }
|
||||||
@@ -28,10 +28,9 @@ export function parseCommand(text: string): Command {
|
|||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
||||||
|
|
||||||
const parts = trimmed.split(" ", 3);
|
const parts = trimmed.split(" ", 2);
|
||||||
const cmd = parts[0] ?? "";
|
const cmd = parts[0] ?? "";
|
||||||
const arg1 = parts[1] ?? "";
|
const arg1 = parts[1] ?? "";
|
||||||
const arg2 = parts[2] ?? "";
|
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "/help":
|
case "/help":
|
||||||
@@ -43,18 +42,15 @@ export function parseCommand(text: string): Command {
|
|||||||
case "/edit":
|
case "/edit":
|
||||||
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
||||||
case "/mcp":
|
case "/mcp":
|
||||||
|
// No inline sub-commands: `/mcp` alone opens the interactive server list.
|
||||||
if (arg1 === "") return { tag: "McpOpen" };
|
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 };
|
return { tag: "Unknown", text: cmd };
|
||||||
case "/model":
|
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":
|
case "/compact":
|
||||||
return { tag: "Compact" };
|
return { tag: "Compact" };
|
||||||
case "/todo":
|
case "/todo":
|
||||||
@@ -85,10 +81,10 @@ export function applyCommand(cmd: Command): Action[] {
|
|||||||
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
||||||
case "Edit":
|
case "Edit":
|
||||||
return [{ tag: "OpenEditor", path: cmd.path }];
|
return [{ tag: "OpenEditor", path: cmd.path }];
|
||||||
case "McpAdd":
|
case "OpenModelMenu":
|
||||||
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
|
return [{ tag: "OpenMenuById", menu: "model" }];
|
||||||
case "ModelList":
|
case "OpenConfigMenu":
|
||||||
return [{ tag: "ModelList" }];
|
return [{ tag: "OpenMenuById", menu: "config" }];
|
||||||
case "Compact":
|
case "Compact":
|
||||||
return [{ tag: "Compact" }];
|
return [{ tag: "Compact" }];
|
||||||
case "TodoOpen":
|
case "TodoOpen":
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { AppStateRest } from "./state.ts";
|
|||||||
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
|
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
|
||||||
import type { Action } from "./action.ts";
|
import type { Action } from "./action.ts";
|
||||||
import { applyCommand, parseCommand } from "./command.ts";
|
import { applyCommand, parseCommand } from "./command.ts";
|
||||||
|
import { resolveMenu } from "./menu.ts";
|
||||||
|
|
||||||
/** A decoded terminal key event (normalised from raw input). */
|
/** A decoded terminal key event (normalised from raw input). */
|
||||||
export interface KeyEvent {
|
export interface KeyEvent {
|
||||||
@@ -32,6 +33,8 @@ export const KeyCode = {
|
|||||||
Down: "ArrowDown",
|
Down: "ArrowDown",
|
||||||
PageUp: "PageUp",
|
PageUp: "PageUp",
|
||||||
PageDown: "PageDown",
|
PageDown: "PageDown",
|
||||||
|
Home: "Home",
|
||||||
|
End: "End",
|
||||||
Esc: "Escape",
|
Esc: "Escape",
|
||||||
Tab: "Tab",
|
Tab: "Tab",
|
||||||
Char: "Char",
|
Char: "Char",
|
||||||
@@ -183,31 +186,31 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Model selector overlay ───────────────────────────────────────────
|
// ── Interactive menu stack ───────────────────────────────────────────
|
||||||
if (state.misc.overlay === "model_selector") {
|
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.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||||
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
|
if (code === KeyCode.Esc) return [{ tag: "MenuBack" }];
|
||||||
if (code === KeyCode.Up) {
|
if (code === KeyCode.Up) return n > 0 ? [{ tag: "MenuUp" }] : [];
|
||||||
const n = Object.keys(state.appConfig.providers).length || 3;
|
if (code === KeyCode.Down) return n > 0 ? [{ tag: "MenuDown" }] : [];
|
||||||
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, false);
|
if (code === KeyCode.PageUp) return n > 0 ? [{ tag: "MenuPageUp" }] : [];
|
||||||
return mark(state);
|
if (code === KeyCode.PageDown) return n > 0 ? [{ tag: "MenuPageDown" }] : [];
|
||||||
}
|
if (code === KeyCode.Home) return n > 0 ? [{ tag: "MenuHome" }] : [];
|
||||||
if (code === KeyCode.Down) {
|
if (code === KeyCode.End) return n > 0 ? [{ tag: "MenuEnd" }] : [];
|
||||||
const n = Object.keys(state.appConfig.providers).length || 3;
|
|
||||||
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, true);
|
|
||||||
return mark(state);
|
|
||||||
}
|
|
||||||
if (code === KeyCode.Enter) {
|
if (code === KeyCode.Enter) {
|
||||||
// Apply the selected provider and dismiss.
|
return enterMenu(state, frame?.id ?? "");
|
||||||
const names = Object.keys(state.appConfig.providers);
|
}
|
||||||
const picked = names[state.misc.selectedIndex] ?? names[0];
|
// vim-style j/k / g/G navigation.
|
||||||
if (picked) {
|
if (code === KeyCode.Char && key.value === "j") return n > 0 ? [{ tag: "MenuDown" }] : [];
|
||||||
state.settings = { ...state.settings, provider: picked };
|
if (code === KeyCode.Char && key.value === "k") return n > 0 ? [{ tag: "MenuUp" }] : [];
|
||||||
toastInfo(state, `Provider set to ${picked}`);
|
if (code === KeyCode.Char && key.value === "g") return n > 0 ? [{ tag: "MenuHome" }] : [];
|
||||||
}
|
if (code === KeyCode.Char && key.value === "G") return n > 0 ? [{ tag: "MenuEnd" }] : [];
|
||||||
state.misc.overlay = "none";
|
// Backspace removes the last filter character (type-to-filter).
|
||||||
state.misc.selectedIndex = 0;
|
if (code === KeyCode.Backspace) return menuFilterBackspace(state);
|
||||||
return mark(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 [];
|
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. */
|
/** Local helper: whether an agent turn is in flight. */
|
||||||
function turnInFlightLocal(state: AppStateRest): boolean {
|
function turnInFlightLocal(state: AppStateRest): boolean {
|
||||||
return state.turnInFlightFlag.value;
|
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 { createTestRenderer } from "@opentui/core/testing";
|
||||||
import { createRoot, flushSync } from "@opentui/react";
|
import { createRoot, flushSync } from "@opentui/react";
|
||||||
import { ZesdexApp } from "./ui.tsx";
|
import { ZesdexApp } from "./ui.tsx";
|
||||||
|
import { applyAction } from "./action.ts";
|
||||||
import {
|
import {
|
||||||
createTuiState,
|
createTuiState,
|
||||||
pushTranscript,
|
pushTranscript,
|
||||||
@@ -119,4 +120,30 @@ describe("ZesdexApp render smoke", () => {
|
|||||||
expect(frame).toContain("line one");
|
expect(frame).toContain("line one");
|
||||||
expect(frame).toContain("line three");
|
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",
|
"/help",
|
||||||
"/quit",
|
"/quit",
|
||||||
"/clear",
|
"/clear",
|
||||||
"/edit",
|
"/edit <path>",
|
||||||
"/mcp add",
|
"/mcp",
|
||||||
"/model",
|
"/model",
|
||||||
"/model ls",
|
"/config",
|
||||||
"/model add",
|
|
||||||
"/todo",
|
"/todo",
|
||||||
"/usage",
|
"/usage",
|
||||||
"/compact",
|
"/compact",
|
||||||
@@ -256,6 +255,7 @@ export const Overlay = {
|
|||||||
ModelSelector: "model_selector",
|
ModelSelector: "model_selector",
|
||||||
ClearConfirm: "clear_confirm",
|
ClearConfirm: "clear_confirm",
|
||||||
Diff: "diff",
|
Diff: "diff",
|
||||||
|
Menu: "menu",
|
||||||
} as const;
|
} as const;
|
||||||
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
|
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
|
||||||
|
|
||||||
@@ -299,6 +299,35 @@ export interface ActivityEntry {
|
|||||||
count?: number;
|
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 ───────────────────────────────────────────────────── */
|
/* ── Misc state ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The "miscellaneous" slice of app state. */
|
/** The "miscellaneous" slice of app state. */
|
||||||
@@ -320,6 +349,8 @@ export class MiscState {
|
|||||||
diffScroll = 0;
|
diffScroll = 0;
|
||||||
/** Breadcrumb of the current turn's activity (tools / sub-agents). */
|
/** Breadcrumb of the current turn's activity (tools / sub-agents). */
|
||||||
activity: ActivityEntry[] = [];
|
activity: ActivityEntry[] = [];
|
||||||
|
/** Stack of interactive menu frames (top = currently shown). */
|
||||||
|
menuStack: MenuFrame[] = [];
|
||||||
|
|
||||||
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||||
this.toasts.push(toast);
|
this.toasts.push(toast);
|
||||||
@@ -523,18 +554,26 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
|||||||
|
|
||||||
─── Overlays ───
|
─── Overlays ───
|
||||||
/help Show this help
|
/help Show this help
|
||||||
|
/model Pick provider → model (interactive)
|
||||||
|
/config Settings menu (internet, review, provider)
|
||||||
/todo Open tasks (todo) overlay
|
/todo Open tasks (todo) overlay
|
||||||
/usage Open usage statistics
|
/usage Open usage statistics
|
||||||
/diff Open git diff overlay
|
/diff Open git diff overlay
|
||||||
/plan Open plan overlay
|
/plan Open plan overlay
|
||||||
/mcp Open MCP server management
|
/mcp Open MCP server management
|
||||||
/model Open model selector
|
|
||||||
/compact Compact conversation
|
/compact Compact conversation
|
||||||
/clear Clear transcript
|
/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 ───
|
─── Editor Mode ───
|
||||||
/edit <path> Open file for inline editing
|
/edit <path> Open file for inline editing
|
||||||
Ctrl+S Save changes (not yet wired)
|
|
||||||
Esc Dismiss editor
|
Esc Dismiss editor
|
||||||
|
|
||||||
─── Legend ───
|
─── Legend ───
|
||||||
|
|||||||
@@ -112,41 +112,82 @@ describe("handleKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleKey model selector", () => {
|
describe("interactive menu stack (/model, /config)", () => {
|
||||||
test("Enter applies the currently selected provider and closes", () => {
|
test("/model command opens a menu (OpenMenuById → model)", () => {
|
||||||
const s = freshState();
|
const s = freshState();
|
||||||
s.misc.overlay = "model_selector";
|
|
||||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||||
s.settings = { provider: "claude" };
|
const actions = applyCommand(parseCommand("/model"));
|
||||||
|
expect(actions).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
|
||||||
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
for (const a of actions) applyAction(s, a);
|
||||||
// The guard mutates state in place (a no-action mark), not via Action[].
|
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
|
||||||
expect(actions).toEqual([]);
|
expect(frame?.id).toBe("model:provider");
|
||||||
expect(s.settings.provider).toBe("claude"); // index 0 unchanged
|
expect(frame?.items).toEqual(["claude", "zen", "router"]);
|
||||||
expect(s.misc.overlay as unknown as string).toBe("none"); // dismissed
|
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();
|
const s = freshState();
|
||||||
s.misc.overlay = "model_selector";
|
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
|
||||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
const actions = applyCommand(parseCommand("/config"));
|
||||||
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
applyAction(s, actions[0]!);
|
||||||
expect(s.misc.selectedIndex).toBe(1);
|
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
|
||||||
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
expect(frame?.id).toBe("config:root");
|
||||||
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
expect(frame?.items).toContain("Internet access");
|
||||||
expect(s.misc.selectedIndex).toBe(0); // wraps 2 → 0
|
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();
|
const s = freshState();
|
||||||
s.misc.overlay = "model_selector";
|
|
||||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||||
s.settings = { provider: "claude" };
|
applyAction(s, { tag: "OpenMenuById", menu: "model" });
|
||||||
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); // → zen
|
applyAction(s, { tag: "MenuDown" });
|
||||||
handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
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.settings.provider).toBe("zen");
|
||||||
expect(s.misc.overlay as unknown as string).toBe("none");
|
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", () => {
|
describe("decodeKey", () => {
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
|
|||||||
case "pagedown":
|
case "pagedown":
|
||||||
case "pgdn":
|
case "pgdn":
|
||||||
return { code: KeyCode.PageDown, ctrl, alt };
|
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 "escape":
|
||||||
case "esc":
|
case "esc":
|
||||||
return { code: KeyCode.Esc, ctrl, alt };
|
return { code: KeyCode.Esc, ctrl, alt };
|
||||||
@@ -422,10 +426,13 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
}, [running, state, setTick]);
|
}, [running, state, setTick]);
|
||||||
|
|
||||||
const overlayActive = state.misc.overlay !== "none";
|
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),
|
// Modal overlays and interactive menus take the full body (below the header,
|
||||||
// replacing the transcript + input so their content is never cramped.
|
// above the status bar), replacing the transcript + input.
|
||||||
const body = overlayActive ? (
|
const body = menuActive ? (
|
||||||
|
<MenuView state={state} height={height} />
|
||||||
|
) : overlayActive ? (
|
||||||
<OverlayView state={state} width={width} height={height} />
|
<OverlayView state={state} width={width} height={height} />
|
||||||
) : (
|
) : (
|
||||||
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
<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 ───────────────────────────────────────────────────── */
|
/* ── Status bar ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function StatusBar(props: {
|
function StatusBar(props: {
|
||||||
|
|||||||
Reference in New Issue
Block a user