feat(tui): implement interactive menu system for model and config selection
This commit is contained in:
@@ -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