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

This commit is contained in:
asepharyana
2026-09-03 16:13:29 +07:00
parent c53fdfa7c9
commit 2100e7ab54
8 changed files with 532 additions and 73 deletions
+68 -3
View File
@@ -72,6 +72,10 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
case "pagedown":
case "pgdn":
return { code: KeyCode.PageDown, ctrl, alt };
case "home":
return { code: KeyCode.Home, ctrl, alt };
case "end":
return { code: KeyCode.End, ctrl, alt };
case "escape":
case "esc":
return { code: KeyCode.Esc, ctrl, alt };
@@ -422,10 +426,13 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
}, [running, state, setTick]);
const overlayActive = state.misc.overlay !== "none";
const menuActive = state.misc.menuStack.length > 0;
// Modal overlays take the full body (below the header, above the status bar),
// replacing the transcript + input so their content is never cramped.
const body = overlayActive ? (
// Modal overlays and interactive menus take the full body (below the header,
// above the status bar), replacing the transcript + input.
const body = menuActive ? (
<MenuView state={state} height={height} />
) : overlayActive ? (
<OverlayView state={state} width={width} height={height} />
) : (
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
@@ -783,6 +790,64 @@ function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode
);
}
/* ── Interactive menu ─────────────────────────────────────────────── */
/** Modal picker for the menu stack — like Claude Code / opencode selectors. */
function MenuView(props: { state: AppStateRest; height: number }): ReactNode {
const { state, height } = props;
const stack = state.misc.menuStack;
const frame = stack[stack.length - 1];
if (!frame) return null;
const filter = (frame.filter ?? "").toLowerCase();
// Rows that match the live filter (empty filter = all rows).
const rawMatched = frame.items
.map((it, i) => ({ it, i }))
.filter((r) => !filter || r.it.toLowerCase().includes(filter));
// Fit the visible rows to the modal height (top rows take priority so the
// selected row stays anchored near the top).
const maxRows = Math.max(4, height - 8);
const visibleStart = Math.max(0, Math.min(rawMatched.length - maxRows, frame.selected));
const matched = rawMatched.slice(visibleStart, visibleStart + maxRows);
const detailOf = frame.details;
// Breadcrumb: titles from the stack, e.g. "Model › pick a model (zen)".
const crumb = stack.map((f) => f.title).join(" › ");
return (
<OverlayFrame title={crumb}>
{/* Quick filter bar at the top */}
<text fg={C.dim}>{filter ? ` filter: ${frame.filter} ` : " type to filter…"}</text>
{matched.map(({ it, i }) => {
const isSel = i === frame.selected;
const detail = detailOf?.[i] ?? "";
return (
<box key={i} flexDirection="row" width="100%">
<text fg={isSel ? C.accent : C.fg} flexGrow={1}>
{isSel ? <b>{`${"▸ "}${it}`}</b> : ` ${it}`}
</text>
{detail && (
<text fg={C.dim} flexShrink={0}>
{detail}
</text>
)}
</box>
);
})}
{matched.length === 0 && (
<text fg={C.red}> no matches — type less or press Backspace.</text>
)}
<text fg={C.dim}>
{` ${frame.hint ?? ""}`}
</text>
{frame.items.length > 10 && (
<text fg={C.dim}>
{` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`}
</text>
)}
</OverlayFrame>
);
}
/* ── Status bar ───────────────────────────────────────────────────── */
function StatusBar(props: {