Commit 2184ee689ace

Vincent Demeester <vincent@sbr.pm>
2026-04-24 10:02:20
feat(pi): add Jira issue autocomplete provider
Added j: and PROJ- triggered autocomplete for Jira issues using addAutocompleteProvider API. Preloads assigned and recently viewed issues on session start. Supports fuzzy search via j:query and key prefix matching via PROJ-123. Activated with Tab.
1 parent 627963a
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/jira/index.ts
@@ -17,8 +17,13 @@
  */
 
 import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
-import type { AutocompleteItem } from "@mariozechner/pi-tui";
-import { Text } from "@mariozechner/pi-tui";
+import {
+	Text,
+	type AutocompleteItem,
+	type AutocompleteProvider,
+	type AutocompleteSuggestions,
+	fuzzyFilter,
+} from "@mariozechner/pi-tui";
 import { Type } from "@sinclair/typebox";
 import { StringEnum } from "@mariozechner/pi-ai";
 
@@ -87,7 +92,10 @@ export default function (pi: ExtensionAPI) {
 	};
 
 	// Session events
-	pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_start", async (_event, ctx) => {
+		reconstructState(ctx);
+		setupJiraAutocomplete(pi, ctx);
+	});
 	pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
 	pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
 	pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
@@ -992,6 +1000,166 @@ export default function (pi: ExtensionAPI) {
 	});
 }
 
+// ============================================================================
+// Autocomplete: Jira Issues
+// ============================================================================
+
+type JiraIssueItem = {
+	key: string;
+	summary: string;
+	status: string;
+};
+
+const JIRA_MAX_SUGGESTIONS = 20;
+
+/**
+ * Extract a Jira trigger token from text before cursor.
+ * Matches:
+ *   - `j:query` โ€” explicit Jira search (preceded by whitespace or start)
+ *   - `PROJ-123` โ€” uppercase project key pattern (preceded by whitespace or start)
+ */
+function extractJiraToken(textBeforeCursor: string): { trigger: "j:" | "key"; query: string } | undefined {
+	// j: trigger
+	const jMatch = textBeforeCursor.match(/(?:^|[ \t])j:([^\s]*)$/);
+	if (jMatch) return { trigger: "j:", query: jMatch[1] };
+
+	// PROJ- pattern (2+ uppercase letters followed by dash and optional digits)
+	const keyMatch = textBeforeCursor.match(/(?:^|[ \t])([A-Z][A-Z]+-\d*)$/);
+	if (keyMatch) return { trigger: "key", query: keyMatch[1] };
+
+	return undefined;
+}
+
+function formatJiraItem(issue: JiraIssueItem): AutocompleteItem {
+	return {
+		value: issue.key,
+		label: issue.key,
+		description: `[${issue.status}] ${issue.summary}`,
+	};
+}
+
+function filterJiraItems(items: JiraIssueItem[], query: string, trigger: "j:" | "key"): AutocompleteItem[] {
+	if (trigger === "key") {
+		// For PROJ-123 pattern, filter by key prefix
+		const upper = query.toUpperCase();
+		const matches = items
+			.filter((item) => item.key.startsWith(upper))
+			.slice(0, JIRA_MAX_SUGGESTIONS)
+			.map(formatJiraItem);
+		if (matches.length > 0) return matches;
+
+		// Fall back to fuzzy
+		return fuzzyFilter(items, query, (item) => `${item.key} ${item.summary}`)
+			.slice(0, JIRA_MAX_SUGGESTIONS)
+			.map(formatJiraItem);
+	}
+
+	// j: trigger โ€” fuzzy search across key and summary
+	if (!query.trim()) {
+		return items.slice(0, JIRA_MAX_SUGGESTIONS).map(formatJiraItem);
+	}
+
+	return fuzzyFilter(items, query, (item) => `${item.key} ${item.summary}`)
+		.slice(0, JIRA_MAX_SUGGESTIONS)
+		.map(formatJiraItem);
+}
+
+function createJiraAutocompleteProvider(
+	current: AutocompleteProvider,
+	getItems: () => Promise<JiraIssueItem[] | undefined>,
+): AutocompleteProvider {
+	return {
+		async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
+			const currentLine = lines[cursorLine] ?? "";
+			const textBeforeCursor = currentLine.slice(0, cursorCol);
+			const token = extractJiraToken(textBeforeCursor);
+
+			if (!token) {
+				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 = filterJiraItems(items, token.query, token.trigger);
+			if (suggestions.length === 0) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			const prefix = token.trigger === "j:" ? `j:${token.query}` : token.query;
+			return { items: suggestions, prefix };
+		},
+
+		applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
+			// Handle j: and PROJ- completions ourselves
+			if (prefix.startsWith("j:") || /^[A-Z]{2,}-/.test(prefix)) {
+				const currentLine = lines[cursorLine] || "";
+				const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
+				const afterCursor = currentLine.slice(cursorCol);
+				const newLine = beforePrefix + item.value + " " + afterCursor;
+				const newLines = [...lines];
+				newLines[cursorLine] = newLine;
+				return {
+					lines: newLines,
+					cursorLine,
+					cursorCol: beforePrefix.length + item.value.length + 1,
+				};
+			}
+			return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
+		},
+
+		shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
+			return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
+		},
+	};
+}
+
+function setupJiraAutocomplete(pi: ExtensionAPI, ctx: ExtensionContext): void {
+	let itemsPromise: Promise<JiraIssueItem[] | undefined> | undefined;
+
+	const getItems = async (): Promise<JiraIssueItem[] | undefined> => {
+		itemsPromise ||= (async () => {
+			// Fetch assigned + recently viewed issues
+			const [assignedResult, recentResult] = await Promise.all([
+				pi.exec("jira", [
+					"issue", "list", "--raw",
+					"-a", "currentUser()",
+					"-s", "~Done",
+					"--paginate", "50",
+				], { timeout: 15000 }),
+				pi.exec("jira", [
+					"issue", "list", "--raw",
+					"--jql", "issue in issueHistory() ORDER BY lastViewed DESC",
+					"--paginate", "50",
+				], { timeout: 15000 }),
+			]);
+
+			const seen = new Set<string>();
+			const items: JiraIssueItem[] = [];
+
+			for (const result of [assignedResult, recentResult]) {
+				if (result.code !== 0) continue;
+				const issues = parseIssueListJSON(result.stdout);
+				for (const issue of issues) {
+					if (seen.has(issue.key)) continue;
+					seen.add(issue.key);
+					items.push({ key: issue.key, summary: issue.summary, status: issue.status });
+				}
+			}
+
+			if (items.length === 0) return undefined;
+			return items;
+		})();
+		return itemsPromise;
+	};
+
+	// Preload in background
+	void getItems();
+	ctx.ui.addAutocompleteProvider((current) => createJiraAutocompleteProvider(current, getItems));
+}
+
 // ============================================================================
 // Rendering Functions
 // ============================================================================