diff --git a/src/core/errors.test.ts b/src/core/errors.test.ts new file mode 100644 index 00000000..b5bb0cfe --- /dev/null +++ b/src/core/errors.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { formatCliError, HunkUserError } from "./errors"; + +const originalDebug = process.env.HUNK_DEBUG; + +afterEach(() => { + if (originalDebug === undefined) { + delete process.env.HUNK_DEBUG; + } else { + process.env.HUNK_DEBUG = originalDebug; + } +}); + +describe("formatCliError", () => { + test("formats expected user errors with optional details and no stack", () => { + expect(formatCliError(new HunkUserError("Not in a repo"))).toBe("hunk: Not in a repo\n"); + expect(formatCliError(new HunkUserError("Invalid ref", ["Try `HEAD~1`."]))).toBe( + "hunk: Invalid ref\n\nTry `HEAD~1`.\n", + ); + }); + + test("hides unexpected stacks unless debug output is explicitly enabled", () => { + const error = new Error("Boom"); + error.stack = "Error: Boom\n at internal"; + + delete process.env.HUNK_DEBUG; + expect(formatCliError(error)).toBe("hunk: Boom\n"); + + process.env.HUNK_DEBUG = "1"; + expect(formatCliError(error)).toBe("Error: Boom\n at internal\n"); + }); + + test("stringifies non-error thrown values", () => { + expect(formatCliError("plain failure")).toBe("hunk: plain failure\n"); + }); +}); diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts new file mode 100644 index 00000000..bebd095d --- /dev/null +++ b/src/hunk-session/cli.test.ts @@ -0,0 +1,497 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + createTestListedSession, + createTestSelectedSessionContext, + createTestSessionFileSummary, + createTestSessionLiveComment, + createTestSessionReview, + createTestSessionReviewFile, + createTestSessionReviewHunk, + createTestSessionSnapshot, +} from "../../test/helpers/session-daemon-fixtures"; +import type { SessionSelectorInput } from "../core/types"; +import { + HUNK_SESSION_API_PATH, + HUNK_SESSION_API_VERSION, + HUNK_SESSION_DAEMON_VERSION, +} from "../session/protocol"; +import { + createHttpHunkSessionCliClient, + formatClearCommentsOutput, + formatCommentApplyOutput, + formatCommentListOutput, + formatCommentOutput, + formatContextOutput, + formatListOutput, + formatNavigationOutput, + formatReloadOutput, + formatRemoveCommentOutput, + formatReviewOutput, + formatSessionOutput, +} from "./cli"; + +const selector = { sessionId: "session-1" } satisfies SessionSelectorInput; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("HTTP Hunk session CLI client", () => { + test("maps CLI methods onto the daemon session API envelope", async () => { + const requests: unknown[] = []; + const session = createTestListedSession(); + const context = createTestSelectedSessionContext(); + const review = createTestSessionReview(); + const comment = { + commentId: "comment-1", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 0, + side: "new" as const, + line: 12, + }; + const responses = { + list: { sessions: [session] }, + get: { session }, + context: { context }, + review: { review }, + navigate: { + result: { + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 1, + }, + }, + reload: { + result: { + sessionId: "session-1", + inputKind: "vcs" as const, + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 1, + selectedFilePath: "src/app.ts", + selectedHunkIndex: 0, + }, + }, + "comment-add": { result: comment }, + "comment-apply": { result: { applied: [comment] } }, + "comment-list": { comments: [createTestSessionLiveComment()] }, + "comment-rm": { + result: { + commentId: "comment-1", + removed: true, + remainingCommentCount: 0, + }, + }, + "comment-clear": { + result: { + removedCount: 1, + remainingCommentCount: 0, + filePath: "src/app.ts", + }, + }, + }; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith(`${HUNK_SESSION_API_PATH}/capabilities`)) { + return Response.json({ + version: HUNK_SESSION_API_VERSION, + daemonVersion: HUNK_SESSION_DAEMON_VERSION, + actions: Object.keys(responses), + }); + } + + expect(url).toEndWith(HUNK_SESSION_API_PATH); + expect(init?.method).toBe("POST"); + const request = JSON.parse(String(init?.body)); + requests.push(request); + return Response.json(responses[request.action as keyof typeof responses]); + }) as typeof fetch; + + const client = createHttpHunkSessionCliClient(); + + expect(await client.getCapabilities()).toMatchObject({ version: HUNK_SESSION_API_VERSION }); + expect(await client.listSessions()).toEqual([session]); + expect(await client.getSession(selector)).toEqual(session); + expect(await client.getSelectedContext(selector)).toEqual(context); + expect( + await client.getSessionReview({ + kind: "session", + action: "review", + selector, + output: "json", + includePatch: true, + }), + ).toEqual(review); + expect( + await client.navigateToHunk({ + kind: "session", + action: "navigate", + selector, + filePath: "src/app.ts", + hunkNumber: 2, + side: "new", + line: 12, + commentDirection: "next", + output: "json", + }), + ).toEqual({ fileId: "file-1", filePath: "src/app.ts", hunkIndex: 1 }); + expect( + await client.reloadSession({ + kind: "session", + action: "reload", + selector, + nextInput: { kind: "vcs", staged: false, options: {} }, + sourcePath: "/repo", + output: "json", + }), + ).toMatchObject({ title: "repo working tree" }); + expect( + await client.addComment({ + kind: "session", + action: "comment-add", + selector, + filePath: "src/app.ts", + side: "new", + line: 12, + summary: "Check this", + rationale: "Preserve mapping", + author: "pi", + reveal: true, + output: "json", + }), + ).toEqual(comment); + expect( + await client.applyComments({ + kind: "session", + action: "comment-apply", + selector, + comments: [{ filePath: "src/app.ts", summary: "Check this" }], + revealMode: "first", + output: "json", + }), + ).toEqual({ applied: [comment] }); + expect( + await client.listComments({ + kind: "session", + action: "comment-list", + selector, + filePath: "src/app.ts", + output: "json", + }), + ).toEqual([createTestSessionLiveComment()]); + expect( + await client.removeComment({ + kind: "session", + action: "comment-rm", + selector, + commentId: "comment-1", + output: "json", + }), + ).toMatchObject({ removed: true }); + expect( + await client.clearComments({ + kind: "session", + action: "comment-clear", + selector, + filePath: "src/app.ts", + confirmed: true, + output: "json", + }), + ).toMatchObject({ removedCount: 1 }); + + expect(requests).toEqual([ + { action: "list" }, + { action: "get", selector }, + { action: "context", selector }, + { action: "review", selector, includePatch: true }, + { + action: "navigate", + selector, + filePath: "src/app.ts", + hunkNumber: 2, + side: "new", + line: 12, + commentDirection: "next", + }, + { + action: "reload", + selector, + nextInput: { kind: "vcs", staged: false, options: {} }, + sourcePath: "/repo", + }, + { + action: "comment-add", + selector, + filePath: "src/app.ts", + side: "new", + line: 12, + summary: "Check this", + rationale: "Preserve mapping", + author: "pi", + reveal: true, + }, + { + action: "comment-apply", + selector, + comments: [{ filePath: "src/app.ts", summary: "Check this" }], + revealMode: "first", + }, + { action: "comment-list", selector, filePath: "src/app.ts" }, + { action: "comment-rm", selector, commentId: "comment-1" }, + { action: "comment-clear", selector, filePath: "src/app.ts" }, + ]); + }); + + test("throws daemon response errors with JSON messages or status text fallbacks", async () => { + globalThis.fetch = (async () => + Response.json( + { error: "No matching session." }, + { status: 404, statusText: "Not Found" }, + )) as unknown as typeof fetch; + + const client = createHttpHunkSessionCliClient(); + await expect(client.listSessions()).rejects.toThrow("No matching session."); + + globalThis.fetch = (async () => + new Response("not json", { + status: 500, + statusText: "Daemon exploded", + })) as unknown as typeof fetch; + + await expect(client.listSessions()).rejects.toThrow("Daemon exploded"); + }); +}); + +describe("Hunk session CLI formatters", () => { + test("list and get output preserve terminal metadata and selected hunk summaries", () => { + const session = createTestListedSession({ + files: [createTestSessionFileSummary({ path: "src/app.ts", additions: 3, deletions: 1 })], + snapshot: createTestSessionSnapshot({ + selectedFilePath: "src/app.ts", + selectedHunkIndex: 2, + showAgentNotes: true, + liveCommentCount: 4, + }), + terminal: { + program: "ghostty", + locations: [ + { source: "tty", tty: "/dev/ttys005" }, + { source: "tmux", paneId: "%7", sessionId: "work" }, + { source: "iterm2", windowId: "1", tabId: "2", paneId: "3", terminalId: "abc" }, + { source: "unknown" }, + ], + }, + }); + + expect(formatListOutput([session])).toBe( + [ + "session-1 repo working tree", + " path: /repo", + " repo: /repo", + " terminal: ghostty", + " location[tty]: /dev/ttys005", + " location[tmux]: pane %7, session work", + " location[iterm2]: window 1, tab 2, pane 3, terminal abc", + " location[unknown]: present", + " focus: src/app.ts hunk 3", + " files: 1", + " comments: 4", + "", + ].join("\n"), + ); + + expect(formatSessionOutput(session)).toContain("Selected: src/app.ts hunk 3\n"); + expect(formatSessionOutput(session)).toContain("Agent notes visible: yes\n"); + expect(formatSessionOutput(session)).toContain("Live comments: 4\n"); + expect(formatSessionOutput(session)).toContain(" - src/app.ts (+3 -1, hunks: 1)"); + }); + + test("empty and unselected summaries stay explicit in human-readable output", () => { + const session = createTestListedSession({ + snapshot: createTestSessionSnapshot({ + selectedFileId: undefined, + selectedFilePath: undefined, + selectedHunkIndex: 0, + }), + }); + const context = createTestSelectedSessionContext({ + cwd: undefined, + repoRoot: undefined, + selectedFile: null, + selectedHunk: null, + showAgentNotes: true, + liveCommentCount: 2, + }); + + expect(formatListOutput([])).toBe("No active Hunk sessions.\n"); + expect(formatListOutput([session])).toContain(" focus: (none)\n"); + expect(formatContextOutput(context)).toBe( + [ + "Session: session-1", + "Title: repo diff", + "Path: -", + "Repo: -", + "File: (none)", + "Hunk: -", + "Old range: -", + "New range: -", + "Agent notes visible: yes", + "Live comments: 2", + "", + ].join("\n"), + ); + }); + + test("review output keeps file order, hunk headers, and no-selection fallbacks", () => { + const firstFile = createTestSessionReviewFile({ + id: "file-1", + path: "src/first.ts", + additions: 2, + deletions: 1, + hunkCount: 2, + hunks: [ + createTestSessionReviewHunk({ index: 0, header: "@@ -1,1 +1,2 @@" }), + createTestSessionReviewHunk({ index: 1, header: "@@ -10,1 +11,1 @@" }), + ], + }); + const secondFile = createTestSessionReviewFile({ + id: "file-2", + path: "src/second.ts", + additions: 0, + deletions: 1, + hunkCount: 1, + }); + + expect( + formatReviewOutput( + createTestSessionReview({ + files: [firstFile, secondFile], + selectedFile: null, + selectedHunk: null, + title: "repo diff", + inputKind: "diff", + liveCommentCount: 1, + }), + ), + ).toBe( + [ + "Session: session-1", + "Title: repo diff", + "Source: /repo", + "Path: -", + "Repo: /repo", + "Input: diff", + "Selected file: (none)", + "Selected hunk: -", + "Agent notes visible: no", + "Live comments: 1", + "Files:", + " - src/first.ts (+2 -1, hunks: 2)", + " hunk 1: @@ -1,1 +1,2 @@", + " hunk 2: @@ -10,1 +11,1 @@", + " - src/second.ts (+0 -1, hunks: 1)", + " hunk 1: @@ -1,1 +1,1 @@", + "", + ].join("\n"), + ); + }); + + test("command result formatters describe comment and navigation side effects", () => { + expect( + formatNavigationOutput(selector, { + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 1, + }), + ).toBe("Focused src/app.ts hunk 2 in session session-1.\n"); + + expect( + formatReloadOutput(selector, { + sessionId: "session-1", + inputKind: "vcs", + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 0, + selectedFilePath: undefined, + selectedHunkIndex: 0, + }), + ).toBe("Reloaded session session-1 with repo working tree (0 files). Selected: (no files).\n"); + + expect( + formatCommentOutput(selector, { + commentId: "comment-1", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 0, + side: "new", + line: 12, + }), + ).toBe( + "Added live comment comment-1 on src/app.ts:12 (new) in hunk 1 for session session-1.\n", + ); + + expect(formatCommentApplyOutput(selector, { applied: [] })).toBe( + "Applied 0 live comments to session session-1.\n", + ); + expect( + formatCommentApplyOutput(selector, { + applied: [ + { + commentId: "comment-2", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 2, + side: "old", + line: 8, + }, + ], + }), + ).toBe( + "Applied 1 live comments to session session-1:\n - comment-2 on src/app.ts:8 (old) hunk 3\n", + ); + + expect(formatCommentListOutput(selector, [])).toBe("No live comments for session session-1.\n"); + expect( + formatCommentListOutput(selector, [ + createTestSessionLiveComment({ + commentId: "comment-3", + filePath: "src/app.ts", + hunkIndex: 1, + side: "new", + line: 20, + summary: "Check this branch", + author: "pi", + }), + ]), + ).toBe( + "comment-3 src/app.ts:20 (new)\n hunk: 2\n summary: Check this branch\n author: pi\n", + ); + + expect( + formatRemoveCommentOutput(selector, { + commentId: "comment-3", + removed: true, + remainingCommentCount: 1, + }), + ).toBe("Removed live comment comment-3 from session session-1. Remaining comments: 1.\n"); + + expect( + formatClearCommentsOutput(selector, { + filePath: "src/app.ts", + removedCount: 2, + remainingCommentCount: 3, + }), + ).toBe( + "Cleared 2 live comments from src/app.ts in session session-1. Remaining comments: 3.\n", + ); + expect( + formatClearCommentsOutput(selector, { + removedCount: 5, + remainingCommentCount: 0, + }), + ).toBe("Cleared 5 live comments from session session-1. Remaining comments: 0.\n"); + }); +}); diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 61e52267..26b7294d 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1000,12 +1000,12 @@ describe("App interactions", () => { await flush(setup); const frame = setup.captureCharFrame(); - expect(frame).toContain("AI note · ▶ new 2"); + expect(frame).toContain("Agent · ▶ new 2"); expect(frame).toContain("Annotation for prefs.ts"); expect(frame).toContain("Why prefs.ts changed"); expect(frame).not.toContain("@@ -1,1 +1,2 @@"); expect(frame).not.toContain("1 - export const message"); - expect(frame.indexOf("AI note · ▶ new 2")).toBeLessThan( + expect(frame.indexOf("Agent · ▶ new 2")).toBeLessThan( frame.indexOf("export const added = true;"), ); } finally { diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index a14be446..0748743b 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -4,12 +4,23 @@ import { annotationRangeLabel } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; -function inlineNoteTitle(noteIndex: number, noteCount: number) { - return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; +/** Resolve the human-facing note author while keeping source as metadata, not styling. */ +function inlineNoteSourceLabel(annotation: AgentAnnotation) { + if (annotation.author?.trim()) { + return annotation.author.trim(); + } + + return annotation.source === "mcp" ? "User" : "Agent"; +} + +/** Build the compact title shown in the shared review-note chrome. */ +function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) { + const source = inlineNoteSourceLabel(annotation); + return noteCount > 1 ? `${source} ${noteIndex + 1}/${noteCount}` : source; } interface AgentInlineNoteLine { - kind: "summary" | "rationale"; + kind: "summary" | "rationale" | "blank"; text: string; } @@ -26,6 +37,20 @@ function splitColumnWidths(width: number) { return { leftWidth, rightWidth }; } +/** Build rounded top-border segments so the close affordance can stay mouse-clickable. */ +function buildInlineNoteTopBorderParts(boxWidth: number, titleText: string, closeText: string) { + const innerWidth = Math.max(0, boxWidth - 2); + const closeSegment = closeText ? ` ${closeText} ` : ""; + const titleWidth = Math.max(0, innerWidth - closeSegment.length); + const titleSegment = fitText(`─ ${titleText} `, titleWidth); + const paddedTitle = `${titleSegment}${"─".repeat(Math.max(0, titleWidth - titleSegment.length))}`; + return { + closeSegment, + prefix: `╭${paddedTitle}`, + suffix: "╮", + }; +} + export function measureAgentInlineNoteHeight({ annotation, anchorSide, @@ -48,18 +73,11 @@ export function measureAgentInlineNoteHeight({ const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); const innerWidth = Math.max(1, boxWidth - 2); const bodyWidth = innerWidth; - const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), - ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ - kind: "rationale" as const, - text, - })) - : []), - ]; + const summaryLines = wrapText(annotation.summary, bodyWidth); + const rationaleLines = annotation.rationale ? wrapText(annotation.rationale, bodyWidth) : []; - // top border + title row + body lines + bottom border - return 3 + lines.length; + // rounded title border + top padding + summary + optional separator/rationale + bottom padding + bottom border + return 4 + summaryLines.length + rationaleLines.length + (rationaleLines.length > 0 ? 1 : 0); } /** Render the note card itself before the start of an annotated range. */ @@ -83,7 +101,7 @@ export function AgentInlineNote({ width: number; }) { const closeText = onClose ? "[x]" : ""; - const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; + const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -99,24 +117,31 @@ export function AgentInlineNote({ ? 0 : Math.min(4, Math.max(0, width - boxWidth)); const innerWidth = Math.max(1, boxWidth - 2); - const titleWidth = Math.max(1, innerWidth - (closeText ? closeText.length + 1 : 0)); const bodyWidth = innerWidth; + const summaryLines = wrapText(annotation.summary, bodyWidth).map((text) => ({ + kind: "summary" as const, + text, + })); + const rationaleLines = annotation.rationale + ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ + kind: "rationale" as const, + text, + })) + : []; const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), - ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ - kind: "rationale" as const, - text, - })) - : []), + { kind: "blank", text: "" }, + ...summaryLines, + ...(rationaleLines.length > 0 ? [{ kind: "blank" as const, text: "" }] : []), + ...rationaleLines, + { kind: "blank", text: "" }, ]; - const topBorder = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`; + const topBorder = buildInlineNoteTopBorderParts(boxWidth, titleText, closeText); const bottomBorder = anchorSide === "new" && canDockRight - ? `└${"─".repeat(Math.max(0, boxWidth - 2))}┤` + ? `╰${"─".repeat(Math.max(0, boxWidth - 2))}┤` : anchorSide === "old" && canDockLeft - ? `├${"─".repeat(Math.max(0, boxWidth - 2))}┘` - : `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`; + ? `├${"─".repeat(Math.max(0, boxWidth - 2))}╯` + : `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; return ( @@ -124,38 +149,28 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - - - {topBorder} - - - - - - - {" ".repeat(boxLeft)} - - + - │ - - - - - {padText(fitText(titleText, titleWidth), titleWidth)} + {topBorder.prefix} - - {closeText ? ( - - {` ${closeText}`} - - ) : null} - + {topBorder.closeSegment ? ( + + + {topBorder.closeSegment} + + + ) : null} - │ + {topBorder.suffix} diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 5427c2bb..5e711efa 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1273,16 +1273,19 @@ describe("UI components", () => { onClose={() => {}} />, 100, - 5, + 7, ); const lines = frame.split("\n"); - expect(lines[0]?.trimStart().startsWith("┌")).toBe(true); - expect(lines[1]).toContain("AI note · ▶ new 2-4"); - expect(lines[1]).toContain("[x]"); + expect(lines[0]?.trimStart().startsWith("╭")).toBe(true); + expect(lines[0]).toContain("Agent · ▶ new 2-4"); + expect(lines[0]).toContain("[x]"); + expect(lines[1]).toContain("│"); + expect(lines[1]?.trim()).toMatch(/^│ +│$/); expect(lines[2]).toContain("Summary line"); - expect(lines[3]).toContain("Rationale line."); - expect(lines[4]?.trimStart().startsWith("└")).toBe(true); + expect(lines[4]).toContain("Rationale line."); + expect(lines[5]?.trim()).toMatch(/^│ +│$/); + expect(lines[6]?.trimStart().startsWith("╰")).toBe(true); }); test("DiffPane renders all visible hunk notes across the review stream", async () => { @@ -1327,13 +1330,13 @@ describe("UI components", () => { 28, ); - expect(frame).toContain("AI note · ▶ new 2"); + expect(frame).toContain("Agent · ▶ new 2"); expect(frame).toContain("Annotation for alpha.ts"); expect(frame).toContain("Why alpha.ts changed"); - expect(frame.indexOf("AI note · ▶ new 2")).toBeLessThan( + expect(frame.indexOf("Agent · ▶ new 2")).toBeLessThan( frame.indexOf("2 + export const add = true;"), ); - expect(frame).toContain("AI note · ▶ new 1"); + expect(frame).toContain("Agent · ▶ new 1"); expect(frame).toContain("Annotation for beta.ts"); expect(frame).toContain("Why beta.ts changed"); expect(frame).not.toContain("alpha.ts note"); @@ -1370,7 +1373,7 @@ describe("UI components", () => { ); const lines = frame.split("\n"); - const noteBottomIndex = lines.findIndex((line) => line.includes("└") && line.includes("┤")); + const noteBottomIndex = lines.findIndex((line) => line.includes("╰") && line.includes("┤")); expect(noteBottomIndex).toBeGreaterThanOrEqual(0); expect(lines[noteBottomIndex + 1]).toContain("export const add = true;"); expect(lines[noteBottomIndex + 1]?.trim()).not.toBe("│"); @@ -1427,8 +1430,8 @@ describe("UI components", () => { 24, ); - expect(frame).toContain("AI note 1/2"); - expect(frame).toContain("AI note 2/2"); + expect(frame).toContain("Agent 1/2"); + expect(frame).toContain("Agent 2/2"); expect(frame).toContain("First note"); expect(frame).toContain("First rationale."); expect(frame).toContain("Second note"); @@ -1939,11 +1942,11 @@ describe("UI components", () => { ); expect(frame).not.toContain("@@ -1,1 +1,2 @@"); - expect(frame).toContain("AI note · hunk"); + expect(frame).toContain("Agent · hunk"); expect(frame).toContain("Ungrounded note"); expect(frame).toContain("Falls back to the first visible"); expect(frame).toContain("row."); - expect(frame.indexOf("AI note · hunk")).toBeLessThan( + expect(frame.indexOf("Agent · hunk")).toBeLessThan( frame.indexOf("1 - export const value = 1;"), ); }); diff --git a/src/ui/diff/plannedReviewRows.test.ts b/src/ui/diff/plannedReviewRows.test.ts new file mode 100644 index 00000000..66205d04 --- /dev/null +++ b/src/ui/diff/plannedReviewRows.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { reviewRowId } from "../lib/ids"; +import type { PlannedReviewRow } from "./reviewRenderPlan"; +import { + measurePlannedSectionGeometry, + plannedReviewRowHeight, + plannedReviewRowVisible, +} from "./plannedReviewRows"; + +const baseOptions = { + showHunkHeaders: true, + layout: "split", + width: 100, +} as const; + +function hunkHeader(key: string, hunkIndex: number, anchorId?: string): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + anchorId, + row: { + type: "hunk-header", + key, + fileId: "file-1", + hunkIndex, + text: "@@ -1,1 +1,1 @@", + }, + }; +} + +function collapsedRow(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + row: { + type: "collapsed", + key, + fileId: "file-1", + hunkIndex, + text: "⋯", + }, + }; +} + +function splitLine(key: string, hunkIndex: number, anchorId?: string): PlannedReviewRow { + return { + kind: "diff-row", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + anchorId, + row: { + type: "split-line", + key, + fileId: "file-1", + hunkIndex, + left: { + kind: "deletion", + sign: "-", + lineNumber: 1, + spans: [{ text: "old" }], + }, + right: { + kind: "addition", + sign: "+", + lineNumber: 1, + spans: [{ text: "new" }], + }, + }, + }; +} + +function inlineNote(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "inline-note", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + annotationId: "note-1", + annotation: { + id: "note-1", + newRange: [1, 1], + summary: "Explain why this branch changed.", + rationale: "The note should reserve space in the hunk bounds.", + }, + anchorSide: "new", + noteCount: 1, + noteIndex: 0, + }; +} + +function guideCap(key: string, hunkIndex: number): PlannedReviewRow { + return { + kind: "note-guide-cap", + key, + stableKey: key, + fileId: "file-1", + hunkIndex, + side: "new", + }; +} + +describe("planned review row geometry", () => { + test("row height and visibility match the terminal rows each planned row renders", () => { + expect(plannedReviewRowHeight(hunkHeader("header", 0), baseOptions)).toBe(1); + expect( + plannedReviewRowHeight(hunkHeader("header", 0), { + ...baseOptions, + showHunkHeaders: false, + }), + ).toBe(0); + expect( + plannedReviewRowVisible(hunkHeader("header", 0), { + ...baseOptions, + showHunkHeaders: false, + }), + ).toBe(false); + expect(plannedReviewRowHeight(splitLine("line", 0), baseOptions)).toBe(1); + expect(plannedReviewRowHeight(guideCap("cap", 0), baseOptions)).toBe(1); + expect(plannedReviewRowHeight(inlineNote("note", 0), baseOptions)).toBeGreaterThan(3); + }); + + test("measured hunk bounds ignore collapsed gaps but include inline notes and guide caps", () => { + const rows = [ + hunkHeader("h0", 0, "hunk-0"), + splitLine("line-0", 0), + collapsedRow("gap", 0), + inlineNote("note", 0), + guideCap("cap", 0), + hunkHeader("h1", 1, "hunk-1"), + splitLine("line-1", 1), + ]; + + const measured = measurePlannedSectionGeometry(rows, baseOptions); + const noteHeight = plannedReviewRowHeight(rows[3]!, baseOptions); + + expect(measured.bodyHeight).toBe(6 + noteHeight); + expect(measured.hunkAnchorRows.get(0)).toBe(0); + expect(measured.hunkAnchorRows.get(1)).toBe(4 + noteHeight); + expect(measured.hunkBounds.get(0)).toEqual({ + top: 0, + height: 3 + noteHeight, + startRowId: reviewRowId("h0"), + endRowId: reviewRowId("cap"), + }); + expect(measured.hunkBounds.get(1)).toEqual({ + top: 4 + noteHeight, + height: 2, + startRowId: reviewRowId("h1"), + endRowId: reviewRowId("line-1"), + }); + }); + + test("hidden hunk headers can anchor navigation without widening visible hunk bounds", () => { + const rows = [hunkHeader("h0", 0, "hunk-0"), splitLine("line-0", 0)]; + + const measured = measurePlannedSectionGeometry(rows, { + ...baseOptions, + showHunkHeaders: false, + }); + + expect(measured.bodyHeight).toBe(1); + expect(measured.hunkAnchorRows.get(0)).toBe(0); + expect(measured.hunkBounds.get(0)).toEqual({ + top: 0, + height: 1, + startRowId: reviewRowId("line-0"), + endRowId: reviewRowId("line-0"), + }); + }); +});