import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Box } from "@earendil-works/pi-tui"; import { parseDiff } from "./diff-parser.ts"; import { fetchLocalDiff, fetchWorkingTreeDiff, GitError } from "./git.ts"; import { writeNotesFile } from "./notes.ts"; import { computePanelHeight, prepareSyntaxHighlights, ReviewPanel, type ReviewMeta, type ReviewPanelAction, } from "./panel.ts"; import { ReviewStore, localReviewKey } from "./state.ts"; export default function diffExtension(pi: ExtensionAPI): void { const store = new ReviewStore(); function persist(): void { pi.appendEntry("review-state", store.getSnapshot()); } pi.on("session_start", async (_event, ctx) => { store.restore(ctx.sessionManager.getBranch()); }); pi.on("session_tree", async (_event, ctx) => { store.restore(ctx.sessionManager.getBranch()); }); pi.registerCommand("diff", { description: "Browse a branch diff, or use /diff local for staged, unstaged, and untracked changes", handler: async (args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("The /diff panel requires interactive TUI mode.", "error"); return; } const argument = args.trim(); const workingTreeMode = argument === "local" || argument === "--local"; const base = workingTreeMode ? undefined : argument || undefined; ctx.ui.setWorkingMessage( workingTreeMode ? "Diffing local working tree against HEAD…" : base ? `Diffing against ${base}…` : "Diffing against auto-detected base branch…", ); ctx.ui.setWorkingVisible(true); let files: ReturnType; let meta: Awaited>["meta"]; let syntaxHighlights: Awaited>; try { const local = workingTreeMode ? await fetchWorkingTreeDiff() : await fetchLocalDiff(base); meta = local.meta; files = parseDiff(local.diffText); ctx.ui.setWorkingMessage("Highlighting diff with Shiki…"); syntaxHighlights = await prepareSyntaxHighlights(files, ctx.ui.theme); } catch (error) { const message = error instanceof GitError ? error.message : String(error); ctx.ui.notify(`Could not load ${workingTreeMode ? "local changes" : "branch diff"}: ${message}`, "error"); return; } finally { ctx.ui.setWorkingVisible(false); } if (files.length === 0) { ctx.ui.notify( meta.kind === "working-tree" ? "No staged, unstaged, or untracked changes in the working tree." : `No changes between ${meta.targetBranch} and ${meta.sourceBranch}.`, "info", ); return; } const key = localReviewKey(meta.repo, meta.targetBranch, meta.sourceBranch); const panelMeta: ReviewMeta = { title: meta.title }; while (true) { const action = await ctx.ui.custom( (tui, theme, keybindings, done) => { const panel = new ReviewPanel(files, panelMeta, store, key, theme, keybindings, done, () => { persist(); tui.requestRender(); }, syntaxHighlights); // A real modal surface, not just characters composited over whatever // happened to be underneath. Box re-applies the background through // nested ANSI resets (syntax colors, diff colors, etc.) and fills every // row to the overlay's declared width. const surface = new Box(0, 0, (text) => theme.bg("customMessageBg", text)); surface.addChild(panel); const onResize = () => tui.requestRender(); process.stdout.on("resize", onResize); return { render: (width: number) => surface.render(width), invalidate: () => surface.invalidate(), handleInput: (data: string) => { panel.handleInput(data); tui.requestRender(); }, dispose: () => process.stdout.off("resize", onResize), }; }, { overlay: true, // maxHeight must match exactly what ReviewPanel budgets for itself in // computePanelHeight() — pi's overlay silently clips extra lines from // the bottom rather than scrolling, so any mismatch here re-introduces // the "panel with no visible bottom edge" bug. overlayOptions: () => ({ width: "100%", maxHeight: computePanelHeight(), anchor: "top-left", }), }, ); if (!action || action.type === "close") break; if (action.type === "comment") { const body = await ctx.ui.input(`Comment on ${action.path}:${action.line}`, "What should the author know?"); if (body?.trim()) { store.addDraft(key, { path: action.path, line: action.line, body: body.trim() }); persist(); } continue; } if (action.type === "submit") { const drafts = store.listDrafts(key); if (drafts.length === 0) { ctx.ui.notify("No draft comments to write out.", "info"); continue; } const confirmed = await ctx.ui.confirm( "Write diff notes?", `Save ${drafts.length} comment${drafts.length === 1 ? "" : "s"} to .pi-diff/${meta.sourceBranch}.md?`, ); if (confirmed) { try { const path = await writeNotesFile(meta.repo, meta.sourceBranch, drafts); store.clearDrafts(key); persist(); ctx.ui.notify(`Wrote ${drafts.length} comment${drafts.length === 1 ? "" : "s"} to ${path}`, "info"); } catch (error) { ctx.ui.notify(`Failed to write notes: ${error instanceof Error ? error.message : String(error)}`, "error"); } } continue; } } }, }); }