Commit 627963ae522c
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
// ============================================================================