Commit 281bbf66fdaa

Vincent Demeester <vincent@sbr.pm>
2026-02-13 10:31:57
feat(org-todos): fuzzy search in inbox-refile selectors
Added fuzzy filtering to both select menus (inbox item and target section). Type to filter with space-separated terms matched against label and description. Shows active filter in header. Backspace to clear filter characters.
1 parent 7c6d891
Changed files (1)
dots
pi
agent
extensions
org-todos
dots/pi/agent/extensions/org-todos/index.ts
@@ -1061,21 +1061,50 @@ export default function (pi: ExtensionAPI) {
     },
   });
 
-  // Helper: show a SelectList and return the chosen value (or null on cancel)
-  async function showSelectMenu(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | null> {
+  // Fuzzy match: all space-separated terms must appear somewhere in label or description
+  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));
+  }
+
+  // Helper: show a SelectList with fuzzy search and return the chosen value (or null on cancel)
+  async function showSelectMenu(ctx: ExtensionContext, title: string, allItems: SelectItem[]): Promise<string | null> {
     return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+      let searchQuery = "";
+
+      function getFilteredItems(): SelectItem[] {
+        if (!searchQuery) return allItems;
+        return allItems.filter((item) => fuzzyMatch(item, searchQuery));
+      }
+
+      let currentItems = getFilteredItems();
+
       const container = new Container();
       container.addChild(new DynamicBorder((str: string) => theme.fg("accent", str)));
-      container.addChild(new Text(theme.fg("accent", theme.bold(title))));
 
-      const selectList = new SelectList(items, Math.min(items.length, 15), {
+      const headerText = new Text("", 0, 0);
+      function updateHeader() {
+        const titleStr = theme.fg("accent", theme.bold(title));
+        if (searchQuery) {
+          headerText.setText(`${titleStr}  ${theme.fg("warning", `filter: ${searchQuery}`)}`);
+        } else {
+          headerText.setText(titleStr);
+        }
+      }
+      updateHeader();
+      container.addChild(headerText);
+
+      const listTheme = {
         selectedPrefix: (text: string) => theme.fg("accent", text),
         selectedText: (text: string) => theme.fg("accent", text),
         description: (text: string) => theme.fg("muted", text),
         scrollInfo: (text: string) => theme.fg("dim", text),
         noMatch: (text: string) => theme.fg("warning", text),
-      });
+      };
 
+      let selectList = new SelectList(currentItems, Math.min(currentItems.length, 15), listTheme);
       selectList.onSelect = (item: SelectItem) => done(item.value);
       selectList.onCancel = () => done(null);
 
@@ -1083,10 +1112,43 @@ export default function (pi: ExtensionAPI) {
       container.addChild(new Text(theme.fg("dim", "Type to filter · enter to confirm · esc to cancel")));
       container.addChild(new DynamicBorder((str: string) => theme.fg("accent", str)));
 
+      function rebuildList() {
+        currentItems = getFilteredItems();
+        const newList = new SelectList(currentItems, Math.min(currentItems.length, 15), listTheme);
+        newList.onSelect = (item: SelectItem) => done(item.value);
+        newList.onCancel = () => done(null);
+        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) { selectList.handleInput(data); tui.requestRender(); },
+        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();
+        },
       };
     });
   }