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