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}