/** * /diff — Elegant diff reviewer with vim keybindings and inline comments. * * Opens a full-screen diff viewer over `git diff`, lets you navigate with vim * keys, attach comments to specific lines, then batches all comments back to * the agent as a structured code-review message. * * Usage: * /diff review changes vs HEAD * /diff HEAD~1 review against an arbitrary git ref * /diff --staged review staged changes only * (any args after /diff are passed through to `git diff`) * * Keybindings (vim-style): * j / k or ↓ / ↑ move cursor down / up * Ctrl+d / Ctrl+u half-page down / up * g / G jump to top / bottom * n / N next / previous file * } / { next / previous hunk * ]c / [c next / previous commented line * v visual mode: select a line range, then c to comment it * c comment on the current line * d delete comment on the current line * Enter or :w send all comments to the agent * q or Esc quit without sending */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui"; import { execFile } from "node:child_process"; type RowKind = "file" | "hunk" | "add" | "del" | "context" | "meta"; interface Row { kind: RowKind; text: string; // raw line text (without trailing newline) file?: string; // file path this row belongs to /** Line reference shown to the agent, e.g. "src/x.ts:42 (+)" */ ref?: string; commentable: boolean; } interface ParsedDiff { rows: Row[]; } interface Comment { file: string; ref: string; lineText: string; comment: string; multiline?: boolean; /** Inclusive [lo, hi] row-index span this comment covers (for range comments). */ span?: [number, number]; } function runGitDiff(args: string[], cwd: string): Promise<{ out: string; err?: string }> { return new Promise((resolve) => { execFile( "git", // Force a plain unified diff: bypass any pager and external/textconv diff // drivers the user may have configured (e.g. difftastic, delta). ["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-color", ...args], { cwd, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { if (error && !stdout) { resolve({ out: "", err: (stderr || error.message).trim() }); return; } resolve({ out: stdout }); }, ); }); } /** * Parse unified diff text into renderable rows. Tracks new-file line numbers * for added/context lines and old-file line numbers for deleted lines so each * commentable row gets a precise reference. */ function parseDiff(diff: string): ParsedDiff { const rows: Row[] = []; let file = ""; let newLine = 0; let oldLine = 0; for (const raw of diff.split("\n")) { if (raw.startsWith("diff --git")) { // e.g. "diff --git a/path b/path" const m = raw.match(/ b\/(.+)$/); file = m ? m[1] : raw.slice("diff --git ".length); rows.push({ kind: "file", text: file, file, commentable: false }); continue; } if ( raw.startsWith("index ") || raw.startsWith("--- ") || raw.startsWith("+++ ") || raw.startsWith("new file") || raw.startsWith("deleted file") || raw.startsWith("similarity ") || raw.startsWith("rename ") || raw.startsWith("old mode") || raw.startsWith("new mode") ) { rows.push({ kind: "meta", text: raw, file, commentable: false }); continue; } if (raw.startsWith("@@")) { // @@ -oldStart,oldCount +newStart,newCount @@ optional section const m = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); oldLine = m ? parseInt(m[1], 10) : 0; newLine = m ? parseInt(m[2], 10) : 0; rows.push({ kind: "hunk", text: raw, file, commentable: false }); continue; } if (raw.startsWith("+")) { rows.push({ kind: "add", text: raw, file, ref: `${file}:${newLine} (+)`, commentable: true, }); newLine++; continue; } if (raw.startsWith("-")) { rows.push({ kind: "del", text: raw, file, ref: `${file}:${oldLine} (-)`, commentable: true, }); oldLine++; continue; } if (raw.startsWith("\\")) { // "\ No newline at end of file" rows.push({ kind: "meta", text: raw, file, commentable: false }); continue; } // context line (starts with a space, or blank) rows.push({ kind: "context", text: raw, file, ref: `${file}:${newLine}`, commentable: true, }); newLine++; oldLine++; } // Drop a trailing empty context row produced by the final newline split. if (rows.length > 0) { const last = rows[rows.length - 1]; if (last.kind === "context" && last.text === "") { rows.pop(); } } return { rows }; } // ---------------------------------------------------------------------------- // Rendering helpers // ---------------------------------------------------------------------------- type ColorFn = (s: string) => string; interface ViewerTheme { add: ColorFn; del: ColorFn; context: ColorFn; fileHeader: ColorFn; hunkHeader: ColorFn; meta: ColorFn; cursor: ColorFn; // applied to the cursor row commentMark: ColorFn; commentText: ColorFn; title: ColorFn; hint: ColorFn; count: ColorFn; } // ---------------------------------------------------------------------------- // DiffViewer component // ---------------------------------------------------------------------------- class DiffViewer implements Component { private cursor = 0; // index into rows private top = 0; // first visible row index private viewport = 20; // visible diff rows (recomputed on render) private commenting = false; // input mode private buffer = ""; // comment-in-progress text private cmdMode = false; // ":" command-line mode (for :w / :q) private cmdBuffer = ""; private visual = false; // visual line-select mode private anchor = 0; // visual selection anchor row index constructor( private rows: Row[], private comments: Map, private t: ViewerTheme, private label: string, private tui: TUI, private onSend: () => void, private onCancel: () => void, ) { this.cursor = this.rows.findIndex((r) => r.commentable); if (this.cursor < 0) this.cursor = 0; } invalidate(): void {} private clampScroll(): void { if (this.cursor < this.top) this.top = this.cursor; if (this.cursor >= this.top + this.viewport) { this.top = this.cursor - this.viewport + 1; } if (this.top < 0) this.top = 0; } private moveTo(idx: number): void { this.cursor = Math.max(0, Math.min(this.rows.length - 1, idx)); this.clampScroll(); } private nextFile(dir: 1 | -1): void { let i = this.cursor + dir; while (i >= 0 && i < this.rows.length && this.rows[i].kind !== "file") i += dir; if (i >= 0 && i < this.rows.length) this.moveTo(i); } private nextHunk(dir: 1 | -1): void { let i = this.cursor + dir; while (i >= 0 && i < this.rows.length && this.rows[i].kind !== "hunk") i += dir; if (i >= 0 && i < this.rows.length) this.moveTo(i); } /** Jump to the next/previous row that already has a comment attached. */ private nextComment(dir: 1 | -1): void { if (this.comments.size === 0) return; let i = this.cursor + dir; while (i >= 0 && i < this.rows.length && !this.comments.has(i)) i += dir; if (i >= 0 && i < this.rows.length) this.moveTo(i); } handleInput(data: string): void { // Comment input mode. if (this.commenting) { this.handleCommentInput(data); this.tui.requestRender(); return; } // Ex command mode (":w", ":q"). if (this.cmdMode) { this.handleCmdInput(data); this.tui.requestRender(); return; } if (matchesKey(data, Key.escape) || data === "q") { if (this.visual) { this.visual = false; this.tui.requestRender(); return; } this.onCancel(); return; } if (matchesKey(data, Key.enter)) { this.onSend(); return; } if (data === ":") { this.cmdMode = true; this.cmdBuffer = ""; this.tui.requestRender(); return; } // Navigation. if (data === "j" || matchesKey(data, Key.down)) this.moveTo(this.cursor + 1); else if (data === "k" || matchesKey(data, Key.up)) this.moveTo(this.cursor - 1); else if (matchesKey(data, Key.ctrl("d"))) this.moveTo(this.cursor + Math.floor(this.viewport / 2)); else if (matchesKey(data, Key.ctrl("u"))) this.moveTo(this.cursor - Math.floor(this.viewport / 2)); else if (data === "G") this.moveTo(this.rows.length - 1); else if (data === "g") this.moveTo(0); else if (data === "n") this.nextFile(1); else if (data === "N") this.nextFile(-1); else if (data === "}") this.nextHunk(1); else if (data === "{") this.nextHunk(-1); else if (data === "]c") this.nextComment(1); else if (data === "[c") this.nextComment(-1); else if (data === "v") this.toggleVisual(); else if (data === "c") this.startComment(); else if (data === "d") this.deleteComment(); else return; // ignore unhandled keys this.tui.requestRender(); } private toggleVisual(): void { if (this.visual) { this.visual = false; } else { this.visual = true; this.anchor = this.cursor; } } /** Inclusive [lo, hi] visual selection range in row indices. */ private selectionRange(): [number, number] { return this.anchor <= this.cursor ? [this.anchor, this.cursor] : [this.cursor, this.anchor]; } /** * Return the comment covering row index `i`, if any. A comment covers its * anchor index and, for range comments, every row within its span. */ private commentCovering(i: number): Comment | undefined { const direct = this.comments.get(i); if (direct) return direct; for (const c of this.comments.values()) { if (c.span && i >= c.span[0] && i <= c.span[1]) return c; } return undefined; } /** Return the anchor row index of the comment covering row `i`, if any. */ private commentAnchorAt(i: number): number | undefined { if (this.comments.has(i)) return i; for (const [idx, c] of this.comments.entries()) { if (c.span && i >= c.span[0] && i <= c.span[1]) return idx; } return undefined; } private startComment(): void { if (this.visual) { // Need at least one commentable row in the selection. const [lo, hi] = this.selectionRange(); let hasCommentable = false; for (let i = lo; i <= hi; i++) { if (this.rows[i]?.commentable) { hasCommentable = true; break; } } if (!hasCommentable) return; this.commenting = true; this.buffer = ""; return; } const row = this.rows[this.cursor]; if (!row?.commentable) return; this.commenting = true; this.buffer = this.comments.get(this.cursor)?.comment ?? ""; } private deleteComment(): void { const anchor = this.commentAnchorAt(this.cursor); if (anchor !== undefined) this.comments.delete(anchor); } private handleCommentInput(data: string): void { if (matchesKey(data, Key.escape)) { this.commenting = false; this.buffer = ""; return; } if (matchesKey(data, Key.enter)) { const text = this.buffer.trim(); if (this.visual) { this.saveRangeComment(text); } else { const row = this.rows[this.cursor]; if (row?.commentable && row.ref && text.length > 0) { this.comments.set(this.cursor, { file: row.file ?? "?", ref: row.ref, lineText: row.text, comment: text, }); } else if (text.length === 0) { this.comments.delete(this.cursor); } } this.commenting = false; this.buffer = ""; return; } if (matchesKey(data, Key.backspace)) { this.buffer = this.buffer.slice(0, -1); return; } // Printable input (handle pasted multi-char too). if (data.length >= 1 && data.charCodeAt(0) >= 32) { this.buffer += data; } } /** Attach a single comment to a visual line range. */ private saveRangeComment(text: string): void { const [lo, hi] = this.selectionRange(); const selected: Row[] = []; for (let i = lo; i <= hi; i++) { const r = this.rows[i]; if (r?.commentable) selected.push(r); } this.visual = false; if (selected.length === 0 || text.length === 0) return; const first = selected[0]; const file = first.file ?? "?"; // Build a concise ascending span ref from the selected line numbers. const nums = selected .map((r) => { const m = r.ref?.match(/:(\d+)/); return m ? parseInt(m[1], 10) : NaN; }) .filter((n) => !Number.isNaN(n)); const loNo = nums.length ? Math.min(...nums) : undefined; const hiNo = nums.length ? Math.max(...nums) : undefined; const ref = selected.length === 1 ? (first.ref ?? file) : loNo !== undefined && hiNo !== undefined && loNo !== hiNo ? `${file}:${loNo}-${hiNo}` : `${file}:${loNo ?? "?"}`; const code = selected.map((r) => r.text.replace(/^[+\- ]/, "")).join("\n"); // Anchor on the first selected row's index so it renders/sorts in place. const anchorIndex = lo + this.rows.slice(lo, hi + 1).findIndex((r) => r.commentable); this.comments.set(anchorIndex, { file, ref, lineText: code, comment: text, multiline: selected.length > 1, span: [anchorIndex, hi], }); } private handleCmdInput(data: string): void { if (matchesKey(data, Key.escape)) { this.cmdMode = false; this.cmdBuffer = ""; return; } if (matchesKey(data, Key.enter)) { const cmd = this.cmdBuffer.trim(); this.cmdMode = false; this.cmdBuffer = ""; if (cmd === "w" || cmd === "wq" || cmd === "x") this.onSend(); else if (cmd === "q" || cmd === "q!") this.onCancel(); return; } if (matchesKey(data, Key.backspace)) { this.cmdBuffer = this.cmdBuffer.slice(0, -1); if (this.cmdBuffer.length === 0) this.cmdMode = false; return; } if (data.length === 1 && data.charCodeAt(0) >= 32) this.cmdBuffer += data; } private styleRow(row: Row): string { switch (row.kind) { case "file": return this.t.fileHeader(`▸ ${row.text}`); case "hunk": return this.t.hunkHeader(row.text); case "meta": return this.t.meta(row.text); case "add": return this.t.add(row.text); case "del": return this.t.del(row.text); default: return this.t.context(row.text); } } render(width: number): string[] { const termRows = (process.stdout.rows && process.stdout.rows > 0 ? process.stdout.rows : 30); // Reserve: title(1) + blank(1) + footer(2) + input(1) = 5 lines of chrome. this.viewport = Math.max(6, Math.min(termRows - 7, 40)); this.clampScroll(); const lines: string[] = []; const total = this.rows.length; const commentCount = this.comments.size; // Title bar. const title = ` git diff ${this.label} `; const counter = `${this.cursor + 1}/${total} ${this.t.count(`${commentCount} comment${commentCount === 1 ? "" : "s"}`)}`; const titleLeft = this.t.title(title); const pad = Math.max(1, width - visibleWidth(titleLeft) - visibleWidth(counter) - 1); lines.push(truncateToWidth(titleLeft + " ".repeat(pad) + counter, width, "")); lines.push(""); // Diff body. const end = Math.min(this.top + this.viewport, total); const gutterW = String(total).length; const [selLo, selHi] = this.visual ? this.selectionRange() : [-1, -1]; for (let i = this.top; i < end; i++) { const row = this.rows[i]; const isCursor = i === this.cursor; const inSelection = this.visual && i >= selLo && i <= selHi; const hasComment = this.commentCovering(i) !== undefined; const mark = hasComment ? this.t.commentMark("❝") : " "; const caret = isCursor ? this.t.title("┃") : inSelection ? this.t.title("│") : " "; const body = this.styleRow(row); const prefix = `${caret}${mark} `; let lineText = prefix + body; lineText = truncateToWidth(lineText, width, ""); if (isCursor || inSelection) { const padded = lineText + " ".repeat(Math.max(0, width - visibleWidth(lineText))); lineText = this.t.cursor(truncateToWidth(padded, width, "")); } lines.push(lineText); // Render an inline comment beneath its line. A single-line comment is // anchored on its own row; a range comment is anchored on the first // selected row but should be drawn below the LAST selected row so it // reads as feedback on the whole block. const below = [...this.comments.values()].find((rc) => { const isRange = rc.span && rc.span[1] > rc.span[0]; const target = isRange ? (rc.span as [number, number])[1] : undefined; return isRange ? target === i : this.comments.get(i) === rc; }); if (below && !(this.commenting && isCursor)) { lines.push( truncateToWidth( ` ${this.t.commentMark("↳")} ${this.t.commentText(below.comment)}`, width, "", ), ); } } // Pad body to a stable height to reduce flicker. const bodyLines = lines.length - 2; for (let i = bodyLines; i < this.viewport; i++) lines.push(""); // Input / footer. if (this.commenting) { const label = this.visual ? "comment range › " : "comment › "; lines.push( truncateToWidth( this.t.commentMark(label) + this.buffer + this.t.title("█"), width, "", ), ); lines.push(this.t.hint(" enter save • esc cancel")); } else if (this.cmdMode) { lines.push(truncateToWidth(this.t.title(":" + this.cmdBuffer + "█"), width, "")); lines.push(this.t.hint(" :w send • :q quit • esc cancel")); } else if (this.visual) { const [lo, hi] = this.selectionRange(); lines.push(this.t.title(` VISUAL ${hi - lo + 1} lines selected`)); lines.push(this.t.hint(" j/k extend • c comment range • v/esc cancel")); } else { // Highlight the key tokens so the command help stands out from the // surrounding dim chrome. const key = (k: string) => this.t.title(k); const sep = this.t.hint(" • "); const line1 = [ `${key("j/k")}${this.t.hint(" move")}`, `${key("^d/^u")}${this.t.hint(" page")}`, `${key("g/G")}${this.t.hint(" top/bot")}`, `${key("n/N")}${this.t.hint(" file")}`, `${key("}/{")}${this.t.hint(" hunk")}`, `${key("]c/[c")}${this.t.hint(" comment")}`, `${key("v")}${this.t.hint(" visual")}`, ].join(sep); const line2 = [ `${key("c")}${this.t.hint(" comment")}`, `${key("d")}${this.t.hint(" delete")}`, `${key("enter/:w")}${this.t.hint(" send")}`, `${key("q/esc")}${this.t.hint(" quit")}`, ].join(sep); lines.push(truncateToWidth(" " + line1, width, "")); lines.push(truncateToWidth(" " + line2, width, "")); } return lines.map((l) => truncateToWidth(l, width, "")); } } export default function (pi: ExtensionAPI) { pi.registerCommand("diff", { description: "Review a git diff with vim keys and inline comments", handler: async (args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("/diff requires interactive (TUI) mode", "error"); return; } const gitArgs = args.trim().length > 0 ? args.trim().split(/\s+/) : ["HEAD"]; const { out, err } = await runGitDiff(gitArgs, ctx.cwd); if (err) { ctx.ui.notify(`git diff failed: ${err}`, "error"); return; } if (!out.trim()) { ctx.ui.notify(`No changes for: git diff ${gitArgs.join(" ")}`, "info"); return; } const { rows } = parseDiff(out); const comments = new Map(); // keyed by row index const result = await ctx.ui.custom<"send" | "cancel">((tui, theme, _kb, done) => { const t: ViewerTheme = { add: (s) => theme.fg("toolDiffAdded", s), del: (s) => theme.fg("toolDiffRemoved", s), context: (s) => theme.fg("toolDiffContext", s), fileHeader: (s) => theme.fg("accent", theme.bold(s)), hunkHeader: (s) => theme.fg("mdQuote", s), meta: (s) => theme.fg("dim", s), cursor: (s) => theme.bg("selectedBg", s), commentMark: (s) => theme.fg("warning", s), commentText: (s) => theme.fg("warning", s), title: (s) => theme.fg("accent", theme.bold(s)), hint: (s) => theme.fg("dim", s), count: (s) => theme.fg("success", s), }; const viewer = new DiffViewer( rows, comments, t, gitArgs.join(" "), tui, () => done("send"), () => done("cancel"), ); return viewer; }); if (result === "cancel") return; if (comments.size === 0) { ctx.ui.notify("No comments to send", "info"); return; } // Build a structured review message and send it as the user. const ordered = [...comments.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1]); const byFile = new Map(); for (const c of ordered) { const list = byFile.get(c.file) ?? []; list.push(c); byFile.set(c.file, list); } const parts: string[] = []; parts.push( `Code review feedback on \`git diff ${gitArgs.join(" ")}\` (${comments.size} comment${comments.size === 1 ? "" : "s"}). Please address each:`, ); for (const [file, list] of byFile) { parts.push(`\n### ${file}`); for (const c of list) { if (c.multiline) { parts.push(`- **${c.ref}**\n\`\`\`\n${c.lineText}\n\`\`\`\n ${c.comment}`); } else { const code = c.lineText.replace(/^[+\- ]/, ""); parts.push(`- **${c.ref}** \`${code.trim()}\`\n ${c.comment}`); } } } const message = parts.join("\n"); await pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" }); ctx.ui.notify(`Sent ${comments.size} review comment(s) to the agent`, "info"); }, }); }