import type { Theme } from "@earendil-works/pi-coding-agent"; import { type KeybindingsManager, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { DiffLine, FileDiff } from "./diff-parser.ts"; import { hashFileDiff } from "./diff-parser.ts"; import { buildTreeRows, type TreeRow } from "./file-tree.ts"; import type { ReviewStore } from "./state.ts"; import { highlightBlock } from "./syntax.ts"; export interface ReviewMeta { /** Shown in the panel header, e.g. "!123 Fix login bug" or "feature/x → main". */ title: string; } export type ReviewPanelAction = { type: "close" } | { type: "comment"; path: string; line: number } | { type: "submit" }; /** Fixed (non-body) lines render() always emits: top rule, title, stats, two inner rules, hint, bottom rule. */ const CHROME_LINES = 7; /** * Total lines this panel wants to occupy, given the current terminal size. * The caller (index.ts) MUST pass this exact number as the overlay's * `maxHeight` — pi's overlay clips excess lines from the bottom silently * (no scroll, no indication), so if this component ever renders more lines * than the overlay was given, the closing border and footer hint just vanish * and the panel looks like it has no bottom edge at all. */ export function computePanelHeight(): number { // Full-screen modal: reserve the entire terminal viewport. Keeping this as // the single height source also guarantees the closing border is never // clipped by pi's overlay compositor. return Math.max(CHROME_LINES, process.stdout.rows ?? 24); } type Focus = "tree" | "diff"; type FlatLine = | { kind: "header"; text: string } | { kind: "blank" } | { kind: "line"; diffLine: DiffLine }; export type SyntaxHighlights = ReadonlyMap; function flatten(file: FileDiff): FlatLine[] { const flat: FlatLine[] = []; file.hunks.forEach((hunk, index) => { if (index > 0) flat.push({ kind: "blank" }); flat.push({ kind: "header", text: hunk.header }); for (const diffLine of hunk.lines) flat.push({ kind: "line", diffLine }); }); return flat; } /** * Precompute syntax tokens with Shiki before entering the synchronous TUI * render loop. Old/new streams are highlighted separately so multiline grammar * state remains correct across deleted and added blocks. */ export async function prepareSyntaxHighlights(files: readonly FileDiff[], theme: Theme): Promise { const entries = await Promise.all( files.map(async (file): Promise => { const flat = flatten(file); const oldSource: string[] = []; const newSource: string[] = []; for (const row of flat) { if (row.kind !== "line") continue; if (row.diffLine.type === "ctx" || row.diffLine.type === "del") oldSource.push(row.diffLine.text); if (row.diffLine.type === "ctx" || row.diffLine.type === "add") newSource.push(row.diffLine.text); } const [oldHighlighted, newHighlighted] = await Promise.all([ highlightBlock(oldSource.join("\n"), file.path, theme), highlightBlock(newSource.join("\n"), file.path, theme), ]); let oldIndex = 0; let newIndex = 0; const highlighted = flat.map((row): string => { if (row.kind !== "line") return ""; if (row.diffLine.type === "del") return oldHighlighted[oldIndex++] ?? row.diffLine.text; if (row.diffLine.type === "add") return newHighlighted[newIndex++] ?? row.diffLine.text; const value = newHighlighted[newIndex++] ?? oldHighlighted[oldIndex] ?? row.diffLine.text; oldIndex++; return value; }); return [file.path, highlighted] as const; }), ); return new Map(entries); } function padTo(text: string, width: number): string { const gap = width - visibleWidth(text); return gap > 0 ? text + " ".repeat(gap) : text; } function statusGlyph(theme: Theme, status: FileDiff["status"]): string { switch (status) { case "added": return theme.fg("success", "A"); case "deleted": return theme.fg("error", "D"); case "renamed": return theme.fg("warning", "R"); default: return theme.fg("dim", "M"); } } export class ReviewPanel { private selectedFile = 0; private treeScroll = 0; private cursor = 0; private diffScroll = 0; private focus: Focus = "tree"; private readonly flatCache = new Map(); private readonly renderCache = new Map(); private readonly treeRows: TreeRow[]; constructor( private readonly files: FileDiff[], private readonly meta: ReviewMeta, private readonly store: ReviewStore, private readonly mrKey: string, private readonly theme: Theme, private readonly keybindings: KeybindingsManager, private readonly onDone: (action: ReviewPanelAction) => void, private readonly onChange: () => void, private readonly syntaxHighlights: SyntaxHighlights = new Map(), ) { this.treeRows = buildTreeRows(files.map((f) => f.path)); } private currentFile(): FileDiff | undefined { return this.files[this.selectedFile]; } private flatLinesFor(file: FileDiff): FlatLine[] { let cached = this.flatCache.get(file.path); if (!cached) { cached = flatten(file); this.flatCache.set(file.path, cached); } return cached; } private renderedLinesFor(file: FileDiff): string[] { let cached = this.renderCache.get(file.path); if (!cached) { const flat = this.flatLinesFor(file); const highlighted = this.syntaxHighlights.get(file.path) ?? []; const maxLine = flat.reduce((max, row) => { if (row.kind !== "line") return max; return Math.max(max, row.diffLine.oldLine ?? 0, row.diffLine.newLine ?? 0); }, 0); const lineNumberWidth = Math.max(1, String(maxLine).length); cached = flat.map((row, index) => { if (row.kind === "header") return this.theme.fg("accent", row.text); if (row.kind === "blank") return ""; const line = row.diffLine; const number = line.newLine ?? line.oldLine ?? 0; const marker = line.type === "add" ? "+" : line.type === "del" ? "-" : " "; const gutterColor = line.type === "add" ? "toolDiffAdded" : line.type === "del" ? "toolDiffRemoved" : "toolDiffContext"; const gutter = this.theme.fg(gutterColor, `${String(number).padStart(lineNumberWidth)}${marker} `); return `${gutter}${highlighted[index] ?? line.text}`; }); this.renderCache.set(file.path, cached); } return cached; } private isViewed(file: FileDiff): boolean { return this.store.isViewed(this.mrKey, file.path, hashFileDiff(file)); } private setViewed(file: FileDiff, viewed: boolean): void { this.store.setViewed(this.mrKey, file.path, hashFileDiff(file), viewed); this.onChange(); } private selectFile(index: number): void { if (!this.files[index]) return; this.selectedFile = index; this.cursor = 0; this.diffScroll = 0; } private selectableTreeRows(): Array<{ row: number; fileIndex: number }> { return this.treeRows.flatMap((entry, row) => (entry.fileIndex === undefined ? [] : [{ row, fileIndex: entry.fileIndex }])); } /** Move by selectable files in the tree's visual order, not raw diff order. */ private moveTreeFile(delta: number): void { const selectable = this.selectableTreeRows(); if (selectable.length === 0) return; const current = Math.max(0, selectable.findIndex((entry) => entry.fileIndex === this.selectedFile)); const next = Math.max(0, Math.min(selectable.length - 1, current + delta)); this.selectFile(selectable[next]!.fileIndex); } /** Move roughly one/half viewport while landing on a selectable file row. */ private moveTreePage(deltaRows: number): void { const selectable = this.selectableTreeRows(); if (selectable.length === 0) return; const currentEntry = selectable.find((entry) => entry.fileIndex === this.selectedFile) ?? selectable[0]!; const targetRow = Math.max(0, Math.min(this.treeRows.length - 1, currentEntry.row + deltaRows)); const destination = deltaRows >= 0 ? (selectable.find((entry) => entry.row >= targetRow) ?? selectable.at(-1)!) : ([...selectable].reverse().find((entry) => entry.row <= targetRow) ?? selectable[0]!); this.selectFile(destination.fileIndex); } private jumpTreeBoundary(end: boolean): void { const selectable = this.selectableTreeRows(); const destination = end ? selectable.at(-1) : selectable[0]; if (destination) this.selectFile(destination.fileIndex); } private jumpToUnviewed(direction: 1 | -1): void { const selectable = this.selectableTreeRows(); if (selectable.length === 0) return; let position = Math.max(0, selectable.findIndex((entry) => entry.fileIndex === this.selectedFile)); for (let step = 0; step < selectable.length; step++) { position = (position + direction + selectable.length) % selectable.length; const fileIndex = selectable[position]!.fileIndex; if (!this.isViewed(this.files[fileIndex]!)) { this.selectFile(fileIndex); return; } } } private setCursor(index: number, visibleRows: number): void { const file = this.currentFile(); if (!file) return; const total = this.flatLinesFor(file).length; this.cursor = Math.max(0, Math.min(Math.max(0, total - 1), index)); if (this.cursor < this.diffScroll) this.diffScroll = this.cursor; else if (this.cursor >= this.diffScroll + visibleRows) this.diffScroll = this.cursor - visibleRows + 1; } private moveCursor(delta: number, visibleRows: number): void { this.setCursor(this.cursor + delta, visibleRows); } private jumpHunk(direction: 1 | -1, visibleRows: number): void { const file = this.currentFile(); if (!file) return; const flat = this.flatLinesFor(file); const headers = flat.flatMap((row, index) => (row.kind === "header" ? [index] : [])); const header = direction === 1 ? headers.find((index) => index > this.cursor) : headers.filter((index) => index < this.cursor).at(-1); if (header === undefined) return; const firstContent = flat.findIndex((row, index) => index > header && row.kind === "line"); this.setCursor(firstContent === -1 ? header : firstContent, visibleRows); } private jumpBoundary(end: boolean, visibleRows: number): void { const file = this.currentFile(); if (!file) return; const flat = this.flatLinesFor(file); if (end) { let lastLine = -1; for (let index = flat.length - 1; index >= 0; index--) { if (flat[index]?.kind === "line") { lastLine = index; break; } } this.setCursor(lastLine === -1 ? flat.length - 1 : lastLine, visibleRows); } else { const firstLine = flat.findIndex((row) => row.kind === "line"); this.setCursor(firstLine === -1 ? 0 : firstLine, visibleRows); } } /** * Diff hunk headers/blank separators aren't valid comment targets. When * entering the diff pane, make sure the cursor actually lands on a real * content line instead of silently sitting somewhere `c`/`Enter` can't act. */ private snapCursorToContent(): void { const file = this.currentFile(); if (!file) return; const flat = this.flatLinesFor(file); if (flat[this.cursor]?.kind === "line") return; const forward = flat.findIndex((f, i) => i >= this.cursor && f.kind === "line"); if (forward !== -1) { this.cursor = forward; return; } const anyLine = flat.findIndex((f) => f.kind === "line"); this.cursor = anyLine !== -1 ? anyLine : 0; } private enterDiffFocus(): void { this.focus = "diff"; this.snapCursorToContent(); } handleInput(data: string): void { if (matchesKey(data, "q") || matchesKey(data, "escape")) return this.onDone({ type: "close" }); if (matchesKey(data, "tab")) { if (this.focus === "tree") this.enterDiffFocus(); else this.focus = "tree"; return; } if (matchesKey(data, "space")) { const file = this.currentFile(); if (file) this.setViewed(file, !this.isViewed(file)); return; } if (matchesKey(data, "n")) return this.jumpToUnviewed(1); if (matchesKey(data, "shift+n")) return this.jumpToUnviewed(-1); if (matchesKey(data, "r")) return this.onDone({ type: "submit" }); const visibleRows = Math.max(1, computePanelHeight() - CHROME_LINES); if (this.focus === "tree") { if (matchesKey(data, "up") || matchesKey(data, "k")) return this.moveTreeFile(-1); if (matchesKey(data, "down") || matchesKey(data, "j")) return this.moveTreeFile(1); if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+b")) return this.moveTreePage(-(visibleRows - 1)); if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+f")) return this.moveTreePage(visibleRows - 1); if (matchesKey(data, "i") || matchesKey(data, "ctrl+u")) return this.moveTreePage(-Math.max(1, Math.floor(visibleRows / 2))); if (matchesKey(data, "d") || matchesKey(data, "ctrl+d")) return this.moveTreePage(Math.max(1, Math.floor(visibleRows / 2))); if (matchesKey(data, "shift+g") || matchesKey(data, "end")) return this.jumpTreeBoundary(true); if (matchesKey(data, "g") || matchesKey(data, "home")) return this.jumpTreeBoundary(false); if (matchesKey(data, "right") || matchesKey(data, "l")) { this.enterDiffFocus(); return; } if (matchesKey(data, "enter")) { const file = this.currentFile(); if (file) this.setViewed(file, !this.isViewed(file)); this.moveTreeFile(1); } return; } // focus === "diff" if (matchesKey(data, "left") || matchesKey(data, "h")) { this.focus = "tree"; return; } if (matchesKey(data, "up") || matchesKey(data, "k")) return this.moveCursor(-1, visibleRows); if (matchesKey(data, "down") || matchesKey(data, "j")) return this.moveCursor(1, visibleRows); if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+b")) return this.moveCursor(-(visibleRows - 1), visibleRows); if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+f")) return this.moveCursor(visibleRows - 1, visibleRows); if (matchesKey(data, "i") || matchesKey(data, "ctrl+u")) return this.moveCursor(-Math.max(1, Math.floor(visibleRows / 2)), visibleRows); if (matchesKey(data, "d") || matchesKey(data, "ctrl+d")) return this.moveCursor(Math.max(1, Math.floor(visibleRows / 2)), visibleRows); if (matchesKey(data, "g")) return this.jumpBoundary(false, visibleRows); if (matchesKey(data, "shift+g")) return this.jumpBoundary(true, visibleRows); if (matchesKey(data, "]")) return this.jumpHunk(1, visibleRows); if (matchesKey(data, "[")) return this.jumpHunk(-1, visibleRows); if (matchesKey(data, "enter") || matchesKey(data, "c")) { const file = this.currentFile(); if (!file) return; const flat = this.flatLinesFor(file)[this.cursor]; if (flat?.kind !== "line") return; const line = flat.diffLine.newLine ?? flat.diffLine.oldLine ?? 0; return this.onDone({ type: "comment", path: file.path, line }); } } invalidate(): void { this.renderCache.clear(); } render(width: number): string[] { const theme = this.theme; const totalAdd = this.files.reduce((sum, f) => sum + f.additions, 0); const totalDel = this.files.reduce((sum, f) => sum + f.deletions, 0); const viewedCount = this.files.filter((f) => this.isViewed(f)).length; const draftCount = this.store.listDrafts(this.mrKey).length; const title = theme.bold(this.meta.title); const stats = `${viewedCount}/${this.files.length} viewed ${theme.fg("success", `+${totalAdd}`)} ${theme.fg("error", `-${totalDel}`)}${draftCount ? ` ${theme.fg("warning", `${draftCount} draft${draftCount === 1 ? "" : "s"}`)}` : ""}`; const rows = Math.max(0, computePanelHeight() - CHROME_LINES); const frameWidth = Math.max(12, width); const innerWidth = frameWidth - 2; const paneSpace = Math.max(2, innerWidth - 3); // 3 cols: " │ " between panes const minRight = Math.min(12, Math.max(1, Math.floor(paneSpace * 0.45))); const leftWidth = Math.max(1, Math.min(44, Math.round(paneSpace * 0.32), paneSpace - minRight)); const rightWidth = Math.max(1, paneSpace - leftWidth); const treeLines = this.renderTree(leftWidth, rows); const diffLines = this.renderDiffPane(rightWidth, rows); // Heavy box-drawing glyphs + brighter semantic colors keep the modal // boundary legible even in low-contrast terminal themes. const edge = (text: string) => theme.fg("borderAccent", text); const divider = (text: string) => theme.fg(this.focus === "diff" ? "accent" : "borderAccent", text); const body: string[] = []; for (let i = 0; i < rows; i++) { const left = padTo(truncateToWidth(treeLines[i] ?? "", leftWidth, ""), leftWidth); const right = padTo(truncateToWidth(diffLines[i] ?? "", rightWidth, ""), rightWidth); body.push(`${edge("┃")}${left} ${divider("┃")} ${right}${edge("┃")}`); } const hint = this.focus === "tree" ? "j/k file · d/i ½ page · PgDn/PgUp page · g/G ends · n/N unviewed · → diff · q close" : "j/k line · d/i ½ page · PgDn/PgUp page · ]/[ hunk · g/G ends · ← files · q close"; const horizontalLeft = "━".repeat(leftWidth + 1); const horizontalRight = "━".repeat(rightWidth + 1); const top = `${edge("┏")}${edge("━".repeat(innerWidth))}${edge("┓")}`; const headerToBody = `${edge("┣")}${edge(horizontalLeft)}${divider("┳")}${edge(horizontalRight)}${edge("┫")}`; const bodyToFooter = `${edge("┣")}${edge(horizontalLeft)}${divider("┻")}${edge(horizontalRight)}${edge("┫")}`; const bottom = `${edge("┗")}${edge("━".repeat(innerWidth))}${edge("┛")}`; const framedLine = (content: string): string => { const inside = padTo(` ${truncateToWidth(content, Math.max(1, innerWidth - 2), "")}`, innerWidth); return `${edge("┃")}${inside}${edge("┃")}`; }; return [ top, framedLine(title), framedLine(stats), headerToBody, ...body, bodyToFooter, framedLine(theme.fg("dim", hint)), bottom, ]; } private renderTree(width: number, rows: number): string[] { const theme = this.theme; if (this.files.length === 0) return [theme.fg("muted", "No changed files.")]; const selectedRow = this.treeRows.findIndex((row) => row.fileIndex === this.selectedFile); const anchor = selectedRow >= 0 ? selectedRow : 0; if (anchor < this.treeScroll) this.treeScroll = anchor; else if (anchor >= this.treeScroll + rows) this.treeScroll = anchor - rows + 1; const lines: string[] = []; const end = Math.min(this.treeRows.length, this.treeScroll + rows); for (let i = this.treeScroll; i < end; i++) { const row = this.treeRows[i]!; const indent = " ".repeat(row.depth); if (row.isDir) { lines.push(truncateToWidth(`${indent}${theme.fg("dim", "▾ " + row.label)}`, width, "")); continue; } const file = this.files[row.fileIndex!]!; const selected = row.fileIndex === this.selectedFile; const marker = selected ? theme.fg("accent", "›") : " "; const viewedMark = this.isViewed(file) ? theme.fg("success", "✓") : theme.fg("dim", "○"); const glyph = statusGlyph(theme, file.status); const stats = `${theme.fg("success", `+${file.additions}`)} ${theme.fg("error", `-${file.deletions}`)}`; const prefix = `${marker} ${viewedMark} ${glyph} ${indent}`; const budget = Math.max(4, width - visibleWidth(prefix) - 8); const label = truncateToWidth(row.label, budget, "…"); const styledLabel = selected && this.focus === "tree" ? theme.bold(label) : label; lines.push(truncateToWidth(`${prefix}${styledLabel} ${stats}`, width, "")); } return lines; } private renderDiffPane(width: number, rows: number): string[] { const file = this.currentFile(); const theme = this.theme; if (!file) return [theme.fg("muted", "Select a file to view its diff.")]; if (file.binary) return [theme.fg("muted", "Binary file — no textual diff to show.")]; const flat = this.flatLinesFor(file); const rendered = this.renderedLinesFor(file); const drafts = new Set(this.store.listDrafts(this.mrKey).filter((d) => d.path === file.path).map((d) => d.line)); const lines: string[] = []; const end = Math.min(rendered.length, this.diffScroll + rows); for (let i = this.diffScroll; i < end; i++) { const isCursor = this.focus === "diff" && i === this.cursor; const flatLine = flat[i]; const draftLine = flatLine?.kind === "line" ? (flatLine.diffLine.newLine ?? flatLine.diffLine.oldLine) : undefined; const hasDraft = draftLine !== undefined && drafts.has(draftLine); const cursorGutter = (isCursor ? theme.fg("accent", "›") : " ") + (hasDraft ? theme.fg("warning", "●") : " "); const changeType = flatLine?.kind === "line" ? flatLine.diffLine.type : undefined; const stripe = changeType === "add" ? theme.fg("toolDiffAdded", "▌") : changeType === "del" ? theme.fg("toolDiffRemoved", "▌") : " "; const raw = `${cursorGutter}${stripe} ${rendered[i] ?? ""}`; const fitted = padTo(truncateToWidth(raw, width, ""), width); // Use Pi's own diff backgrounds, so this follows the active theme and // looks like the native Edit receipt: tinted full-row bands plus a // strong change stripe, while syntax foreground colors remain intact. if (changeType === "add") lines.push(theme.bg("toolSuccessBg", fitted)); else if (changeType === "del") lines.push(theme.bg("toolErrorBg", fitted)); else lines.push(fitted); } return lines; } }