Commit 60194f73ba3d
Changed files (1)
dots
pi
agent
extensions
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]!);
},
});