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"),
+ });
+ });
+});