Commit 6e01b57bb8bf

Vincent Demeester <vincent@sbr.pm>
2026-02-06 10:50:45
feat(org-todos): add quick commands with natural language dates
New commands: - /todo-add: Quick capture with @Section scheduled:date deadline:date - /todo-done: Mark TODO as DONE - /todo-next: Mark TODO as NEXT (prioritized) - /todo-upcoming: Show upcoming N days - /todo-update: Update scheduled/deadline/priority/state - /todo-note: Add timestamped note to TODO Natural language date parsing via chrono-node supports: tomorrow, next friday, in 3 days, feb 15, etc.
1 parent 6e9acde
Changed files (4)
dots/pi/agent/extensions/org-todos/bun.lock
@@ -0,0 +1,15 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "org-todos",
+      "dependencies": {
+        "chrono-node": "^2.7.0",
+      },
+    },
+  },
+  "packages": {
+    "chrono-node": ["chrono-node@2.9.0", "", {}, "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ=="],
+  }
+}
dots/pi/agent/extensions/org-todos/index.test.ts
@@ -351,3 +351,50 @@ describe("org-todos extension", () => {
     });
   });
 });
+
+// Tests for new command parsing
+describe("command parsing", () => {
+  // Import parseCommandArgs by extracting the logic
+  // Since it's not exported, we test the patterns directly
+  
+  test("parseNaturalDate patterns", () => {
+    const chrono = require("chrono-node");
+    
+    // Test chrono works
+    const tomorrow = chrono.parseDate("tomorrow");
+    expect(tomorrow).toBeDefined();
+    expect(tomorrow.getDate()).toBe(new Date().getDate() + 1);
+    
+    const nextFriday = chrono.parseDate("next friday");
+    expect(nextFriday).toBeDefined();
+    expect(nextFriday.getDay()).toBe(5); // Friday
+    
+    const specific = chrono.parseDate("2026-03-15");
+    expect(specific).toBeDefined();
+    expect(specific.getMonth()).toBe(2); // March (0-indexed)
+  });
+
+  test("command arg patterns", () => {
+    // Test regex patterns used in parseCommandArgs
+    
+    // @Section pattern
+    const sectionMatch = "Buy milk @Personal".match(/@(\w+)/);
+    expect(sectionMatch).toBeDefined();
+    expect(sectionMatch![1]).toBe("Personal");
+    
+    // scheduled: pattern
+    const schedMatch = "Task scheduled:tomorrow".match(/scheduled:([^\s]+)/i);
+    expect(schedMatch).toBeDefined();
+    expect(schedMatch![1]).toBe("tomorrow");
+    
+    // priority: pattern
+    const prioMatch = "Task priority:2".match(/priority:(\d)/i);
+    expect(prioMatch).toBeDefined();
+    expect(prioMatch![1]).toBe("2");
+    
+    // state: pattern
+    const stateMatch = "Task state:NEXT".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+    expect(stateMatch).toBeDefined();
+    expect(stateMatch![1]).toBe("NEXT");
+  });
+});
dots/pi/agent/extensions/org-todos/index.ts
@@ -10,8 +10,17 @@
  *            done, state, schedule, deadline, priority, add, append
  *
  * Commands:
- *   /todos - Show today's tasks (scheduled + overdue + NEXT)
- *   /todo-search <query> - Search TODOs
+ *   /todos                - Show today's tasks (scheduled + overdue + NEXT)
+ *   /todo-search <query>  - Search TODOs
+ *   /todo-add <title>     - Add new TODO (supports @Section scheduled:date deadline:date priority:N)
+ *   /todo-done <heading>  - Mark TODO as DONE
+ *   /todo-next <heading>  - Mark TODO as NEXT (prioritized)
+ *   /todo-upcoming [days] - Show upcoming tasks (default 7 days)
+ *   /todo-update <heading> - Update TODO (scheduled:date deadline:date priority:N state:STATE)
+ *   /todo-note <heading>  - Add a note to a TODO
+ *
+ * Natural language dates (via chrono-node):
+ *   scheduled:tomorrow, deadline:next friday, scheduled:in 3 days, etc.
  *
  * Configuration:
  *   ORG_TODO_FILE env var or defaults to ~/desktop/org/todos.org
@@ -25,9 +34,11 @@ import type { ExtensionAPI } 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";
 
 // Configuration
 const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
+const DEFAULT_SECTION = "Inbox"; // Default section for quick adds
 
 interface OrgTodoResult {
   success: boolean;
@@ -160,6 +171,86 @@ function formatTodoMarkdown(todo: any): string {
   return result;
 }
 
+/**
+ * Parse natural language date to YYYY-MM-DD format
+ * Supports: "tomorrow", "next friday", "in 3 days", "feb 15", etc.
+ */
+function parseNaturalDate(text: string): string | null {
+  const result = chrono.parseDate(text);
+  if (!result) return null;
+  
+  const year = result.getFullYear();
+  const month = String(result.getMonth() + 1).padStart(2, "0");
+  const day = String(result.getDate()).padStart(2, "0");
+  return `${year}-${month}-${day}`;
+}
+
+/**
+ * Parse command arguments for todo-add and todo-update
+ * Supports: @Section scheduled:date deadline:date priority:N state:STATE
+ */
+function parseCommandArgs(args: string): {
+  title: string;
+  section?: string;
+  scheduled?: string;
+  deadline?: string;
+  priority?: number;
+  state?: string;
+} {
+  let remaining = args;
+  let section: string | undefined;
+  let scheduled: string | undefined;
+  let deadline: string | undefined;
+  let priority: number | undefined;
+  let state: string | undefined;
+  
+  // Extract @Section
+  const sectionMatch = remaining.match(/@(\w+)/);
+  if (sectionMatch) {
+    section = sectionMatch[1];
+    remaining = remaining.replace(/@\w+/, "").trim();
+  }
+  
+  // Extract scheduled:date
+  const scheduledMatch = remaining.match(/scheduled:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:deadline:|priority:|state:|@|$)|$)/i);
+  if (scheduledMatch) {
+    const dateStr = scheduledMatch[1].trim();
+    scheduled = parseNaturalDate(dateStr) || dateStr;
+    remaining = remaining.replace(scheduledMatch[0], "").trim();
+  }
+  
+  // Extract deadline:date
+  const deadlineMatch = remaining.match(/deadline:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:scheduled:|priority:|state:|@|$)|$)/i);
+  if (deadlineMatch) {
+    const dateStr = deadlineMatch[1].trim();
+    deadline = parseNaturalDate(dateStr) || dateStr;
+    remaining = remaining.replace(deadlineMatch[0], "").trim();
+  }
+  
+  // Extract priority:N
+  const priorityMatch = remaining.match(/priority:(\d)/i);
+  if (priorityMatch) {
+    priority = parseInt(priorityMatch[1], 10);
+    remaining = remaining.replace(priorityMatch[0], "").trim();
+  }
+  
+  // Extract state:STATE
+  const stateMatch = remaining.match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
+  if (stateMatch) {
+    state = stateMatch[1].toUpperCase();
+    remaining = remaining.replace(stateMatch[0], "").trim();
+  }
+  
+  return {
+    title: remaining.trim(),
+    section,
+    scheduled,
+    deadline,
+    priority,
+    state,
+  };
+}
+
 export default function (pi: ExtensionAPI) {
   // Register the org_todo tool
   pi.registerTool({
@@ -504,4 +595,280 @@ export default function (pi: ExtensionAPI) {
       });
     },
   });
+
+  // Register /todo-add command
+  pi.registerCommand("todo-add", {
+    description: "Add a new TODO. Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date] [priority:N]",
+    handler: async (args, ctx) => {
+      if (!args?.trim()) {
+        ctx.ui.notify("Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date]", "error");
+        return;
+      }
+      
+      const parsed = parseCommandArgs(args);
+      
+      if (!parsed.title) {
+        ctx.ui.notify("Error: TODO title is required", "error");
+        return;
+      }
+      
+      const section = parsed.section || DEFAULT_SECTION;
+      const schedArg = parsed.scheduled ? `"${parsed.scheduled}"` : "nil";
+      const prioArg = parsed.priority !== undefined ? parsed.priority : "nil";
+      
+      // First check if section exists
+      const sectionsResult = execEmacs("(pi/org-todo-sections)");
+      if (sectionsResult.success) {
+        const sections = Array.isArray(sectionsResult.data) 
+          ? sectionsResult.data 
+          : Object.values(sectionsResult.data || {});
+        if (!sections.includes(section)) {
+          ctx.ui.notify(`Section "${section}" not found. Available: ${sections.join(", ")}`, "error");
+          return;
+        }
+      }
+      
+      const elisp = `(pi/org-todo-add "${parsed.title.replace(/"/g, '\\"')}" "${section}" nil ${schedArg} ${prioArg} nil)`;
+      const result = execEmacs(elisp);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed to add TODO: ${result.error}`, "error");
+        return;
+      }
+      
+      // Set deadline separately if provided
+      if (parsed.deadline) {
+        execEmacs(`(pi/org-todo-deadline "${parsed.title.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
+      }
+      
+      // Build confirmation message
+      const lines: string[] = [];
+      lines.push(`## โœ… TODO Added`);
+      lines.push("");
+      lines.push(`**${parsed.title}** added to *${section}*`);
+      if (parsed.scheduled) lines.push(`- ๐Ÿ“… Scheduled: ${parsed.scheduled}`);
+      if (parsed.deadline) lines.push(`- โฐ Deadline: ${parsed.deadline}`);
+      if (parsed.priority) lines.push(`- Priority: #${parsed.priority}`);
+      
+      pi.sendMessage({
+        customType: "org-todos-add",
+        content: lines.join("\n"),
+        display: true,
+      });
+    },
+  });
+
+  // Register /todo-done command
+  pi.registerCommand("todo-done", {
+    description: "Mark a TODO as done. Usage: /todo-done <heading>",
+    handler: async (args, ctx) => {
+      const heading = (args || "").trim();
+      
+      if (!heading) {
+        ctx.ui.notify("Usage: /todo-done <heading>", "error");
+        return;
+      }
+      
+      const result = execEmacs(`(pi/org-todo-done "${heading.replace(/"/g, '\\"')}")`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed: ${result.error}`, "error");
+        return;
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos-done",
+        content: `## โœ… Done\n\n**${heading}** marked as DONE`,
+        display: true,
+      });
+    },
+  });
+
+  // Register /todo-next command
+  pi.registerCommand("todo-next", {
+    description: "Mark a TODO as NEXT (prioritized). Usage: /todo-next <heading>",
+    handler: async (args, ctx) => {
+      const heading = (args || "").trim();
+      
+      if (!heading) {
+        ctx.ui.notify("Usage: /todo-next <heading>", "error");
+        return;
+      }
+      
+      const result = execEmacs(`(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "NEXT")`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed: ${result.error}`, "error");
+        return;
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos-next",
+        content: `## โžก๏ธ Prioritized\n\n**${heading}** marked as NEXT`,
+        display: true,
+      });
+    },
+  });
+
+  // Register /todo-upcoming command
+  pi.registerCommand("todo-upcoming", {
+    description: "Show upcoming tasks. Usage: /todo-upcoming [days]",
+    handler: async (args, ctx) => {
+      const days = parseInt((args || "").trim(), 10) || 7;
+      
+      const result = execEmacs(`(pi/org-todo-upcoming nil ${days})`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed: ${result.error}`, "error");
+        return;
+      }
+      
+      const lines: string[] = [];
+      lines.push(`## ๐Ÿ“† Upcoming (next ${days} days)`);
+      lines.push("");
+      
+      if (!result.data || result.data.length === 0) {
+        lines.push("*No upcoming tasks* ๐ŸŽ‰");
+      } else {
+        for (const todo of result.data) {
+          lines.push(`- ${formatTodoMarkdown(todo)}`);
+        }
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos-upcoming",
+        content: lines.join("\n"),
+        display: true,
+      });
+    },
+  });
+
+  // Register /todo-update command
+  pi.registerCommand("todo-update", {
+    description: "Update a TODO. Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]",
+    handler: async (args, ctx) => {
+      if (!args?.trim()) {
+        ctx.ui.notify("Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]", "error");
+        return;
+      }
+      
+      const parsed = parseCommandArgs(args);
+      
+      if (!parsed.title) {
+        ctx.ui.notify("Error: TODO heading is required", "error");
+        return;
+      }
+      
+      const heading = parsed.title;
+      const updates: string[] = [];
+      
+      // Apply updates
+      if (parsed.scheduled) {
+        const result = execEmacs(`(pi/org-todo-schedule "${heading.replace(/"/g, '\\"')}" "${parsed.scheduled}")`);
+        if (result.success) updates.push(`๐Ÿ“… Scheduled: ${parsed.scheduled}`);
+        else ctx.ui.notify(`Failed to set schedule: ${result.error}`, "warning");
+      }
+      
+      if (parsed.deadline) {
+        const result = execEmacs(`(pi/org-todo-deadline "${heading.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
+        if (result.success) updates.push(`โฐ Deadline: ${parsed.deadline}`);
+        else ctx.ui.notify(`Failed to set deadline: ${result.error}`, "warning");
+      }
+      
+      if (parsed.priority !== undefined) {
+        const result = execEmacs(`(pi/org-todo-priority "${heading.replace(/"/g, '\\"')}" ${parsed.priority})`);
+        if (result.success) updates.push(`Priority: #${parsed.priority}`);
+        else ctx.ui.notify(`Failed to set priority: ${result.error}`, "warning");
+      }
+      
+      if (parsed.state) {
+        const result = execEmacs(`(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "${parsed.state}")`);
+        if (result.success) updates.push(`State: ${parsed.state}`);
+        else ctx.ui.notify(`Failed to set state: ${result.error}`, "warning");
+      }
+      
+      if (updates.length === 0) {
+        ctx.ui.notify("No updates specified. Use scheduled:, deadline:, priority:, or state:", "warning");
+        return;
+      }
+      
+      const lines: string[] = [];
+      lines.push(`## ๐Ÿ“ Updated`);
+      lines.push("");
+      lines.push(`**${heading}**`);
+      lines.push("");
+      for (const update of updates) {
+        lines.push(`- ${update}`);
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos-update",
+        content: lines.join("\n"),
+        display: true,
+      });
+    },
+  });
+
+  // Register /todo-note command
+  pi.registerCommand("todo-note", {
+    description: "Add a note to a TODO. Usage: /todo-note <heading> <note>",
+    handler: async (args, ctx) => {
+      if (!args?.trim()) {
+        ctx.ui.notify("Usage: /todo-note <heading> <note>", "error");
+        return;
+      }
+      
+      // Split on first newline or after recognizable heading
+      // Try to find the heading by looking for an existing TODO
+      const input = args.trim();
+      
+      // Simple heuristic: first line or up to first period/newline is heading
+      let heading: string;
+      let note: string;
+      
+      const newlineIdx = input.indexOf("\n");
+      if (newlineIdx > 0) {
+        heading = input.slice(0, newlineIdx).trim();
+        note = input.slice(newlineIdx + 1).trim();
+      } else {
+        // Try to find a natural split - look for common patterns
+        // "Heading: note" or "Heading - note" or just space-separated
+        const colonIdx = input.indexOf(": ");
+        const dashIdx = input.indexOf(" - ");
+        
+        if (colonIdx > 0 && colonIdx < 60) {
+          heading = input.slice(0, colonIdx).trim();
+          note = input.slice(colonIdx + 2).trim();
+        } else if (dashIdx > 0 && dashIdx < 60) {
+          heading = input.slice(0, dashIdx).trim();
+          note = input.slice(dashIdx + 3).trim();
+        } else {
+          ctx.ui.notify("Could not parse heading and note. Use format: /todo-note Heading: your note here", "error");
+          return;
+        }
+      }
+      
+      if (!heading || !note) {
+        ctx.ui.notify("Both heading and note are required", "error");
+        return;
+      }
+      
+      // Format as org-mode content with timestamp
+      const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
+      const orgContent = `\n[${timestamp}] ${note}`;
+      
+      const result = execEmacs(`(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${orgContent.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed: ${result.error}`, "error");
+        return;
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos-note",
+        content: `## ๐Ÿ“ Note Added\n\n**${heading}**\n\n> ${note}`,
+        display: true,
+      });
+    },
+  });
 }
dots/pi/agent/extensions/org-todos/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "org-todos",
+  "version": "1.0.0",
+  "dependencies": {
+    "chrono-node": "^2.7.0"
+  }
+}