Commit 496f4adc8305

Vincent Demeester <vincent@sbr.pm>
2026-04-24 10:03:53
feat(pi): add Org TODO autocomplete provider
Added t: triggered autocomplete for org-mode TODOs using addAutocompleteProvider API. Preloads active TODOs (TODO, NEXT, STRT) via emacsclient on session start. Supports fuzzy search across heading and state. Activated with Tab.
1 parent 2184ee6
Changed files (1)
dots
pi
agent
extensions
org-todos
dots/pi/agent/extensions/org-todos/index.ts
@@ -36,7 +36,16 @@ import { execSync } from "node:child_process";
 import { homedir } from "node:os";
 import { join } from "node:path";
 import * as chrono from "chrono-node";
-import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
+import {
+	Container,
+	type SelectItem,
+	SelectList,
+	Text,
+	type AutocompleteItem,
+	type AutocompleteProvider,
+	type AutocompleteSuggestions,
+	fuzzyFilter,
+} from "@mariozechner/pi-tui";
 
 // Configuration
 const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
@@ -1412,6 +1421,7 @@ export default function (pi: ExtensionAPI) {
   pi.on("session_start", async (_event, ctx) => {
     updateInboxStatus(ctx);
     updateTodayStatus(ctx);
+    setupTodoAutocomplete(ctx);
     
     // Set up periodic updates every 5 minutes
     // Use unref() so the interval doesn't prevent process exit
@@ -1427,3 +1437,110 @@ export default function (pi: ExtensionAPI) {
     });
   });
 }
+
+// ============================================================================
+// Autocomplete: Org TODOs
+// ============================================================================
+
+type OrgTodoItem = {
+	heading: string;
+	todo: string;
+};
+
+const TODO_MAX_SUGGESTIONS = 20;
+
+function extractTodoToken(textBeforeCursor: string): string | undefined {
+	const match = textBeforeCursor.match(/(?:^|[ \t])t:([^\s]*)$/);
+	return match?.[1];
+}
+
+function formatTodoItem(item: OrgTodoItem): AutocompleteItem {
+	return {
+		value: item.heading,
+		label: item.heading,
+		description: `[${item.todo}]`,
+	};
+}
+
+function filterTodoItems(items: OrgTodoItem[], query: string): AutocompleteItem[] {
+	if (!query.trim()) {
+		return items.slice(0, TODO_MAX_SUGGESTIONS).map(formatTodoItem);
+	}
+
+	return fuzzyFilter(items, query, (item) => `${item.todo} ${item.heading}`)
+		.slice(0, TODO_MAX_SUGGESTIONS)
+		.map(formatTodoItem);
+}
+
+function createTodoAutocompleteProvider(
+	current: AutocompleteProvider,
+	getItems: () => Promise<OrgTodoItem[] | undefined>,
+): AutocompleteProvider {
+	return {
+		async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
+			const currentLine = lines[cursorLine] ?? "";
+			const textBeforeCursor = currentLine.slice(0, cursorCol);
+			const query = extractTodoToken(textBeforeCursor);
+
+			if (query === undefined) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			const items = await getItems();
+			if (options.signal.aborted || !items || items.length === 0) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			const suggestions = filterTodoItems(items, query);
+			if (suggestions.length === 0) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			return { items: suggestions, prefix: `t:${query}` };
+		},
+
+		applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
+			if (prefix.startsWith("t:")) {
+				const currentLine = lines[cursorLine] || "";
+				const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
+				const afterCursor = currentLine.slice(cursorCol);
+				// Quote the heading since it may contain spaces
+				const value = item.value.includes(" ") ? `"${item.value}"` : item.value;
+				const newLine = beforePrefix + value + " " + afterCursor;
+				const newLines = [...lines];
+				newLines[cursorLine] = newLine;
+				return {
+					lines: newLines,
+					cursorLine,
+					cursorCol: beforePrefix.length + value.length + 1,
+				};
+			}
+			return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
+		},
+
+		shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
+			return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
+		},
+	};
+}
+
+function setupTodoAutocomplete(ctx: ExtensionContext): void {
+	let itemsPromise: Promise<OrgTodoItem[] | undefined> | undefined;
+
+	const getItems = async (): Promise<OrgTodoItem[] | undefined> => {
+		itemsPromise ||= (async () => {
+			const result = execEmacs("(pi/org-todo-list)");
+			if (!result.success || !Array.isArray(result.data)) return undefined;
+
+			return result.data.map((item: any) => ({
+				heading: item.heading,
+				todo: item.todo || "TODO",
+			}));
+		})();
+		return itemsPromise;
+	};
+
+	// Preload in background
+	void getItems();
+	ctx.ui.addAutocompleteProvider((current) => createTodoAutocompleteProvider(current, getItems));
+}