import type { Theme } from "@earendil-works/pi-coding-agent"; import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI, } from "@earendil-works/pi-tui"; import { anchorFromDiffLine, commentsForSha, findCommentAt, findCommentLineIndex, removeCommentAt, setCommentStatus, upsertComment, userComments, type ReviewComment, type ReviewResult, type Severity, } from "./comments"; import type { CommitEntry } from "./git"; import { findItemIndex, findLineIndex, normalizePath } from "./locate"; import { isCommentable, parseUnifiedDiff, type DiffLine } from "./parse-unidiff"; import { stopsToReviewComments, type WalkthroughStop } from "./walkthrough"; type Focus = "commits" | "diff" | "comment"; export interface ReviewViewOptions { items: CommitEntry[]; initialDiff: string; loadDiffFor: (sha: string | null) => Promise; theme: Theme; tui: TUI; done: (result: ReviewResult) => void; termRows: number; /** Agent-provided walkthrough stops; anchored stops become read-only agent comments. */ stops?: WalkthroughStop[]; /** Structured reviewer presentation (v0.7); takes precedence over stops. */ agentComments?: ReviewComment[]; summaries?: WalkthroughStop[]; /** Called when the user presses w: ask the agent for a walkthrough. */ onRequestWalkthrough?: () => void; /** Seed comments (session store, user comments only); mutated list is returned via done(). */ initialComments?: ReviewComment[]; } /** Truncate or space-pad a (possibly ANSI-styled) line to exactly `width` columns. */ function fitToWidth(line: string, width: number): string { const vw = visibleWidth(line); if (vw > width) return truncateToWidth(line, width, ""); if (vw < width) return line + " ".repeat(width - vw); return line; } /** * Two-pane code review overlay: commit list on the left, rendered diff on * the right. * * ADR-0001: walkthrough stops are rendered as read-only agent comments * (◆) alongside editable user comments (●); per-stop navigation (n/p) * jumps between agent comments. File-less overview text stays as banner * chrome. * * Drawn as a rounded box (╭─╮ / │ │ / ╰─╯) so the floating overlay has a * visible frame against the chat behind it. */ export class ReviewView implements Component, Focusable { /** Set by TUI when this overlay owns focus; forwarded to the Input. */ focused = false; private focus: Focus = "commits"; private selected = 0; private cursor = 0; private diffLines: DiffLine[]; private loadToken = 0; private noteIndex = 0; /** Read-only agent walkthrough notes (ADR-0001). */ private agentNotes: ReviewComment[]; private readonly dismissedFindingIds = new Set(); /** File-less overview text shown as banner chrome. */ private readonly summaries: WalkthroughStop[]; /** User comments (the only editable/persisted kind). */ private comments: ReviewComment[]; private readonly input = new Input(); /** Status toast shown briefly in the footer (e.g. "comment saved"). */ private status: string | null = null; constructor(private readonly opts: ReviewViewOptions) { const split = stopsToReviewComments(opts.stops ?? [], opts.items); this.agentNotes = opts.agentComments ?? split.comments; this.summaries = opts.summaries ?? split.summaries; this.comments = userComments(opts.initialComments ?? []); this.diffLines = parseUnifiedDiff(opts.initialDiff); this.input.onSubmit = (value) => this.commitCommentInput(value); this.input.onEscape = () => this.cancelCommentInput(); if (this.agentNotes.length > 0) { void this.gotoNote(0); } } private get theme(): Theme { return this.opts.theme; } private border(ch: string): string { return this.theme.fg("border", ch); } private currentSha(): string | null { return this.opts.items[this.selected]!.sha; } private currentAnchor() { const line = this.diffLines[this.cursor]; if (!line) return null; return anchorFromDiffLine(line, this.currentSha()); } private setStatus(msg: string): void { this.status = msg; } private renderDiffLine(line: DiffLine, mark: string): string { const t = this.theme; switch (line.kind) { case "file": return t.fg("accent", t.bold(line.text)); case "hunk": return t.fg("muted", line.text); case "add": return ( mark + t.fg("toolDiffAdded", `+${String(line.newLine ?? "").padStart(4)} ${line.text}`) ); case "del": return ( mark + t.fg("toolDiffRemoved", `-${String(line.oldLine ?? "").padStart(4)} ${line.text}`) ); case "context": return ( mark + t.fg("toolDiffContext", ` ${String(line.newLine ?? "").padStart(4)} ${line.text}`) ); default: return t.fg("dim", line.text); } } private async select(index: number): Promise { this.selected = index; this.cursor = 0; const token = ++this.loadToken; const raw = await this.opts.loadDiffFor(this.opts.items[index]!.sha); if (token !== this.loadToken) return; this.diffLines = parseUnifiedDiff(raw); this.opts.tui.requestRender(); } // ---- agent notes (walkthrough) ---- private severityChip(sev: Severity): string { const t = this.theme; if (sev === "critical") return t.fg("error", " · ‼critical"); if (sev === "high") return t.fg("warning", " · ‼high"); if (sev === "medium") return t.fg("accent", " · medium"); return t.fg("dim", sev === "info" ? " · info" : " · low"); } /** Stale = note belongs to the current item but its anchor no longer * resolves against the current diff (code moved since the walkthrough). */ private noteIsStale(note: ReviewComment): boolean { const cur = this.currentSha(); const onCurrent = (note.sha === null) === (cur === null) && (cur === null || note.sha === cur); if (!onCurrent) return false; return findCommentLineIndex(this.diffLines, note) < 0; } private async gotoNote(i: number): Promise { if (this.agentNotes.length === 0) return; this.noteIndex = ((i % this.agentNotes.length) + this.agentNotes.length) % this.agentNotes.length; const note = this.agentNotes[this.noteIndex]!; const stop: WalkthroughStop = { sha: note.sha, file: note.file, line: note.line, title: note.title ?? "", detail: note.body, }; const target = findItemIndex(this.opts.items, stop, this.selected); if (target !== this.selected) { await this.select(target); } const exact = findCommentLineIndex(this.diffLines, note); this.cursor = exact >= 0 ? exact : findLineIndex(this.diffLines, stop); this.focus = "diff"; this.opts.tui.requestRender(); } private async dismissCurrentFinding(): Promise { const note = this.agentNotes[this.noteIndex]; if (!note?.fingerprint) { this.setStatus("only structured findings can be dismissed"); this.opts.tui.requestRender(); return; } this.dismissedFindingIds.add(note.id); this.agentNotes = this.agentNotes.filter((candidate) => candidate.id !== note.id); this.setStatus(`finding dismissed: ${note.title ?? note.body}`); if (this.agentNotes.length > 0) { await this.gotoNote(Math.min(this.noteIndex, this.agentNotes.length - 1)); } else { this.noteIndex = 0; this.opts.tui.requestRender(); } } // ---- comments ---- private beginCommentInput(): void { const anchor = this.currentAnchor(); if (!anchor) { this.setStatus("cursor is not on a commentable line"); this.opts.tui.requestRender(); return; } const existing = findCommentAt(this.comments, anchor, "user"); this.input.setValue(existing?.body ?? ""); this.input.focused = true; this.focus = "comment"; this.status = null; this.opts.tui.requestRender(); } private commitCommentInput(value: string): void { const anchor = this.currentAnchor(); if (!anchor) { this.cancelCommentInput(); return; } const before = this.comments.length; this.comments = upsertComment(this.comments, anchor, value); const after = this.comments.length; this.input.setValue(""); this.input.focused = false; this.focus = "diff"; if (!value.trim()) { this.setStatus(before > after ? "comment removed" : "cancelled"); } else if (after === before) { this.setStatus("comment updated"); } else { this.setStatus("comment added"); } this.opts.tui.requestRender(); } private cancelCommentInput(): void { this.input.setValue(""); this.input.focused = false; this.focus = "diff"; this.setStatus("cancelled"); this.opts.tui.requestRender(); } private deleteCommentAtCursor(): void { const anchor = this.currentAnchor(); if (!anchor) { this.setStatus("cursor is not on a commentable line"); this.opts.tui.requestRender(); return; } const existing = findCommentAt(this.comments, anchor, "user"); if (!existing) { const agentNote = findCommentAt(this.agentNotes, anchor, "agent"); this.setStatus(agentNote ? "agent notes are read-only" : "no comment on this line"); this.opts.tui.requestRender(); return; } this.comments = removeCommentAt(this.comments, anchor, "user"); this.setStatus("comment removed"); this.opts.tui.requestRender(); } /** r: toggle open/resolved on the user comment under the cursor (ADR-0005). * Resolved comments persist but are not fed back to the agent. */ private toggleResolvedAtCursor(): void { const anchor = this.currentAnchor(); if (!anchor) { this.setStatus("cursor is not on a commentable line"); this.opts.tui.requestRender(); return; } const existing = findCommentAt(this.comments, anchor, "user"); if (!existing) { this.setStatus("no comment on this line"); this.opts.tui.requestRender(); return; } const next = existing.status === "resolved" ? "open" : "resolved"; this.comments = setCommentStatus(this.comments, anchor, next); this.setStatus(next === "resolved" ? "comment resolved" : "comment reopened"); this.opts.tui.requestRender(); } /** Jump to next/prev user comment, wrapping; switches commit when needed. */ private async jumpComment(dir: 1 | -1): Promise { if (this.comments.length === 0) { this.setStatus("no comments yet"); this.opts.tui.requestRender(); return; } // Build ordered list: by item order, then file, then line. const ordered = this.orderedComments(); const anchor = this.currentAnchor(); let idx = -1; if (anchor) { idx = ordered.findIndex( (c) => c.file === anchor.file && c.side === anchor.side && c.line === anchor.line && (c.sha === null) === (anchor.sha === null) && (anchor.sha === null || c.sha === anchor.sha), ); } const next = ordered[((idx < 0 ? (dir === 1 ? -1 : 0) : idx) + dir + ordered.length) % ordered.length]!; await this.gotoComment(next); } private orderedComments(): ReviewComment[] { const itemOrder = new Map(); this.opts.items.forEach((it, i) => itemOrder.set(it.sha ?? "working", i)); return this.comments.slice().sort((a, b) => { const ai = itemOrder.get(a.sha ?? "working") ?? 999; const bi = itemOrder.get(b.sha ?? "working") ?? 999; if (ai !== bi) return ai - bi; if (a.file !== b.file) return a.file.localeCompare(b.file); if (a.side !== b.side) return a.side === "new" ? -1 : 1; return a.line - b.line; }); } private async gotoComment(c: ReviewComment): Promise { const target = this.opts.items.findIndex( (it) => (it.sha === null) === (c.sha === null) && (c.sha === null || it.sha === c.sha), ); if (target >= 0 && target !== this.selected) { await this.select(target); } const idx = findCommentLineIndex(this.diffLines, c); if (idx >= 0) this.cursor = idx; this.focus = "diff"; this.setStatus(`comment on ${c.file}:${c.line}`); this.opts.tui.requestRender(); } private close(): void { // Return everything; callers filter to user comments (ADR-0002). this.opts.done({ comments: [...this.comments, ...this.agentNotes], dismissedFindingIds: [...this.dismissedFindingIds], }); } // ---- layout ---- private bodyHeight(outerHeight: number, bannerRows: number, extraRows: number): number { // top border, subject, title sep, [banner], body, footer sep, help, [input], bottom const chrome = 2 + 1 + bannerRows + 1 + 1 + extraRows + 1; return Math.max(5, outerHeight - chrome); } private outerHeight(): number { return Math.max(12, Math.floor(this.opts.termRows * 0.85)); } /** Banner chrome: overview summaries plus the current agent note. */ private bannerRows(innerW: number): string[] { const t = this.theme; const rows: string[] = []; for (const summary of this.summaries.slice(0, 4)) { const detail = wrapTextWithAnsi(summary.detail, innerW - 2).slice( 0, summary.kind === "overview" ? 2 : 3, ); rows.push( fitToWidth( " " + t.fg("dim", t.bold(summary.title)) + t.fg("dim", detail[0] ? ` — ${detail[0]}` : ""), innerW, ), ); for (const line of detail.slice(1)) { rows.push(fitToWidth(" " + t.fg("dim", line), innerW)); } } if (this.agentNotes.length > 0) { const note = this.agentNotes[this.noteIndex]!; const tag = `[${this.noteIndex + 1}/${this.agentNotes.length}]`; const kind = note.kind ? t.fg("warning", ` · ${note.kind}`) : ""; const sev = note.severity ? this.severityChip(note.severity) : ""; const stale = this.noteIsStale(note) ? t.fg("dim", " · stale") : ""; const loc = t.fg("dim", ` · ${note.file}:${note.line}`); rows.push( fitToWidth( t.fg("accent", t.bold(` ${tag} `)) + t.bold(note.title ?? note.body) + kind + sev + stale + loc, innerW, ), ); const detailLines = wrapTextWithAnsi(note.body, innerW - 2).slice(0, 2); for (const dl of detailLines) { rows.push(fitToWidth(" " + t.fg("muted", dl), innerW)); } if (note.impact) { rows.push(fitToWidth(" " + t.fg("warning", `Impact: ${note.impact}`), innerW)); } if (note.evidence) { rows.push(fitToWidth(" " + t.fg("dim", `Evidence: ${note.evidence}`), innerW)); } } return rows.slice(0, Math.min(14, Math.max(3, this.outerHeight() - 10))); } handleInput(data: string): void { const tui = this.opts.tui; // Comment input mode: route keys to Input if (this.focus === "comment") { this.input.focused = true; this.input.handleInput(data); tui.requestRender(); return; } if (matchesKey(data, Key.escape)) { this.close(); return; } // Global comment shortcuts (work from either pane) if (matchesKey(data, "c")) { if (this.focus === "commits") this.focus = "diff"; this.beginCommentInput(); return; } if (matchesKey(data, "d")) { if (this.focus !== "diff") { // only delete when staring at a line this.focus = "diff"; } this.deleteCommentAtCursor(); return; } if (matchesKey(data, "r")) { if (this.focus !== "diff") { this.focus = "diff"; } this.toggleResolvedAtCursor(); return; } if (matchesKey(data, "]")) { void this.jumpComment(1); return; } if (matchesKey(data, "[")) { void this.jumpComment(-1); return; } if (this.agentNotes.length > 0) { if (matchesKey(data, "x")) { void this.dismissCurrentFinding(); return; } if (matchesKey(data, "n")) { void this.gotoNote(this.noteIndex + 1); return; } if (matchesKey(data, "p")) { void this.gotoNote(this.noteIndex - 1); return; } } else if (matchesKey(data, "w") && this.opts.onRequestWalkthrough) { // Close with current comments, then request walkthrough. this.opts.onRequestWalkthrough(); this.close(); return; } if (matchesKey(data, Key.tab)) { this.focus = this.focus === "commits" ? "diff" : "commits"; tui.requestRender(); return; } const outerH = this.outerHeight(); const bannerRows = this.summaries.length > 0 || this.agentNotes.length > 0 ? 3 : 0; // composer rows only apply while focus==='comment', which returns earlier const page = this.bodyHeight(outerH, bannerRows, 0); if (this.focus === "commits") { if (matchesKey(data, Key.up) && this.selected > 0) { void this.select(this.selected - 1); } else if (matchesKey(data, Key.down) && this.selected < this.opts.items.length - 1) { void this.select(this.selected + 1); } else if (matchesKey(data, Key.enter)) { this.focus = "diff"; } tui.requestRender(); return; } // diff pane if (matchesKey(data, Key.up) && this.cursor > 0) { this.cursor--; this.status = null; } else if (matchesKey(data, Key.down) && this.cursor < this.diffLines.length - 1) { this.cursor++; this.status = null; } else if (matchesKey(data, Key.pageUp)) { this.cursor = Math.max(0, this.cursor - page); } else if (matchesKey(data, Key.pageDown)) { this.cursor = Math.min(this.diffLines.length - 1, this.cursor + page); } else if (matchesKey(data, Key.left)) { this.focus = "commits"; } else if (matchesKey(data, Key.enter)) { // Enter on a commentable line → edit/add comment const line = this.diffLines[this.cursor]; if (line && isCommentable(line)) { this.beginCommentInput(); return; } } tui.requestRender(); } render(width: number): string[] { const t = this.theme; const items = this.opts.items; const B = (ch: string) => this.border(ch); const innerW = Math.max(20, width - 2); // Keep Input focus in sync (TUI sets ReviewView.focused) this.input.focused = this.focused && this.focus === "comment"; // Banner content const bannerInner = this.bannerRows(innerW); // Peek: show comment(s) under cursor (when not composing) const cursorAnchor = this.diffLines[this.cursor] ? anchorFromDiffLine(this.diffLines[this.cursor]!, this.currentSha()) : null; const cursorComment = cursorAnchor ? findCommentAt(this.comments, cursorAnchor, "user") : undefined; const cursorNote = cursorAnchor ? findCommentAt(this.agentNotes, cursorAnchor, "agent") : undefined; const peekRows: string[] = []; if (this.focus !== "comment") { if (cursorNote) { const head = cursorNote.title ? `${cursorNote.title} — ` : ""; const peeks = wrapTextWithAnsi(`🤖 ${head}${cursorNote.body}`, innerW - 2).slice(0, 2); for (const p of peeks) peekRows.push(fitToWidth(" " + t.fg("accent", p), innerW)); } if (cursorComment) { const label = cursorComment.status === "resolved" ? `[resolved] ${cursorComment.body}` : cursorComment.body; const peeks = wrapTextWithAnsi(`💬 ${label}`, innerW - 2).slice(0, 2); for (const p of peeks) peekRows.push(fitToWidth(" " + t.fg("warning", p), innerW)); } } const composing = this.focus === "comment"; const extraRows = (composing ? 2 : 0) + peekRows.length; const body = this.bodyHeight(this.outerHeight(), bannerInner.length, extraRows); const leftWidth = Math.max(22, Math.min(40, Math.floor(innerW * 0.28))); const rightWidth = Math.max(10, innerW - leftWidth - 3); const lines: string[] = []; // top border with title const nComments = this.comments.length; const nNotes = this.agentNotes.length; const titleText = ` Code Review · ${items.length} item(s)` + (nComments > 0 ? ` · ${nComments} comment${nComments === 1 ? "" : "s"}` : "") + (nNotes > 0 ? ` · ${nNotes} note${nNotes === 1 ? "" : "s"}` : "") + " "; const titleStyled = t.fg("accent", t.bold(titleText)); const titleW = visibleWidth(titleText); const leftPad = Math.max(0, Math.floor((innerW - titleW) / 2)); const rightPad = Math.max(0, innerW - titleW - leftPad); lines.push(B("╭") + B("─".repeat(leftPad)) + titleStyled + B("─".repeat(rightPad)) + B("╮")); const subject = truncateToWidth( ` ${items[this.selected]!.label} · ${items[this.selected]!.subject}`, innerW, "", ); lines.push(B("│") + fitToWidth(t.fg("dim", subject), innerW) + B("│")); lines.push(B("├") + B("─".repeat(innerW)) + B("┤")); if (bannerInner.length > 0) { for (const row of bannerInner) lines.push(B("│") + row + B("│")); lines.push(B("├") + B("─".repeat(innerW)) + B("┤")); } // two-pane body const leftStart = Math.min( Math.max(0, this.selected - Math.floor(body / 2)), Math.max(0, items.length - body), ); const leftLines: string[] = []; for (let r = 0; r < body; r++) { const i = leftStart + r; if (i >= items.length) { leftLines.push(" ".repeat(leftWidth)); continue; } const item = items[i]!; const isSelected = i === this.selected; const nOnItem = commentsForSha(this.comments, item.sha).length; const badge = nOnItem > 0 ? t.fg("warning", ` (${nOnItem})`) : ""; const marker = isSelected ? (this.focus === "commits" ? "▸ " : "• ") : " "; const text = `${marker}${item.label} ${item.subject}`; // reserve room for badge in width calc roughly const base = truncateToWidth(text, Math.max(4, leftWidth - (nOnItem > 0 ? 5 : 0)), ""); let styled: string; if (isSelected && this.focus === "commits") { styled = t.bg("selectedBg", t.fg("accent", t.bold(base))) + badge; } else if (isSelected) { styled = t.fg("accent", base) + badge; } else if (item.sha === null) { styled = t.fg("warning", base) + badge; } else { styled = base + badge; } leftLines.push(fitToWidth(styled, leftWidth)); } // Marker lookup: user comments ● (resolved ○), agent notes ◆ (paths // normalized so agent-provided paths with ./ or a/ prefixes still match). const shaUserComments = commentsForSha(this.comments, this.currentSha()); const userStatus: Map = new Map( shaUserComments.map((c) => [`${normalizePath(c.file)}\0${c.side}\0${c.line}`, c.status] as const), ); const shaNotes = commentsForSha(this.agentNotes, this.currentSha()); const noteKeys = new Set( shaNotes.map((c) => `${normalizePath(c.file)}\0${c.side}\0${c.line}`), ); const rightStart = Math.min( Math.max(0, this.cursor - Math.floor(body / 2)), Math.max(0, this.diffLines.length - body), ); const rightLines: string[] = []; for (let r = 0; r < body; r++) { const i = rightStart + r; if (i >= this.diffLines.length) { rightLines.push(" ".repeat(rightWidth)); continue; } const dl = this.diffLines[i]!; const anchor = anchorFromDiffLine(dl, this.currentSha()); let mark = " "; if (anchor) { const key = `${normalizePath(anchor.file)}\0${anchor.side}\0${anchor.line}`; const st = userStatus.get(key); if (st !== undefined) mark = st === "resolved" ? t.fg("dim", "○") : t.fg("warning", "●"); else if (noteKeys.has(key)) mark = t.fg("accent", "◆"); } let line = this.renderDiffLine(dl, mark); if (i === this.cursor && (this.focus === "diff" || this.focus === "comment")) { line = t.bg("selectedBg", fitToWidth(truncateToWidth(line, rightWidth, ""), rightWidth)); } else { line = fitToWidth(truncateToWidth(line, rightWidth, ""), rightWidth); } rightLines.push(line); } const paneSep = B("│"); for (let r = 0; r < body; r++) { lines.push(B("│") + leftLines[r]! + " " + paneSep + " " + rightLines[r]! + B("│")); } // comment/note peek under cursor if (peekRows.length > 0) { lines.push(B("├") + B("─".repeat(innerW)) + B("┤")); for (const p of peekRows) lines.push(B("│") + p + B("│")); } // composer if (composing) { const anchor = cursorAnchor; const loc = anchor ? `${anchor.file}:${anchor.line} (${anchor.side})` : "unknown"; lines.push(B("├") + B("─".repeat(innerW)) + B("┤")); const label = t.fg("accent", " comment ") + t.fg("dim", loc + " · enter save · esc cancel"); lines.push(B("│") + fitToWidth(label, innerW) + B("│")); const inputLines = this.input.render(Math.max(1, innerW - 2)); const inputRow = fitToWidth(" " + (inputLines[0] ?? ""), innerW); lines.push(B("│") + inputRow + B("│")); } // footer lines.push(B("├") + B("─".repeat(innerW)) + B("┤")); let help: string; if (this.status) { help = ` ${this.status}`; } else if (composing) { help = " type comment · enter save · esc cancel"; } else { const walkHint = this.agentNotes.length > 0 ? " · n/p note" : " · w walkthrough"; const cHint = " · c comment · d del · r resolve · [/] jump"; const findingHint = this.agentNotes.some((note) => note.fingerprint) ? " · x dismiss" : ""; help = this.focus === "commits" ? ` ↑↓ commit · enter/tab diff${cHint}${findingHint}${walkHint} · esc` : ` ↑↓ scroll · enter/c comment${cHint}${findingHint}${walkHint} · esc`; } lines.push(B("│") + fitToWidth(t.fg("dim", help), innerW) + B("│")); lines.push(B("╰") + B("─".repeat(innerW)) + B("╯")); return lines; } invalidate(): void { this.input.invalidate(); } }