import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import type { CommandContext, ProbeItem, SelectItem } from "../types.ts"; function normalizeSelectItems(items: Array): SelectItem[] { return items.map((item) => (typeof item === "string" ? { value: item, label: item } : item)); } export async function selectOne( ctx: CommandContext, title: string, items: Array, options?: { initialIndex?: number }, ): Promise { const normalizedItems = normalizeSelectItems(items); if (normalizedItems.length === 0) return null; return await ctx.ui.custom((tui, theme, _kb, done) => { let cursor = Math.max(0, Math.min(options?.initialIndex ?? 0, normalizedItems.length - 1)); let query = ""; let cachedLines: string[] | undefined; const maxVisible = 12; function getVisibleItems() { const lowerQuery = query.trim().toLowerCase(); if (!lowerQuery) return normalizedItems; return normalizedItems.filter((item) => { const haystack = `${item.label} ${item.suffix ?? ""} ${item.description ?? ""} ${item.searchText ?? ""}`.toLowerCase(); return haystack.includes(lowerQuery); }); } function refresh() { const visibleItems = getVisibleItems(); if (visibleItems.length === 0) cursor = 0; else if (cursor >= visibleItems.length) cursor = visibleItems.length - 1; cachedLines = undefined; tui.requestRender(); } return { render(width: number) { if (cachedLines) return cachedLines; const visibleItems = getVisibleItems(); const safeWidth = Math.max(10, width); const lines: string[] = []; const add = (line = "") => lines.push(truncateToWidth(line, safeWidth)); const border = theme.fg("accent", "─".repeat(safeWidth)); add(border); add(` ${theme.fg("accent", theme.bold(title))}`); add(` ${theme.fg("text", `Search: ${query || "-"}`)}`); add(); if (visibleItems.length === 0) { add(theme.fg("warning", " No matches.")); } else { const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), Math.max(0, visibleItems.length - maxVisible))); const end = Math.min(visibleItems.length, start + maxVisible); for (let i = start; i < end; i++) { const item = visibleItems[i]; const active = i === cursor; const prefix = active ? theme.fg("accent", "> ") : " "; const label = active ? theme.fg("accent", item.label) : theme.fg("text", item.label); const suffix = item.suffix ? theme.fg("dim", item.suffix) : ""; add(`${prefix}${label}${suffix}`); if (item.description) { for (const line of item.description.split("\n")) { add(` ${theme.fg("muted", line)}`); } } } if (visibleItems.length > maxVisible) { add(); add(theme.fg("dim", ` ${start + 1}-${end} of ${visibleItems.length}`)); } } add(); add(theme.fg("dim", " Type to search • ↑↓ move (wraps) • enter confirm • backspace delete • esc cancel")); add(border); cachedLines = lines; return lines; }, invalidate() { cachedLines = undefined; }, handleInput(data: string) { const visibleItems = getVisibleItems(); if (matchesKey(data, Key.up)) { if (visibleItems.length === 0) return; cursor = cursor === 0 ? visibleItems.length - 1 : cursor - 1; refresh(); return; } if (matchesKey(data, Key.down)) { if (visibleItems.length === 0) return; cursor = cursor === visibleItems.length - 1 ? 0 : cursor + 1; refresh(); return; } if (matchesKey(data, Key.enter)) { const item = visibleItems[cursor]; done(item?.value ?? null); return; } if (matchesKey(data, Key.escape)) { done(null); return; } if (data === "\u007f" || data === "\b") { if (query.length > 0) { query = query.slice(0, -1); refresh(); } return; } if (data >= " " && data !== "\u001b" && data !== "\r" && data !== "\n") { query += data; cursor = 0; refresh(); } }, }; }); } // Tri-state multi-select: each item cycles through its own allowed states on // space. "[ ]" off, "[-]" mid, "[x]" on — the caller decides what each state // means per item (e.g. skip/remove, keep-without-update, add/update). export type TriState = "off" | "mid" | "on"; export type TriItem = { value: string; label: string; description?: string; searchText?: string; // States this item cycles through, in toggle order. states: TriState[]; initial: TriState; }; export async function pickTriState( ctx: CommandContext, title: string, items: TriItem[], legend = "[ ] remove/skip • [-] keep without update • [x] keep/add with latest metadata", ): Promise | null> { if (items.length === 0) return new Map(); return await ctx.ui.custom | null>((tui, theme, _kb, done) => { let cursor = 0; let query = ""; const state = new Map(items.map((item) => [item.value, item.initial])); let cachedLines: string[] | undefined; const maxVisible = 12; function getVisibleItems() { const lowerQuery = query.trim().toLowerCase(); if (!lowerQuery) return items; return items.filter((item) => { const haystack = `${item.label} ${item.value} ${item.description ?? ""} ${item.searchText ?? ""}`.toLowerCase(); return haystack.includes(lowerQuery); }); } function refresh() { const visibleItems = getVisibleItems(); if (visibleItems.length === 0) cursor = 0; else if (cursor >= visibleItems.length) cursor = visibleItems.length - 1; cachedLines = undefined; tui.requestRender(); } function box(s: TriState): string { if (s === "on") return theme.fg("success", "[x]"); if (s === "mid") return theme.fg("warning", "[-]"); return theme.fg("muted", "[ ]"); } return { render(width: number) { if (cachedLines) return cachedLines; const visibleItems = getVisibleItems(); const safeWidth = Math.max(10, width); const lines: string[] = []; const add = (line = "") => lines.push(truncateToWidth(line, safeWidth)); const border = theme.fg("accent", "─".repeat(safeWidth)); add(border); add(` ${theme.fg("accent", theme.bold(title))}`); add(` ${theme.fg("text", `Search: ${query || "-"}`)}`); add(` ${theme.fg("muted", `${items.length} models`)}`); add(); if (visibleItems.length === 0) { add(theme.fg("warning", " No matching models.")); } else { const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), Math.max(0, visibleItems.length - maxVisible))); const end = Math.min(visibleItems.length, start + maxVisible); for (let i = start; i < end; i++) { const item = visibleItems[i]; const active = i === cursor; const prefix = active ? theme.fg("accent", "> ") : " "; const label = active ? theme.fg("accent", item.label) : theme.fg("text", item.label); const desc = item.description ? ` ${theme.fg("muted", item.description.replace(/\s*\n\s*/g, " "))}` : ""; add(`${prefix}${box(state.get(item.value) ?? item.initial)} ${label}${desc}`); } if (visibleItems.length > maxVisible) { add(); add(theme.fg("dim", ` ${start + 1}-${end} of ${visibleItems.length}`)); } } add(); add(theme.fg("dim", " Type to search • ↑↓ move (wraps) • space toggle • enter confirm • backspace delete • esc cancel")); add(theme.fg("dim", ` ${legend}`)); add(border); cachedLines = lines; return lines; }, invalidate() { cachedLines = undefined; }, handleInput(data: string) { const visibleItems = getVisibleItems(); if (matchesKey(data, Key.up)) { if (visibleItems.length === 0) return; cursor = cursor === 0 ? visibleItems.length - 1 : cursor - 1; refresh(); return; } if (matchesKey(data, Key.down)) { if (visibleItems.length === 0) return; cursor = cursor === visibleItems.length - 1 ? 0 : cursor + 1; refresh(); return; } if (data === " ") { const item = visibleItems[cursor]; if (!item) return; const current = state.get(item.value) ?? item.initial; const index = item.states.indexOf(current); state.set(item.value, item.states[(index + 1) % item.states.length]); refresh(); return; } if (matchesKey(data, Key.enter)) { done(new Map(state)); return; } if (matchesKey(data, Key.escape)) { done(null); return; } if (data === "007f" || data === "\b") { if (query.length > 0) { query = query.slice(0, -1); refresh(); } return; } if (data >= " " && data !== "001b" && data !== "\r" && data !== "\n") { query += data; cursor = 0; refresh(); } }, }; }); } export async function pickMany( ctx: CommandContext, title: string, items: ProbeItem[], ): Promise { return await ctx.ui.custom((tui, theme, _kb, done) => { let cursor = 0; let query = ""; const selected = new Set(); let cachedLines: string[] | undefined; const maxVisible = 12; function getVisibleItems() { const lowerQuery = query.trim().toLowerCase(); if (!lowerQuery) return items; return items.filter((item) => { const haystack = `${item.label} ${item.value} ${item.description ?? ""}`.toLowerCase(); return haystack.includes(lowerQuery); }); } function refresh() { const visibleItems = getVisibleItems(); if (visibleItems.length === 0) cursor = 0; else if (cursor >= visibleItems.length) cursor = visibleItems.length - 1; cachedLines = undefined; tui.requestRender(); } return { render(width: number) { if (cachedLines) return cachedLines; const visibleItems = getVisibleItems(); const safeWidth = Math.max(10, width); const lines: string[] = []; const add = (line = "") => lines.push(truncateToWidth(line, safeWidth)); const border = theme.fg("accent", "─".repeat(safeWidth)); add(border); add(` ${theme.fg("accent", theme.bold(title))}`); add(` ${theme.fg("text", `Search: ${query || "-"}`)}`); add(` ${theme.fg("muted", `${selected.size} selected • ${visibleItems.length}/${items.length} shown`)}`); add(); if (visibleItems.length === 0) { add(theme.fg("warning", " No matching models.")); } else { const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), Math.max(0, visibleItems.length - maxVisible))); const end = Math.min(visibleItems.length, start + maxVisible); for (let i = start; i < end; i++) { const item = visibleItems[i]; const active = i === cursor; const checked = selected.has(item.value); const prefix = active ? theme.fg("accent", "> ") : " "; const box = checked ? theme.fg("success", "[x]") : theme.fg("muted", "[ ]"); const label = active ? theme.fg("accent", item.label) : theme.fg("text", item.label); const desc = item.description ? ` ${theme.fg("muted", item.description.replace(/\s*\n\s*/g, " "))}` : ""; add(`${prefix}${box} ${label}${desc}`); } if (visibleItems.length > maxVisible) { add(); add(theme.fg("dim", ` ${start + 1}-${end} of ${visibleItems.length}`)); } } add(); add(theme.fg("dim", " Type to search • ↑↓ move (wraps) • space toggle • enter confirm • backspace delete • esc cancel")); if (selected.size === 0) { add(theme.fg("warning", " Select at least one model before confirming.")); } add(border); cachedLines = lines; return lines; }, invalidate() { cachedLines = undefined; }, handleInput(data: string) { const visibleItems = getVisibleItems(); if (matchesKey(data, Key.up)) { if (visibleItems.length === 0) return; cursor = cursor === 0 ? visibleItems.length - 1 : cursor - 1; refresh(); return; } if (matchesKey(data, Key.down)) { if (visibleItems.length === 0) return; cursor = cursor === visibleItems.length - 1 ? 0 : cursor + 1; refresh(); return; } if (data === " ") { const value = visibleItems[cursor]?.value; if (!value) return; if (selected.has(value)) selected.delete(value); else selected.add(value); refresh(); return; } if (matchesKey(data, Key.enter)) { if (selected.size > 0) done(Array.from(selected)); return; } if (matchesKey(data, Key.escape)) { done(null); return; } if (data === "\u007f" || data === "\b") { if (query.length > 0) { query = query.slice(0, -1); refresh(); } return; } if (data >= " " && data !== "\u001b" && data !== "\r" && data !== "\n") { query += data; cursor = 0; refresh(); } }, }; }); }