Commit 034a45045bb9

Vincent Demeester <vincent@sbr.pm>
2026-02-10 16:40:05
feat: add fuzzy search to /review PR selector
Added search-as-you-type filtering to the PR selector in /review pr. Typing filters the list by matching all space-separated terms against PR title, author, branch, and labels. Backspace removes characters, header shows active filter.
1 parent e5eebf0
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/review.ts
@@ -1092,7 +1092,7 @@ export default function reviewExtension(pi: ExtensionAPI) {
 		});
 
 		// Build select items
-		const items: SelectItem[] = sorted.map((pr) => {
+		const allItems: SelectItem[] = sorted.map((pr) => {
 			const draft = pr.isDraft ? " [draft]" : "";
 			const review = reviewDecisionLabel(pr.reviewDecision);
 			return {
@@ -1103,27 +1103,55 @@ export default function reviewExtension(pi: ExtensionAPI) {
 		});
 
 		// Add manual entry option at the bottom
-		items.push({
+		const manualItem: SelectItem = {
 			value: "__manual__",
 			label: "Enter PR number manually…",
 			description: "(for cross-repo or closed PRs)",
-		});
+		};
 
-		// Show selector
+		/** Fuzzy-match: all terms (split by space) must appear somewhere in the searchable text */
+		function fuzzyMatch(item: SelectItem, query: string): boolean {
+			if (!query) return true;
+			const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
+			const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
+			return terms.every((term) => searchable.includes(term));
+		}
+
+		// Show selector with search-as-you-type
 		const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+			let searchQuery = "";
+
+			function getFilteredItems(): SelectItem[] {
+				if (!searchQuery) return [...allItems, manualItem];
+				const filtered = allItems.filter((item) => fuzzyMatch(item, searchQuery));
+				filtered.push(manualItem);
+				return filtered;
+			}
+
+			let currentItems = getFilteredItems();
+
 			const container = new Container();
 			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
-			container.addChild(new Text(theme.fg("accent", theme.bold("Select a pull request to review"))));
 
-			const selectList = new SelectList(items, Math.min(items.length, 12), {
+			const headerText = new Text("", 0, 0);
+			function updateHeader() {
+				const title = theme.fg("accent", theme.bold("Select a pull request to review"));
+				if (searchQuery) {
+					headerText.setText(`${title}  ${theme.fg("warning", `filter: ${searchQuery}`)}`);
+				} else {
+					headerText.setText(title);
+				}
+			}
+			updateHeader();
+			container.addChild(headerText);
+
+			let selectList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
 				selectedPrefix: (text) => theme.fg("accent", text),
 				selectedText: (text) => theme.fg("accent", text),
 				description: (text) => theme.fg("muted", text),
 				scrollInfo: (text) => theme.fg("dim", text),
 				noMatch: (text) => theme.fg("warning", text),
 			});
-
-			selectList.searchable = true;
 			selectList.onSelect = (item) => done(item.value);
 			selectList.onCancel = () => done(null);
 
@@ -1131,10 +1159,47 @@ export default function reviewExtension(pi: ExtensionAPI) {
 			container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
 			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 
+			function rebuildList() {
+				currentItems = getFilteredItems();
+				const newList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
+					selectedPrefix: (text) => theme.fg("accent", text),
+					selectedText: (text) => theme.fg("accent", text),
+					description: (text) => theme.fg("muted", text),
+					scrollInfo: (text) => theme.fg("dim", text),
+					noMatch: (text) => theme.fg("warning", text),
+				});
+				newList.onSelect = (item) => done(item.value);
+				newList.onCancel = () => done(null);
+				// Replace in container (index 2: after border + header)
+				const idx = container.children.indexOf(selectList);
+				if (idx !== -1) container.children[idx] = newList;
+				selectList = newList;
+				updateHeader();
+			}
+
 			return {
 				render(width: number) { return container.render(width); },
 				invalidate() { container.invalidate(); },
 				handleInput(data: string) {
+					// Backspace: remove last char from search
+					if (data === "\x7f" || data === "\b") {
+						if (searchQuery.length > 0) {
+							searchQuery = searchQuery.slice(0, -1);
+							rebuildList();
+							tui.requestRender();
+						}
+						return;
+					}
+
+					// Printable characters: append to search
+					if (data.length === 1 && data >= " " && data <= "~") {
+						searchQuery += data;
+						rebuildList();
+						tui.requestRender();
+						return;
+					}
+
+					// Everything else (arrows, enter, escape): pass to SelectList
 					selectList.handleInput(data);
 					tui.requestRender();
 				},