/** * Generic keyboard-navigable selection list for the TUI (model/provider pickers). * * Split into a pure state machine (`SelectList`) and a pure renderer * (`renderSelectList`) so the picker logic is fully unit-testable without a real * TTY. The renderer is viewport-aware: it shows a scrolling window of `rows` * around the cursor and fits each line to `cols`, so long lists never overflow * the terminal height/width. */ import chalk from "chalk"; import { padLineTo } from "./layout"; import { visibleWidth } from "./color"; import { truncate as truncateAnsi } from "../terminal"; /** Synthetic first tab that shows every item regardless of its provider/group. */ export const ALL_TAB = "ALL"; export interface SelectItem { value: T; label: string; /** Optional group header shown above the first item of each group. */ group?: string; /** Disabled items are shown dimmed and skipped by cursor navigation. */ disabled?: boolean; /** Optional right-aligned hint/badge (e.g. "✓ ready · 200k"). */ hint?: string; /** When true, `hint` is already styled and should not be wrapped in gray. */ hintRaw?: boolean; /** Tree indentation depth for nested/sub-list choices. */ depth?: number; /** Tree connector for nested rows; defaults to "mid" when depth > 0. */ branch?: "mid" | "last"; } export class SelectList { private readonly items: SelectItem[]; private query = ""; private tabIndex = 0; // 0 = ALL; 1..N = a provider/group tab private cursor = 0; // index into the *visible* list constructor(items: SelectItem[]) { this.items = items; this.cursor = this.firstEnabled(this.computeVisible()); } /** Items matching the current filter (case-insensitive substring on label). */ visible(): SelectItem[] { return this.computeVisible(); } private computeVisible(): SelectItem[] { let list = this.items; const tab = this.activeTab(); if (tab !== ALL_TAB) list = list.filter(i => (i.group ?? "") === tab); const q = this.query.trim().toLowerCase(); if (!q) return list; return list.filter(i => i.label.toLowerCase().includes(q) || (i.group ?? "").toLowerCase().includes(q)); } /** Distinct provider/group tabs: "ALL" first, then each group in first-seen order. */ tabList(): string[] { const seen: string[] = []; for (const it of this.items) { if (it.group && !seen.includes(it.group)) seen.push(it.group); } return [ALL_TAB, ...seen]; } /** Active tab — "ALL" shows everything; otherwise items of one provider/group. */ activeTab(): string { const tabs = this.tabList(); return tabs[Math.min(this.tabIndex, tabs.length - 1)] ?? ALL_TAB; } /** Cycle the active provider/group tab (wraps); resets the cursor to the first match. */ cycleTab(dir: 1 | -1): void { const n = this.tabList().length; if (n <= 1) return; this.tabIndex = (Math.min(this.tabIndex, n - 1) + dir + n) % n; this.cursor = this.firstEnabled(this.computeVisible()); } private firstEnabled(list: SelectItem[]): number { const i = list.findIndex(it => !it.disabled); return i < 0 ? 0 : i; } /** Current cursor index within the visible list (clamped). */ cursorIndex(): number { const n = this.computeVisible().length; if (n === 0) return 0; return Math.max(0, Math.min(this.cursor, n - 1)); } isEmpty(): boolean { return this.computeVisible().length === 0; } /** The currently selected item (skips when empty / all disabled). */ selected(): SelectItem | undefined { const list = this.computeVisible(); const item = list[this.cursorIndex()]; return item && !item.disabled ? item : undefined; } /** Set the filter query; cursor jumps to the first enabled match. */ setFilter(query: string): void { this.query = query; this.cursor = this.firstEnabled(this.computeVisible()); } filter(): string { return this.query; } /** Append a character to the filter (typing). */ typeChar(ch: string): void { this.setFilter(this.query + ch); } /** Remove the last filter character (backspace). */ backspace(): void { this.setFilter(this.query.slice(0, -1)); } private step(dir: 1 | -1): void { const list = this.computeVisible(); const n = list.length; if (n === 0) return; let i = this.cursorIndex(); for (let tries = 0; tries < n; tries++) { i = (i + dir + n) % n; if (!list[i]!.disabled) break; } this.cursor = i; } up(): void { this.step(-1); } down(): void { this.step(1); } /** Move by a page (clamped, no wrap), landing on an enabled item. */ page(dir: 1 | -1, size = 5): void { const list = this.computeVisible(); const n = list.length; if (n === 0) return; let i = Math.max(0, Math.min(n - 1, this.cursorIndex() + dir * Math.max(1, size))); // settle onto the nearest enabled item in the travel direction while (i >= 0 && i < n && list[i]!.disabled) i += dir; if (i < 0 || i >= n) i = this.firstEnabled(list); this.cursor = i; } } export interface RenderSelectOptions { /** Title line shown above the list. */ title?: string; /** Max body rows for the scrolling window (default 10). */ rows?: number; /** Total width to fit each line to (default: natural). */ cols?: number; /** Use unicode glyphs for the cursor/markers (default true). */ unicode?: boolean; /** Apply chalk color (default true). */ color?: boolean; /** Render a provider/group tab bar (gajae-code `/model` parity) and the `tab` key hint. */ showTabs?: boolean; } /** * Render a `SelectList` to lines: optional title, a scrolling window of items * with the cursor highlighted, group headers, right-aligned hints, and a footer * with the active filter + key hints. Pure — no I/O. */ export function renderSelectList(list: SelectList, opts: RenderSelectOptions = {}): string[] { const unicode = opts.unicode !== false; const color = opts.color !== false; const rows = Math.max(1, opts.rows ?? 10); const cols = opts.cols; const pointer = unicode ? "\u276f" : ">"; // ❯ / > const tint = (s: string, fn: (x: string) => string) => (color ? fn(s) : s); const out: string[] = []; if (opts.title) { for (const rawTitle of opts.title.split("\n")) { const title = rawTitle ? tint(rawTitle, chalk.bold) : ""; out.push(cols ? clampToCols(title, cols) : title); } } if (opts.showTabs) { const tabs = list.tabList(); if (tabs.length > 1) { const active = list.activeTab(); const bar = " " + tabs.map(t => (t === active ? tint(`[${t}]`, chalk.cyan.bold) : tint(` ${t} `, chalk.gray))).join(" "); out.push(cols ? clampToCols(bar, cols) : bar); } } const items = list.visible(); if (items.length === 0) { const empty = tint(" (no matches)", chalk.gray); out.push(cols ? clampToCols(empty, cols) : empty); } else { const cur = list.cursorIndex(); // Scrolling window centered-ish on the cursor. let start = Math.max(0, cur - Math.floor(rows / 2)); start = Math.min(start, Math.max(0, items.length - rows)); const end = Math.min(items.length, start + rows); if (start > 0) { const more = tint(` \u2191 ${start} more`, chalk.gray); out.push(cols ? clampToCols(more, cols) : more); } let lastGroup: string | undefined; for (let i = start; i < end; i++) { const it = items[i]!; if (it.group && it.group !== lastGroup) { const group = tint(` ${it.group}`, chalk.gray); out.push(cols ? clampToCols(group, cols) : group); lastGroup = it.group; } const isCur = i === cur; const marker = isCur ? tint(pointer, chalk.cyan) : " "; const prefix = treePrefix(it, { unicode }); let label = it.disabled ? tint(`${prefix}${it.label}`, chalk.gray) : isCur ? tint(`${prefix}${it.label}`, chalk.cyan.bold) : `${prefix}${it.label}`; let line = `${marker} ${label}`; if (it.hint) { const hint = it.hintRaw ? it.hint : tint(it.hint, chalk.gray); if (cols) { // right-align the hint within cols const used = visibleWidth(line) + visibleWidth(hint) + 1; const gap = Math.max(1, cols - used); line = `${line}${" ".repeat(gap)}${hint}`; } else { line = `${line} ${hint}`; } } out.push(cols ? clampToCols(line, cols) : line); } if (end < items.length) { const more = tint(` \u2193 ${items.length - end} more`, chalk.gray); out.push(cols ? clampToCols(more, cols) : more); } } const q = list.filter(); const filterPart = q ? `filter: ${q}` : "type to filter"; const showTab = opts.showTabs && list.tabList().length > 1; const tabHint = showTab ? (unicode ? " \u00b7 tab provider" : " . tab provider") : ""; const keys = (unicode ? "\u2191/\u2193 move \u00b7 enter select \u00b7 esc cancel" : "up/down move . enter select . esc cancel") + tabHint; const footer = tint(` ${filterPart} \u2014 ${keys}`, chalk.gray); out.push(cols ? clampToCols(footer, cols) : footer); return out; } function clampToCols(line: string, cols: number): string { if (visibleWidth(line) <= cols) return padLineTo(line, cols, "left"); const cut = truncateAnsi(line, cols); return visibleWidth(cut) <= cols ? padLineTo(cut, cols, "left") : cut; } function treePrefix(item: SelectItem, opts: { unicode: boolean }): string { const depth = Math.max(0, item.depth ?? 0); if (depth <= 0) return ""; const pad = " ".repeat(Math.max(0, depth - 1)); if (!opts.unicode) return `${pad}${item.branch === "last" ? "`-" : "|-"} `; return `${pad}${item.branch === "last" ? "└─" : "├─"} `; }