flake-update-20260505
   1/**
   2 * Code Review Extension (inspired by Codex's review feature)
   3 *
   4 * Provides a `/review` command that prompts the agent to review code changes.
   5 * Supports multiple review modes:
   6 * - Review a GitHub pull request (checks out the PR locally)
   7 * - Review against a base branch (PR style)
   8 * - Review uncommitted changes
   9 * - Review a specific commit
  10 * - Custom review instructions
  11 *
  12 * Usage:
  13 * - `/review` - show interactive selector
  14 * - `/review 123` - review PR #123 (checks out locally)
  15 * - `/review tektoncd/pipeline#123` - review PR #123 from tektoncd/pipeline
  16 * - `/review https://github.com/owner/repo/pull/123` - review PR from URL
  17 * - `/review pr 123` - review PR #123 (alternative syntax)
  18 * - `/review uncommitted` - review uncommitted changes directly
  19 * - `/review branch main` - review against main branch
  20 * - `/review commit abc123` - review specific commit
  21 * - `/review folder src docs` - review specific folders/files (snapshot, not diff)
  22 * - `/review custom "check for security issues"` - custom instructions
  23 *
  24 * Project-specific review guidelines:
  25 * - If a REVIEW_GUIDELINES.md file exists in the same directory as .pi,
  26 *   its contents are appended to the review prompt.
  27 *
  28 * Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
  29 */
  30
  31import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
  32import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent";
  33import { Container, type SelectItem, SelectList, Text, Key } from "@mariozechner/pi-tui";
  34import path from "node:path";
  35import { promises as fs, readdirSync, readFileSync } from "node:fs";
  36import { execSync } from "node:child_process";
  37
  38// =============================================================================
  39// Helper Functions for Saving Reviews
  40// =============================================================================
  41
  42function getYearMonth(): string {
  43	const now = new Date();
  44	const year = now.getFullYear();
  45	const month = String(now.getMonth() + 1).padStart(2, "0");
  46	return `${year}-${month}`;
  47}
  48
  49function formatTimestamp(date: Date): string {
  50	return date.toISOString().replace("T", " ").slice(0, 19);
  51}
  52
  53function extractOrgRepo(remoteUrl: string): { org: string; repo: string } | null {
  54	// Extract org and repo from various git remote formats
  55	// Examples:
  56	//   https://github.com/NixOS/nixpkgs.git -> { org: "nixos", repo: "nixpkgs" }
  57	//   git@github.com:tektoncd/pipeline.git -> { org: "tektoncd", repo: "pipeline" }
  58	//   https://github.com/kubernetes/kubernetes -> { org: "kubernetes", repo: "kubernetes" }
  59	
  60	// Remove .git suffix
  61	const cleanUrl = remoteUrl.replace(/\.git$/, "");
  62	
  63	// Extract org/repo pattern
  64	const match = cleanUrl.match(/[:/]([^/]+)\/([^/]+)$/);
  65	if (match) {
  66		// Lowercase for case-insensitive matching
  67		const org = match[1].toLowerCase();
  68		const repo = match[2].toLowerCase();
  69		return { org, repo };
  70	}
  71	
  72	return null;
  73}
  74
  75async function loadRepositorySpecificRules(cwd: string): Promise<string | null> {
  76	try {
  77		// Get git remote URL
  78		const remoteUrl = execSync("git remote get-url origin", {
  79			cwd,
  80			encoding: "utf-8",
  81		}).trim();
  82
  83		const orgRepo = extractOrgRepo(remoteUrl);
  84		if (!orgRepo) {
  85			return null;
  86		}
  87
  88		const { org, repo } = orgRepo;
  89		const home = process.env.HOME || process.env.USERPROFILE || "";
  90		const skillsDir = path.join(home, ".config", "claude", "skills");
  91
  92		// Priority 1: Review skill with repo-specific file
  93		// ~/.config/claude/skills/Review/repositories/<org>-<repo>.md
  94		const reviewSkillDir = path.join(skillsDir, "Review");
  95		const repoSpecificFile = path.join(reviewSkillDir, "repositories", `${org}-${repo}.md`);
  96		
  97		try {
  98			const content = await fs.readFile(repoSpecificFile, "utf-8");
  99			const filename = path.relative(skillsDir, repoSpecificFile);
 100			return `
 101
 102# Repository-Specific Review Guidelines: ${org}/${repo}
 103
 104From: ~/.config/claude/skills/${filename}
 105
 106${content}
 107`;
 108		} catch {
 109			// File doesn't exist, continue to next priority
 110		}
 111
 112		// Priority 2: Review skill general workflow
 113		// ~/.config/claude/skills/Review/workflows/Review.md
 114		const reviewWorkflow = path.join(reviewSkillDir, "workflows", "Review.md");
 115		try {
 116			const content = await fs.readFile(reviewWorkflow, "utf-8");
 117			const filename = path.relative(skillsDir, reviewWorkflow);
 118			return `
 119
 120# General Review Guidelines
 121
 122From: ~/.config/claude/skills/${filename}
 123
 124${content}
 125`;
 126		} catch {
 127			// File doesn't exist, continue to next priority
 128		}
 129
 130		// Priority 3: Review skill general guidelines
 131		// ~/.config/claude/skills/Review/SKILL.md
 132		const reviewSkill = path.join(reviewSkillDir, "SKILL.md");
 133		try {
 134			const content = await fs.readFile(reviewSkill, "utf-8");
 135			const filename = path.relative(skillsDir, reviewSkill);
 136			return `
 137
 138# General Review Guidelines
 139
 140From: ~/.config/claude/skills/${filename}
 141
 142${content}
 143`;
 144		} catch {
 145			// File doesn't exist, continue to fallback
 146		}
 147
 148		// Fallback: Legacy repo-specific skill (e.g., Nixpkgs skill)
 149		// Try to find a skill directory matching the repository name
 150		const skillDirs = await fs.readdir(skillsDir);
 151		const matchingSkill = skillDirs.find(
 152			(dir) => dir.toLowerCase() === repo
 153		);
 154
 155		if (!matchingSkill) {
 156			return null;
 157		}
 158
 159		const skillDir = path.join(skillsDir, matchingSkill);
 160		
 161		// Try to load review-specific files in priority order
 162		const reviewFiles = [
 163			path.join(skillDir, "review-checklist.md"),
 164			path.join(skillDir, "workflows", "Review.md"),
 165			path.join(skillDir, "SKILL.md"),
 166		];
 167		
 168		for (const filepath of reviewFiles) {
 169			try {
 170				const content = await fs.readFile(filepath, "utf-8");
 171				const filename = path.relative(skillsDir, filepath);
 172				
 173				// For SKILL.md, try to extract review section
 174				if (filepath.endsWith("SKILL.md")) {
 175					const reviewSection = content.match(/## Review Best Practices[\s\S]*?(?=##\s+[A-Z]|$)/);
 176					if (reviewSection) {
 177						return `
 178
 179# Repository-Specific Review Guidelines: ${repo}
 180
 181From: ~/.config/claude/skills/${filename}
 182
 183${reviewSection[0]}
 184`;
 185					}
 186				}
 187				
 188				// For other files, include the whole content
 189				return `
 190
 191# Repository-Specific Review Guidelines: ${repo}
 192
 193From: ~/.config/claude/skills/${filename}
 194
 195${content}
 196`;
 197			} catch {
 198				// Try next file
 199				continue;
 200			}
 201		}
 202		
 203		// None found
 204		return null;
 205	} catch {
 206		return null;
 207	}
 208}
 209
 210async function saveReviewMetadata(reviewData: {
 211	type: string;
 212	target: string;
 213	sessionFile: string;
 214	cwd: string;
 215}): Promise<string> {
 216	const home = process.env.HOME || process.env.USERPROFILE || "";
 217	const yearMonth = getYearMonth();
 218	const reviewsDir = path.join(home, ".local", "share", "ai", "reviews", yearMonth);
 219	
 220	// Ensure directory exists
 221	await fs.mkdir(reviewsDir, { recursive: true });
 222	
 223	const timestamp = Date.now();
 224	const filename = `review-${timestamp}.md`;
 225	const filepath = path.join(reviewsDir, filename);
 226	
 227	const now = new Date();
 228	
 229	// Try to get git context
 230	let gitBranch = "unknown";
 231	let gitCommit = "unknown";
 232	try {
 233		gitBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim();
 234		gitCommit = execSync("git rev-parse HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim().slice(0, 8);
 235	} catch {
 236		// Ignore errors
 237	}
 238	
 239	const markdown = `# Code Review: ${reviewData.target}
 240
 241**Date:** ${formatTimestamp(now)}
 242**Type:** ${reviewData.type}
 243**Session:** ${reviewData.sessionFile}
 244**Branch:** ${gitBranch}
 245**Commit:** ${gitCommit}
 246
 247## Review Target
 248
 249${reviewData.target}
 250
 251## Notes
 252
 253Review session created. See session file for AI-generated review findings.
 254
 255To extract findings and create TODOs, use the org-todos extension.
 256
 257## Next Actions
 258
 259- [ ] Review AI findings
 260- [ ] Create TODOs for critical/high issues
 261- [ ] Apply suggested fixes
 262- [ ] Re-review after changes
 263`;
 264
 265	await fs.writeFile(filepath, markdown, "utf-8");
 266	return filepath;
 267}
 268
 269// State to track fresh session review (where we branched from).
 270// Module-level state means only one review can be active at a time.
 271// This is intentional - the UI and /end-review command assume a single active review.
 272let reviewOriginId: string | undefined = undefined;
 273// Temporary clone directory for cross-repo PR reviews (cleaned up on /end-review)
 274let reviewCloneDir: string | undefined = undefined;
 275
 276const REVIEW_STATE_TYPE = "review-session";
 277
 278type ReviewSessionState = {
 279	active: boolean;
 280	originId?: string;
 281	cloneDir?: string;
 282};
 283
 284function setReviewWidget(ctx: ExtensionContext, active: boolean) {
 285	if (!ctx.hasUI) return;
 286	if (!active) {
 287		ctx.ui.setWidget("review", undefined);
 288		return;
 289	}
 290
 291	ctx.ui.setWidget("review", (_tui, theme) => {
 292		const text = new Text(theme.fg("warning", "Review session active, return with /end-review"), 0, 0);
 293		return {
 294			render(width: number) {
 295				return text.render(width);
 296			},
 297			invalidate() {
 298				text.invalidate();
 299			},
 300		};
 301	});
 302}
 303
 304function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
 305	let state: ReviewSessionState | undefined;
 306	for (const entry of ctx.sessionManager.getBranch()) {
 307		if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
 308			state = entry.data as ReviewSessionState | undefined;
 309		}
 310	}
 311
 312	return state;
 313}
 314
 315function applyReviewState(ctx: ExtensionContext) {
 316	const state = getReviewState(ctx);
 317
 318	if (state?.active && state.originId) {
 319		reviewOriginId = state.originId;
 320		reviewCloneDir = state.cloneDir;
 321		setReviewWidget(ctx, true);
 322		return;
 323	}
 324
 325	reviewOriginId = undefined;
 326	reviewCloneDir = undefined;
 327	setReviewWidget(ctx, false);
 328}
 329
 330/**
 331 * Clean up review state and optionally remove the temporary clone directory.
 332 */
 333async function clearReviewState(ctx: ExtensionContext, pi: ExtensionAPI) {
 334	setReviewWidget(ctx, false);
 335	reviewOriginId = undefined;
 336
 337	// Clean up temporary clone directory for cross-repo PR reviews
 338	if (reviewCloneDir) {
 339		try {
 340			await fs.rm(reviewCloneDir, { recursive: true, force: true });
 341			ctx.ui.notify(`Cleaned up temporary clone: ${reviewCloneDir}`, "info");
 342		} catch {
 343			ctx.ui.notify(`Warning: Failed to clean up ${reviewCloneDir}`, "warning");
 344		}
 345		reviewCloneDir = undefined;
 346	}
 347
 348	pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
 349}
 350
 351// Review target types (matching Codex's approach)
 352type ReviewTarget =
 353	| { type: "uncommitted" }
 354	| { type: "baseBranch"; branch: string }
 355	| { type: "commit"; sha: string; title?: string }
 356	| { type: "custom"; instructions: string }
 357	| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string; repo?: string; cloneDir?: string }
 358	| { type: "folder"; paths: string[] };
 359
 360// Prompts (adapted from Codex)
 361const UNCOMMITTED_PROMPT =
 362	"Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
 363
 364const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
 365	"Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
 366
 367const BASE_BRANCH_PROMPT_FALLBACK =
 368	"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
 369
 370const COMMIT_PROMPT_WITH_TITLE =
 371	'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
 372
 373const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
 374
 375const PULL_REQUEST_PROMPT =
 376	'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.';
 377
 378const PULL_REQUEST_PROMPT_FALLBACK =
 379	'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.';
 380
 381const FOLDER_REVIEW_PROMPT =
 382	"Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
 383
 384// =============================================================================
 385// Review Focus Areas (dynamically discovered from reviewer agents)
 386// =============================================================================
 387
 388interface ReviewerAgent {
 389	/** Focus key, e.g. "general", "security", "go" */
 390	focus: string;
 391	/** Agent name from frontmatter, e.g. "reviewer-security" */
 392	agent: string;
 393	/** Human-readable label, e.g. "Security" */
 394	label: string;
 395	/** Description from agent frontmatter */
 396	description: string;
 397}
 398
 399/**
 400 * Discover reviewer agents from ~/.pi/agent/agents/reviewer*.md
 401 * The base `reviewer.md` is treated as "general".
 402 * Agents named `reviewer-<focus>.md` become focus areas.
 403 * Descriptions are read from the agent frontmatter.
 404 */
 405function discoverReviewerAgents(): ReviewerAgent[] {
 406	const home = process.env.HOME || process.env.USERPROFILE || "";
 407	const agentsDir = path.join(home, ".pi", "agent", "agents");
 408
 409	let entries: string[];
 410	try {
 411		entries = readdirSync(agentsDir).filter(
 412			(f) => f.startsWith("reviewer") && f.endsWith(".md"),
 413		);
 414	} catch {
 415		return [];
 416	}
 417
 418	const agents: ReviewerAgent[] = [];
 419	for (const filename of entries.sort()) {
 420		const filePath = path.join(agentsDir, filename);
 421		let content: string;
 422		try {
 423			content = readFileSync(filePath, "utf-8");
 424		} catch {
 425			continue;
 426		}
 427
 428		// Parse YAML frontmatter
 429		const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
 430		if (!fmMatch) continue;
 431
 432		const fm = fmMatch[1];
 433		const nameMatch = fm.match(/^name:\s*(.+)$/m);
 434		const descMatch = fm.match(/^description:\s*(.+)$/m);
 435		if (!nameMatch) continue;
 436
 437		const agentName = nameMatch[1].trim();
 438		const description = descMatch ? descMatch[1].trim() : agentName;
 439
 440		// Derive focus key: "reviewer" → "general", "reviewer-security" → "security"
 441		let focus: string;
 442		let label: string;
 443		if (filename === "reviewer.md") {
 444			focus = "general";
 445			label = "General";
 446		} else {
 447			focus = filename.replace(/^reviewer-/, "").replace(/\.md$/, "");
 448			label = focus.charAt(0).toUpperCase() + focus.slice(1);
 449		}
 450
 451		agents.push({ focus, agent: agentName, label, description });
 452	}
 453
 454	return agents;
 455}
 456
 457async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
 458	let currentDir = path.resolve(cwd);
 459
 460	while (true) {
 461		const piDir = path.join(currentDir, ".pi");
 462		const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
 463
 464		const piStats = await fs.stat(piDir).catch(() => null);
 465		if (piStats?.isDirectory()) {
 466			const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
 467			if (guidelineStats?.isFile()) {
 468				try {
 469					const content = await fs.readFile(guidelinesPath, "utf8");
 470					const trimmed = content.trim();
 471					return trimmed ? trimmed : null;
 472				} catch {
 473					return null;
 474				}
 475			}
 476			return null;
 477		}
 478
 479		const parentDir = path.dirname(currentDir);
 480		if (parentDir === currentDir) {
 481			return null;
 482		}
 483		currentDir = parentDir;
 484	}
 485}
 486
 487/**
 488 * Get the merge base between HEAD and a branch
 489 */
 490async function getMergeBase(
 491	pi: ExtensionAPI,
 492	branch: string,
 493	cwd?: string,
 494): Promise<string | null> {
 495	const execOpts = cwd ? { cwd } : undefined;
 496	try {
 497		// First try to get the upstream tracking branch
 498		const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
 499			"rev-parse",
 500			"--abbrev-ref",
 501			`${branch}@{upstream}`,
 502		], execOpts);
 503
 504		if (upstreamCode === 0 && upstream.trim()) {
 505			const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()], execOpts);
 506			if (code === 0 && mergeBase.trim()) {
 507				return mergeBase.trim();
 508			}
 509		}
 510
 511		// Fall back to using the branch directly
 512		const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch], execOpts);
 513		if (code === 0 && mergeBase.trim()) {
 514			return mergeBase.trim();
 515		}
 516
 517		return null;
 518	} catch {
 519		return null;
 520	}
 521}
 522
 523/**
 524 * Get list of local branches
 525 */
 526async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
 527	const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
 528	if (code !== 0) return [];
 529	return stdout
 530		.trim()
 531		.split("\n")
 532		.filter((b) => b.trim());
 533}
 534
 535/**
 536 * Get list of recent commits
 537 */
 538async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
 539	const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
 540	if (code !== 0) return [];
 541
 542	return stdout
 543		.trim()
 544		.split("\n")
 545		.filter((line) => line.trim())
 546		.map((line) => {
 547			const [sha, ...rest] = line.trim().split(" ");
 548			return { sha, title: rest.join(" ") };
 549		});
 550}
 551
 552/**
 553 * Check if there are uncommitted changes (staged, unstaged, or untracked)
 554 */
 555async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
 556	const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
 557	return code === 0 && stdout.trim().length > 0;
 558}
 559
 560/**
 561 * Check if there are changes that would prevent switching branches
 562 * (staged or unstaged changes to tracked files - untracked files are fine)
 563 */
 564async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
 565	// Check for staged or unstaged changes to tracked files
 566	const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
 567	if (code !== 0) return false;
 568
 569	// Filter out untracked files (lines starting with ??)
 570	const lines = stdout.trim().split("\n").filter((line) => line.trim());
 571	const trackedChanges = lines.filter((line) => !line.startsWith("??"));
 572	return trackedChanges.length > 0;
 573}
 574
 575/**
 576 * Parsed PR reference with optional repository context
 577 */
 578type PrReference = {
 579	number: number;
 580	repo?: string; // "owner/repo" format, undefined means current repo
 581};
 582
 583/**
 584 * Parse a PR reference (URL, number, or owner/repo#number) and return the PR number + optional repo
 585 */
 586function parsePrReference(ref: string): PrReference | null {
 587	const trimmed = ref.trim();
 588
 589	// Try as a number first
 590	const num = parseInt(trimmed, 10);
 591	if (!isNaN(num) && num > 0) {
 592		return { number: num };
 593	}
 594
 595	// Try to extract from GitHub URL
 596	// Formats: https://github.com/owner/repo/pull/123
 597	//          github.com/owner/repo/pull/123
 598	const urlMatch = trimmed.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
 599	if (urlMatch) {
 600		return { number: parseInt(urlMatch[2], 10), repo: urlMatch[1] };
 601	}
 602
 603	// Try to extract from owner/repo#number format
 604	// Format: tektoncd/pipeline#1234
 605	const repoMatch = trimmed.match(/^([^/]+\/[^#]+)#(\d+)$/);
 606	if (repoMatch) {
 607		return { number: parseInt(repoMatch[2], 10), repo: repoMatch[1] };
 608	}
 609
 610	return null;
 611}
 612
 613/**
 614 * Get PR information from GitHub CLI
 615 */
 616async function getPrInfo(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
 617	const args = ["pr", "view", String(prNumber), "--json", "baseRefName,title,headRefName"];
 618	if (repo) {
 619		args.push("-R", repo);
 620	}
 621	const { stdout, code } = await pi.exec("gh", args);
 622
 623	if (code !== 0) return null;
 624
 625	try {
 626		const data = JSON.parse(stdout);
 627		return {
 628			baseBranch: data.baseRefName,
 629			title: data.title,
 630			headBranch: data.headRefName,
 631		};
 632	} catch {
 633		return null;
 634	}
 635}
 636
 637/**
 638 * Checkout a PR using GitHub CLI
 639 */
 640async function checkoutPr(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ success: boolean; error?: string }> {
 641	const args = ["pr", "checkout", String(prNumber)];
 642	if (repo) {
 643		args.push("-R", repo);
 644	}
 645	const { stdout, stderr, code } = await pi.exec("gh", args);
 646
 647	if (code !== 0) {
 648		return { success: false, error: stderr || stdout || "Failed to checkout PR" };
 649	}
 650
 651	return { success: true };
 652}
 653
 654/**
 655 * Get the current branch name
 656 */
 657async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
 658	const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
 659	if (code === 0 && stdout.trim()) {
 660		return stdout.trim();
 661	}
 662	return null;
 663}
 664
 665/**
 666 * Get the default branch (main or master)
 667 */
 668async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
 669	// Try to get from remote HEAD
 670	const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
 671	if (code === 0 && stdout.trim()) {
 672		return stdout.trim().replace("origin/", "");
 673	}
 674
 675	// Fall back to checking if main or master exists
 676	const branches = await getLocalBranches(pi);
 677	if (branches.includes("main")) return "main";
 678	if (branches.includes("master")) return "master";
 679
 680	return "main"; // Default fallback
 681}
 682
 683/**
 684 * Build the review prompt based on target
 685 */
 686async function buildReviewPrompt(pi: ExtensionAPI, target: ReviewTarget): Promise<string> {
 687	switch (target.type) {
 688		case "uncommitted":
 689			return UNCOMMITTED_PROMPT;
 690
 691		case "baseBranch": {
 692			const mergeBase = await getMergeBase(pi, target.branch);
 693			if (mergeBase) {
 694				return BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
 695					/{mergeBaseSha}/g,
 696					mergeBase,
 697				);
 698			}
 699			return BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
 700		}
 701
 702		case "commit":
 703			if (target.title) {
 704				return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
 705			}
 706			return COMMIT_PROMPT.replace("{sha}", target.sha);
 707
 708		case "custom":
 709			return target.instructions;
 710
 711		case "pullRequest": {
 712			const mergeBase = await getMergeBase(pi, target.baseBranch, target.cloneDir);
 713			let prompt: string;
 714			if (mergeBase) {
 715				prompt = PULL_REQUEST_PROMPT
 716					.replace(/{prNumber}/g, String(target.prNumber))
 717					.replace(/{title}/g, target.title)
 718					.replace(/{baseBranch}/g, target.baseBranch)
 719					.replace(/{mergeBaseSha}/g, mergeBase);
 720			} else {
 721				prompt = PULL_REQUEST_PROMPT_FALLBACK
 722					.replace(/{prNumber}/g, String(target.prNumber))
 723					.replace(/{title}/g, target.title)
 724					.replace(/{baseBranch}/g, target.baseBranch);
 725			}
 726			if (target.cloneDir) {
 727				prompt += `\n\nIMPORTANT: This is a cross-repository PR. The repository has been cloned to \`${target.cloneDir}\`. You MUST \`cd ${target.cloneDir}\` before running any git or file commands. All file paths are relative to that directory.`;
 728			}
 729			return prompt;
 730		}
 731
 732		case "folder":
 733			return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
 734	}
 735}
 736
 737/**
 738 * Get user-facing hint for the review target
 739 */
 740function getUserFacingHint(target: ReviewTarget): string {
 741	switch (target.type) {
 742		case "uncommitted":
 743			return "current changes";
 744		case "baseBranch":
 745			return `changes against '${target.branch}'`;
 746		case "commit": {
 747			const shortSha = target.sha.slice(0, 7);
 748			return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
 749		}
 750		case "custom":
 751			return target.instructions.length > 40 ? target.instructions.slice(0, 37) + "..." : target.instructions;
 752
 753		case "pullRequest": {
 754			const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
 755			const repoPrefix = target.repo ? `${target.repo}#` : "PR #";
 756			return `${repoPrefix}${target.prNumber}: ${shortTitle}`;
 757		}
 758
 759		case "folder": {
 760			const joined = target.paths.join(", ");
 761			return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
 762		}
 763	}
 764}
 765
 766// Review preset options for the selector
 767const REVIEW_PRESETS = [
 768	{ value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
 769	{ value: "baseBranch", label: "Review against a base branch", description: "(local)" },
 770	{ value: "uncommitted", label: "Review uncommitted changes", description: "" },
 771	{ value: "commit", label: "Review a commit", description: "" },
 772	{ value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
 773	{ value: "custom", label: "Custom review instructions", description: "" },
 774] as const;
 775
 776export default function reviewExtension(pi: ExtensionAPI) {
 777	pi.on("session_start", (_event, ctx) => {
 778		applyReviewState(ctx);
 779	});
 780
 781	pi.on("session_switch", (_event, ctx) => {
 782		applyReviewState(ctx);
 783	});
 784
 785	pi.on("session_tree", (_event, ctx) => {
 786		applyReviewState(ctx);
 787	});
 788
 789	/**
 790	 * Determine the smart default review type based on git state
 791	 */
 792	async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
 793		// Priority 1: If there are uncommitted changes, default to reviewing them
 794		if (await hasUncommittedChanges(pi)) {
 795			return "uncommitted";
 796		}
 797
 798		// Priority 2: If on a feature branch (not the default branch), default to PR-style review
 799		const currentBranch = await getCurrentBranch(pi);
 800		const defaultBranch = await getDefaultBranch(pi);
 801		if (currentBranch && currentBranch !== defaultBranch) {
 802			return "baseBranch";
 803		}
 804
 805		// Priority 3: Default to reviewing a specific commit
 806		return "commit";
 807	}
 808
 809	/**
 810	 * Show the review preset selector
 811	 */
 812	async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
 813		// Determine smart default and reorder items
 814		const smartDefault = await getSmartDefault();
 815		const items: SelectItem[] = REVIEW_PRESETS
 816			.slice() // copy to avoid mutating original
 817			.sort((a, b) => {
 818				// Put smart default first
 819				if (a.value === smartDefault) return -1;
 820				if (b.value === smartDefault) return 1;
 821				return 0;
 822			})
 823			.map((preset) => ({
 824				value: preset.value,
 825				label: preset.label,
 826				description: preset.description,
 827			}));
 828
 829		while (true) {
 830			const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
 831				const container = new Container();
 832				container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 833				container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
 834
 835				const selectList = new SelectList(items, Math.min(items.length, 10), {
 836					selectedPrefix: (text) => theme.fg("accent", text),
 837					selectedText: (text) => theme.fg("accent", text),
 838					description: (text) => theme.fg("muted", text),
 839					scrollInfo: (text) => theme.fg("dim", text),
 840					noMatch: (text) => theme.fg("warning", text),
 841				});
 842
 843				selectList.onSelect = (item) => done(item.value);
 844				selectList.onCancel = () => done(null);
 845
 846				container.addChild(selectList);
 847				container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
 848				container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 849
 850				return {
 851					render(width: number) {
 852						return container.render(width);
 853					},
 854					invalidate() {
 855						container.invalidate();
 856					},
 857					handleInput(data: string) {
 858						selectList.handleInput(data);
 859						tui.requestRender();
 860					},
 861				};
 862			});
 863
 864			if (!result) return null;
 865
 866			// Handle each preset type
 867			switch (result) {
 868				case "uncommitted":
 869					return { type: "uncommitted" };
 870
 871				case "baseBranch": {
 872					const target = await showBranchSelector(ctx);
 873					if (target) return target;
 874					break;
 875				}
 876
 877				case "commit": {
 878					const target = await showCommitSelector(ctx);
 879					if (target) return target;
 880					break;
 881				}
 882
 883				case "custom": {
 884					const target = await showCustomInput(ctx);
 885					if (target) return target;
 886					break;
 887				}
 888
 889				case "folder": {
 890					const target = await showFolderInput(ctx);
 891					if (target) return target;
 892					break;
 893				}
 894
 895				case "pullRequest": {
 896					const target = await showPrInput(ctx);
 897					if (target) return target;
 898					break;
 899				}
 900
 901				default:
 902					return null;
 903			}
 904		}
 905	}
 906
 907	/**
 908	 * Show branch selector for base branch review
 909	 */
 910	async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
 911		const branches = await getLocalBranches(pi);
 912		const defaultBranch = await getDefaultBranch(pi);
 913
 914		if (branches.length === 0) {
 915			ctx.ui.notify("No branches found", "error");
 916			return null;
 917		}
 918
 919		// Sort branches with default branch first
 920		const sortedBranches = branches.sort((a, b) => {
 921			if (a === defaultBranch) return -1;
 922			if (b === defaultBranch) return 1;
 923			return a.localeCompare(b);
 924		});
 925
 926		const items: SelectItem[] = sortedBranches.map((branch) => ({
 927			value: branch,
 928			label: branch,
 929			description: branch === defaultBranch ? "(default)" : "",
 930		}));
 931
 932		const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
 933			const container = new Container();
 934			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 935			container.addChild(new Text(theme.fg("accent", theme.bold("Select base branch"))));
 936
 937			const selectList = new SelectList(items, Math.min(items.length, 10), {
 938				selectedPrefix: (text) => theme.fg("accent", text),
 939				selectedText: (text) => theme.fg("accent", text),
 940				description: (text) => theme.fg("muted", text),
 941				scrollInfo: (text) => theme.fg("dim", text),
 942				noMatch: (text) => theme.fg("warning", text),
 943			});
 944
 945			// Enable search
 946			selectList.searchable = true;
 947
 948			selectList.onSelect = (item) => done(item.value);
 949			selectList.onCancel = () => done(null);
 950
 951			container.addChild(selectList);
 952			container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
 953			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 954
 955			return {
 956				render(width: number) {
 957					return container.render(width);
 958				},
 959				invalidate() {
 960					container.invalidate();
 961				},
 962				handleInput(data: string) {
 963					selectList.handleInput(data);
 964					tui.requestRender();
 965				},
 966			};
 967		});
 968
 969		if (!result) return null;
 970		return { type: "baseBranch", branch: result };
 971	}
 972
 973	/**
 974	 * Show commit selector
 975	 */
 976	async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
 977		const commits = await getRecentCommits(pi, 20);
 978
 979		if (commits.length === 0) {
 980			ctx.ui.notify("No commits found", "error");
 981			return null;
 982		}
 983
 984		const items: SelectItem[] = commits.map((commit) => ({
 985			value: commit.sha,
 986			label: `${commit.sha.slice(0, 7)} ${commit.title}`,
 987			description: "",
 988		}));
 989
 990		const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
 991			const container = new Container();
 992			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
 993			container.addChild(new Text(theme.fg("accent", theme.bold("Select commit to review"))));
 994
 995			const selectList = new SelectList(items, Math.min(items.length, 10), {
 996				selectedPrefix: (text) => theme.fg("accent", text),
 997				selectedText: (text) => theme.fg("accent", text),
 998				description: (text) => theme.fg("muted", text),
 999				scrollInfo: (text) => theme.fg("dim", text),
1000				noMatch: (text) => theme.fg("warning", text),
1001			});
1002
1003			// Enable search
1004			selectList.searchable = true;
1005
1006			selectList.onSelect = (item) => {
1007				const commit = commits.find((c) => c.sha === item.value);
1008				if (commit) {
1009					done(commit);
1010				} else {
1011					done(null);
1012				}
1013			};
1014			selectList.onCancel = () => done(null);
1015
1016			container.addChild(selectList);
1017			container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1018			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1019
1020			return {
1021				render(width: number) {
1022					return container.render(width);
1023				},
1024				invalidate() {
1025					container.invalidate();
1026				},
1027				handleInput(data: string) {
1028					selectList.handleInput(data);
1029					tui.requestRender();
1030				},
1031			};
1032		});
1033
1034		if (!result) return null;
1035		return { type: "commit", sha: result.sha, title: result.title };
1036	}
1037
1038	/**
1039	 * Show custom instructions input
1040	 */
1041	async function showCustomInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1042		const result = await ctx.ui.editor(
1043			"Enter review instructions:",
1044			"Review the code for security vulnerabilities and potential bugs...",
1045		);
1046
1047		if (!result?.trim()) return null;
1048		return { type: "custom", instructions: result.trim() };
1049	}
1050
1051	function parseReviewPaths(value: string): string[] {
1052		return value
1053			.split(/\s+/)
1054			.map((item) => item.trim())
1055			.filter((item) => item.length > 0);
1056	}
1057
1058	/**
1059	 * Show folder input
1060	 */
1061	async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1062		const result = await ctx.ui.editor(
1063			"Enter folders/files to review (space-separated or one per line):",
1064			".",
1065		);
1066
1067		if (!result?.trim()) return null;
1068		const paths = parseReviewPaths(result);
1069		if (paths.length === 0) return null;
1070
1071		return { type: "folder", paths };
1072	}
1073
1074	/**
1075	 * Show focus area selector for the review.
1076	 * Dynamically discovers reviewer-*.md agents from ~/.pi/agent/agents/.
1077	 * Returns the focus key ("general", "security", "full", etc.) or null if cancelled.
1078	 */
1079	async function showFocusSelector(ctx: ExtensionContext): Promise<{ focus: string; agents: ReviewerAgent[] } | null> {
1080		// Discover all reviewer agents fresh each time (allows adding agents mid-session)
1081		const allAgents = discoverReviewerAgents();
1082
1083		if (allAgents.length === 0) {
1084			ctx.ui.notify("No reviewer agents found in ~/.pi/agent/agents/", "error");
1085			return null;
1086		}
1087
1088		const items: SelectItem[] = allAgents.map((ra) => ({
1089			value: ra.focus,
1090			label: ra.label,
1091			description: ra.description,
1092		}));
1093
1094		// Add "full" option at the end
1095		items.push({
1096			value: "full",
1097			label: "Full review",
1098			description: `All ${allAgents.length} reviewers in parallel`,
1099		});
1100
1101		const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1102			const container = new Container();
1103			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1104			container.addChild(new Text(theme.fg("accent", theme.bold("Select review focus"))));
1105
1106			const selectList = new SelectList(items, Math.min(items.length, 10), {
1107				selectedPrefix: (text) => theme.fg("accent", text),
1108				selectedText: (text) => theme.fg("accent", text),
1109				description: (text) => theme.fg("muted", text),
1110				scrollInfo: (text) => theme.fg("dim", text),
1111				noMatch: (text) => theme.fg("warning", text),
1112			});
1113
1114			selectList.onSelect = (item) => done(item.value);
1115			selectList.onCancel = () => done(null);
1116
1117			container.addChild(selectList);
1118			container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
1119			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1120
1121			return {
1122				render(width: number) {
1123					return container.render(width);
1124				},
1125				invalidate() {
1126					container.invalidate();
1127				},
1128				handleInput(data: string) {
1129					selectList.handleInput(data);
1130					tui.requestRender();
1131				},
1132			};
1133		});
1134
1135		if (!result) return null;
1136		return { focus: result, agents: allAgents };
1137	}
1138
1139	/**
1140	 * Fetch open PRs from GitHub for the selector
1141	 */
1142	async function fetchOpenPRs(): Promise<Array<{ number: number; title: string; author: string; branch: string; base: string; isDraft: boolean; reviewDecision: string }>> {
1143		const { stdout, code } = await pi.exec("gh", [
1144			"pr", "list",
1145			"--state", "open",
1146			"--json", "number,title,author,headRefName,baseRefName,isDraft,reviewDecision",
1147			"--limit", "30",
1148		]);
1149
1150		if (code !== 0) return [];
1151
1152		try {
1153			const data = JSON.parse(stdout);
1154			if (!Array.isArray(data)) return [];
1155			return data.map((item: any) => ({
1156				number: item.number ?? 0,
1157				title: item.title ?? "",
1158				author: item.author?.login ?? "",
1159				branch: item.headRefName ?? "",
1160				base: item.baseRefName ?? "",
1161				isDraft: item.isDraft ?? false,
1162				reviewDecision: item.reviewDecision ?? "",
1163			}));
1164		} catch {
1165			return [];
1166		}
1167	}
1168
1169	/**
1170	 * Get review decision label for display
1171	 */
1172	function reviewDecisionLabel(decision: string): string {
1173		switch (decision) {
1174			case "APPROVED": return " ✓approved";
1175			case "CHANGES_REQUESTED": return " ✗changes requested";
1176			case "REVIEW_REQUIRED": return " ⏳review needed";
1177			default: return "";
1178		}
1179	}
1180
1181	/**
1182	 * Show PR selector with list of open PRs + manual entry option
1183	 */
1184	async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1185		// Fetch open PRs
1186		ctx.ui.notify("Fetching open PRs...", "info");
1187		const prs = await fetchOpenPRs();
1188
1189		// Try to get current user for smart ordering
1190		let currentUser = "";
1191		const { stdout: userOut, code: userCode } = await pi.exec("gh", ["api", "user", "--jq", ".login"], { timeout: 5000 });
1192		if (userCode === 0) currentUser = userOut.trim();
1193
1194		// Sort: PRs needing your review first, then your own, then others
1195		const sorted = prs.slice().sort((a, b) => {
1196			const aScore = a.reviewDecision === "REVIEW_REQUIRED" && a.author !== currentUser ? 0
1197				: a.author === currentUser ? 1
1198				: 2;
1199			const bScore = b.reviewDecision === "REVIEW_REQUIRED" && b.author !== currentUser ? 0
1200				: b.author === currentUser ? 1
1201				: 2;
1202			return aScore - bScore;
1203		});
1204
1205		// Build select items
1206		const allItems: SelectItem[] = sorted.map((pr) => {
1207			const draft = pr.isDraft ? " [draft]" : "";
1208			const review = reviewDecisionLabel(pr.reviewDecision);
1209			return {
1210				value: String(pr.number),
1211				label: `#${pr.number} ${pr.title}${draft}${review}`,
1212				description: `@${pr.author}  ${pr.branch}${pr.base}`,
1213			};
1214		});
1215
1216		// Add manual entry option at the bottom
1217		const manualItem: SelectItem = {
1218			value: "__manual__",
1219			label: "Enter PR number manually…",
1220			description: "(for cross-repo or closed PRs)",
1221		};
1222
1223		/** Fuzzy-match: all terms (split by space) must appear somewhere in the searchable text */
1224		function fuzzyMatch(item: SelectItem, query: string): boolean {
1225			if (!query) return true;
1226			const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
1227			const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1228			return terms.every((term) => searchable.includes(term));
1229		}
1230
1231		// Show selector with search-as-you-type
1232		const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1233			let searchQuery = "";
1234
1235			function getFilteredItems(): SelectItem[] {
1236				if (!searchQuery) return [...allItems, manualItem];
1237				const filtered = allItems.filter((item) => fuzzyMatch(item, searchQuery));
1238				filtered.push(manualItem);
1239				return filtered;
1240			}
1241
1242			let currentItems = getFilteredItems();
1243
1244			const container = new Container();
1245			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1246
1247			const headerText = new Text("", 0, 0);
1248			function updateHeader() {
1249				const title = theme.fg("accent", theme.bold("Select a pull request to review"));
1250				if (searchQuery) {
1251					headerText.setText(`${title}  ${theme.fg("warning", `filter: ${searchQuery}`)}`);
1252				} else {
1253					headerText.setText(title);
1254				}
1255			}
1256			updateHeader();
1257			container.addChild(headerText);
1258
1259			let selectList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
1260				selectedPrefix: (text) => theme.fg("accent", text),
1261				selectedText: (text) => theme.fg("accent", text),
1262				description: (text) => theme.fg("muted", text),
1263				scrollInfo: (text) => theme.fg("dim", text),
1264				noMatch: (text) => theme.fg("warning", text),
1265			});
1266			selectList.onSelect = (item) => done(item.value);
1267			selectList.onCancel = () => done(null);
1268
1269			container.addChild(selectList);
1270			container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1271			container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1272
1273			function rebuildList() {
1274				currentItems = getFilteredItems();
1275				const newList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
1276					selectedPrefix: (text) => theme.fg("accent", text),
1277					selectedText: (text) => theme.fg("accent", text),
1278					description: (text) => theme.fg("muted", text),
1279					scrollInfo: (text) => theme.fg("dim", text),
1280					noMatch: (text) => theme.fg("warning", text),
1281				});
1282				newList.onSelect = (item) => done(item.value);
1283				newList.onCancel = () => done(null);
1284				// Replace in container (index 2: after border + header)
1285				const idx = container.children.indexOf(selectList);
1286				if (idx !== -1) container.children[idx] = newList;
1287				selectList = newList;
1288				updateHeader();
1289			}
1290
1291			return {
1292				render(width: number) { return container.render(width); },
1293				invalidate() { container.invalidate(); },
1294				handleInput(data: string) {
1295					// Backspace: remove last char from search
1296					if (data === "\x7f" || data === "\b") {
1297						if (searchQuery.length > 0) {
1298							searchQuery = searchQuery.slice(0, -1);
1299							rebuildList();
1300							tui.requestRender();
1301						}
1302						return;
1303					}
1304
1305					// Printable characters: append to search
1306					if (data.length === 1 && data >= " " && data <= "~") {
1307						searchQuery += data;
1308						rebuildList();
1309						tui.requestRender();
1310						return;
1311					}
1312
1313					// Everything else (arrows, enter, escape): pass to SelectList
1314					selectList.handleInput(data);
1315					tui.requestRender();
1316				},
1317			};
1318		});
1319
1320		if (!selected) return null;
1321
1322		// Build the ref string for handlePrCheckout
1323		let prRef: string;
1324		let prRepo: string | undefined;
1325		if (selected === "__manual__") {
1326			const input = await ctx.ui.editor(
1327				"Enter PR number, owner/repo#number, or URL (e.g. 123, tektoncd/pipeline#456, or https://github.com/owner/repo/pull/123):",
1328				"",
1329			);
1330			if (!input?.trim()) return null;
1331
1332			const parsed = parsePrReference(input);
1333			if (!parsed) {
1334				ctx.ui.notify("Invalid PR reference. Enter a number, owner/repo#number, or GitHub PR URL.", "error");
1335				return null;
1336			}
1337			prRef = input.trim();
1338			prRepo = parsed.repo;
1339		} else {
1340			prRef = selected;
1341		}
1342
1343		// Delegate to handlePrCheckout which handles both local and cross-repo PRs
1344		return await handlePrCheckout(ctx, prRef, prRepo);
1345	}
1346
1347	/**
1348	 * Execute the review
1349	 */
1350	async function executeReview(ctx: ExtensionCommandContext, target: ReviewTarget, useFreshSession: boolean, focus: string, reviewerAgents: ReviewerAgent[]): Promise<void> {
1351		// Check if we're already in a review
1352		if (reviewOriginId) {
1353			ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1354			return;
1355		}
1356
1357		// Track clone directory for cross-repo PR reviews
1358		const cloneDir = target.type === "pullRequest" ? target.cloneDir : undefined;
1359		reviewCloneDir = cloneDir;
1360
1361		// Handle fresh session mode
1362		if (useFreshSession) {
1363			// Store current position (where we'll return to)
1364			const originId = ctx.sessionManager.getLeafId() ?? undefined;
1365			if (!originId) {
1366				ctx.ui.notify("Failed to determine review origin. Try again from a session with messages.", "error");
1367				reviewCloneDir = undefined;
1368				return;
1369			}
1370			reviewOriginId = originId;
1371
1372			// Keep a local copy so session_tree events during navigation don't wipe it
1373			const lockedOriginId = originId;
1374
1375			// Find the first user message in the session
1376			const entries = ctx.sessionManager.getEntries();
1377			const firstUserMessage = entries.find(
1378				(e) => e.type === "message" && e.message.role === "user",
1379			);
1380
1381			if (!firstUserMessage) {
1382				ctx.ui.notify("No user message found in session", "error");
1383				reviewOriginId = undefined;
1384				reviewCloneDir = undefined;
1385				return;
1386			}
1387
1388			// Navigate to first user message to create a new branch from that point
1389			// Label it as "code-review" so it's visible in the tree
1390			try {
1391				const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" });
1392				if (result.cancelled) {
1393					reviewOriginId = undefined;
1394					reviewCloneDir = undefined;
1395					return;
1396				}
1397			} catch (error) {
1398				// Clean up state if navigation fails
1399				reviewOriginId = undefined;
1400				reviewCloneDir = undefined;
1401				ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error");
1402				return;
1403			}
1404
1405			// Restore origin after navigation events (session_tree can reset it)
1406			reviewOriginId = lockedOriginId;
1407
1408			// Clear the editor (navigating to user message fills it with the message text)
1409			ctx.ui.setEditorText("");
1410
1411			// Show widget indicating review is active
1412			setReviewWidget(ctx, true);
1413
1414			// Persist review state so tree navigation can restore/reset it
1415			pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId, cloneDir });
1416		}
1417
1418		const reviewPrompt = await buildReviewPrompt(pi, target);
1419		const hint = getUserFacingHint(target);
1420		const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
1421		const repoRules = await loadRepositorySpecificRules(ctx.cwd);
1422
1423		// Build the task context that will be sent to subagent(s)
1424		let taskContext = reviewPrompt;
1425
1426		if (projectGuidelines) {
1427			taskContext += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`;
1428		}
1429
1430		if (repoRules) {
1431			taskContext += repoRules;
1432		}
1433
1434		// Look up the selected agent (or all agents for "full")
1435		const selectedAgent = reviewerAgents.find((ra) => ra.focus === focus);
1436		const focusLabel = focus === "full" ? "Full" : (selectedAgent?.label ?? focus);
1437		const modeHint = useFreshSession ? " (fresh session)" : "";
1438		ctx.ui.notify(`Starting ${focusLabel} review: ${hint}${modeHint}`, "info");
1439
1440		// Save review metadata to ai-storage
1441		try {
1442			const reviewPath = await saveReviewMetadata({
1443				type: target.type,
1444				target: `[${focusLabel}] ${hint}`,
1445				sessionFile: ctx.sessionManager.getSessionFile(),
1446				cwd: ctx.cwd,
1447			});
1448
1449			ctx.ui.notify(`Review metadata saved: ${path.basename(reviewPath)}`, "success");
1450		} catch (error: any) {
1451			// Don't fail the review if saving fails
1452			ctx.ui.notify(`Warning: Failed to save review metadata: ${error.message}`, "warning");
1453		}
1454
1455		// Dispatch to subagent(s) based on focus area
1456		if (focus === "full") {
1457			// Run all discovered reviewer agents in parallel
1458			const subagentTasks = reviewerAgents.map((ra) => ({
1459				agent: ra.agent,
1460				task: `${ra.label}-focused review: ${taskContext}`,
1461			}));
1462
1463			const focusNames = reviewerAgents.map((ra) => ra.focus).join("/");
1464			const tasksJson = JSON.stringify(subagentTasks);
1465			const subagentPrompt = `Run a full code review using parallel subagents. Dispatch the following reviewers using the subagent tool in parallel mode:
1466
1467${tasksJson}
1468
1469After all reviewers complete, present a **consolidated review report**:
14701. Group findings by file, deduplicating overlapping issues (keep the most specific)
14712. Note which reviewer (${focusNames}) flagged each issue
14723. Use the highest priority tag when reviewers disagree
14734. Provide a unified verdict: "correct" if no P0/P1 issues, "needs attention" otherwise`;
1474
1475			pi.sendUserMessage(subagentPrompt);
1476		} else if (selectedAgent) {
1477			// Single focused review — dispatch to one subagent
1478			const subagentPrompt = `Run a ${focusLabel.toLowerCase()}-focused code review using the subagent tool.
1479
1480Dispatch to the \`${selectedAgent.agent}\` agent with this task:
1481
1482${taskContext}
1483
1484Present the subagent's findings directly to the user.`;
1485
1486			pi.sendUserMessage(subagentPrompt);
1487		} else {
1488			ctx.ui.notify(`No reviewer agent found for focus "${focus}"`, "error");
1489		}
1490	}
1491
1492	/**
1493	 * Parse command arguments for direct invocation
1494	 * Returns the target or a special marker for PR that needs async handling
1495	 */
1496	function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string; repo?: string } | null {
1497		if (!args?.trim()) return null;
1498
1499		const trimmed = args.trim();
1500
1501		// Check if it looks like a PR reference (URL, number, or owner/repo#number)
1502		// This allows `/review 123`, `/review https://...`, or `/review tektoncd/pipeline#1234`
1503		const prRef = parsePrReference(trimmed);
1504		if (prRef !== null) {
1505			return { type: "pr", ref: trimmed, repo: prRef.repo };
1506		}
1507
1508		const parts = trimmed.split(/\s+/);
1509		const subcommand = parts[0]?.toLowerCase();
1510
1511		switch (subcommand) {
1512			case "uncommitted":
1513				return { type: "uncommitted" };
1514
1515			case "branch": {
1516				const branch = parts[1];
1517				if (!branch) return null;
1518				return { type: "baseBranch", branch };
1519			}
1520
1521			case "commit": {
1522				const sha = parts[1];
1523				if (!sha) return null;
1524				const title = parts.slice(2).join(" ") || undefined;
1525				return { type: "commit", sha, title };
1526			}
1527
1528			case "custom": {
1529				const instructions = parts.slice(1).join(" ");
1530				if (!instructions) return null;
1531				return { type: "custom", instructions };
1532			}
1533
1534			case "folder": {
1535				const paths = parseReviewPaths(parts.slice(1).join(" "));
1536				if (paths.length === 0) return null;
1537				return { type: "folder", paths };
1538			}
1539
1540			case "pr": {
1541				const ref = parts[1];
1542				if (!ref) return { type: "pr", ref: "__select__" };
1543				const parsed = parsePrReference(ref);
1544				return { type: "pr", ref, repo: parsed?.repo };
1545			}
1546
1547			default:
1548				return null;
1549		}
1550	}
1551
1552	/**
1553	 * Handle PR checkout and return a ReviewTarget (or null on failure)
1554	 *
1555	 * For cross-repo PRs (when repo is set), clones the repository to a temp
1556	 * directory and checks out the PR there instead of in the current repo.
1557	 */
1558	async function handlePrCheckout(ctx: ExtensionContext, ref: string, repo?: string): Promise<ReviewTarget | null> {
1559		const prRef = parsePrReference(ref);
1560		if (!prRef) {
1561			ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
1562			return null;
1563		}
1564
1565		// Use repo from the parsed reference if not explicitly provided
1566		const effectiveRepo = repo ?? prRef.repo;
1567		const repoLabel = effectiveRepo ? ` (${effectiveRepo})` : "";
1568		const isCrossRepo = !!effectiveRepo;
1569
1570		// For local repo PRs, check for pending changes (cross-repo clones to temp dir so no conflict)
1571		if (!isCrossRepo && await hasPendingChanges(pi)) {
1572			ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
1573			return null;
1574		}
1575
1576		// Get PR info
1577		ctx.ui.notify(`Fetching PR #${prRef.number} info${repoLabel}...`, "info");
1578		const prInfo = await getPrInfo(pi, prRef.number, effectiveRepo);
1579
1580		if (!prInfo) {
1581			ctx.ui.notify(`Could not find PR #${prRef.number}${repoLabel}. Make sure gh is authenticated and the PR exists.`, "error");
1582			return null;
1583		}
1584
1585		let cloneDir: string | undefined;
1586
1587		if (isCrossRepo) {
1588			// Clone the repository to a temp directory for cross-repo PRs
1589			const tmpBase = path.join(process.env.TMPDIR || "/tmp", "pi-review");
1590			await fs.mkdir(tmpBase, { recursive: true });
1591			const sanitizedRepo = effectiveRepo!.replace(/\//g, "-");
1592			cloneDir = path.join(tmpBase, `${sanitizedRepo}-pr-${prRef.number}`);
1593
1594			// Remove existing clone if present (stale from previous review)
1595			try {
1596				await fs.rm(cloneDir, { recursive: true, force: true });
1597			} catch {
1598				// Ignore
1599			}
1600
1601			ctx.ui.notify(`Cloning ${effectiveRepo} to ${cloneDir}...`, "info");
1602			const { stderr: cloneErr, code: cloneCode } = await pi.exec("gh", [
1603				"repo", "clone", effectiveRepo!, cloneDir,
1604			]);
1605
1606			if (cloneCode !== 0) {
1607				ctx.ui.notify(`Failed to clone ${effectiveRepo}: ${cloneErr}`, "error");
1608				return null;
1609			}
1610
1611			// Checkout the PR inside the clone
1612			ctx.ui.notify(`Checking out PR #${prRef.number} in clone...`, "info");
1613			const { stderr: coErr, code: coCode } = await pi.exec("gh", [
1614				"pr", "checkout", String(prRef.number), "-R", effectiveRepo!,
1615			], { cwd: cloneDir });
1616
1617			if (coCode !== 0) {
1618				ctx.ui.notify(`Failed to checkout PR: ${coErr}`, "error");
1619				// Clean up failed clone
1620				await fs.rm(cloneDir, { recursive: true, force: true }).catch(() => {});
1621				return null;
1622			}
1623
1624			ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch}) in ${cloneDir}`, "info");
1625		} else {
1626			// Local repo checkout
1627			ctx.ui.notify(`Checking out PR #${prRef.number}...`, "info");
1628			const checkoutResult = await checkoutPr(pi, prRef.number);
1629
1630			if (!checkoutResult.success) {
1631				ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
1632				return null;
1633			}
1634
1635			ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch})`, "info");
1636		}
1637
1638		return {
1639			type: "pullRequest",
1640			prNumber: prRef.number,
1641			baseBranch: prInfo.baseBranch,
1642			title: prInfo.title,
1643			repo: effectiveRepo,
1644			cloneDir,
1645		};
1646	}
1647
1648	// Register the /review command
1649	pi.registerCommand("review", {
1650		description: "Review code changes (PR, uncommitted, branch, commit, folder, or custom)",
1651		handler: async (args, ctx) => {
1652			if (!ctx.hasUI) {
1653				ctx.ui.notify("Review requires interactive mode", "error");
1654				return;
1655			}
1656
1657			// Check if we're already in a review
1658			if (reviewOriginId) {
1659				ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1660				return;
1661			}
1662
1663			// Check if we're in a git repository
1664			const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
1665			if (code !== 0) {
1666				ctx.ui.notify("Not a git repository", "error");
1667				return;
1668			}
1669
1670			// Try to parse direct arguments
1671			let target: ReviewTarget | null = null;
1672			let fromSelector = false;
1673			const parsed = parseArgs(args);
1674
1675			if (parsed) {
1676				if (parsed.type === "pr") {
1677					if (parsed.ref === "__select__") {
1678						// `/review pr` with no number — show PR selector
1679						target = await showPrInput(ctx);
1680					} else {
1681						// `/review pr 123` or `/review 123` — direct checkout
1682						target = await handlePrCheckout(ctx, parsed.ref, parsed.repo);
1683					}
1684					if (!target) {
1685						ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
1686					}
1687				} else {
1688					target = parsed;
1689				}
1690			}
1691
1692			// If no args or invalid args, show selector
1693			if (!target) {
1694				fromSelector = true;
1695			}
1696
1697			while (true) {
1698				if (!target && fromSelector) {
1699					target = await showReviewSelector(ctx);
1700				}
1701
1702				if (!target) {
1703					ctx.ui.notify("Review cancelled", "info");
1704					return;
1705				}
1706
1707				// Select review focus area
1708				const focusResult = await showFocusSelector(ctx);
1709
1710				if (!focusResult) {
1711					if (fromSelector) {
1712						target = null;
1713						continue;
1714					}
1715					ctx.ui.notify("Review cancelled", "info");
1716					return;
1717				}
1718
1719				// Determine if we should use fresh session mode
1720				// Check if this is a new session (no messages yet)
1721				const entries = ctx.sessionManager.getEntries();
1722				const messageCount = entries.filter((e) => e.type === "message").length;
1723
1724				let useFreshSession = false;
1725
1726				if (messageCount > 0) {
1727					// Existing session - ask user which mode they want
1728					const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]);
1729
1730					if (choice === undefined) {
1731						if (fromSelector) {
1732							target = null;
1733							continue;
1734						}
1735						ctx.ui.notify("Review cancelled", "info");
1736						return;
1737					}
1738
1739					useFreshSession = choice === "Empty branch";
1740				}
1741				// If messageCount === 0, useFreshSession stays false (current session mode)
1742
1743				await executeReview(ctx, target, useFreshSession, focusResult.focus, focusResult.agents);
1744				return;
1745			}
1746		},
1747	});
1748
1749	// Custom prompt for review summaries - focuses on capturing review findings
1750	const REVIEW_SUMMARY_PROMPT = `We are switching to a coding session to continue working on the code. 
1751Create a structured summary of this review branch for context when returning later.
1752	
1753You MUST summarize the code review that was performed in this branch so that the user can act on it.
1754
17551. What was reviewed (files, changes, scope)
17562. Key findings and their priority levels (P0-P3)
17573. The overall verdict (correct vs needs attention)
17584. Any action items or recommendations
1759
1760YOU MUST append a message with this EXACT format at the end of your summary:
1761
1762## Next Steps
17631. [What should happen next to act on the review]
1764
1765## Constraints & Preferences
1766- [Any constraints, preferences, or requirements mentioned]
1767- [Or "(none)" if none were mentioned]
1768
1769## Code Review Findings
1770
1771[P0] Short Title
1772
1773File: path/to/file.ext:line_number
1774
1775\`\`\`
1776affected code snippet
1777\`\`\`
1778
1779Preserve exact file paths, function names, and error messages.
1780`;
1781
1782	// Register the /end-review command
1783	pi.registerCommand("end-review", {
1784		description: "Complete review and return to original position",
1785		handler: async (args, ctx) => {
1786			if (!ctx.hasUI) {
1787				ctx.ui.notify("End-review requires interactive mode", "error");
1788				return;
1789			}
1790
1791			// Check if we're in a fresh session review
1792			if (!reviewOriginId) {
1793				const state = getReviewState(ctx);
1794				if (state?.active && state.originId) {
1795					reviewOriginId = state.originId;
1796					reviewCloneDir = state.cloneDir;
1797				} else if (state?.active) {
1798					await clearReviewState(ctx, pi);
1799					ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
1800					return;
1801				} else {
1802					ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
1803					return;
1804				}
1805			}
1806
1807			// Ask about summarization (Summarize is default/first option)
1808			const summaryChoice = await ctx.ui.select("Summarize review branch?", [
1809				"Summarize",
1810				"No summary",
1811			]);
1812
1813			if (summaryChoice === undefined) {
1814				// User cancelled - keep state so they can call /end-review again
1815				ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
1816				return;
1817			}
1818
1819			const wantsSummary = summaryChoice === "Summarize";
1820			const originId = reviewOriginId;
1821
1822			if (wantsSummary) {
1823				// Show spinner while summarizing
1824				const result = await ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
1825					const loader = new BorderedLoader(tui, theme, "Summarizing review branch...");
1826					loader.onAbort = () => done(null);
1827
1828					ctx.navigateTree(originId!, {
1829						summarize: true,
1830						customInstructions: REVIEW_SUMMARY_PROMPT,
1831						replaceInstructions: true,
1832					})
1833						.then(done)
1834						.catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
1835
1836					return loader;
1837				});
1838
1839				if (result === null) {
1840					// User aborted - keep state so they can try again
1841					ctx.ui.notify("Summarization cancelled. Use /end-review to try again.", "info");
1842					return;
1843				}
1844
1845				if (result.error) {
1846					// Real error - keep state so they can try again
1847					ctx.ui.notify(`Summarization failed: ${result.error}`, "error");
1848					return;
1849				}
1850
1851				// Clear state only on success
1852				await clearReviewState(ctx, pi);
1853
1854				if (result.cancelled) {
1855					ctx.ui.notify("Navigation cancelled", "info");
1856					return;
1857				}
1858
1859				// Pre-fill prompt if editor is empty
1860				if (!ctx.ui.getEditorText().trim()) {
1861					ctx.ui.setEditorText("Act on the code review");
1862				}
1863
1864				ctx.ui.notify("Review complete! Returned to original position.", "info");
1865			} else {
1866				// No summary - just navigate back
1867				try {
1868					const result = await ctx.navigateTree(originId!, { summarize: false });
1869
1870					if (result.cancelled) {
1871						// Keep state so they can try again
1872						ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
1873						return;
1874					}
1875
1876					// Clear state only on success
1877					await clearReviewState(ctx, pi);
1878					ctx.ui.notify("Review complete! Returned to original position.", "info");
1879				} catch (error) {
1880					// Keep state so they can try again
1881					ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error");
1882				}
1883			}
1884		},
1885	});
1886}