Commit 9bfc002dba00

Vincent Demeester <vincent@sbr.pm>
2026-02-13 10:22:41
feat(org-todos): interactive selectors for inbox-refile
Replaced text-based inbox-refile with two-step interactive SelectList menus: first pick the inbox item, then pick the target section. Supports type-to-filter. Heading argument still works to skip the first selector.
1 parent ec650b7
Changed files (1)
dots
pi
agent
extensions
org-todos
dots/pi/agent/extensions/org-todos/index.ts
@@ -30,13 +30,13 @@
  *   - org-ql and pi-org-todos.el loaded
  */
 
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 import { DynamicBorder } from "@mariozechner/pi-coding-agent";
 import { execSync } from "node:child_process";
 import { homedir } from "node:os";
 import { join } from "node:path";
 import * as chrono from "chrono-node";
-import { Container, Text } from "@mariozechner/pi-tui";
+import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
 
 // Configuration
 const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
@@ -1061,18 +1061,73 @@ export default function (pi: ExtensionAPI) {
     },
   });
 
-  // Register /inbox-refile command - Refile inbox item with interactive menu
+  // 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> {
+    return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+      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), {
+        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),
+      });
+
+      selectList.onSelect = (item: SelectItem) => done(item.value);
+      selectList.onCancel = () => done(null);
+
+      container.addChild(selectList);
+      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)));
+
+      return {
+        render(width: number) { return container.render(width); },
+        invalidate() { container.invalidate(); },
+        handleInput(data: string) { selectList.handleInput(data); tui.requestRender(); },
+      };
+    });
+  }
+
+  // Register /inbox-refile command - Refile inbox item with interactive selectors
   pi.registerCommand("inbox-refile", {
-    description: "Refile inbox item to a section. Usage: /inbox-refile <heading>",
+    description: "Refile inbox item to a section (interactive)",
     handler: async (args, ctx) => {
-      if (!args?.trim()) {
-        ctx.ui.notify("Usage: /inbox-refile <heading>", "error");
+      // Step 1: Get inbox items
+      const inboxResult = execEmacs(`(pi/org-todo-inbox-all)`);
+      
+      if (!inboxResult.success || !inboxResult.data || inboxResult.data.length === 0) {
+        ctx.ui.notify("Inbox is empty!", "info");
         return;
       }
       
-      const heading = args.trim();
+      // Step 2: Select inbox item (skip if heading provided as argument)
+      let heading = (args || "").trim();
       
-      // Get available refile targets
+      if (!heading) {
+        const inboxItems: SelectItem[] = inboxResult.data.map((item: any) => {
+          const prefix = item.todo ? `[${item.todo}] ` : "";
+          const label = `${prefix}${item.heading}`;
+          // Strip org link markup for cleaner display
+          const cleanLabel = label.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
+          return {
+            value: item.heading,
+            label: cleanLabel,
+            description: item.todo ? undefined : "link/note",
+          };
+        });
+        
+        const selected = await showSelectMenu(ctx, "Select inbox item to refile", inboxItems);
+        if (!selected) {
+          ctx.ui.notify("Refile cancelled", "info");
+          return;
+        }
+        heading = selected;
+      }
+      
+      // Step 3: Get refile targets and select section
       const targetsResult = execEmacs("(pi/org-todo-get-refile-targets)");
       
       if (!targetsResult.success || !targetsResult.data) {
@@ -1080,34 +1135,35 @@ export default function (pi: ExtensionAPI) {
         return;
       }
       
-      const sections = targetsResult.data.map((t: any) => t.section);
+      const sectionItems: SelectItem[] = targetsResult.data.map((t: any) => ({
+        value: t.section,
+        label: t.section,
+      }));
       
-      // Display menu for selection
-      const lines: string[] = [];
-      lines.push(`## 📋 Refile: "${heading}"`);
-      lines.push("");
-      lines.push("**Available sections:**");
-      lines.push("");
-      sections.forEach((section: string, i: number) => {
-        lines.push(`${i + 1}. **${section}**`);
-      });
-      lines.push("");
-      lines.push("To refile to a section, I'll call the org_todo tool with:");
-      lines.push("```json");
-      lines.push(`{`);
-      lines.push(`  "action": "refile",`);
-      lines.push(`  "heading": "${heading}",`);
-      lines.push(`  "section": "<choose from list above>"`);
-      lines.push(`}`);
-      lines.push("```");
-      lines.push("");
-      lines.push("*Which section would you like to refile to?*");
+      // Clean heading for display
+      const displayHeading = heading.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2").slice(0, 60);
+      const section = await showSelectMenu(ctx, `Refile "${displayHeading}" to:`, sectionItems);
+      if (!section) {
+        ctx.ui.notify("Refile cancelled", "info");
+        return;
+      }
+      
+      // Step 4: Perform refile
+      const refileResult = execEmacs(`(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}")`);
+      
+      if (!refileResult.success) {
+        ctx.ui.notify(`Refile failed: ${refileResult.error}`, "error");
+        return;
+      }
       
       pi.sendMessage({
         customType: "org-todos",
-        content: lines.join("\n"),
+        content: `## ✅ Refiled\n\n**${displayHeading}** → *${section}*`,
         display: true,
       });
+      
+      // Update inbox count
+      updateInboxStatus(ctx);
     },
   });