ui.ts
1 import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; 2 import { DynamicBorder } from "@mariozechner/pi-coding-agent"; 3 import { Container, type SelectItem, SelectList, Text, matchesKey } from "@mariozechner/pi-tui"; 4 import type { Snippet } from "./snippets"; 5 import { getSnippetPreview, truncatePreview } from "./snippets"; 6 import { buildSearchIndex, rankedFilterItems } from "./search"; 7 8 const PREVIEW_WIDTH = 52; 9 10 function buildSnippetLabel(snippet: Snippet, index: number, indexWidth: number, timeWidth: number): string { 11 const preview = truncatePreview(getSnippetPreview(snippet), PREVIEW_WIDTH).padEnd(PREVIEW_WIDTH, " "); 12 const number = String(index + 1).padStart(indexWidth, " "); 13 const type = snippet.type === "block" ? "Block" : "Inline"; 14 const lang = snippet.type === "block" && snippet.language ? ` (${snippet.language})` : ""; 15 const time = snippet.sourceLabel.padEnd(timeWidth, " "); 16 return `${number}. ${preview} ${time} ${type}${lang}`; 17 } 18 19 export type PickResult = { 20 snippet: Snippet; 21 action?: "copy" | "insert"; 22 }; 23 24 export async function pickSnippet(ctx: ExtensionCommandContext, snippets: Snippet[]): Promise<PickResult | undefined> { 25 const indexWidth = String(snippets.length).length; 26 const timeWidth = Math.max(...snippets.map((snippet) => snippet.sourceLabel.length)); 27 const items: SelectItem[] = snippets.map((snippet, idx) => ({ 28 value: String(idx), 29 label: buildSnippetLabel(snippet, idx, indexWidth, timeWidth), 30 description: "", 31 })); 32 const searchIndex = buildSearchIndex(snippets, items); 33 34 const selectedIndex = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => { 35 const container = new Container(); 36 37 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 38 const title = new Text(theme.fg("accent", theme.bold("Select Code Snippet")), 1, 0); 39 container.addChild(title); 40 41 const list = new SelectList(items, Math.min(items.length, 12), { 42 selectedPrefix: (t) => theme.fg("accent", t), 43 selectedText: (t) => theme.fg("accent", t), 44 description: (t) => theme.fg("muted", t), 45 scrollInfo: (t) => theme.fg("dim", t), 46 noMatch: (t) => theme.fg("warning", t), 47 }); 48 49 list.onSelect = (item) => done(`copy:${item.value}`); 50 list.onCancel = () => done(null); 51 container.addChild(list); 52 53 const help = new Text( 54 theme.fg("dim", "Filter: (none) Enter copy Right/Tab insert Up/Down navigate Esc cancel"), 55 1, 56 0, 57 ); 58 container.addChild(help); 59 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 60 61 let filter = ""; 62 const updateFilter = (next: string) => { 63 filter = next; 64 const listAny = list as unknown as { filteredItems: SelectItem[]; selectedIndex: number }; 65 listAny.filteredItems = rankedFilterItems(filter, items, searchIndex); 66 listAny.selectedIndex = 0; 67 68 help.setText( 69 theme.fg( 70 "dim", 71 `Filter: ${filter.length > 0 ? filter : "(none)"} Enter copy Right/Tab insert Up/Down navigate Esc cancel`, 72 ), 73 ); 74 list.invalidate(); 75 tui.requestRender(); 76 }; 77 78 return { 79 render: (width: number) => container.render(width), 80 invalidate: () => container.invalidate(), 81 handleInput: (data: string) => { 82 if (matchesKey(data, "backspace")) { 83 if (filter.length > 0) updateFilter(filter.slice(0, -1)); 84 return; 85 } 86 87 if (matchesKey(data, "right") || matchesKey(data, "tab")) { 88 const selected = list.getSelectedItem(); 89 if (selected) done(`insert:${selected.value}`); 90 return; 91 } 92 93 if (data.length === 1 && data >= " " && data <= "~") { 94 updateFilter(filter + data); 95 return; 96 } 97 98 list.handleInput?.(data); 99 tui.requestRender(); 100 }, 101 }; 102 }); 103 104 if (selectedIndex === null || selectedIndex === undefined) return undefined; 105 const [action, rawIndex] = selectedIndex.split(":"); 106 const index = Number.parseInt(rawIndex ?? "", 10); 107 if (Number.isNaN(index)) return undefined; 108 const snippet = snippets[index]; 109 if (!snippet) return undefined; 110 if (action === "copy" || action === "insert") { 111 return { snippet, action }; 112 } 113 return { snippet }; 114 } 115 116 export async function pickAction(ctx: ExtensionCommandContext): Promise<"copy" | "insert" | "run" | undefined> { 117 const action = await ctx.ui.select("Action", ["Copy", "Insert", "Run"]); 118 if (!action) return undefined; 119 return action.toLowerCase() as "copy" | "insert" | "run"; 120 }