Commit 627963ae522c

Vincent Demeester <vincent@sbr.pm>
2026-04-24 09:59:03
feat(pi): add GitHub issue/PR autocomplete provider
Added #-triggered autocomplete for issues and PRs using the ctx.ui.addAutocompleteProvider API. Preloads open issues and PRs per repo with session-scoped caching. Supports cross-repo lookup via org/repo# (Tab) and live fallback for cache misses.
1 parent 8c181cc
Changed files (1)
dots
pi
agent
extensions
github
dots/pi/agent/extensions/github/index.ts
@@ -15,7 +15,13 @@
  */
 
 import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
-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";
 
@@ -124,7 +130,10 @@ export default function (pi: ExtensionAPI) {
 		if (recentIssues.length > 20) recentIssues = recentIssues.slice(-20);
 	};
 
-	pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_start", async (_event, ctx) => {
+		reconstructState(ctx);
+		setupIssueAutocomplete(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));
@@ -950,6 +959,243 @@ export default function (pi: ExtensionAPI) {
 	});
 }
 
+// ============================================================================
+// Autocomplete: GitHub Issues/PRs
+// ============================================================================
+
+type GitHubItem = {
+	number: number;
+	title: string;
+	state: string;
+	type: "issue" | "pr";
+};
+
+const MAX_ITEMS = 100;
+const MAX_SUGGESTIONS = 20;
+
+function extractHashToken(textBeforeCursor: string): { repo?: string; query: string } | undefined {
+	const match = textBeforeCursor.match(/(?:^|[ \t])(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#([^\s#]*)$/);
+	if (!match) return undefined;
+	return { repo: match[1], query: match[2] };
+}
+
+function formatGitHubItem(item: GitHubItem, repoPrefix?: string): AutocompleteItem {
+	const kind = item.type === "pr" ? "pr" : "issue";
+	const prefix = repoPrefix ? `${repoPrefix}#` : "#";
+	return {
+		value: `${prefix}${item.number}`,
+		label: `${prefix}${item.number}`,
+		description: `[${kind}] ${item.title}`,
+	};
+}
+
+function filterGitHubItems(items: GitHubItem[], query: string, repoPrefix?: string): AutocompleteItem[] {
+	if (!query.trim()) {
+		return items.slice(0, MAX_SUGGESTIONS).map((i) => formatGitHubItem(i, repoPrefix));
+	}
+
+	if (/^\d+$/.test(query)) {
+		const numericMatches = items
+			.filter((item) => String(item.number).startsWith(query))
+			.slice(0, MAX_SUGGESTIONS)
+			.map((i) => formatGitHubItem(i, repoPrefix));
+		if (numericMatches.length > 0) return numericMatches;
+	}
+
+	return fuzzyFilter(items, query, (item) => `${item.number} ${item.title}`)
+		.slice(0, MAX_SUGGESTIONS)
+		.map((i) => formatGitHubItem(i, repoPrefix));
+}
+
+/** Manages cached items per repo, with on-demand loading and live lookups for cache misses. */
+class GitHubItemCache {
+	private caches = new Map<string, Promise<GitHubItem[] | undefined>>();
+	private liveLookups = new Map<string, Promise<GitHubItem | undefined>>();
+
+	constructor(
+		private pi: ExtensionAPI,
+		private ctx: ExtensionContext,
+	) {}
+
+	/** Get items for a repo (empty string = current repo). Lazy-loads on first call. */
+	getItems(repo: string): Promise<GitHubItem[] | undefined> {
+		let promise = this.caches.get(repo);
+		if (!promise) {
+			promise = this.loadItems(repo);
+			this.caches.set(repo, promise);
+		}
+		return promise;
+	}
+
+	/** Look up a specific number not found in cache. Returns the item and merges it into the cache. */
+	async liveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
+		const key = `${repo}#${number}`;
+		let promise = this.liveLookups.get(key);
+		if (promise) return promise;
+
+		promise = this.doLiveLookup(repo, number);
+		this.liveLookups.set(key, promise);
+
+		const item = await promise;
+		if (item) {
+			// Merge into cache
+			const items = await this.caches.get(repo);
+			if (items && !items.find((i) => i.number === number)) {
+				items.push(item);
+				items.sort((a, b) => b.number - a.number);
+			}
+		}
+		return item;
+	}
+
+	private async loadItems(repo: string): Promise<GitHubItem[] | undefined> {
+		const repoArgs = repo ? ["--repo", repo] : [];
+		const [issueResult, prResult] = await Promise.all([
+			execGh(this.pi, this.ctx, [
+				"issue", "list", ...repoArgs, "--state", "open",
+				"--limit", String(MAX_ITEMS),
+				"--json", "number,title,state",
+			], { timeout: 15000 }),
+			execGh(this.pi, this.ctx, [
+				"pr", "list", ...repoArgs, "--state", "open",
+				"--limit", String(MAX_ITEMS),
+				"--json", "number,title,state",
+			], { timeout: 15000 }),
+		]);
+
+		const items: GitHubItem[] = [];
+
+		if (issueResult.code === 0) {
+			try {
+				for (const issue of JSON.parse(issueResult.stdout)) {
+					items.push({ ...issue, type: "issue" });
+				}
+			} catch {}
+		}
+
+		if (prResult.code === 0) {
+			try {
+				for (const pr of JSON.parse(prResult.stdout)) {
+					items.push({ ...pr, type: "pr" });
+				}
+			} catch {}
+		}
+
+		if (items.length === 0) return undefined;
+
+		items.sort((a, b) => b.number - a.number);
+		return items;
+	}
+
+	private async doLiveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
+		const repoArgs = repo ? ["--repo", repo] : [];
+
+		// Try issue first, then PR
+		const issueResult = await execGh(this.pi, this.ctx, [
+			"issue", "view", String(number), ...repoArgs,
+			"--json", "number,title,state",
+		], { timeout: 10000 });
+
+		if (issueResult.code === 0) {
+			try {
+				const data = JSON.parse(issueResult.stdout);
+				return { number: data.number, title: data.title, state: data.state, type: "issue" };
+			} catch {}
+		}
+
+		const prResult = await execGh(this.pi, this.ctx, [
+			"pr", "view", String(number), ...repoArgs,
+			"--json", "number,title,state",
+		], { timeout: 10000 });
+
+		if (prResult.code === 0) {
+			try {
+				const data = JSON.parse(prResult.stdout);
+				return { number: data.number, title: data.title, state: data.state, type: "pr" };
+			} catch {}
+		}
+
+		return undefined;
+	}
+}
+
+function createGitHubAutocompleteProvider(
+	current: AutocompleteProvider,
+	cache: GitHubItemCache,
+): AutocompleteProvider {
+	return {
+		async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
+			const currentLine = lines[cursorLine] ?? "";
+			const textBeforeCursor = currentLine.slice(0, cursorCol);
+			const token = extractHashToken(textBeforeCursor);
+
+			if (!token) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			const repo = token.repo || "";
+			const items = await cache.getItems(repo);
+			if (options.signal.aborted) return null;
+
+			let suggestions: AutocompleteItem[] = [];
+			if (items && items.length > 0) {
+				suggestions = filterGitHubItems(items, token.query, token.repo);
+			}
+
+			// If query looks like a full number with no matches, try a live lookup
+			if (suggestions.length === 0 && /^\d+$/.test(token.query) && token.query.length >= 1) {
+				const num = parseInt(token.query, 10);
+				const found = await cache.liveLookup(repo, num);
+				if (options.signal.aborted) return null;
+				if (found) {
+					suggestions = [formatGitHubItem(found, token.repo)];
+				}
+			}
+
+			if (suggestions.length === 0) {
+				return current.getSuggestions(lines, cursorLine, cursorCol, options);
+			}
+
+			const prefix = token.repo ? `${token.repo}#${token.query}` : `#${token.query}`;
+			return { items: suggestions, prefix };
+		},
+
+		applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
+			// Handle #-prefixed completions ourselves to avoid the default
+			// provider misinterpreting org/repo# as a file path
+			if (prefix.includes("#")) {
+				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) {
+			// Always allow trigger — we handle # context in getSuggestions.
+			// Returning false here would block Tab from working for org/repo# completions.
+			return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
+		},
+	};
+}
+
+function setupIssueAutocomplete(pi: ExtensionAPI, ctx: ExtensionContext): void {
+	const cache = new GitHubItemCache(pi, ctx);
+
+	// Preload current repo in background
+	void cache.getItems("");
+
+	ctx.ui.addAutocompleteProvider((current) => createGitHubAutocompleteProvider(current, cache));
+}
+
 // ============================================================================
 // Rendering Functions
 // ============================================================================