import type { ExtensionCommandContext, KeybindingsManager, Theme, } from "@earendil-works/pi-coding-agent"; import { filterItems, type PickerItem, visibleWindow } from "../core/picker.js"; const MAX_VISIBLE = 10; const HINT = "↑↓ navigate · enter select · esc cancel"; /** Rows reserved for the title, the search line, the hint and the scroll counter. */ const CHROME_ROWS = 8; /** * How many list rows fit in the terminal. Pi renders the component inline, so a * list taller than the screen would scroll the dialog out of view. Below eight * rows the title, the search line and the hint no longer fit even with one row. */ export function visibleRows(rows: number): number { if (!Number.isFinite(rows) || rows <= 0) { return 3; } return Math.max(1, Math.min(MAX_VISIBLE, Math.floor(rows) - CHROME_ROWS)); } export interface PickerRequest { title: string; subtitle?: string; items: readonly PickerItem[]; } /** * Conservative column count: a tab is three columns, and every code point at * or above U+1100 (CJK, Hangul, emoji) is two. Over-estimating only truncates * earlier, and Pi aborts rendering when a line is wider than the terminal. */ function columnWidth(character: string): number { if (character === "\t") { return 3; } return (character.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1; } export function displayWidth(text: string): number { let width = 0; for (const character of text) { width += columnWidth(character); } return width; } function truncate(text: string, width: number): string { let result = ""; let used = 0; for (const character of text) { const size = columnWidth(character); if (used + size > width) { break; } const code = character.codePointAt(0) ?? 0; // A rendered element is one line: control characters that would break // that, or inject terminal escapes, become spaces. const safe = character === "\t" || (code >= 0x20 && code !== 0x7f) ? character : " "; result += safe; used += size; } return result; } /** * Searchable list for the interactive TUI. * * Pi's own extension selector renders every option it receives and has no * filter, so handing it a full model catalog pushes the dialog off the screen. * This component keeps at most ten rows visible and filters as the user types. */ export class SearchList { private query = ""; private selected = 0; constructor( private readonly title: string, private readonly subtitle: string | undefined, private readonly items: readonly PickerItem[], private readonly theme: Theme, private readonly keybindings: KeybindingsManager, private readonly done: (value: string | undefined) => void, private readonly maxVisible: number = MAX_VISIBLE, ) {} invalidate(): void {} render(width: number): string[] { const filtered = filterItems(this.items, this.query); const lines: string[] = [ this.theme.fg("accent", this.theme.bold(truncate(this.title, width))), ]; if (this.subtitle) { lines.push(this.theme.fg("muted", truncate(this.subtitle, width))); } const prefix = truncate("Search: ", width); const searchText = this.query === "" ? "type to filter" : this.query; const search = truncate( searchText, Math.max(0, width - displayWidth(prefix)), ); lines.push( `${this.theme.fg("muted", prefix)}${ this.query === "" ? this.theme.fg("dim", search) : search }`, ); lines.push(""); if (filtered.length === 0) { lines.push(this.theme.fg("muted", truncate(" No matches", width))); } else { const { start, end } = visibleWindow( filtered.length, this.selected, this.maxVisible, ); for (let index = start; index < end; index += 1) { const item = filtered[index]; if (!item) { continue; } const isSelected = index === this.selected; const line = truncate( `${isSelected ? "→ " : " "}${item.label}`, width, ); lines.push(this.theme.fg(isSelected ? "accent" : "text", line)); } if (filtered.length > this.maxVisible) { lines.push( this.theme.fg( "muted", truncate(` ${start + 1}-${end} of ${filtered.length}`, width), ), ); } } lines.push(""); lines.push(this.theme.fg("muted", truncate(HINT, width))); return lines; } handleInput(data: string): void { if (this.keybindings.matches(data, "tui.select.up")) { this.move(-1); return; } if (this.keybindings.matches(data, "tui.select.down")) { this.move(1); return; } if (this.keybindings.matches(data, "tui.select.pageUp")) { this.move(-this.maxVisible); return; } if (this.keybindings.matches(data, "tui.select.pageDown")) { this.move(this.maxVisible); return; } if (this.keybindings.matches(data, "tui.select.confirm") || data === "\n") { this.done(filterItems(this.items, this.query)[this.selected]?.value); return; } if (this.keybindings.matches(data, "tui.select.cancel")) { this.done(undefined); return; } if (data === "\x7f" || data === "\b") { this.query = this.query.slice(0, -1); this.selected = 0; return; } // Printable ASCII only: enough for provider and model ids, and it keeps // escape sequences from leaking into the filter. if (/^[ -~]$/.test(data)) { this.query += data; this.selected = 0; } } private move(delta: number): void { const total = filterItems(this.items, this.query).length; if (total === 0) { return; } this.selected = Math.min(Math.max(this.selected + delta, 0), total - 1); } } /** * Asks the user to pick one item. In the TUI it opens the searchable list; in * other modes it falls back to Pi's selector, paged so a long catalog never * fills the screen. */ export async function selectItem( ctx: ExtensionCommandContext, request: PickerRequest, ): Promise { if (ctx.mode === "tui") { const { title, subtitle, items } = request; return ctx.ui.custom( (tui, theme, keybindings, done) => new SearchList( title, subtitle, items, theme, keybindings, done, visibleRows(tui.terminal.rows), ), ); } return selectPaged(ctx, request); } async function selectPaged( ctx: ExtensionCommandContext, request: PickerRequest, ): Promise { const { title, items } = request; if (items.length <= MAX_VISIBLE) { const choice = await ctx.ui.select( title, items.map((item) => item.label), ); return items.find((item) => item.label === choice)?.value; } let offset = 0; for (;;) { const page = items.slice(offset, offset + MAX_VISIBLE); const options = page.map((item) => item.label); if (offset > 0) { options.push("← Previous page"); } if (offset + MAX_VISIBLE < items.length) { options.push("More…"); } const choice = await ctx.ui.select(title, options); if (choice === undefined) { return undefined; } if (choice === "← Previous page") { offset = Math.max(0, offset - MAX_VISIBLE); continue; } if (choice === "More…") { offset += MAX_VISIBLE; continue; } const index = options.indexOf(choice); return index >= 0 ? page[index]?.value : undefined; } }