main
   1/**
   2 * Pi Extension: Org-mode TODO Management
   3 *
   4 * Provides TODO management using org-mode files as the backend.
   5 * Uses emacsclient to communicate with Emacs daemon for fast,
   6 * accurate org-mode parsing via org-ql.
   7 *
   8 * Tool: org_todo
   9 *   Actions: list, scheduled, upcoming, overdue, search, get,
  10 *            done, state, schedule, deadline, priority, add, append
  11 *
  12 * Commands:
  13 *   /todos                - Show today's tasks (scheduled + overdue + NEXT)
  14 *   /todo-search <query>  - Search TODOs
  15 *   /todo-add <title>     - Add new TODO (supports @Section scheduled:date deadline:date priority:N)
  16 *   /todo-done <heading>  - Mark TODO as DONE
  17 *   /todo-next <heading>  - Mark TODO as NEXT (prioritized)
  18 *   /todo-upcoming [days] - Show upcoming tasks (default 7 days)
  19 *   /todo-update <heading> - Update TODO (scheduled:date deadline:date priority:N state:STATE)
  20 *   /todo-note <heading>  - Add a note to a TODO
  21 *
  22 * Natural language dates (via chrono-node):
  23 *   scheduled:tomorrow, deadline:next friday, scheduled:in 3 days, etc.
  24 *
  25 * Configuration:
  26 *   ORG_TODO_FILE env var or defaults to ~/desktop/org/todos.org
  27 *
  28 * Requirements:
  29 *   - Emacs daemon running (emacs --daemon)
  30 *   - org-ql and pi-org-todos.el loaded
  31 */
  32
  33import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
  34import { DynamicBorder } from "@mariozechner/pi-coding-agent";
  35import { execSync } from "node:child_process";
  36import { homedir } from "node:os";
  37import { join } from "node:path";
  38import * as chrono from "chrono-node";
  39import {
  40	Container,
  41	type SelectItem,
  42	SelectList,
  43	Text,
  44	type AutocompleteItem,
  45	type AutocompleteProvider,
  46	type AutocompleteSuggestions,
  47	fuzzyFilter,
  48} from "@mariozechner/pi-tui";
  49
  50// Configuration
  51const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
  52const INBOX_FILE = join(homedir(), "desktop/org/inbox.org");
  53const DEFAULT_SECTION = "Inbox"; // Default section for quick adds
  54
  55interface OrgTodoResult {
  56  success: boolean;
  57  data?: any;
  58  error?: string;
  59}
  60
  61/**
  62 * Execute elisp via emacsclient and parse JSON result
  63 */
  64function execEmacs(elisp: string): OrgTodoResult {
  65  try {
  66    // Escape single quotes in elisp for shell
  67    const escaped = elisp.replace(/'/g, "'\\''");
  68    
  69    // Try emacsclient first (fast, uses daemon)
  70    const result = execSync(`emacsclient --eval '${escaped}'`, {
  71      encoding: "utf-8",
  72      timeout: 10000,
  73      stdio: ["pipe", "pipe", "pipe"],
  74    });
  75
  76    // emacsclient returns elisp-escaped string, need to parse it
  77    // Result looks like: "{\"success\":true,\"data\":[...]}"
  78    // We need to unescape the outer quotes and parse
  79    let jsonStr = result.trim();
  80    
  81    // Remove outer quotes if present (emacsclient wraps result in quotes)
  82    if (jsonStr.startsWith('"') && jsonStr.endsWith('"')) {
  83      jsonStr = jsonStr.slice(1, -1);
  84    }
  85    
  86    // Unescape escaped quotes
  87    jsonStr = jsonStr.replace(/\\"/g, '"');
  88    // Unescape escaped backslashes
  89    jsonStr = jsonStr.replace(/\\\\/g, '\\');
  90    
  91    return JSON.parse(jsonStr);
  92  } catch (error: any) {
  93    // Check if emacsclient failed (daemon not running)
  94    if (error.message?.includes("emacsclient") || error.status === 1) {
  95      return {
  96        success: false,
  97        error: "Emacs daemon not running. Start with: emacs --daemon",
  98      };
  99    }
 100    return {
 101      success: false,
 102      error: error.message || String(error),
 103    };
 104  }
 105}
 106
 107/**
 108 * Strip org-mode link markup [[url][title]] → title, [[url]] → url
 109 */
 110function stripOrgLinks(text: string): string {
 111  // [[url][title]] → title
 112  text = text.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
 113  // [[url]] → url
 114  text = text.replace(/\[\[([^\]]*)\]\]/g, "$1");
 115  return text;
 116}
 117
 118/**
 119 * Format TODO item for display
 120 */
 121function formatTodo(todo: any): string {
 122  const parts: string[] = [];
 123  
 124  // State with color hint
 125  const state = todo.todo || "TODO";
 126  parts.push(`[${state}]`);
 127  
 128  // Priority
 129  if (todo.priority) {
 130    parts.push(`[#${todo.priority}]`);
 131  }
 132  
 133  // Heading (strip org links)
 134  parts.push(stripOrgLinks(todo.heading));
 135  
 136  // Tags
 137  if (todo.tags && todo.tags.length > 0) {
 138    parts.push(`:${todo.tags.join(":")}:`);
 139  }
 140  
 141  // Scheduled/Deadline
 142  const dates: string[] = [];
 143  if (todo.scheduled) {
 144    dates.push(`SCHEDULED: ${todo.scheduled}`);
 145  }
 146  if (todo.deadline) {
 147    dates.push(`DEADLINE: ${todo.deadline}`);
 148  }
 149  if (dates.length > 0) {
 150    parts.push(`(${dates.join(", ")})`);
 151  }
 152  
 153  return parts.join(" ");
 154}
 155
 156/**
 157 * Format TODO item for markdown display
 158 */
 159function formatTodoMarkdown(todo: any): string {
 160  const parts: string[] = [];
 161  
 162  // State badge
 163  const state = todo.todo || "TODO";
 164  parts.push(`**[${state}]**`);
 165  
 166  // Priority
 167  if (todo.priority) {
 168    parts.push(`\`#${todo.priority}\``);
 169  }
 170  
 171  // Heading (strip org links)
 172  parts.push(stripOrgLinks(todo.heading));
 173  
 174  // Tags
 175  if (todo.tags && todo.tags.length > 0) {
 176    const tagStr = todo.tags.map((t: string) => `\`${t}\``).join(" ");
 177    parts.push(tagStr);
 178  }
 179  
 180  // Scheduled/Deadline on new line
 181  const dates: string[] = [];
 182  if (todo.scheduled) {
 183    dates.push(`📅 ${todo.scheduled}`);
 184  }
 185  if (todo.deadline) {
 186    dates.push(`${todo.deadline}`);
 187  }
 188  
 189  let result = parts.join(" ");
 190  if (dates.length > 0) {
 191    result += ` *(${dates.join(", ")})*`;
 192  }
 193  
 194  return result;
 195}
 196
 197/**
 198 * Parse natural language date to YYYY-MM-DD format
 199 * Supports: "tomorrow", "next friday", "in 3 days", "feb 15", etc.
 200 */
 201function parseNaturalDate(text: string): string | null {
 202  const result = chrono.parseDate(text);
 203  if (!result) return null;
 204  
 205  const year = result.getFullYear();
 206  const month = String(result.getMonth() + 1).padStart(2, "0");
 207  const day = String(result.getDate()).padStart(2, "0");
 208  return `${year}-${month}-${day}`;
 209}
 210
 211/**
 212 * Parse command arguments for todo-add and todo-update
 213 * Supports: @Section scheduled:date deadline:date priority:N state:STATE
 214 */
 215function parseCommandArgs(args: string): {
 216  title: string;
 217  section?: string;
 218  scheduled?: string;
 219  deadline?: string;
 220  priority?: number;
 221  state?: string;
 222} {
 223  let remaining = args;
 224  let section: string | undefined;
 225  let scheduled: string | undefined;
 226  let deadline: string | undefined;
 227  let priority: number | undefined;
 228  let state: string | undefined;
 229  
 230  // Extract @Section
 231  const sectionMatch = remaining.match(/@(\w+)/);
 232  if (sectionMatch) {
 233    section = sectionMatch[1];
 234    remaining = remaining.replace(/@\w+/, "").trim();
 235  }
 236  
 237  // Extract scheduled:date
 238  const scheduledMatch = remaining.match(/scheduled:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:deadline:|priority:|state:|@|$)|$)/i);
 239  if (scheduledMatch) {
 240    const dateStr = scheduledMatch[1].trim();
 241    scheduled = parseNaturalDate(dateStr) || dateStr;
 242    remaining = remaining.replace(scheduledMatch[0], "").trim();
 243  }
 244  
 245  // Extract deadline:date
 246  const deadlineMatch = remaining.match(/deadline:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:scheduled:|priority:|state:|@|$)|$)/i);
 247  if (deadlineMatch) {
 248    const dateStr = deadlineMatch[1].trim();
 249    deadline = parseNaturalDate(dateStr) || dateStr;
 250    remaining = remaining.replace(deadlineMatch[0], "").trim();
 251  }
 252  
 253  // Extract priority:N
 254  const priorityMatch = remaining.match(/priority:(\d)/i);
 255  if (priorityMatch) {
 256    priority = parseInt(priorityMatch[1], 10);
 257    remaining = remaining.replace(priorityMatch[0], "").trim();
 258  }
 259  
 260  // Extract state:STATE
 261  const stateMatch = remaining.match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
 262  if (stateMatch) {
 263    state = stateMatch[1].toUpperCase();
 264    remaining = remaining.replace(stateMatch[0], "").trim();
 265  }
 266  
 267  return {
 268    title: remaining.trim(),
 269    section,
 270    scheduled,
 271    deadline,
 272    priority,
 273    state,
 274  };
 275}
 276
 277export default function (pi: ExtensionAPI) {
 278  // Register message renderers for custom TODO messages
 279  // These apply color-coding to TODO state keywords
 280  const customTypes = [
 281    "org-todos",
 282    "org-todos-search",
 283    "org-todos-add",
 284    "org-todos-done",
 285    "org-todos-next",
 286    "org-todos-upcoming",
 287    "org-todos-update",
 288    "org-todos-note",
 289  ];
 290
 291  for (const customType of customTypes) {
 292    pi.registerMessageRenderer(customType, (message, options, theme) => {
 293      let text = message.content as string;
 294      
 295      // Apply colors to TODO state keywords
 296      // Using bold and colors that work on both light and dark themes
 297      text = text.replace(/\[TODO\]/g, theme.bold(theme.fg("mdHeading", "[TODO]")));     // Orange/amber bold - needs action
 298      text = text.replace(/\[NEXT\]/g, theme.bold(theme.fg("accent", "[NEXT]")));        // Accent bold - queued next
 299      text = text.replace(/\[STRT\]/g, theme.bold(theme.fg("mdLink", "[STRT]")));        // Link color bold - in progress
 300      text = text.replace(/\[WAIT\]/g, theme.bold(theme.fg("muted", "[WAIT]")));         // Muted bold - waiting
 301      text = text.replace(/\[DONE\]/g, theme.bold(theme.fg("success", "[DONE]")));       // Green bold - completed
 302      text = text.replace(/\[CANX\]/g, theme.bold(theme.fg("error", "[CANX]")));         // Red bold - cancelled
 303      
 304      // Wrap in Container with borders for visual separation
 305      const container = new Container();
 306      container.addChild(new DynamicBorder((s: string) => theme.fg("borderMuted", s)));
 307      container.addChild(new Text(text, 1, 1));  // Add padding
 308      container.addChild(new DynamicBorder((s: string) => theme.fg("borderMuted", s)));
 309      return container;
 310    });
 311  }
 312
 313  // Register the org_todo tool
 314  // eslint-disable-next-line @typescript-eslint/no-explicit-any
 315  (pi as any).registerTool({
 316    name: "org_todo",
 317    label: "Org TODO",
 318    promptSnippet: "Manage org-mode TODOs. Actions: list, scheduled, upcoming, overdue, search, get, done, state, schedule, deadline, priority, add, append, inbox-list, inbox-count, inbox-add, refile-targets, refile",
 319    promptGuidelines: [
 320      "NEVER edit .org files directly — always use the org_todo tool for all TODO operations",
 321      "For scheduling, use YYYY-MM-DD date format (natural language dates are NOT supported by this tool)",
 322      "Use inbox-add for quick capture, then refile to the appropriate section later",
 323    ],
 324    description: `Manage org-mode TODOs. Actions:
 325- list: List active TODOs (TODO, NEXT, STRT)
 326- scheduled: Get today's scheduled items
 327- upcoming: Get tasks in next N days (default 7)
 328- overdue: Get overdue tasks
 329- search: Search TODOs by query
 330- get: Get full content of a TODO
 331- done: Mark TODO as DONE
 332- state: Change TODO state (TODO, NEXT, STRT, WAIT, DONE, CANX)
 333- schedule: Set scheduled date
 334- deadline: Set deadline date
 335- priority: Set priority (1-5)
 336- add: Create new TODO
 337- append: Append content to TODO
 338- inbox-list: List all inbox items
 339- inbox-count: Get count of inbox items
 340- inbox-add: Add item to inbox
 341- refile-targets: Get available refile target sections
 342- refile: Refile item from inbox to a section`,
 343    parameters: {
 344      type: "object",
 345      properties: {
 346        action: {
 347          type: "string",
 348          enum: [
 349            "list", "scheduled", "upcoming", "overdue", "search", "get",
 350            "done", "state", "schedule", "deadline", "priority", "add", "append",
 351            "sections", "statistics", "archive",
 352            "inbox-list", "inbox-count", "inbox-add",
 353            "refile-targets", "refile"
 354          ],
 355          description: "Action to perform",
 356        },
 357        heading: {
 358          type: "string",
 359          description: "TODO heading (for get, done, state, schedule, etc.)",
 360        },
 361        query: {
 362          type: "string",
 363          description: "Search query (for search action)",
 364        },
 365        section: {
 366          type: "string",
 367          description: "Section name (for add action or by-section filter)",
 368        },
 369        state: {
 370          type: "string",
 371          enum: ["TODO", "NEXT", "STRT", "WAIT", "DONE", "CANX"],
 372          description: "TODO state (for state action)",
 373        },
 374        date: {
 375          type: "string",
 376          description: "Date in YYYY-MM-DD format (for schedule/deadline)",
 377        },
 378        days: {
 379          type: "number",
 380          description: "Number of days (for upcoming action, default 7)",
 381        },
 382        priority: {
 383          type: "number",
 384          description: "Priority 1-5 (1=highest)",
 385        },
 386        content: {
 387          type: "string",
 388          description: "Content to append (org-mode format)",
 389        },
 390        tags: {
 391          type: "array",
 392          items: { type: "string" },
 393          description: "Tags for new TODO",
 394        },
 395      },
 396      required: ["action"],
 397    },
 398    execute: async (toolCallId, params, signal, onUpdate, ctx) => {
 399      const { action, heading, query, section, state, date, days, priority, content, tags } = params;
 400      
 401      let elisp: string;
 402      
 403      switch (action) {
 404        case "list":
 405          if (section) {
 406            elisp = `(pi/org-todo-by-section "${section}")`;
 407          } else {
 408            elisp = "(pi/org-todo-list)";
 409          }
 410          break;
 411          
 412        case "scheduled":
 413          elisp = `(pi/org-todo-scheduled nil "${date || "today"}")`;
 414          break;
 415          
 416        case "upcoming":
 417          elisp = `(pi/org-todo-upcoming nil ${days || 7})`;
 418          break;
 419          
 420        case "overdue":
 421          elisp = "(pi/org-todo-overdue)";
 422          break;
 423          
 424        case "search":
 425          if (!query) {
 426            return {
 427              content: [{ type: "text", text: "Error: query is required for search action" }],
 428            };
 429          }
 430          elisp = `(pi/org-todo-search "${query.replace(/"/g, '\\"')}")`;
 431          break;
 432          
 433        case "get":
 434          if (!heading) {
 435            return {
 436              content: [{ type: "text", text: "Error: heading is required for get action" }],
 437            };
 438          }
 439          elisp = `(pi/org-todo-get "${heading.replace(/"/g, '\\"')}")`;
 440          break;
 441          
 442        case "done":
 443          if (!heading) {
 444            return {
 445              content: [{ type: "text", text: "Error: heading is required for done action" }],
 446            };
 447          }
 448          elisp = `(pi/org-todo-done "${heading.replace(/"/g, '\\"')}")`;
 449          break;
 450          
 451        case "state":
 452          if (!heading || !state) {
 453            return {
 454              content: [{ type: "text", text: "Error: heading and state are required for state action" }],
 455            };
 456          }
 457          elisp = `(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "${state}")`;
 458          break;
 459          
 460        case "schedule":
 461          if (!heading || !date) {
 462            return {
 463              content: [{ type: "text", text: "Error: heading and date are required for schedule action" }],
 464            };
 465          }
 466          elisp = `(pi/org-todo-schedule "${heading.replace(/"/g, '\\"')}" "${date}")`;
 467          break;
 468          
 469        case "deadline":
 470          if (!heading || !date) {
 471            return {
 472              content: [{ type: "text", text: "Error: heading and date are required for deadline action" }],
 473            };
 474          }
 475          elisp = `(pi/org-todo-deadline "${heading.replace(/"/g, '\\"')}" "${date}")`;
 476          break;
 477          
 478        case "priority":
 479          if (!heading || priority === undefined) {
 480            return {
 481              content: [{ type: "text", text: "Error: heading and priority are required for priority action" }],
 482            };
 483          }
 484          elisp = `(pi/org-todo-priority "${heading.replace(/"/g, '\\"')}" ${priority})`;
 485          break;
 486          
 487        case "add":
 488          if (!heading || !section) {
 489            return {
 490              content: [{ type: "text", text: "Error: heading and section are required for add action" }],
 491            };
 492          }
 493          const schedArg = date ? `"${date}"` : "nil";
 494          const prioArg = priority !== undefined ? priority : "nil";
 495          const tagsArg = tags && tags.length > 0 ? `'(${tags.map(t => `"${t}"`).join(" ")})` : "nil";
 496          elisp = `(pi/org-todo-add "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}" nil ${schedArg} ${prioArg} ${tagsArg})`;
 497          // If content provided, append it after creating the TODO
 498          if (content) {
 499            const addResult = execEmacs(elisp);
 500            if (!addResult.success) {
 501              return {
 502                content: [{ type: "text", text: `Error: ${addResult.error}` }],
 503              };
 504            }
 505            elisp = `(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${content.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`;
 506          }
 507          break;
 508          
 509        case "append":
 510          if (!heading || !content) {
 511            return {
 512              content: [{ type: "text", text: "Error: heading and content are required for append action" }],
 513            };
 514          }
 515          elisp = `(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${content.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`;
 516          break;
 517          
 518        case "sections":
 519          elisp = "(pi/org-todo-sections)";
 520          break;
 521          
 522        case "statistics":
 523          elisp = "(pi/org-todo-statistics)";
 524          break;
 525          
 526        case "archive":
 527          elisp = "(pi/org-todo-archive-done)";
 528          break;
 529          
 530        case "inbox-list":
 531          elisp = `(pi/org-todo-list "${INBOX_FILE}" "TODO,NEXT,STRT,WAIT")`;
 532          break;
 533          
 534        case "inbox-count":
 535          elisp = `(pi/org-todo-inbox-all)`;
 536          break;
 537          
 538        case "inbox-add":
 539          if (!heading) {
 540            return {
 541              content: [{ type: "text", text: "Error: heading is required for inbox-add action" }],
 542            };
 543          }
 544          const schedInbox = date ? `"${date}"` : "nil";
 545          const prioInbox = priority !== undefined ? priority : "nil";
 546          const tagsInbox = tags && tags.length > 0 ? `'(${tags.map(t => `"${t}"`).join(" ")})` : "nil";
 547          elisp = `(pi/org-todo-add "${heading.replace(/"/g, '\\"')}" "Inbox" "${INBOX_FILE}" ${schedInbox} ${prioInbox} ${tagsInbox})`;
 548          break;
 549          
 550        case "refile-targets":
 551          elisp = "(pi/org-todo-get-refile-targets)";
 552          break;
 553          
 554        case "refile":
 555          if (!heading || !section) {
 556            return {
 557              content: [{ type: "text", text: "Error: heading and section are required for refile action" }],
 558            };
 559          }
 560          elisp = `(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}")`;
 561          break;
 562          
 563        default:
 564          return {
 565            content: [{ type: "text", text: `Unknown action: ${action}` }],
 566          };
 567      }
 568      
 569      const result = execEmacs(elisp);
 570      
 571      if (!result.success) {
 572        return {
 573          content: [{ type: "text", text: `Error: ${result.error}` }],
 574        };
 575      }
 576      
 577      // Format output based on action
 578      let text: string;
 579      
 580      if (action === "refile-targets" && Array.isArray(result.data)) {
 581        if (result.data.length === 0) {
 582          text = "No refile targets found.";
 583        } else {
 584          text = result.data.map((t: any) => {
 585            const indent = "  ".repeat((t.level || 1) - 1);
 586            return `${indent}- ${t.section} (${t.file?.replace(/.*\//, '')})`;
 587          }).join("\n");
 588        }
 589      } else if (action === "sections" && Array.isArray(result.data)) {
 590        if (result.data.length === 0) {
 591          text = "No sections found.";
 592        } else {
 593          text = result.data.map((s: any) => `- ${s.section || s}`).join("\n");
 594        }
 595      } else if (Array.isArray(result.data)) {
 596        // List of TODOs
 597        if (result.data.length === 0) {
 598          text = "No TODOs found.";
 599        } else {
 600          text = result.data.map(formatTodo).join("\n");
 601        }
 602      } else if (typeof result.data === "object") {
 603        // Single result or statistics
 604        text = JSON.stringify(result.data, null, 2);
 605      } else {
 606        text = String(result.data);
 607      }
 608      
 609      return {
 610        content: [{ type: "text", text }],
 611      };
 612    },
 613  });
 614
 615
 616  // Register /todos command
 617  pi.registerCommand("todos", {
 618    description: "Show today's tasks (scheduled + overdue + NEXT). Usage: /todos [section]",
 619    handler: async (args, ctx) => {
 620      const sectionFilter = (args || "").trim() || null;
 621
 622      // When a section filter is provided, we need to get the headings in that
 623      // section first, then intersect with the scheduled/overdue/next results.
 624      // We compare by heading text since the elisp doesn't return section info.
 625      let sectionHeadings: Set<string> | null = null;
 626      if (sectionFilter) {
 627        const sectionResult = execEmacs(`(pi/org-todo-by-section "${sectionFilter.replace(/"/g, '\\"')}")`);
 628        if (!sectionResult.success) {
 629          // Section might not exist — show available sections
 630          const sections = execEmacs("(pi/org-todo-sections)");
 631          const sectionList = sections.success && sections.data
 632            ? (Array.isArray(sections.data) ? sections.data : Object.values(sections.data)) as string[]
 633            : [];
 634          ctx.ui.notify(`Section "${sectionFilter}" not found. Available: ${sectionList.join(", ")}`, "error");
 635          return;
 636        }
 637        sectionHeadings = new Set(
 638          (sectionResult.data || []).map((t: any) => t.heading)
 639        );
 640      }
 641
 642      // Helper: filter todos by section headings set
 643      function filterBySection(todos: any[]): any[] {
 644        if (!sectionHeadings) return todos;
 645        return todos.filter((t: any) => sectionHeadings!.has(t.heading));
 646      }
 647
 648      // Fetch scheduled, overdue, and NEXT items
 649      const scheduled = execEmacs("(pi/org-todo-scheduled)");
 650      const overdue = execEmacs("(pi/org-todo-overdue)");
 651      const next = execEmacs('(pi/org-todo-list nil "NEXT")');
 652      
 653      if (!scheduled.success && !overdue.success && !next.success) {
 654        ctx.ui.notify("Failed to fetch TODOs. Is Emacs daemon running?", "error");
 655        return;
 656      }
 657      
 658      // Apply section filter
 659      const filteredOverdue = filterBySection(overdue.success && overdue.data ? overdue.data : []);
 660      const filteredScheduled = filterBySection(scheduled.success && scheduled.data ? scheduled.data : []);
 661      const filteredNext = filterBySection(next.success && next.data ? next.data : []);
 662      
 663      // Build markdown content
 664      const lines: string[] = [];
 665      
 666      const title = sectionFilter 
 667        ? `## 📋 Today's Tasks — ${sectionFilter}`
 668        : "## 📋 Today's Tasks";
 669      lines.push(title);
 670      lines.push("");
 671      
 672      // Overdue section
 673      if (filteredOverdue.length > 0) {
 674        lines.push(`### ⚠️ Overdue (${filteredOverdue.length})`);
 675        lines.push("");
 676        for (const todo of filteredOverdue) {
 677          lines.push(`- ${formatTodoMarkdown(todo)}`);
 678        }
 679        lines.push("");
 680      }
 681      
 682      // Scheduled section
 683      if (filteredScheduled.length > 0) {
 684        lines.push(`### 📅 Scheduled Today (${filteredScheduled.length})`);
 685        lines.push("");
 686        for (const todo of filteredScheduled) {
 687          lines.push(`- ${formatTodoMarkdown(todo)}`);
 688        }
 689        lines.push("");
 690      }
 691      
 692      // NEXT section
 693      if (filteredNext.length > 0) {
 694        lines.push(`### ➡️ Next Actions (${filteredNext.length})`);
 695        lines.push("");
 696        for (const todo of filteredNext) {
 697          lines.push(`- ${formatTodoMarkdown(todo)}`);
 698        }
 699        lines.push("");
 700      }
 701      
 702      // Empty state
 703      const hasContent = filteredOverdue.length > 0 || filteredScheduled.length > 0 || filteredNext.length > 0;
 704        
 705      if (!hasContent) {
 706        if (sectionFilter) {
 707          lines.push(`*No tasks for today in "${sectionFilter}".* 🎉`);
 708        } else {
 709          lines.push("*No tasks for today.* 🎉");
 710        }
 711      }
 712      
 713      // Send as a message that appears in the conversation
 714      pi.sendMessage({
 715        customType: "org-todos",
 716        content: lines.join("\n"),
 717        display: true,
 718      });
 719    },
 720  });
 721
 722  // Register /todo-search command
 723  pi.registerCommand("todo-search", {
 724    description: "Search TODOs. Usage: /todo-search <query>",
 725    handler: async (args, ctx) => {
 726      const query = (args || "").trim();
 727      
 728      if (!query) {
 729        ctx.ui.notify("Usage: /todo-search <query>", "error");
 730        return;
 731      }
 732      
 733      const result = execEmacs(`(pi/org-todo-search "${query.replace(/"/g, '\\"')}" nil t)`);
 734      
 735      if (!result.success) {
 736        ctx.ui.notify(`Search failed: ${result.error}`, "error");
 737        return;
 738      }
 739      
 740      if (!result.data || result.data.length === 0) {
 741        ctx.ui.notify(`No TODOs found matching "${query}"`, "info");
 742        return;
 743      }
 744      
 745      // Build markdown content
 746      const lines: string[] = [];
 747      lines.push(`## 🔍 Search: "${query}"`);
 748      lines.push("");
 749      lines.push(`*${result.data.length} result(s)*`);
 750      lines.push("");
 751      
 752      for (const todo of result.data) {
 753        const matchedIn = todo.matched_in === "heading" ? "" : " *(matched in content)*";
 754        lines.push(`- ${formatTodoMarkdown(todo)}${matchedIn}`);
 755      }
 756      
 757      // Send as a message that appears in the conversation
 758      pi.sendMessage({
 759        customType: "org-todos-search",
 760        content: lines.join("\n"),
 761        display: true,
 762      });
 763    },
 764  });
 765
 766  // Register /todo-add command
 767  pi.registerCommand("todo-add", {
 768    description: "Add a new TODO. Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date] [priority:N]",
 769    handler: async (args, ctx) => {
 770      if (!args?.trim()) {
 771        ctx.ui.notify("Usage: /todo-add <title> [@Section] [scheduled:date] [deadline:date]", "error");
 772        return;
 773      }
 774      
 775      const parsed = parseCommandArgs(args);
 776      
 777      if (!parsed.title) {
 778        ctx.ui.notify("Error: TODO title is required", "error");
 779        return;
 780      }
 781      
 782      const section = parsed.section || DEFAULT_SECTION;
 783      const schedArg = parsed.scheduled ? `"${parsed.scheduled}"` : "nil";
 784      const prioArg = parsed.priority !== undefined ? parsed.priority : "nil";
 785      
 786      // First check if section exists
 787      const sectionsResult = execEmacs("(pi/org-todo-sections)");
 788      if (sectionsResult.success) {
 789        const sections = Array.isArray(sectionsResult.data) 
 790          ? sectionsResult.data 
 791          : Object.values(sectionsResult.data || {});
 792        if (!sections.includes(section)) {
 793          ctx.ui.notify(`Section "${section}" not found. Available: ${sections.join(", ")}`, "error");
 794          return;
 795        }
 796      }
 797      
 798      const elisp = `(pi/org-todo-add "${parsed.title.replace(/"/g, '\\"')}" "${section}" nil ${schedArg} ${prioArg} nil)`;
 799      const result = execEmacs(elisp);
 800      
 801      if (!result.success) {
 802        ctx.ui.notify(`Failed to add TODO: ${result.error}`, "error");
 803        return;
 804      }
 805      
 806      // Set deadline separately if provided
 807      if (parsed.deadline) {
 808        execEmacs(`(pi/org-todo-deadline "${parsed.title.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
 809      }
 810      
 811      // Build confirmation message
 812      const lines: string[] = [];
 813      lines.push(`## ✅ TODO Added`);
 814      lines.push("");
 815      lines.push(`**${parsed.title}** added to *${section}*`);
 816      if (parsed.scheduled) lines.push(`- 📅 Scheduled: ${parsed.scheduled}`);
 817      if (parsed.deadline) lines.push(`- ⏰ Deadline: ${parsed.deadline}`);
 818      if (parsed.priority) lines.push(`- Priority: #${parsed.priority}`);
 819      
 820      pi.sendMessage({
 821        customType: "org-todos-add",
 822        content: lines.join("\n"),
 823        display: true,
 824      });
 825    },
 826  });
 827
 828  // Register /todo-done command
 829  // Helper: fetch active TODOs and let user pick one via fuzzy selector
 830  async function selectTodo(ctx: ExtensionContext, title: string, filterQuery?: string, states?: string): Promise<{ heading: string; display: string } | null> {
 831    const stateFilter = states || "TODO,NEXT,STRT,WAIT";
 832    const listResult = execEmacs(`(pi/org-todo-list nil "${stateFilter}")`);
 833
 834    if (!listResult.success || !listResult.data || listResult.data.length === 0) {
 835      ctx.ui.notify("No active TODOs found.", "info");
 836      return null;
 837    }
 838
 839    const items: SelectItem[] = listResult.data.map((todo: any, i: number) => ({
 840      value: String(i),
 841      label: stripOrgLinks(formatTodo(todo)),
 842      description: todo.scheduled || todo.deadline || undefined,
 843    }));
 844
 845    const filteredItems = filterQuery ? items.filter(item => {
 846      const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
 847      return filterQuery.toLowerCase().split(/\s+/).every(t => searchable.includes(t));
 848    }) : items;
 849
 850    if (filteredItems.length === 0) {
 851      ctx.ui.notify(`No TODOs matching "${filterQuery}"`, "info");
 852      return null;
 853    }
 854
 855    const selectedIdx = await showSelectMenu(ctx, title, filteredItems);
 856
 857    if (selectedIdx === null) return null;
 858
 859    const todo = listResult.data[parseInt(selectedIdx, 10)];
 860    return {
 861      heading: todo.heading,
 862      display: stripOrgLinks(formatTodo(todo)),
 863    };
 864  }
 865
 866  pi.registerCommand("todo-done", {
 867    description: "Mark a TODO as done. Usage: /todo-done [filter] (interactive selector)",
 868    handler: async (args, ctx) => {
 869      const filter = (args || "").trim() || undefined;
 870
 871      const selected = await selectTodo(ctx, "Mark as DONE", filter);
 872      if (!selected) {
 873        if (filter) ctx.ui.notify("Cancelled or no match.", "info");
 874        return;
 875      }
 876
 877      const result = execEmacs(`(pi/org-todo-done "${selected.heading.replace(/"/g, '\\"')}")`);
 878
 879      if (!result.success) {
 880        ctx.ui.notify(`Failed: ${result.error}`, "error");
 881        return;
 882      }
 883
 884      pi.sendMessage({
 885        customType: "org-todos-done",
 886        content: `## ✅ Done\n\n${selected.display}`,
 887        display: true,
 888      });
 889
 890      // Update today status as completing a task affects the count
 891      updateTodayStatus(ctx);
 892    },
 893  });
 894
 895  // Register /todo-next command
 896  pi.registerCommand("todo-next", {
 897    description: "Mark a TODO as NEXT (prioritized). Usage: /todo-next [filter] (interactive selector)",
 898    handler: async (args, ctx) => {
 899      const filter = (args || "").trim() || undefined;
 900
 901      const selected = await selectTodo(ctx, "Mark as NEXT", filter, "TODO,STRT,WAIT");
 902      if (!selected) {
 903        if (filter) ctx.ui.notify("Cancelled or no match.", "info");
 904        return;
 905      }
 906
 907      const result = execEmacs(`(pi/org-todo-state "${selected.heading.replace(/"/g, '\\"')}" "NEXT")`);
 908
 909      if (!result.success) {
 910        ctx.ui.notify(`Failed: ${result.error}`, "error");
 911        return;
 912      }
 913
 914      pi.sendMessage({
 915        customType: "org-todos-next",
 916        content: `## ➡️ Prioritized\n\n${selected.display}`,
 917        display: true,
 918      });
 919    },
 920  });
 921
 922  // Register /todo-upcoming command
 923  pi.registerCommand("todo-upcoming", {
 924    description: "Show upcoming tasks. Usage: /todo-upcoming [days]",
 925    handler: async (args, ctx) => {
 926      const days = parseInt((args || "").trim(), 10) || 7;
 927      
 928      const result = execEmacs(`(pi/org-todo-upcoming nil ${days})`);
 929      
 930      if (!result.success) {
 931        ctx.ui.notify(`Failed: ${result.error}`, "error");
 932        return;
 933      }
 934      
 935      const lines: string[] = [];
 936      lines.push(`## 📆 Upcoming (next ${days} days)`);
 937      lines.push("");
 938      
 939      if (!result.data || result.data.length === 0) {
 940        lines.push("*No upcoming tasks* 🎉");
 941      } else {
 942        for (const todo of result.data) {
 943          lines.push(`- ${formatTodoMarkdown(todo)}`);
 944        }
 945      }
 946      
 947      pi.sendMessage({
 948        customType: "org-todos-upcoming",
 949        content: lines.join("\n"),
 950        display: true,
 951      });
 952    },
 953  });
 954
 955  // Register /todo-update command
 956  pi.registerCommand("todo-update", {
 957    description: "Update a TODO. Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]",
 958    handler: async (args, ctx) => {
 959      if (!args?.trim()) {
 960        ctx.ui.notify("Usage: /todo-update <heading> [scheduled:date] [deadline:date] [priority:N] [state:STATE]", "error");
 961        return;
 962      }
 963      
 964      const parsed = parseCommandArgs(args);
 965      
 966      if (!parsed.title) {
 967        ctx.ui.notify("Error: TODO heading is required", "error");
 968        return;
 969      }
 970      
 971      const heading = parsed.title;
 972      const updates: string[] = [];
 973      
 974      // Apply updates
 975      if (parsed.scheduled) {
 976        const result = execEmacs(`(pi/org-todo-schedule "${heading.replace(/"/g, '\\"')}" "${parsed.scheduled}")`);
 977        if (result.success) updates.push(`📅 Scheduled: ${parsed.scheduled}`);
 978        else ctx.ui.notify(`Failed to set schedule: ${result.error}`, "warning");
 979      }
 980      
 981      if (parsed.deadline) {
 982        const result = execEmacs(`(pi/org-todo-deadline "${heading.replace(/"/g, '\\"')}" "${parsed.deadline}")`);
 983        if (result.success) updates.push(`⏰ Deadline: ${parsed.deadline}`);
 984        else ctx.ui.notify(`Failed to set deadline: ${result.error}`, "warning");
 985      }
 986      
 987      if (parsed.priority !== undefined) {
 988        const result = execEmacs(`(pi/org-todo-priority "${heading.replace(/"/g, '\\"')}" ${parsed.priority})`);
 989        if (result.success) updates.push(`Priority: #${parsed.priority}`);
 990        else ctx.ui.notify(`Failed to set priority: ${result.error}`, "warning");
 991      }
 992      
 993      if (parsed.state) {
 994        const result = execEmacs(`(pi/org-todo-state "${heading.replace(/"/g, '\\"')}" "${parsed.state}")`);
 995        if (result.success) updates.push(`State: ${parsed.state}`);
 996        else ctx.ui.notify(`Failed to set state: ${result.error}`, "warning");
 997      }
 998      
 999      if (updates.length === 0) {
1000        ctx.ui.notify("No updates specified. Use scheduled:, deadline:, priority:, or state:", "warning");
1001        return;
1002      }
1003      
1004      const lines: string[] = [];
1005      lines.push(`## 📝 Updated`);
1006      lines.push("");
1007      lines.push(`**${heading}**`);
1008      lines.push("");
1009      for (const update of updates) {
1010        lines.push(`- ${update}`);
1011      }
1012      
1013      pi.sendMessage({
1014        customType: "org-todos-update",
1015        content: lines.join("\n"),
1016        display: true,
1017      });
1018    },
1019  });
1020
1021  // Register /todo-note command
1022  pi.registerCommand("todo-note", {
1023    description: "Add a note to a TODO. Usage: /todo-note <heading> <note>",
1024    handler: async (args, ctx) => {
1025      if (!args?.trim()) {
1026        ctx.ui.notify("Usage: /todo-note <heading> <note>", "error");
1027        return;
1028      }
1029      
1030      // Split on first newline or after recognizable heading
1031      // Try to find the heading by looking for an existing TODO
1032      const input = args.trim();
1033      
1034      // Simple heuristic: first line or up to first period/newline is heading
1035      let heading: string;
1036      let note: string;
1037      
1038      const newlineIdx = input.indexOf("\n");
1039      if (newlineIdx > 0) {
1040        heading = input.slice(0, newlineIdx).trim();
1041        note = input.slice(newlineIdx + 1).trim();
1042      } else {
1043        // Try to find a natural split - look for common patterns
1044        // "Heading: note" or "Heading - note" or just space-separated
1045        const colonIdx = input.indexOf(": ");
1046        const dashIdx = input.indexOf(" - ");
1047        
1048        if (colonIdx > 0 && colonIdx < 60) {
1049          heading = input.slice(0, colonIdx).trim();
1050          note = input.slice(colonIdx + 2).trim();
1051        } else if (dashIdx > 0 && dashIdx < 60) {
1052          heading = input.slice(0, dashIdx).trim();
1053          note = input.slice(dashIdx + 3).trim();
1054        } else {
1055          ctx.ui.notify("Could not parse heading and note. Use format: /todo-note Heading: your note here", "error");
1056          return;
1057        }
1058      }
1059      
1060      if (!heading || !note) {
1061        ctx.ui.notify("Both heading and note are required", "error");
1062        return;
1063      }
1064      
1065      // Format as org-mode content with timestamp
1066      const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
1067      const orgContent = `\n[${timestamp}] ${note}`;
1068      
1069      const result = execEmacs(`(pi/org-todo-append "${heading.replace(/"/g, '\\"')}" "${orgContent.replace(/"/g, '\\"').replace(/\n/g, '\\n')}")`);
1070      
1071      if (!result.success) {
1072        ctx.ui.notify(`Failed: ${result.error}`, "error");
1073        return;
1074      }
1075      
1076      pi.sendMessage({
1077        customType: "org-todos-note",
1078        content: `## 📝 Note Added\n\n**${heading}**\n\n> ${note}`,
1079        display: true,
1080      });
1081    },
1082  });
1083
1084  // Register /inbox command - Quick view of inbox items (both TODOs and links)
1085  pi.registerCommand("inbox", {
1086    description: "View all inbox items (TODOs and links)",
1087    handler: async (args, ctx) => {
1088      const result = execEmacs(`(pi/org-todo-inbox-all)`);
1089      
1090      if (!result.success) {
1091        ctx.ui.notify(`Failed to fetch inbox: ${result.error}`, "error");
1092        return;
1093      }
1094      
1095      const todos = result.data?.filter((item: any) => item.todo) || [];
1096      const links = result.data?.filter((item: any) => !item.todo) || [];
1097      
1098      const lines: string[] = [];
1099      lines.push("## 📥 Inbox");
1100      lines.push("");
1101      
1102      if (!result.data || result.data.length === 0) {
1103        lines.push("*Inbox is empty* ✨");
1104      } else {
1105        lines.push(`*${result.data.length} item(s)* (${todos.length} tasks, ${links.length} links/notes)`);
1106        lines.push("");
1107        
1108        if (todos.length > 0) {
1109          lines.push("### ✅ Tasks");
1110          lines.push("");
1111          for (const todo of todos) {
1112            lines.push(`- ${formatTodoMarkdown(todo)}`);
1113          }
1114          lines.push("");
1115        }
1116        
1117        if (links.length > 0) {
1118          lines.push("### 🔗 Links & Notes");
1119          lines.push("");
1120          for (const item of links) {
1121            lines.push(`- ${stripOrgLinks(item.heading)}`);
1122          }
1123        }
1124      }
1125      
1126      pi.sendMessage({
1127        customType: "org-todos",
1128        content: lines.join("\n"),
1129        display: true,
1130      });
1131    },
1132  });
1133
1134  // Register /inbox-add command - Quick capture to inbox
1135  pi.registerCommand("inbox-add", {
1136    description: "Quick capture to inbox. Usage: /inbox-add <title> [scheduled:date] [priority:N]",
1137    handler: async (args, ctx) => {
1138      if (!args?.trim()) {
1139        ctx.ui.notify("Usage: /inbox-add <title> [scheduled:date] [priority:N]", "error");
1140        return;
1141      }
1142      
1143      const parsed = parseCommandArgs(args);
1144      
1145      if (!parsed.title) {
1146        ctx.ui.notify("Error: TODO title is required", "error");
1147        return;
1148      }
1149      
1150      const schedArg = parsed.scheduled ? `"${parsed.scheduled}"` : "nil";
1151      const prioArg = parsed.priority !== undefined ? parsed.priority : "nil";
1152      
1153      // Inbox.org has a simple structure - we just add at top level
1154      const elisp = `(with-current-buffer (find-file-noselect "${INBOX_FILE}")
1155        (goto-char (point-max))
1156        (insert "\\n* TODO ${parsed.title.replace(/"/g, '\\"')}")
1157        ${parsed.scheduled ? `(org-schedule nil "${parsed.scheduled}")` : ''}
1158        ${parsed.priority !== undefined ? `(org-priority ${parsed.priority})` : ''}
1159        (save-buffer)
1160        (kill-buffer)
1161        (json-encode (list (cons 'success t))))`;
1162      
1163      const result = execEmacs(elisp);
1164      
1165      if (!result.success) {
1166        ctx.ui.notify(`Failed to add to inbox: ${result.error}`, "error");
1167        return;
1168      }
1169      
1170      const lines: string[] = [];
1171      lines.push(`## 📥 Added to Inbox`);
1172      lines.push("");
1173      lines.push(`**${parsed.title}**`);
1174      if (parsed.scheduled) lines.push(`- 📅 Scheduled: ${parsed.scheduled}`);
1175      if (parsed.priority) lines.push(`- Priority: #${parsed.priority}`);
1176      
1177      pi.sendMessage({
1178        customType: "org-todos-add",
1179        content: lines.join("\n"),
1180        display: true,
1181      });
1182      
1183      // Update status bars (inbox count and today if scheduled)
1184      updateInboxStatus(ctx);
1185      if (parsed.scheduled) {
1186        updateTodayStatus(ctx);
1187      }
1188    },
1189  });
1190
1191  // Fuzzy match: all space-separated terms must appear somewhere in label or description
1192  function fuzzyMatch(item: SelectItem, query: string): boolean {
1193    if (!query) return true;
1194    const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
1195    const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1196    return terms.every((term) => searchable.includes(term));
1197  }
1198
1199  // Helper: show a SelectList with fuzzy search and return the chosen value (or null on cancel)
1200  async function showSelectMenu(ctx: ExtensionContext, title: string, allItems: SelectItem[]): Promise<string | null> {
1201    return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1202      let searchQuery = "";
1203
1204      function getFilteredItems(): SelectItem[] {
1205        if (!searchQuery) return allItems;
1206        return allItems.filter((item) => fuzzyMatch(item, searchQuery));
1207      }
1208
1209      let currentItems = getFilteredItems();
1210
1211      const container = new Container();
1212      container.addChild(new DynamicBorder((str: string) => theme.fg("accent", str)));
1213
1214      const headerText = new Text("", 0, 0);
1215      function updateHeader() {
1216        const titleStr = theme.fg("accent", theme.bold(title));
1217        if (searchQuery) {
1218          headerText.setText(`${titleStr}  ${theme.fg("warning", `filter: ${searchQuery}`)}`);
1219        } else {
1220          headerText.setText(titleStr);
1221        }
1222      }
1223      updateHeader();
1224      container.addChild(headerText);
1225
1226      const listTheme = {
1227        selectedPrefix: (text: string) => theme.fg("accent", text),
1228        selectedText: (text: string) => theme.fg("accent", text),
1229        description: (text: string) => theme.fg("muted", text),
1230        scrollInfo: (text: string) => theme.fg("dim", text),
1231        noMatch: (text: string) => theme.fg("warning", text),
1232      };
1233
1234      let selectList = new SelectList(currentItems, Math.min(currentItems.length, 15), listTheme);
1235      selectList.onSelect = (item: SelectItem) => done(item.value);
1236      selectList.onCancel = () => done(null);
1237
1238      container.addChild(selectList);
1239      container.addChild(new Text(theme.fg("dim", "Type to filter · enter to confirm · esc to cancel")));
1240      container.addChild(new DynamicBorder((str: string) => theme.fg("accent", str)));
1241
1242      function rebuildList() {
1243        currentItems = getFilteredItems();
1244        const newList = new SelectList(currentItems, Math.min(currentItems.length, 15), listTheme);
1245        newList.onSelect = (item: SelectItem) => done(item.value);
1246        newList.onCancel = () => done(null);
1247        const idx = container.children.indexOf(selectList);
1248        if (idx !== -1) container.children[idx] = newList;
1249        selectList = newList;
1250        updateHeader();
1251      }
1252
1253      return {
1254        render(width: number) { return container.render(width); },
1255        invalidate() { container.invalidate(); },
1256        handleInput(data: string) {
1257          // Backspace: remove last char from search
1258          if (data === "\x7f" || data === "\b") {
1259            if (searchQuery.length > 0) {
1260              searchQuery = searchQuery.slice(0, -1);
1261              rebuildList();
1262              tui.requestRender();
1263            }
1264            return;
1265          }
1266
1267          // Printable characters: append to search
1268          if (data.length === 1 && data >= " " && data <= "~") {
1269            searchQuery += data;
1270            rebuildList();
1271            tui.requestRender();
1272            return;
1273          }
1274
1275          // Everything else (arrows, enter, escape): pass to SelectList
1276          selectList.handleInput(data);
1277          tui.requestRender();
1278        },
1279      };
1280    });
1281  }
1282
1283  // Register /inbox-refile command - Refile inbox item with interactive selectors
1284  pi.registerCommand("inbox-refile", {
1285    description: "Refile inbox item to a section (interactive)",
1286    handler: async (args, ctx) => {
1287      // Step 1: Get inbox items
1288      const inboxResult = execEmacs(`(pi/org-todo-inbox-all)`);
1289      
1290      if (!inboxResult.success || !inboxResult.data || inboxResult.data.length === 0) {
1291        ctx.ui.notify("Inbox is empty!", "info");
1292        return;
1293      }
1294      
1295      // Step 2: Select inbox item (skip if heading provided as argument)
1296      let heading = (args || "").trim();
1297      let sourcePosition: number | null = null;
1298      
1299      if (!heading) {
1300        const inboxItems: SelectItem[] = inboxResult.data.map((item: any, i: number) => {
1301          const prefix = item.todo ? `[${item.todo}] ` : "";
1302          const label = stripOrgLinks(`${prefix}${item.heading}`);
1303          return {
1304            value: String(i), // index into inboxResult.data
1305            label,
1306            description: item.todo ? undefined : "link/note",
1307          };
1308        });
1309        
1310        const selectedIdx = await showSelectMenu(ctx, "Select inbox item to refile", inboxItems);
1311        if (selectedIdx === null) {
1312          ctx.ui.notify("Refile cancelled", "info");
1313          return;
1314        }
1315        const sourceItem = inboxResult.data[parseInt(selectedIdx, 10)];
1316        heading = sourceItem.heading;
1317        sourcePosition = sourceItem.position;
1318      }
1319      
1320      // Step 3: Get refile targets and select section
1321      const targetsResult = execEmacs("(pi/org-todo-get-refile-targets)");
1322      
1323      if (!targetsResult.success || !targetsResult.data) {
1324        ctx.ui.notify("Failed to get refile targets", "error");
1325        return;
1326      }
1327      
1328      // Build flat list with path for display, use index to identify target
1329      const sectionItems: SelectItem[] = targetsResult.data.map((t: any, i: number) => {
1330        const indent = t.level > 1 ? "  ".repeat(t.level - 1) : "";
1331        return {
1332          value: String(i), // index into targetsResult.data
1333          label: `${indent}${stripOrgLinks(t.section)}`,
1334          description: t.level > 1 ? stripOrgLinks(t.path) : undefined,
1335        };
1336      });
1337      
1338      // Clean heading for display
1339      const displayHeading = stripOrgLinks(heading).slice(0, 60);
1340      const targetIdx = await showSelectMenu(ctx, `Refile "${displayHeading}" to:`, sectionItems);
1341      if (targetIdx === null) {
1342        ctx.ui.notify("Refile cancelled", "info");
1343        return;
1344      }
1345      
1346      // Step 4: Perform refile using positions for accuracy (avoids encoding/regex issues)
1347      const target = targetsResult.data[parseInt(targetIdx, 10)];
1348      const srcPosArg = sourcePosition ? ` ${sourcePosition}` : "";
1349      const refileResult = execEmacs(`(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${target.section.replace(/"/g, '\\"')}" nil nil ${target.position}${srcPosArg})`);
1350      
1351      if (!refileResult.success) {
1352        ctx.ui.notify(`Refile failed: ${refileResult.error}`, "error");
1353        return;
1354      }
1355      
1356      const displayTarget = stripOrgLinks(target.path || target.section);
1357      pi.sendMessage({
1358        customType: "org-todos",
1359        content: `## ✅ Refiled\n\n**${displayHeading}** → *${displayTarget}*`,
1360        display: true,
1361      });
1362      
1363      // Update inbox count
1364      updateInboxStatus(ctx);
1365    },
1366  });
1367
1368  // Helper function to update inbox count in status bar
1369  function updateInboxStatus(ctx: any) {
1370    try {
1371      const result = execEmacs(`(pi/org-todo-inbox-all)`);
1372      
1373      if (result.success && Array.isArray(result.data)) {
1374        const count = result.data.length;
1375        if (count > 0) {
1376          ctx.ui.setStatus("inbox-count", ctx.ui.theme.fg("warning", `📥 ${count}`));
1377        } else {
1378          ctx.ui.setStatus("inbox-count", undefined);
1379        }
1380      }
1381    } catch (e) {
1382      // Silently fail - inbox status is not critical
1383    }
1384  }
1385
1386  // Helper function to update today's tasks status in status bar
1387  function updateTodayStatus(ctx: any) {
1388    try {
1389      // Get scheduled and overdue counts
1390      const scheduledResult = execEmacs("(pi/org-todo-scheduled)");
1391      const overdueResult = execEmacs("(pi/org-todo-overdue)");
1392      
1393      if (scheduledResult.success && overdueResult.success) {
1394        const schedCount = Array.isArray(scheduledResult.data) ? scheduledResult.data.length : 0;
1395        const overdueCount = Array.isArray(overdueResult.data) ? overdueResult.data.length : 0;
1396        
1397        if (schedCount === 0 && overdueCount === 0) {
1398          // Nothing scheduled or overdue - show clear status
1399          ctx.ui.setStatus("today-todos", ctx.ui.theme.fg("success", "✓"));
1400        } else if (overdueCount > 0 && schedCount > 0) {
1401          // Both overdue and scheduled - show both with overdue in red
1402          ctx.ui.setStatus("today-todos", 
1403            ctx.ui.theme.fg("error", `⚠️ ${overdueCount}`) + " " + 
1404            ctx.ui.theme.fg("accent", `📅 ${schedCount}`));
1405        } else if (overdueCount > 0) {
1406          // Only overdue - show in red
1407          ctx.ui.setStatus("today-todos", 
1408            ctx.ui.theme.fg("error", `⚠️ ${overdueCount}`));
1409        } else {
1410          // Only scheduled - show in accent color
1411          ctx.ui.setStatus("today-todos", 
1412            ctx.ui.theme.fg("accent", `📅 ${schedCount}`));
1413        }
1414      }
1415    } catch (e) {
1416      // Silently fail - today status is not critical
1417    }
1418  }
1419
1420  // Update status bars on session start
1421  pi.on("session_start", async (_event, ctx) => {
1422    updateInboxStatus(ctx);
1423    updateTodayStatus(ctx);
1424    setupTodoAutocomplete(ctx);
1425    
1426    // Set up periodic updates every 5 minutes
1427    // Use unref() so the interval doesn't prevent process exit
1428    const updateInterval = setInterval(() => {
1429      updateInboxStatus(ctx);
1430      updateTodayStatus(ctx);
1431    }, 5 * 60 * 1000); // 5 minutes in milliseconds
1432    updateInterval.unref();
1433    
1434    // Clean up interval on session end
1435    (pi as any).on("session_end", async () => {
1436      clearInterval(updateInterval);
1437    });
1438  });
1439}
1440
1441// ============================================================================
1442// Autocomplete: Org TODOs
1443// ============================================================================
1444
1445type OrgTodoItem = {
1446	heading: string;
1447	todo: string;
1448};
1449
1450const TODO_MAX_SUGGESTIONS = 20;
1451
1452function extractTodoToken(textBeforeCursor: string): string | undefined {
1453	const match = textBeforeCursor.match(/(?:^|[ \t])t:([^\s]*)$/);
1454	return match?.[1];
1455}
1456
1457function formatTodoItem(item: OrgTodoItem): AutocompleteItem {
1458	return {
1459		value: item.heading,
1460		label: item.heading,
1461		description: `[${item.todo}]`,
1462	};
1463}
1464
1465function filterTodoItems(items: OrgTodoItem[], query: string): AutocompleteItem[] {
1466	if (!query.trim()) {
1467		return items.slice(0, TODO_MAX_SUGGESTIONS).map(formatTodoItem);
1468	}
1469
1470	return fuzzyFilter(items, query, (item) => `${item.todo} ${item.heading}`)
1471		.slice(0, TODO_MAX_SUGGESTIONS)
1472		.map(formatTodoItem);
1473}
1474
1475function createTodoAutocompleteProvider(
1476	current: AutocompleteProvider,
1477	getItems: () => Promise<OrgTodoItem[] | undefined>,
1478): AutocompleteProvider {
1479	return {
1480		async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
1481			const currentLine = lines[cursorLine] ?? "";
1482			const textBeforeCursor = currentLine.slice(0, cursorCol);
1483			const query = extractTodoToken(textBeforeCursor);
1484
1485			if (query === undefined) {
1486				return current.getSuggestions(lines, cursorLine, cursorCol, options);
1487			}
1488
1489			const items = await getItems();
1490			if (options.signal.aborted || !items || items.length === 0) {
1491				return current.getSuggestions(lines, cursorLine, cursorCol, options);
1492			}
1493
1494			const suggestions = filterTodoItems(items, query);
1495			if (suggestions.length === 0) {
1496				return current.getSuggestions(lines, cursorLine, cursorCol, options);
1497			}
1498
1499			return { items: suggestions, prefix: `t:${query}` };
1500		},
1501
1502		applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1503			if (prefix.startsWith("t:")) {
1504				const currentLine = lines[cursorLine] || "";
1505				const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
1506				const afterCursor = currentLine.slice(cursorCol);
1507				// Quote the heading since it may contain spaces
1508				const value = item.value.includes(" ") ? `"${item.value}"` : item.value;
1509				const newLine = beforePrefix + value + " " + afterCursor;
1510				const newLines = [...lines];
1511				newLines[cursorLine] = newLine;
1512				return {
1513					lines: newLines,
1514					cursorLine,
1515					cursorCol: beforePrefix.length + value.length + 1,
1516				};
1517			}
1518			return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1519		},
1520
1521		shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1522			return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
1523		},
1524	};
1525}
1526
1527function setupTodoAutocomplete(ctx: ExtensionContext): void {
1528	let itemsPromise: Promise<OrgTodoItem[] | undefined> | undefined;
1529
1530	const getItems = async (): Promise<OrgTodoItem[] | undefined> => {
1531		itemsPromise ||= (async () => {
1532			const result = execEmacs("(pi/org-todo-list)");
1533			if (!result.success || !Array.isArray(result.data)) return undefined;
1534
1535			return result.data.map((item: any) => ({
1536				heading: item.heading,
1537				todo: item.todo || "TODO",
1538			}));
1539		})();
1540		return itemsPromise;
1541	};
1542
1543	// Preload in background
1544	void getItems();
1545	ctx.ui.addAutocompleteProvider((current) => createTodoAutocompleteProvider(current, getItems));
1546}