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}