Commit 60194f73ba3d

Vincent Demeester <vincent@sbr.pm>
2026-05-18 14:35:26
feat(pi): add fuzzy filtering to /mode selector
Replaced plain ctx.ui.select() with SelectList component that supports type-to-filter fuzzy matching. Also added fuzzy matching for /mode <name> CLI argument with prefix, substring, and subsequence matching fallbacks.
1 parent 170fd43
Changed files (1)
dots
pi
agent
dots/pi/agent/extensions/prompt-editor.ts
@@ -1,5 +1,6 @@
 import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
-import { CustomEditor, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
+import { CustomEditor, DynamicBorder, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
+import { Container, type SelectItem, SelectList, Text, matchesKey, Key } from "@mariozechner/pi-tui";
 import path from "node:path";
 import os from "node:os";
 import fs from "node:fs/promises";
@@ -707,7 +708,76 @@ async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<vo
 	while (true) {
 		await ensureRuntime(pi, ctx);
 		const names = orderedModeNames(runtime.data.modes);
-		const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]);
+
+		const items: SelectItem[] = names.map((name) => {
+			const spec = runtime.data.modes[name];
+			const parts: string[] = [];
+			if (spec?.provider && spec?.modelId) parts.push(`${spec.provider}/${spec.modelId}`);
+			if (spec?.thinkingLevel) parts.push(`thinking: ${spec.thinkingLevel}`);
+			const current = name === runtime.currentMode ? " (current)" : "";
+			return {
+				value: name,
+				label: name + current,
+				description: parts.join("  "),
+			};
+		});
+		items.push({ value: MODE_UI_CONFIGURE, label: MODE_UI_CONFIGURE });
+
+		const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+			let filterText = "";
+			const container = new Container();
+			container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+			const titleText = new Text(theme.fg("accent", theme.bold(`Mode (current: ${runtime.currentMode})`)), 1, 0);
+			container.addChild(titleText);
+
+			const filterDisplay = new Text(theme.fg("dim", "Type to filter..."), 1, 0);
+			container.addChild(filterDisplay);
+
+			const selectList = new SelectList(items, Math.min(items.length, 15), {
+				selectedPrefix: (t: string) => theme.fg("accent", t),
+				selectedText: (t: string) => theme.fg("accent", t),
+				description: (t: string) => theme.fg("muted", t),
+				scrollInfo: (t: string) => theme.fg("dim", t),
+				noMatch: (t: string) => theme.fg("warning", t),
+			});
+			selectList.onSelect = (item) => done(item.value);
+			selectList.onCancel = () => done(null);
+			container.addChild(selectList);
+
+			container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
+			container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+
+			const updateFilterDisplay = () => {
+				if (filterText) {
+					filterDisplay.setText(theme.fg("accent", "Filter: ") + theme.fg("text", filterText) + theme.fg("dim", "▏"));
+				} else {
+					filterDisplay.setText(theme.fg("dim", "Type to filter..."));
+				}
+			};
+
+			return {
+				render: (w: number) => container.render(w),
+				invalidate: () => container.invalidate(),
+				handleInput: (data: string) => {
+					if (matchesKey(data, Key.backspace)) {
+						if (filterText.length > 0) {
+							filterText = filterText.slice(0, -1);
+							selectList.setFilter(filterText);
+							updateFilterDisplay();
+						}
+					} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
+						// Printable character
+						filterText += data;
+						selectList.setFilter(filterText);
+						updateFilterDisplay();
+					} else {
+						selectList.handleInput(data);
+					}
+					tui.requestRender();
+				},
+			};
+		});
+
 		if (!choice) return;
 
 		if (choice === MODE_UI_CONFIGURE) {
@@ -1193,7 +1263,56 @@ export default function (pi: ExtensionAPI) {
 				return;
 			}
 
-			// /mode <name>
+			// /mode <name> — with fuzzy matching
+			await ensureRuntime(pi, ctx);
+			const query = tokens.join(" ").toLowerCase();
+			const names = orderedModeNames(runtime.data.modes);
+
+			// Exact match first
+			const exact = names.find((n) => n.toLowerCase() === query);
+			if (exact) {
+				await applyMode(pi, ctx, exact);
+				return;
+			}
+
+			// Prefix match
+			const prefixMatches = names.filter((n) => n.toLowerCase().startsWith(query));
+			if (prefixMatches.length === 1) {
+				await applyMode(pi, ctx, prefixMatches[0]!);
+				return;
+			}
+
+			// Substring match
+			const substringMatches = names.filter((n) => n.toLowerCase().includes(query));
+			if (substringMatches.length === 1) {
+				await applyMode(pi, ctx, substringMatches[0]!);
+				return;
+			}
+
+			// Fuzzy match: all query chars appear in order
+			const fuzzyMatch = (name: string, q: string): boolean => {
+				let qi = 0;
+				for (let i = 0; i < name.length && qi < q.length; i++) {
+					if (name[i] === q[qi]) qi++;
+				}
+				return qi === q.length;
+			};
+			const fuzzyMatches = names.filter((n) => fuzzyMatch(n.toLowerCase(), query));
+			if (fuzzyMatches.length === 1) {
+				await applyMode(pi, ctx, fuzzyMatches[0]!);
+				return;
+			}
+
+			// Multiple matches or no match
+			if (fuzzyMatches.length > 1) {
+				if (ctx.hasUI) {
+					const choice = await ctx.ui.select(`Multiple modes match "${query}"`, fuzzyMatches);
+					if (choice) await handleModeChoiceUI(pi, ctx, choice);
+				}
+				return;
+			}
+
+			// No match at all — try original name
 			await applyMode(pi, ctx, tokens[0]!);
 		},
 	});