main
1/**
2 * Pi Extension: GitHub Management
3 *
4 * Provides GitHub integration via the gh CLI with:
5 * - Read operations: PR list/view/diff, issue list/view, checks, runs, repo, releases
6 * - Write operations (with approval): PR create/merge/review/comment/close/ready,
7 * issue create/close/comment/edit, checks restart
8 * - Custom rendering for PRs, issues, checks
9 * - Slash commands for instant results
10 * - Auto-detection of GitHub PR/issue URLs
11 *
12 * Requirements:
13 * - gh CLI: https://cli.github.com/
14 * - Authenticated: gh auth login
15 */
16
17import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
18import {
19 Text,
20 type AutocompleteItem,
21 type AutocompleteProvider,
22 type AutocompleteSuggestions,
23 fuzzyFilter,
24} from "@earendil-works/pi-tui";
25import { Type } from "@sinclair/typebox";
26import { StringEnum } from "@earendil-works/pi-ai";
27
28import type { GhDetails } from "./types";
29import {
30 handlePRList,
31 handlePRView,
32 handlePRDiff,
33 handlePRCreate,
34 handlePRCheckout,
35 handlePRMerge,
36 handlePRReview,
37 handlePRComment,
38 handlePRReady,
39 handlePRClose,
40 handlePRLineComment,
41 handlePRReviewWithComments,
42 handlePRReviewsList,
43 handlePRReviewEdit,
44 handlePRReviewCommentsList,
45 handlePRReviewCommentEdit,
46 handlePRReviewCommentDelete,
47} from "./actions/pr";
48import {
49 handleChecks,
50 handleChecksLog,
51 handleChecksRestart,
52 handleRunList,
53 handleRunView,
54} from "./actions/checks";
55import {
56 handleIssueList,
57 handleIssueView,
58 handleIssueCreate,
59 handleIssueClose,
60 handleIssueComment,
61 handleIssueEdit,
62 handleIssueAddSubIssue,
63 handleIssueRemoveSubIssue,
64} from "./actions/issue";
65import { handleRepoView, handleReleaseList } from "./actions/repo";
66import {
67 parsePRList,
68 parseIssueList,
69 parseChecks,
70 truncate,
71 getPRStateIcon,
72 getCheckIcon,
73 getRunStatusIcon,
74 getReviewDecisionText,
75 formatRelativeDate,
76 resetGitRoot,
77 execGh,
78 prepareGithubArguments,
79} from "./utils";
80
81export default function (pi: ExtensionAPI) {
82 // ========================================================================
83 // State Management
84 // ========================================================================
85
86 let currentUser = "";
87 let recentPRs: { number: number; title: string }[] = [];
88 let recentIssues: { number: number; title: string }[] = [];
89
90 const reconstructState = (ctx: ExtensionContext) => {
91 currentUser = "";
92 recentPRs = [];
93 recentIssues = [];
94 resetGitRoot(); // Reset git root detection on session change
95
96 for (const entry of ctx.sessionManager.getBranch()) {
97 if (entry.type !== "message") continue;
98 const msg = entry.message;
99 if (msg.role !== "toolResult" || msg.toolName !== "github") continue;
100
101 const details = msg.details as GhDetails | undefined;
102 if (!details) continue;
103
104 // Track recent PR numbers
105 if (details.prNumber && !recentPRs.find((p) => p.number === details.prNumber)) {
106 recentPRs.push({ number: details.prNumber, title: "" });
107 }
108 if (details.prNumbers) {
109 for (const n of details.prNumbers) {
110 if (!recentPRs.find((p) => p.number === n)) {
111 recentPRs.push({ number: n, title: "" });
112 }
113 }
114 }
115
116 // Track recent issue numbers
117 if (details.issueNumber && !recentIssues.find((i) => i.number === details.issueNumber)) {
118 recentIssues.push({ number: details.issueNumber, title: "" });
119 }
120 if (details.issueNumbers) {
121 for (const n of details.issueNumbers) {
122 if (!recentIssues.find((i) => i.number === n)) {
123 recentIssues.push({ number: n, title: "" });
124 }
125 }
126 }
127 }
128
129 // Keep only last 20
130 if (recentPRs.length > 20) recentPRs = recentPRs.slice(-20);
131 if (recentIssues.length > 20) recentIssues = recentIssues.slice(-20);
132 };
133
134 pi.on("session_start", async (_event, ctx) => {
135 reconstructState(ctx);
136 setupIssueAutocomplete(pi, ctx);
137 });
138 pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
139 pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
140 pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
141
142 // Helper: fetch current user lazily
143 async function ensureCurrentUser(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> {
144 if (currentUser) return currentUser;
145 const result = await execGh(pi, ctx, ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
146 if (result.code === 0) {
147 currentUser = result.stdout.trim();
148 }
149 return currentUser;
150 }
151
152 // ========================================================================
153 // Tool Registration
154 // ========================================================================
155
156 pi.registerTool({
157 name: "github",
158 label: "GitHub",
159 description:
160 "Manage GitHub PRs, issues, checks, and runs via gh CLI. " +
161 "Write operations require user approval. " +
162 "IMPORTANT: Call write operations ONE AT A TIME, never in parallel — parallel approval dialogs deadlock the UI. For pr-review and pr-comment, pass every target in numbers to use one approval dialog. " +
163 "checks-log accepts runId or number (PR) — PR auto-selects first failed run. " +
164 "pr-review-comments submits a review with inline comments. " +
165 "issue-create with parent auto-links as sub-issue.",
166
167 parameters: Type.Object({
168 action: StringEnum([
169 "pr-list",
170 "pr-view",
171 "pr-diff",
172 "pr-create",
173 "pr-checkout",
174 "pr-merge",
175 "pr-review",
176 "pr-comment",
177 "pr-ready",
178 "pr-line-comment",
179 "pr-review-comments",
180 "pr-reviews-list",
181 "pr-review-edit",
182 "pr-review-comments-list",
183 "pr-review-comment-edit",
184 "pr-review-comment-delete",
185 "pr-close",
186 "checks",
187 "checks-log",
188 "checks-restart",
189 "run-list",
190 "run-view",
191 "issue-list",
192 "issue-view",
193 "issue-create",
194 "issue-close",
195 "issue-comment",
196 "issue-edit",
197 "issue-add-sub-issue",
198 "issue-remove-sub-issue",
199 "repo-view",
200 "release-list",
201 ] as const),
202
203 // PR/Issue number. PR review/comment use numbers, including for one PR.
204 number: Type.Optional(Type.Number({ description: "PR or issue number (not for pr-review/pr-comment)" })),
205 numbers: Type.Optional(Type.Array(Type.Number(), { minItems: 1, description: "PR numbers for pr-review or pr-comment; use a one-item array for one PR" })),
206
207 // PR list filters
208 state: Type.Optional(Type.String({ description: "Filter by state: open, closed, merged, all" })),
209 author: Type.Optional(Type.String({ description: "Filter by author (username or 'me')" })),
210 label: Type.Optional(Type.String({ description: "Filter by label" })),
211 base: Type.Optional(Type.String({ description: "Filter PRs by base branch" })),
212 limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
213
214 // PR create
215 title: Type.Optional(Type.String({ description: "PR or issue title" })),
216 body: Type.Optional(Type.String({ description: "PR/issue body or comment text" })),
217 head: Type.Optional(Type.String({ description: "Head branch for PR (owner:branch). Overrides auto-detection from cwd." })),
218 draft: Type.Optional(Type.Boolean({ description: "Create as draft PR" })),
219 reviewers: Type.Optional(Type.Array(Type.String(), { description: "PR reviewers to request" })),
220 labels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
221
222 // PR merge
223 method: Type.Optional(Type.String({ description: "Merge method: merge, squash, rebase" })),
224 deleteBranch: Type.Optional(Type.Boolean({ description: "Delete branch after merge" })),
225
226 // Review
227 reviewAction: Type.Optional(Type.String({ description: "Review action: approve, request-changes, comment" })),
228 reviewId: Type.Optional(Type.Number({ description: "Review ID (from pr-reviews-list, for pr-review-edit)" })),
229 commentId: Type.Optional(Type.Number({ description: "Review comment ID (from pr-review-comments-list, for edit/delete)" })),
230
231 // Line comments (pr-line-comment, pr-review-comments)
232 path: Type.Optional(Type.String({ description: "File path in the diff for inline comment" })),
233 line: Type.Optional(Type.Number({ description: "Line number in the diff for inline comment" })),
234 side: Type.Optional(Type.String({ description: "Diff side: RIGHT (additions, default) or LEFT (deletions)" })),
235 startLine: Type.Optional(Type.Number({ description: "Start line for multi-line comment range" })),
236 startSide: Type.Optional(Type.String({ description: "Diff side for start line" })),
237 comments: Type.Optional(Type.Array(
238 Type.Object({
239 path: Type.String({ description: "File path" }),
240 body: Type.String({ description: "Comment text" }),
241 line: Type.Number({ description: "Line number" }),
242 side: Type.Optional(Type.String({ description: "RIGHT or LEFT" })),
243 startLine: Type.Optional(Type.Number({ description: "Start line for range" })),
244 startSide: Type.Optional(Type.String({ description: "Start side for range" })),
245 }),
246 { description: "Array of inline comments for pr-review-comments action" },
247 )),
248
249 // Checks/Runs
250 runId: Type.Optional(Type.Number({ description: "Workflow run ID" })),
251 failedOnly: Type.Optional(Type.Boolean({ description: "Restart only failed jobs (default true)" })),
252 branch: Type.Optional(Type.String({ description: "Filter runs by branch" })),
253 status: Type.Optional(Type.String({ description: "Filter runs by status" })),
254 workflow: Type.Optional(Type.String({ description: "Filter runs by workflow name" })),
255
256 // Issue filters
257 assignee: Type.Optional(Type.String({ description: "Filter issues by assignee (or 'me')" })),
258 milestone: Type.Optional(Type.String({ description: "Filter issues by milestone" })),
259
260 // Issue edit
261 addLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
262 removeLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to remove" })),
263 addAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to add" })),
264 removeAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to remove" })),
265
266 // Issue close
267 reason: Type.Optional(Type.String({ description: "Close reason: completed, not planned" })),
268
269 // Sub-issues
270 parent: Type.Optional(Type.Number({ description: "Parent issue number (for issue-create, links created issue as sub-issue)" })),
271 subIssueNumber: Type.Optional(Type.Number({ description: "Sub-issue number (for issue-add-sub-issue, issue-remove-sub-issue)" })),
272 }),
273
274 prepareArguments: prepareGithubArguments,
275
276 async execute(toolCallId, params, signal, onUpdate, ctx) {
277 try {
278 switch (params.action) {
279 // PR actions
280 case "pr-list":
281 return await handlePRList(pi, params, signal, onUpdate, ctx, currentUser);
282 case "pr-view":
283 return await handlePRView(pi, params, signal, onUpdate, ctx);
284 case "pr-diff":
285 return await handlePRDiff(pi, params, signal, onUpdate, ctx);
286 case "pr-create":
287 return await handlePRCreate(pi, params, signal, onUpdate, ctx);
288 case "pr-checkout":
289 return await handlePRCheckout(pi, params, signal, onUpdate, ctx);
290 case "pr-merge":
291 return await handlePRMerge(pi, params, signal, onUpdate, ctx);
292 case "pr-review":
293 return await handlePRReview(pi, params, signal, onUpdate, ctx);
294 case "pr-comment":
295 return await handlePRComment(pi, params, signal, onUpdate, ctx);
296 case "pr-ready":
297 return await handlePRReady(pi, params, signal, onUpdate, ctx);
298 case "pr-line-comment":
299 return await handlePRLineComment(pi, params, signal, onUpdate, ctx);
300 case "pr-review-comments":
301 return await handlePRReviewWithComments(pi, params, signal, onUpdate, ctx);
302 case "pr-reviews-list":
303 return await handlePRReviewsList(pi, params, signal, onUpdate, ctx);
304 case "pr-review-edit":
305 return await handlePRReviewEdit(pi, params, signal, onUpdate, ctx);
306 case "pr-review-comments-list":
307 return await handlePRReviewCommentsList(pi, params, signal, onUpdate, ctx);
308 case "pr-review-comment-edit":
309 return await handlePRReviewCommentEdit(pi, params, signal, onUpdate, ctx);
310 case "pr-review-comment-delete":
311 return await handlePRReviewCommentDelete(pi, params, signal, onUpdate, ctx);
312 case "pr-close":
313 return await handlePRClose(pi, params, signal, onUpdate, ctx);
314
315 // Check/Run actions
316 case "checks":
317 return await handleChecks(pi, params, signal, onUpdate, ctx);
318 case "checks-log":
319 return await handleChecksLog(pi, params, signal, onUpdate, ctx);
320 case "checks-restart":
321 return await handleChecksRestart(pi, params, signal, onUpdate, ctx);
322 case "run-list":
323 return await handleRunList(pi, params, signal, onUpdate, ctx);
324 case "run-view":
325 return await handleRunView(pi, params, signal, onUpdate, ctx);
326
327 // Issue actions
328 case "issue-list":
329 return await handleIssueList(pi, params, signal, onUpdate, ctx, currentUser);
330 case "issue-view":
331 return await handleIssueView(pi, params, signal, onUpdate, ctx);
332 case "issue-create":
333 return await handleIssueCreate(pi, params, signal, onUpdate, ctx);
334 case "issue-close":
335 return await handleIssueClose(pi, params, signal, onUpdate, ctx);
336 case "issue-comment":
337 return await handleIssueComment(pi, params, signal, onUpdate, ctx);
338 case "issue-edit":
339 return await handleIssueEdit(pi, params, signal, onUpdate, ctx);
340 case "issue-add-sub-issue":
341 return await handleIssueAddSubIssue(pi, params, signal, onUpdate, ctx);
342 case "issue-remove-sub-issue":
343 return await handleIssueRemoveSubIssue(pi, params, signal, onUpdate, ctx);
344
345 // Repo actions
346 case "repo-view":
347 return await handleRepoView(pi, params, signal, onUpdate, ctx);
348 case "release-list":
349 return await handleReleaseList(pi, params, signal, onUpdate, ctx);
350
351 default:
352 return {
353 content: [{ type: "text", text: `Unknown action: ${params.action}` }],
354 details: { action: params.action, error: "unknown_action" } as GhDetails,
355 isError: true,
356 };
357 }
358 } catch (error) {
359 return {
360 content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
361 details: { action: params.action, error: String(error) } as GhDetails,
362 isError: true,
363 };
364 }
365 },
366
367 // ====================================================================
368 // Custom Rendering
369 // ====================================================================
370
371 renderCall(args, theme) {
372 let text = theme.fg("toolTitle", theme.bold("github "));
373 text += theme.fg("muted", args.action);
374
375 if (args.number) {
376 text += " " + theme.fg("accent", `#${args.number}`);
377 }
378 if (args.numbers?.length) {
379 text += " " + theme.fg("accent", args.numbers.map((number) => `#${number}`).join(", "));
380 }
381 if (args.runId) {
382 text += " " + theme.fg("accent", String(args.runId));
383 }
384 if (args.title) {
385 text += " " + theme.fg("dim", `"${truncate(args.title, 50)}"`);
386 }
387
388 return new Text(text, 0, 0);
389 },
390
391 renderResult(result, { expanded }, theme) {
392 const details = result.details as GhDetails | undefined;
393
394 if (!details) {
395 const text = result.content[0];
396 return new Text(text?.type === "text" ? text.text : "", 0, 0);
397 }
398
399 if (details.error) {
400 return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
401 }
402
403 if (details.modifyRequested) {
404 return new Text(theme.fg("warning", "✎ User requested modifications"), 0, 0);
405 }
406
407 if (details.cancelled) {
408 return new Text(theme.fg("error", "✗ Rejected by user"), 0, 0);
409 }
410
411 switch (details.action) {
412 case "pr-list":
413 return renderPRList(details, expanded, theme);
414 case "pr-view":
415 return renderLongOutput(details, expanded, theme, "PR");
416 case "pr-diff":
417 return renderDiff(details, expanded, theme);
418 case "pr-create":
419 return renderCreated(details, theme, "PR", details.prNumber, details.prUrl);
420 case "pr-checkout":
421 return new Text(theme.fg("success", `✓ Checked out PR #${details.prNumber}`), 0, 0);
422 case "pr-merge":
423 return new Text(
424 theme.fg("success", "✓ Merged ") +
425 theme.fg("accent", `#${details.prNumber}`) +
426 theme.fg("muted", ` (${details.mergeMethod || "merge"})`),
427 0,
428 0,
429 );
430 case "pr-review":
431 case "pr-comment":
432 return renderBatchWrite(details, theme);
433 case "pr-ready":
434 return new Text(theme.fg("success", "✓ PR ready for review: ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
435 case "pr-line-comment":
436 return new Text(
437 theme.fg("success", "✓ Inline comment on ") +
438 theme.fg("accent", `#${details.prNumber}`) +
439 theme.fg("muted", ` (${details.output || ""})`),
440 0,
441 0,
442 );
443 case "pr-review-comments":
444 return new Text(
445 theme.fg("success", `✓ ${details.reviewAction} `) +
446 theme.fg("accent", `#${details.prNumber}`) +
447 theme.fg("muted", ` (${details.commentCount} inline comment${details.commentCount === 1 ? "" : "s"})`),
448 0,
449 0,
450 );
451 case "pr-reviews-list":
452 return new Text(
453 theme.fg("success", "Reviews ") +
454 theme.fg("accent", `#${details.prNumber}`) +
455 theme.fg("muted", ` (${details.output})`),
456 0,
457 0,
458 );
459 case "pr-review-edit":
460 return new Text(
461 theme.fg("success", "✓ Edited review ") +
462 theme.fg("accent", `${details.reviewId}`) +
463 theme.fg("muted", ` on #${details.prNumber}`),
464 0,
465 0,
466 );
467 case "pr-review-comments-list":
468 return new Text(
469 theme.fg("success", "Review comments ") +
470 theme.fg("accent", `#${details.prNumber}`) +
471 theme.fg("muted", ` (${details.output})`),
472 0,
473 0,
474 );
475 case "pr-review-comment-edit":
476 return new Text(
477 theme.fg("success", "✓ Edited comment ") +
478 theme.fg("accent", `${details.commentId}`),
479 0,
480 0,
481 );
482 case "pr-review-comment-delete":
483 return new Text(
484 theme.fg("error", "✗ Deleted comment ") +
485 theme.fg("accent", `${details.commentId}`),
486 0,
487 0,
488 );
489 case "pr-close":
490 return new Text(theme.fg("success", "✓ Closed ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
491
492 case "checks":
493 return renderChecks(details, expanded, theme);
494 case "checks-log":
495 return renderLongOutput(details, expanded, theme, "Logs");
496 case "checks-restart":
497 return new Text(theme.fg("success", `✓ Restarted run ${details.runId}`), 0, 0);
498 case "run-list":
499 return renderRunList(details, expanded, theme);
500 case "run-view":
501 return renderLongOutput(details, expanded, theme, "Run");
502
503 case "issue-list":
504 return renderIssueList(details, expanded, theme);
505 case "issue-view":
506 return renderLongOutput(details, expanded, theme, "Issue");
507 case "issue-create":
508 return renderCreated(details, theme, "Issue", details.issueNumber, details.issueUrl, details.parentNumber);
509 case "issue-close":
510 return new Text(theme.fg("success", "✓ Closed issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
511 case "issue-comment":
512 return new Text(theme.fg("success", "✓ Comment added to issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
513 case "issue-edit":
514 return new Text(theme.fg("success", "✓ Updated issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
515 case "issue-add-sub-issue":
516 return new Text(
517 theme.fg("success", "✓ Added ") +
518 theme.fg("accent", `#${details.subIssueNumber}`) +
519 theme.fg("success", " as sub-issue of ") +
520 theme.fg("accent", `#${details.parentNumber}`),
521 0,
522 0,
523 );
524 case "issue-remove-sub-issue":
525 return new Text(
526 theme.fg("success", "✓ Removed ") +
527 theme.fg("accent", `#${details.subIssueNumber}`) +
528 theme.fg("success", " as sub-issue of ") +
529 theme.fg("accent", `#${details.parentNumber}`),
530 0,
531 0,
532 );
533
534 case "repo-view":
535 case "release-list":
536 return renderLongOutput(details, expanded, theme, "");
537
538 default:
539 return new Text(details.output || "", 0, 0);
540 }
541 },
542 });
543
544 // ========================================================================
545 // Slash Commands
546 // ========================================================================
547
548 // /gh - Show my open PRs
549 pi.registerCommand("gh", {
550 description: "Show my open PRs in this repo",
551 handler: async (_args, ctx) => {
552 if (!ctx.hasUI) {
553 ctx.ui.notify("/gh requires interactive mode", "error");
554 return;
555 }
556
557 const user = await ensureCurrentUser(ctx);
558 const result = await execGh(
559 pi,
560 ctx,
561 [
562 "pr",
563 "list",
564 "--author",
565 user || "@me",
566 "--state",
567 "open",
568 "--json",
569 "number,title,state,headRefName,baseRefName,isDraft,reviewDecision,additions,deletions,url",
570 ],
571 { timeout: 30000 },
572 );
573
574 if (result.code !== 0) {
575 ctx.ui.notify(`Error: ${result.stderr}`, "error");
576 return;
577 }
578
579 const prs = parsePRList(result.stdout);
580
581 // Track
582 for (const pr of prs) {
583 if (!recentPRs.find((p) => p.number === pr.number)) {
584 recentPRs.push({ number: pr.number, title: pr.title });
585 }
586 }
587 if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
588
589 const lines: string[] = [];
590 lines.push("## My Open PRs");
591 lines.push("");
592
593 if (prs.length === 0) {
594 lines.push("*No open PRs* ✨");
595 } else {
596 lines.push("| # | Title | Branch | Review | Changes |");
597 lines.push("|---|-------|--------|--------|---------|");
598 for (const pr of prs) {
599 const draft = pr.isDraft ? " 📝" : "";
600 const review = getReviewDecisionText(pr.reviewDecision);
601 const changes = `+${pr.additions}/-${pr.deletions}`;
602 lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 50)} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
603 }
604 }
605
606 pi.sendMessage({
607 customType: "gh-prs",
608 content: lines.join("\n"),
609 display: true,
610 });
611 },
612 });
613
614 // /gh-prs - Show all open PRs
615 pi.registerCommand("gh-prs", {
616 description: "Show all open PRs in this repo",
617 handler: async (_args, ctx) => {
618 if (!ctx.hasUI) {
619 ctx.ui.notify("/gh-prs requires interactive mode", "error");
620 return;
621 }
622
623 const result = await execGh(
624 pi,
625 ctx,
626 [
627 "pr",
628 "list",
629 "--state",
630 "open",
631 "--json",
632 "number,title,state,author,headRefName,baseRefName,isDraft,labels,reviewDecision,additions,deletions,url",
633 "--limit",
634 "20",
635 ],
636 { timeout: 30000 },
637 );
638
639 if (result.code !== 0) {
640 ctx.ui.notify(`Error: ${result.stderr}`, "error");
641 return;
642 }
643
644 const prs = parsePRList(result.stdout);
645
646 // Track
647 for (const pr of prs) {
648 if (!recentPRs.find((p) => p.number === pr.number)) {
649 recentPRs.push({ number: pr.number, title: pr.title });
650 }
651 }
652 if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
653
654 const lines: string[] = [];
655 lines.push("## Open Pull Requests");
656 lines.push("");
657
658 if (prs.length === 0) {
659 lines.push("*No open PRs* ✨");
660 } else {
661 lines.push("| # | Title | Author | Branch | Review | Changes |");
662 lines.push("|---|-------|--------|--------|--------|---------|");
663 for (const pr of prs) {
664 const draft = pr.isDraft ? " 📝" : "";
665 const review = getReviewDecisionText(pr.reviewDecision);
666 const changes = `+${pr.additions}/-${pr.deletions}`;
667 lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 40)} | @${pr.author} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
668 }
669 }
670
671 pi.sendMessage({
672 customType: "gh-prs",
673 content: lines.join("\n"),
674 display: true,
675 });
676 },
677 });
678
679 // /gh-pr <number> - View specific PR
680 pi.registerCommand("gh-pr", {
681 description: "View a PR (e.g., /gh-pr 123)",
682 getArgumentCompletions: (prefix: string) => {
683 if (recentPRs.length === 0) return null;
684 const items = recentPRs.map((p) => ({
685 value: String(p.number),
686 label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
687 }));
688 if (!prefix.trim()) return items;
689 const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
690 return filtered.length > 0 ? filtered : null;
691 },
692 handler: async (args, ctx) => {
693 if (!args?.trim()) {
694 ctx.ui.notify("Usage: /gh-pr <number>", "error");
695 return;
696 }
697
698 const number = parseInt(args.trim(), 10);
699 if (isNaN(number)) {
700 ctx.ui.notify(`Invalid PR number: ${args}`, "error");
701 return;
702 }
703
704 const result = await execGh(
705 pi,
706 ctx,
707 ["pr", "view", String(number), "--json", "number,title,state,author,headRefName,baseRefName,isDraft,url,reviewDecision,additions,deletions,changedFiles,body,statusCheckRollup"],
708 { timeout: 30000 },
709 );
710
711 if (result.code !== 0) {
712 ctx.ui.notify(`Error: ${result.stderr}`, "error");
713 return;
714 }
715
716 let data: any;
717 try {
718 data = JSON.parse(result.stdout);
719 } catch {
720 ctx.ui.notify("Could not parse PR data", "error");
721 return;
722 }
723
724 // Track
725 if (!recentPRs.find((p) => p.number === data.number)) {
726 recentPRs.push({ number: data.number, title: data.title });
727 }
728
729 const checks = data.statusCheckRollup ?? [];
730 const passed = checks.filter((c: any) => c.conclusion === "SUCCESS").length;
731 const failed = checks.filter((c: any) => c.conclusion === "FAILURE").length;
732 const pending = checks.filter((c: any) => !c.conclusion).length;
733
734 const lines: string[] = [];
735 lines.push(`## PR #${data.number}: ${data.title}`);
736 lines.push("");
737 lines.push(`- **State:** ${data.state}${data.isDraft ? " (draft)" : ""}`);
738 lines.push(`- **Author:** @${data.author?.login ?? "?"}`);
739 lines.push(`- **Branch:** ${data.headRefName} → ${data.baseRefName}`);
740 lines.push(`- **Review:** ${getReviewDecisionText(data.reviewDecision ?? "")}`);
741 lines.push(`- **Changes:** ${data.changedFiles} files (+${data.additions}/-${data.deletions})`);
742 if (checks.length > 0) {
743 lines.push(`- **Checks:** ${passed}✓ ${failed}✗ ${pending}⏳`);
744 }
745 lines.push(`- **URL:** ${data.url}`);
746
747 if (data.body) {
748 lines.push("");
749 lines.push("### Description");
750 lines.push("");
751 lines.push(data.body);
752 }
753
754 pi.sendMessage({
755 customType: "gh-pr-view",
756 content: lines.join("\n"),
757 display: true,
758 });
759 },
760 });
761
762 // /gh-checks <number> - Show check status
763 pi.registerCommand("gh-checks", {
764 description: "Show check status for a PR (e.g., /gh-checks 123)",
765 getArgumentCompletions: (prefix: string) => {
766 if (recentPRs.length === 0) return null;
767 const items = recentPRs.map((p) => ({
768 value: String(p.number),
769 label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
770 }));
771 if (!prefix.trim()) return items;
772 const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
773 return filtered.length > 0 ? filtered : null;
774 },
775 handler: async (args, ctx) => {
776 if (!args?.trim()) {
777 ctx.ui.notify("Usage: /gh-checks <number>", "error");
778 return;
779 }
780
781 const number = parseInt(args.trim(), 10);
782 if (isNaN(number)) {
783 ctx.ui.notify(`Invalid PR number: ${args}`, "error");
784 return;
785 }
786
787 const result = await execGh(
788 pi,
789 ctx,
790 ["pr", "view", String(number), "--json", "statusCheckRollup"],
791 { timeout: 30000 },
792 );
793
794 if (result.code !== 0) {
795 ctx.ui.notify(`Error: ${result.stderr}`, "error");
796 return;
797 }
798
799 const checks = parseChecks(result.stdout);
800
801 const lines: string[] = [];
802 lines.push(`## Checks for PR #${number}`);
803 lines.push("");
804
805 if (checks.length === 0) {
806 lines.push("*No checks found*");
807 } else {
808 const passed = checks.filter((c) => c.conclusion === "SUCCESS").length;
809 const failed = checks.filter((c) => c.conclusion === "FAILURE").length;
810 const pending = checks.filter((c) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED").length;
811
812 lines.push(`**Summary:** ${passed} passed, ${failed} failed, ${pending} pending`);
813 lines.push("");
814 lines.push("| Status | Name | Details |");
815 lines.push("|--------|------|---------|");
816 for (const check of checks) {
817 const icon = getCheckIcon(check);
818 lines.push(`| ${icon} | ${check.name} | ${check.conclusion || check.status || "pending"} |`);
819 }
820 }
821
822 pi.sendMessage({
823 customType: "gh-checks",
824 content: lines.join("\n"),
825 display: true,
826 });
827 },
828 });
829
830 // /gh-issues - Show open issues
831 pi.registerCommand("gh-issues", {
832 description: "Show open issues in this repo",
833 handler: async (_args, ctx) => {
834 if (!ctx.hasUI) {
835 ctx.ui.notify("/gh-issues requires interactive mode", "error");
836 return;
837 }
838
839 const result = await execGh(
840 pi,
841 ctx,
842 ["issue", "list", "--state", "open", "--json", "number,title,state,labels,assignees,url", "--limit", "20"],
843 { timeout: 30000 },
844 );
845
846 if (result.code !== 0) {
847 ctx.ui.notify(`Error: ${result.stderr}`, "error");
848 return;
849 }
850
851 const issues = parseIssueList(result.stdout);
852
853 // Track
854 for (const issue of issues) {
855 if (!recentIssues.find((i) => i.number === issue.number)) {
856 recentIssues.push({ number: issue.number, title: issue.title });
857 }
858 }
859 if (recentIssues.length > 20) recentIssues.splice(0, recentIssues.length - 20);
860
861 const lines: string[] = [];
862 lines.push("## Open Issues");
863 lines.push("");
864
865 if (issues.length === 0) {
866 lines.push("*No open issues* ✨");
867 } else {
868 lines.push("| # | Title | Labels | Assignees |");
869 lines.push("|---|-------|--------|-----------|");
870 for (const issue of issues) {
871 const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "-";
872 const assignees = issue.assignees.length > 0 ? issue.assignees.map((a) => `@${a}`).join(", ") : "-";
873 lines.push(`| #${issue.number} | ${truncate(issue.title, 50)} | ${labels} | ${assignees} |`);
874 }
875 }
876
877 pi.sendMessage({
878 customType: "gh-issues",
879 content: lines.join("\n"),
880 display: true,
881 });
882 },
883 });
884
885 // /gh-runs - Show recent workflow runs
886 pi.registerCommand("gh-runs", {
887 description: "Show recent workflow runs",
888 handler: async (_args, ctx) => {
889 if (!ctx.hasUI) {
890 ctx.ui.notify("/gh-runs requires interactive mode", "error");
891 return;
892 }
893
894 const result = await execGh(
895 pi,
896 ctx,
897 ["run", "list", "--json", "databaseId,name,displayTitle,status,conclusion,headBranch,url,createdAt", "--limit", "15"],
898 { timeout: 30000 },
899 );
900
901 if (result.code !== 0) {
902 ctx.ui.notify(`Error: ${result.stderr}`, "error");
903 return;
904 }
905
906 let runs: any[];
907 try {
908 runs = JSON.parse(result.stdout);
909 } catch {
910 ctx.ui.notify("Could not parse run data", "error");
911 return;
912 }
913
914 const lines: string[] = [];
915 lines.push("## Recent Workflow Runs");
916 lines.push("");
917
918 if (runs.length === 0) {
919 lines.push("*No recent runs*");
920 } else {
921 lines.push("| Status | ID | Workflow | Title | Branch | Age |");
922 lines.push("|--------|-----|----------|-------|--------|-----|");
923 for (const run of runs) {
924 const icon = run.conclusion === "success" ? "✓" : run.conclusion === "failure" ? "✗" : "⏳";
925 const age = formatRelativeDate(run.createdAt);
926 lines.push(`| ${icon} | ${run.databaseId} | ${run.name} | ${truncate(run.displayTitle, 35)} | ${run.headBranch} | ${age} |`);
927 }
928 }
929
930 pi.sendMessage({
931 customType: "gh-runs",
932 content: lines.join("\n"),
933 display: true,
934 });
935 },
936 });
937
938 // ========================================================================
939 // Auto-detection: GitHub PR/Issue URLs
940 // ========================================================================
941
942 pi.on("input", async (event, ctx) => {
943 if (event.source !== "interactive") return { action: "continue" as const };
944
945 const text = event.text.trim();
946
947 // Detect GitHub PR URLs
948 const prUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/);
949 if (prUrlMatch) {
950 return { action: "transform" as const, text: `View GitHub PR #${prUrlMatch[1]}` };
951 }
952
953 // Detect GitHub issue URLs
954 const issueUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/);
955 if (issueUrlMatch) {
956 return { action: "transform" as const, text: `View GitHub issue #${issueUrlMatch[1]}` };
957 }
958
959 return { action: "continue" as const };
960 });
961}
962
963// ============================================================================
964// Autocomplete: GitHub Issues/PRs
965// ============================================================================
966
967type GitHubItem = {
968 number: number;
969 title: string;
970 state: string;
971 type: "issue" | "pr";
972};
973
974const MAX_ITEMS = 100;
975const MAX_SUGGESTIONS = 20;
976
977function extractHashToken(textBeforeCursor: string): { repo?: string; query: string } | undefined {
978 const match = textBeforeCursor.match(/(?:^|[ \t])(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#([^\s#]*)$/);
979 if (!match) return undefined;
980 return { repo: match[1], query: match[2] };
981}
982
983function formatGitHubItem(item: GitHubItem, repoPrefix?: string): AutocompleteItem {
984 const kind = item.type === "pr" ? "pr" : "issue";
985 const prefix = repoPrefix ? `${repoPrefix}#` : "#";
986 return {
987 value: `${prefix}${item.number}`,
988 label: `${prefix}${item.number}`,
989 description: `[${kind}] ${item.title}`,
990 };
991}
992
993function filterGitHubItems(items: GitHubItem[], query: string, repoPrefix?: string): AutocompleteItem[] {
994 if (!query.trim()) {
995 return items.slice(0, MAX_SUGGESTIONS).map((i) => formatGitHubItem(i, repoPrefix));
996 }
997
998 if (/^\d+$/.test(query)) {
999 const numericMatches = items
1000 .filter((item) => String(item.number).startsWith(query))
1001 .slice(0, MAX_SUGGESTIONS)
1002 .map((i) => formatGitHubItem(i, repoPrefix));
1003 if (numericMatches.length > 0) return numericMatches;
1004 }
1005
1006 return fuzzyFilter(items, query, (item) => `${item.number} ${item.title}`)
1007 .slice(0, MAX_SUGGESTIONS)
1008 .map((i) => formatGitHubItem(i, repoPrefix));
1009}
1010
1011/** Manages cached items per repo, with on-demand loading and live lookups for cache misses. */
1012class GitHubItemCache {
1013 private caches = new Map<string, Promise<GitHubItem[] | undefined>>();
1014 private liveLookups = new Map<string, Promise<GitHubItem | undefined>>();
1015
1016 constructor(
1017 private pi: ExtensionAPI,
1018 private ctx: ExtensionContext,
1019 ) {}
1020
1021 /** Get items for a repo (empty string = current repo). Lazy-loads on first call. */
1022 getItems(repo: string): Promise<GitHubItem[] | undefined> {
1023 let promise = this.caches.get(repo);
1024 if (!promise) {
1025 promise = this.loadItems(repo);
1026 this.caches.set(repo, promise);
1027 }
1028 return promise;
1029 }
1030
1031 /** Look up a specific number not found in cache. Returns the item and merges it into the cache. */
1032 async liveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
1033 const key = `${repo}#${number}`;
1034 let promise = this.liveLookups.get(key);
1035 if (promise) return promise;
1036
1037 promise = this.doLiveLookup(repo, number);
1038 this.liveLookups.set(key, promise);
1039
1040 const item = await promise;
1041 if (item) {
1042 // Merge into cache
1043 const items = await this.caches.get(repo);
1044 if (items && !items.find((i) => i.number === number)) {
1045 items.push(item);
1046 items.sort((a, b) => b.number - a.number);
1047 }
1048 }
1049 return item;
1050 }
1051
1052 private async loadItems(repo: string): Promise<GitHubItem[] | undefined> {
1053 const repoArgs = repo ? ["--repo", repo] : [];
1054 const [issueResult, prResult] = await Promise.all([
1055 execGh(this.pi, this.ctx, [
1056 "issue", "list", ...repoArgs, "--state", "open",
1057 "--limit", String(MAX_ITEMS),
1058 "--json", "number,title,state",
1059 ], { timeout: 15000 }),
1060 execGh(this.pi, this.ctx, [
1061 "pr", "list", ...repoArgs, "--state", "open",
1062 "--limit", String(MAX_ITEMS),
1063 "--json", "number,title,state",
1064 ], { timeout: 15000 }),
1065 ]);
1066
1067 const items: GitHubItem[] = [];
1068
1069 if (issueResult.code === 0) {
1070 try {
1071 for (const issue of JSON.parse(issueResult.stdout)) {
1072 items.push({ ...issue, type: "issue" });
1073 }
1074 } catch {}
1075 }
1076
1077 if (prResult.code === 0) {
1078 try {
1079 for (const pr of JSON.parse(prResult.stdout)) {
1080 items.push({ ...pr, type: "pr" });
1081 }
1082 } catch {}
1083 }
1084
1085 if (items.length === 0) return undefined;
1086
1087 items.sort((a, b) => b.number - a.number);
1088 return items;
1089 }
1090
1091 private async doLiveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
1092 const repoArgs = repo ? ["--repo", repo] : [];
1093
1094 // Try issue first, then PR
1095 const issueResult = await execGh(this.pi, this.ctx, [
1096 "issue", "view", String(number), ...repoArgs,
1097 "--json", "number,title,state",
1098 ], { timeout: 10000 });
1099
1100 if (issueResult.code === 0) {
1101 try {
1102 const data = JSON.parse(issueResult.stdout);
1103 return { number: data.number, title: data.title, state: data.state, type: "issue" };
1104 } catch {}
1105 }
1106
1107 const prResult = await execGh(this.pi, this.ctx, [
1108 "pr", "view", String(number), ...repoArgs,
1109 "--json", "number,title,state",
1110 ], { timeout: 10000 });
1111
1112 if (prResult.code === 0) {
1113 try {
1114 const data = JSON.parse(prResult.stdout);
1115 return { number: data.number, title: data.title, state: data.state, type: "pr" };
1116 } catch {}
1117 }
1118
1119 return undefined;
1120 }
1121}
1122
1123function createGitHubAutocompleteProvider(
1124 current: AutocompleteProvider,
1125 cache: GitHubItemCache,
1126): AutocompleteProvider {
1127 return {
1128 async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
1129 const currentLine = lines[cursorLine] ?? "";
1130 const textBeforeCursor = currentLine.slice(0, cursorCol);
1131 const token = extractHashToken(textBeforeCursor);
1132
1133 if (!token) {
1134 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1135 }
1136
1137 const repo = token.repo || "";
1138 const items = await cache.getItems(repo);
1139 if (options.signal.aborted) return null;
1140
1141 let suggestions: AutocompleteItem[] = [];
1142 if (items && items.length > 0) {
1143 suggestions = filterGitHubItems(items, token.query, token.repo);
1144 }
1145
1146 // If query looks like a full number with no matches, try a live lookup
1147 if (suggestions.length === 0 && /^\d+$/.test(token.query) && token.query.length >= 1) {
1148 const num = parseInt(token.query, 10);
1149 const found = await cache.liveLookup(repo, num);
1150 if (options.signal.aborted) return null;
1151 if (found) {
1152 suggestions = [formatGitHubItem(found, token.repo)];
1153 }
1154 }
1155
1156 if (suggestions.length === 0) {
1157 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1158 }
1159
1160 const prefix = token.repo ? `${token.repo}#${token.query}` : `#${token.query}`;
1161 return { items: suggestions, prefix };
1162 },
1163
1164 applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1165 // Handle #-prefixed completions ourselves to avoid the default
1166 // provider misinterpreting org/repo# as a file path
1167 if (prefix.includes("#")) {
1168 const currentLine = lines[cursorLine] || "";
1169 const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
1170 const afterCursor = currentLine.slice(cursorCol);
1171 const newLine = beforePrefix + item.value + " " + afterCursor;
1172 const newLines = [...lines];
1173 newLines[cursorLine] = newLine;
1174 return {
1175 lines: newLines,
1176 cursorLine,
1177 cursorCol: beforePrefix.length + item.value.length + 1,
1178 };
1179 }
1180 return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1181 },
1182
1183 shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1184 // Always allow trigger — we handle # context in getSuggestions.
1185 // Returning false here would block Tab from working for org/repo# completions.
1186 return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
1187 },
1188 };
1189}
1190
1191function setupIssueAutocomplete(pi: ExtensionAPI, ctx: ExtensionContext): void {
1192 const cache = new GitHubItemCache(pi, ctx);
1193
1194 // Preload current repo in background
1195 void cache.getItems("");
1196
1197 ctx.ui.addAutocompleteProvider((current) => createGitHubAutocompleteProvider(current, cache));
1198}
1199
1200// ============================================================================
1201// Rendering Functions
1202// ============================================================================
1203
1204function renderBatchWrite(details: GhDetails, theme: Theme): Text {
1205 const succeeded = details.succeeded ?? [];
1206 const failed = details.failed ?? [];
1207 let text = theme.fg("success", `✓ ${succeeded.length} succeeded`);
1208 if (failed.length > 0) text += theme.fg("error", `, ${failed.length} failed`);
1209 if (details.prNumbers?.length) {
1210 text += theme.fg("muted", ` (${details.prNumbers.map((number) => `#${number}`).join(", ")})`);
1211 }
1212 return new Text(text, 0, 0);
1213}
1214
1215function renderPRList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1216 if (!details.output) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
1217
1218 const lines = details.output.split("\n").filter((l) => l.trim());
1219 if (lines.length === 0) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
1220
1221 let text = theme.fg("muted", `${lines.length} PR(s):`);
1222
1223 const display = expanded ? lines : lines.slice(0, 5);
1224 for (const line of display) {
1225 text += `\n${theme.fg("text", line)}`;
1226 }
1227
1228 if (!expanded && lines.length > 5) {
1229 text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
1230 }
1231
1232 return new Text(text, 0, 0);
1233}
1234
1235function renderIssueList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1236 if (!details.output) return new Text(theme.fg("dim", "No issues found"), 0, 0);
1237
1238 const lines = details.output.split("\n").filter((l) => l.trim());
1239 if (lines.length === 0) return new Text(theme.fg("dim", "No issues found"), 0, 0);
1240
1241 let text = theme.fg("muted", `${lines.length} issue(s):`);
1242
1243 const display = expanded ? lines : lines.slice(0, 5);
1244 for (const line of display) {
1245 text += `\n${theme.fg("text", line)}`;
1246 }
1247
1248 if (!expanded && lines.length > 5) {
1249 text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
1250 }
1251
1252 return new Text(text, 0, 0);
1253}
1254
1255function renderChecks(details: GhDetails, expanded: boolean, theme: Theme): Text {
1256 if (!details.output) return new Text(theme.fg("dim", "No checks"), 0, 0);
1257
1258 const lines = details.output.split("\n").filter((l) => l.trim());
1259 if (lines.length === 0) return new Text(theme.fg("dim", "No checks"), 0, 0);
1260
1261 // First line is the summary
1262 let text = theme.fg("muted", lines[0]);
1263
1264 const checkLines = lines.slice(1);
1265 const display = expanded ? checkLines : checkLines.slice(0, 8);
1266 for (const line of display) {
1267 if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
1268 else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
1269 else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
1270 else text += `\n${theme.fg("text", line)}`;
1271 }
1272
1273 if (!expanded && checkLines.length > 8) {
1274 text += `\n${theme.fg("dim", `... ${checkLines.length - 8} more (expand for all)`)}`;
1275 }
1276
1277 return new Text(text, 0, 0);
1278}
1279
1280function renderRunList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1281 if (!details.output) return new Text(theme.fg("dim", "No runs"), 0, 0);
1282
1283 const lines = details.output.split("\n").filter((l) => l.trim());
1284 if (lines.length === 0) return new Text(theme.fg("dim", "No runs"), 0, 0);
1285
1286 // First line is the summary
1287 let text = theme.fg("muted", lines[0]);
1288
1289 const runLines = lines.slice(1);
1290 const display = expanded ? runLines : runLines.slice(0, 8);
1291 for (const line of display) {
1292 if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
1293 else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
1294 else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
1295 else text += `\n${theme.fg("text", line)}`;
1296 }
1297
1298 if (!expanded && runLines.length > 8) {
1299 text += `\n${theme.fg("dim", `... ${runLines.length - 8} more (expand for all)`)}`;
1300 }
1301
1302 return new Text(text, 0, 0);
1303}
1304
1305function renderLongOutput(details: GhDetails, expanded: boolean, theme: Theme, prefix: string): Text {
1306 if (!details.output) return new Text(theme.fg("dim", `No ${prefix.toLowerCase()} data`), 0, 0);
1307
1308 if (expanded) {
1309 return new Text(details.output, 0, 0);
1310 }
1311
1312 const lines = details.output.split("\n");
1313 const preview = lines.slice(0, 15).join("\n");
1314 let text = preview;
1315
1316 if (lines.length > 15) {
1317 text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
1318 }
1319
1320 return new Text(text, 0, 0);
1321}
1322
1323function renderDiff(details: GhDetails, expanded: boolean, theme: Theme): Text {
1324 const summary = details.output || "Diff fetched";
1325 if (expanded) {
1326 return new Text(summary, 0, 0);
1327 }
1328 return new Text(theme.fg("muted", summary), 0, 0);
1329}
1330
1331function renderCreated(details: GhDetails, theme: Theme, kind: string, number?: number, url?: string, parentNumber?: number): Text {
1332 let text = theme.fg("success", `✓ Created ${kind} `);
1333 if (number) text += theme.fg("accent", theme.bold(`#${number}`));
1334 if (url) text += theme.fg("dim", ` ${url}`);
1335 if (parentNumber) text += theme.fg("muted", ` (sub-issue of #${parentNumber})`);
1336 return new Text(text, 0, 0);
1337}