Commit 2184ee689ace
Changed files (1)
dots
pi
agent
extensions
jira
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
// ============================================================================