Commit 496f4adc8305
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));
+}