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  }