import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { CommentStore, formatCommentsForAgent, pruneComments, userComments, type ReviewComment, type ReviewResult, } from "./comments"; import { applyAgentResponses, conversationReviewItems, pruneAgentResponses, type AgentResponse, type AgentResponseInput, } from "./conversations"; import { getHeadSha, listReviewItems, loadDiff, loadFileContent } from "./git"; import { loadReviewState, saveReviewState } from "./review-state"; import { dismissReviewFindings, reconcileReviewReport, reviewReportToPresentation, validateReviewFindingLocations, type ReviewFinding, type ReviewReport, type ReviewReportInput, } from "./review-report"; import { ReviewView } from "./review-view"; import { stopsToReviewComments, type WalkthroughStop } from "./walkthrough"; import { openWebReview } from "./web-review"; const walkthroughPrompt = (surface: ReviewSurface) => `Please give me a guided walkthrough of the current changes. Inspect the uncommitted changes (git diff HEAD) and the commits in the base..HEAD range (git log, git show), then call the \`review\` tool with \`surface: "${surface}"\`, a summary and an ordered list of walkthrough stops: the key changes, design decisions, and risky spots I should pay attention to. For each stop provide: - sha: short commit sha from git log (omit or empty for uncommitted changes) - file: path exactly as it appears in the diff header (repo-relative, e.g. src/foo.ts — not absolute) - line: line number on the NEW side of the file (the number after + in the @@ hunk header, counting added/context lines) - title: short headline - detail: the context I need to evaluate it - kind: optional tag like change / risk / note - severity: for risky stops, one of high / medium / low (drives the risk filter) - suggestion: optional replacement code for the anchored line(s) — only when you have a concrete fix to propose; shown read-only, I can ask you to apply it Only point stops at lines that actually appear in the diff (added or context lines). Prefer the first changed line of each conceptual edit.`; const reviewerPrompt = ( mode: "quick" | "deep", surface: ReviewSurface, previous: ReviewFinding[] = [], ) => { const prior = previous.length > 0 ? `\nPrior finding identities (reuse identity when the same issue remains; do not revive dismissed findings):\n${previous .slice(0, 50) .map( (finding) => `- ${finding.status} · ${finding.location.file} · ${finding.identity} — ${finding.title}`, ) .join("\n")}\n` : ""; return `Review the current changes as an independent code reviewer, then call the \`review\` tool with \`surface: "${surface}"\` and a structured \`report\`. Review mode: ${mode} Inspect uncommitted changes and commits in the base..HEAD range. ${ mode === "deep" ? "Explore relevant callers, tests, configuration, and invariants outside the diff. Run focused checks when useful." : "Focus on changed lines and their immediate context. Report only high-signal correctness, security, and regression risks." } Before reporting a finding: 1. Form a concrete candidate issue. 2. Try to disprove it using the current repository. 3. Keep it only when you can state the impact and specific evidence. For every finding provide a stable identity slug based on symbol/invariant/failure mode (not prose or line number), plus title, claim, impact, evidence, severity (critical/high/medium/low/info), category, confidence (0..1), and an exact diff location { sha?, file, line, side? }. Suggestions are optional. Do not report style preferences or speculative concerns as findings. Also report checks actually run and coverage: reviewedFiles plus skipped files with reasons.${prior} Call \`review\` exactly once after the investigation.`; }; const findingSchema = Type.Object({ identity: Type.String({ description: "Stable semantic slug based on symbol, invariant, and failure mode" }), title: Type.String(), claim: Type.String(), impact: Type.String(), evidence: Type.String(), severity: Type.Union([ Type.Literal("critical"), Type.Literal("high"), Type.Literal("medium"), Type.Literal("low"), Type.Literal("info"), ]), category: Type.String(), confidence: Type.Number({ minimum: 0, maximum: 1 }), location: Type.Object({ sha: Type.Optional(Type.Union([Type.String(), Type.Null()])), file: Type.String(), line: Type.Integer({ minimum: 1 }), side: Type.Optional(Type.Union([Type.Literal("new"), Type.Literal("old")])), }), suggestion: Type.Optional(Type.String()), }); const reportSchema = Type.Object({ mode: Type.Union([Type.Literal("quick"), Type.Literal("deep")]), summary: Type.String(), findings: Type.Array(findingSchema), checks: Type.Array( Type.Object({ command: Type.String(), status: Type.Union([Type.Literal("passed"), Type.Literal("failed"), Type.Literal("skipped")]), summary: Type.String(), }), ), coverage: Type.Object({ reviewedFiles: Type.Array(Type.String()), skipped: Type.Array(Type.Object({ file: Type.String(), reason: Type.String() })), }), }); const addressedCommentSchema = Type.Object({ commentId: Type.String({ description: "Stable conversation id returned by the previous review call" }), summary: Type.String({ description: "Concise account of how the agent handled this conversation" }), changedLocations: Type.Array( Type.Object({ file: Type.String({ description: "Repo-relative file changed while addressing the comment" }), line: Type.Optional(Type.Integer({ minimum: 1 })), before: Type.Optional(Type.String({ description: "Relevant code before the change" })), after: Type.Optional(Type.String({ description: "Relevant code after the change" })), }), ), checks: Type.Array( Type.Object({ command: Type.String(), status: Type.Union([Type.Literal("passed"), Type.Literal("failed"), Type.Literal("skipped")]), summary: Type.String(), }), ), }); /** Session-scoped: user comments survive reopen until the pi process exits (ADR-0001). * Seeded from / flushed to disk on first open / every close (ADR-0005). */ const commentStore = new CommentStore(); let storeSeeded = false; function textResult(text: string) { return { content: [{ type: "text" as const, text }], details: {} }; } type ReviewSurface = "tui" | "web"; interface OpenReviewOptions { stops?: WalkthroughStop[]; report?: ReviewReportInput; /** Evidence for user conversations handled since the previous review round. */ addressedComments?: AgentResponseInput[]; /** When true and the user left comments, inject them into the chat as a follow-up. */ sendCommentsAsFollowUp?: boolean; /** Review UI to use; default local browser. */ surface?: ReviewSurface; } /** * Open the review panel (TUI overlay or local web surface). Returns null when * there is nothing to review. The returned comments are user comments only — * agent walkthrough notes never leave the session (ADR-0002). */ async function openReview( pi: ExtensionAPI, ctx: ExtensionContext, opts: OpenReviewOptions = {}, ): Promise<{ comments: ReviewComment[] } | null> { // ADR-0005: on-disk state feeds the delta item and seeds persisted comments. const state = await loadReviewState(ctx.cwd); let items = await listReviewItems(ctx.cwd, { since: state.lastReviewedHead }); if (items.length === 0) { const hasPendingConversation = state.comments.some( (comment) => comment.author === "user" && comment.status !== "resolved", ); if (!hasPendingConversation && !opts.addressedComments?.length) return null; items = conversationReviewItems(state.comments); if (items.length === 0) { // Let applyAgentResponses below produce a precise unknown-id error. items = [{ sha: null, label: "conversation", subject: "Persisted review conversations" }]; } } if (!storeSeeded) { storeSeeded = true; commentStore.replace(pruneComments(state.comments, items)); } const applied = applyAgentResponses( commentStore.list(), pruneAgentResponses(state.agentResponses, commentStore.list()), opts.addressedComments ?? [], ); commentStore.replace(applied.comments); let agentResponses: AgentResponse[] = applied.responses; // The delta item (sha null + since) diffs the working tree against the // last-reviewed commit instead of HEAD. const deltaSince = items.find((i) => i.since)?.since; const loadDiffFor = (sha: string | null) => sha === null && deltaSince ? loadDiff(ctx.cwd, null, deltaSince) : loadDiff(ctx.cwd, sha); const loadFileFor = (sha: string | null, file: string, side: "new" | "old") => loadFileContent(ctx.cwd, sha, file, side, sha === null ? deltaSince : undefined); let report: ReviewReport | undefined; let presentation: | { comments: ReviewComment[]; summaries: WalkthroughStop[] } | undefined; if (opts.report) { const generatedAt = new Date().toISOString(); const submitted = reconcileReviewReport(opts.report, [], generatedAt); const locationErrors = await validateReviewFindingLocations(submitted, items, loadDiffFor); if (locationErrors.length > 0) { throw new Error(`Invalid review finding locations:\n- ${locationErrors.join("\n- ")}`); } report = reconcileReviewReport(opts.report, state.lastReport?.findings ?? [], generatedAt); presentation = reviewReportToPresentation(report, items); } else if (!opts.stops?.length && state.lastReport) { report = state.lastReport; presentation = reviewReportToPresentation(report, items); } let all: ReviewComment[]; let dismissedFindingIds: string[] = []; if (opts.surface === "web") { const split = presentation ?? stopsToReviewComments(opts.stops ?? [], items); const result = await openWebReview({ cwd: ctx.cwd, items, agentComments: split.comments, summaries: split.summaries, initialComments: commentStore.list(), agentResponses, loadDiffFor, loadFileFor, }); all = result.comments; agentResponses = result.agentResponses; dismissedFindingIds = result.dismissedFindingIds; } else { const initialDiff = await loadDiffFor(items[0]!.sha); const result = await ctx.ui.custom( (tui, theme, _keybindings, done) => new ReviewView({ items, initialDiff, loadDiffFor, theme, tui, done, termRows: tui.terminal.rows, stops: opts.stops, agentComments: presentation?.comments, summaries: presentation?.summaries, initialComments: commentStore.list(), onRequestWalkthrough: () => { // Walkthrough request closes the panel via done path; send prompt after. pi.sendUserMessage(walkthroughPrompt("tui"), { deliverAs: "followUp" }); }, }), { overlay: true, overlayOptions: { width: "95%", maxHeight: "90%", anchor: "center" }, }, ); all = result?.comments ?? []; dismissedFindingIds = result?.dismissedFindingIds ?? []; } if (report && dismissedFindingIds.length > 0) { report = dismissReviewFindings(report, dismissedFindingIds); } // ADR-0002: only user comments persist and may re-trigger the agent. const comments = userComments(all); commentStore.replace(comments); // ADR-0005: persist across sessions; record HEAD for the next delta item. await saveReviewState(ctx.cwd, { version: 3, comments: commentStore.list(), agentResponses: pruneAgentResponses(agentResponses, commentStore.list()), lastReviewedHead: (await getHeadSha(ctx.cwd)) ?? undefined, ...(report || state.lastReport ? { lastReport: report ?? state.lastReport } : {}), }); if (opts.sendCommentsAsFollowUp && comments.length > 0) { pi.sendUserMessage(formatCommentsForAgent(comments), { deliverAs: "followUp" }); } return { comments }; } function notify(ctx: ExtensionContext, message: string, level: "info" | "error") { if (ctx.hasUI) ctx.ui.notify(message, level); } export default function (pi: ExtensionAPI) { pi.registerCommand("code-eye-web", { description: "Run a deep AI review, then open it in the browser", handler: async (args, ctx) => { const requestedMode = args.trim() || "deep"; if (requestedMode !== "quick" && requestedMode !== "deep") { notify(ctx, "Usage: /code-eye-web [quick|deep]", "error"); return; } const state = await loadReviewState(ctx.cwd); pi.sendUserMessage(reviewerPrompt(requestedMode, "web", state.lastReport?.findings), { deliverAs: "followUp", }); }, }); pi.registerTool({ name: "review", label: "Code Review", description: "Submit an evidence-backed structured code review and open it in an interactive panel. " + "Prefer the report field (mode, findings, checks, coverage); legacy summary/stops remain supported. " + "Use this after making changes when the user should review your work. The panel shows commits in base..HEAD " + "and uncommitted changes; findings point the user at verified risks with impact and evidence. " + "The user can leave line comments on the diff. " + "The tool blocks until the user closes the panel and returns any comments they left — address each one. " + "When you handle a returned conversation, call review again with an addressedComments entry for its stable " + "commentId, summary, changed locations, and checks. This records your response and marks it agent-addressed; " + "only the user can resolve it. " + "Comments the user marked resolved are not returned. Replies to your findings or walkthrough notes come back " + "tagged as questions or suggestion-adoption requests. " + "Closing with no conversations waiting for agent work means no further agent action is needed.", parameters: Type.Object({ report: Type.Optional(reportSchema), addressedComments: Type.Optional( Type.Array(addressedCommentSchema, { description: "Evidence-backed responses to conversations returned by an earlier review round", }), ), summary: Type.Optional(Type.String({ description: "One-paragraph overview of what changed and why" })), stops: Type.Optional( Type.Array( Type.Object({ title: Type.String({ description: "Short headline for this stop" }), detail: Type.String({ description: "Why this needs attention; context the user needs" }), sha: Type.Optional( Type.String({ description: "Short commit sha this stop belongs to; omit for uncommitted changes", }), ), file: Type.Optional(Type.String({ description: "File path within that commit's diff" })), line: Type.Optional(Type.Number({ description: "Line number in the new version of the file" })), kind: Type.Optional(Type.String({ description: "Category tag, e.g. change, risk, note" })), severity: Type.Optional( Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")], { description: "Risk level for risky stops; shown as a colored chip and drives the risk filter", }), ), suggestion: Type.Optional( Type.String({ description: "Replacement code for the anchored line(s); shown read-only, the user can ask you to apply it", }), ), }), ), ), surface: Type.Optional( Type.Union([Type.Literal("tui"), Type.Literal("web")], { description: 'Review UI: "web" local browser (default) or pi-only "tui" overlay', }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const surface: ReviewSurface = params.surface ?? "web"; if (surface === "tui" && !ctx.hasUI) { return textResult("Review panel is not available in this mode; summarize the changes in chat instead."); } const stops: WalkthroughStop[] = []; if (params.summary) { stops.push({ title: "Overview", detail: params.summary, kind: "overview" }); } if (params.stops) stops.push(...params.stops); // Tool path: comments come back in the tool result (no extra follow-up message). const opened = await openReview(pi, ctx, { stops: stops.length > 0 ? stops : undefined, report: params.report as ReviewReportInput | undefined, addressedComments: params.addressedComments as AgentResponseInput[] | undefined, sendCommentsAsFollowUp: false, surface, }); if (!opened) { return textResult("Nothing to review: no changes or conversations are waiting for review."); } // ADR-0002: empty user-comment result is a short "no further action" message. return textResult(formatCommentsForAgent(opened.comments)); }, renderCall(args, theme) { const n = args.report?.findings.length ?? args.stops?.length ?? 0; const surface = args.surface === "tui" ? "tui · " : "web · "; return new Text( theme.fg("toolTitle", theme.bold("review ")) + theme.fg( "muted", surface + (args.report ? `${args.report.mode} · ${n} finding(s)` : n > 0 ? `${n} walkthrough stop(s)` : "plain"), ), 0, 0, ); }, renderResult(result, { isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "Waiting for user review…"), 0, 0); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; const hasComments = /left \d+ comment/.test(text); return new Text( hasComments ? theme.fg("warning", `✓ ${text.split("\n")[0]}`) : theme.fg("success", `✓ ${text}`), 0, 0, ); }, }); }