/** * picker — the bordered, type-to-filter list primitive /consult is built on. * * showFilterablePicker renders a panel (title + optional prose + a live * "Filter:" line + a SelectList + a nav hint) and resolves to the selected * value, or null on cancel. The same primitive backs the main menu and every * sub-picker (model, effort, mode, on/off) — it's just a list of SelectItems * with a fuzzy filter, so it doesn't care whether the items are models or menu * entries. * * Layout lifted from the rpiv-advisor advisor-ui panel: DynamicBorder top and * bottom, theme-wired SelectList, MAX_VISIBLE_ROWS cap so a 200-model list * doesn't blow the panel height. */ import { DynamicBorder, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui"; import { filterItems, isBackspace, isPrintable } from "./fuzzy.js"; const MAX_VISIBLE_ROWS = 10; const NAV_HINT = "type to filter • ↑↓ navigate • enter select • esc cancel"; function selectListTheme(theme: Theme) { return { selectedPrefix: (t: string) => theme.bg("selectedBg", theme.fg("accent", t)), selectedText: (t: string) => theme.bg("selectedBg", theme.bold(t)), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }; } function buildSelectPanel( theme: Theme, title: string, proseLines: string[], query: string, selectList: SelectList, ): Container { const container = new Container(); const border = () => new DynamicBorder((s: string) => theme.fg("accent", s)); container.addChild(border()); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); container.addChild(new Spacer(1)); for (const line of proseLines) { container.addChild(new Text(line, 1, 0)); container.addChild(new Spacer(1)); } const filterText = query.length > 0 ? `Filter: ${query}` : "Type to filter…"; container.addChild(new Text(theme.fg(query.length > 0 ? "accent" : "dim", filterText), 1, 0)); container.addChild(new Spacer(1)); container.addChild(selectList); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", NAV_HINT), 1, 0)); container.addChild(new Spacer(1)); container.addChild(border()); return container; } export interface FilterablePickerOptions { title: string; proseLines?: string[]; items: SelectItem[]; /** Value to preselect while the query is empty (e.g. the current setting). */ preferredValue?: string; } /** * Show a bordered, filterable list. Resolves to the chosen value, or null if * the user cancels (esc). The filter matches subsequence against * `label value`, ranked so contiguous and word-boundary hits win. */ export function showFilterablePicker(ctx: ExtensionContext, opts: FilterablePickerOptions): Promise { return ctx.ui.custom((tui, theme, _kb, done) => { let query = ""; let selectList: SelectList; let container: Container; const rebuild = () => { const filtered = filterItems(opts.items, query); const visibleRows = Math.min(Math.max(filtered.length, 1), MAX_VISIBLE_ROWS); selectList = new SelectList(filtered, visibleRows, selectListTheme(theme)); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); if (query.length === 0 && opts.preferredValue) { const idx = filtered.findIndex((item) => item.value === opts.preferredValue); if (idx >= 0) selectList.setSelectedIndex(idx); } container = buildSelectPanel(theme, opts.title, opts.proseLines ?? [], query, selectList); }; rebuild(); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { if (isBackspace(data)) { if (query.length > 0) { query = query.slice(0, -1); rebuild(); } } else if (isPrintable(data)) { query += data; rebuild(); } else { selectList.handleInput(data); } tui.requestRender(); }, }; }); }