Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable user-visible changes to Hunk are documented in this file.
- Added Catppuccin Latte and Mocha as built-in themes.
- Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code.
- Added inline expansion for collapsed unchanged file content. Click an unchanged-context row (`▾ N unchanged lines` when expandable, otherwise the static `··· N unchanged lines ···` form) or press `e` while a hunk is selected to reveal surrounding and trailing file lines without leaving the review. The affordance is shown only for input modes that have reachable source content (`hunk diff`, `show`, `stash show`, file-pair `diff` and `difftool`, untracked files); raw `hunk patch` input still renders as before. Failed and in-flight loads surface a one-line status ("Loading…", "Could not load N unchanged lines") on the gap row. Expanded context rows use the same syntax highlighting as the surrounding diff.
- Surfaced the agent author name in inline notes and the matching agent popover so multi-agent reviews are readable at a glance, with a fallback title when an annotation has no author.

### Changed

Expand Down
18 changes: 14 additions & 4 deletions examples/3-agent-review-demo/agent-context.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
{
"newRange": [1, 3],
"summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.",
"rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places."
"rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places.",
"author": "sonnet"
}
]
},
Expand All @@ -20,7 +21,14 @@
{
"newRange": [15, 35],
"summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.",
"rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent."
"rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent.",
"author": "sonnet"
},
{
"newRange": [20, 27],
"summary": "Worth checking the score floor — could mask edge cases.",
"rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.",
"author": "prism"
}
]
},
Expand All @@ -31,7 +39,8 @@
{
"newRange": [1, 8],
"summary": "The preview now shows only the top three ranked commands.",
"rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI."
"rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI.",
"author": "prism"
}
]
},
Expand All @@ -42,7 +51,8 @@
{
"newRange": [1, 8],
"summary": "The test covers a dashed query form so the new normalization helper has a visible behavioral contract.",
"rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases."
"rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases.",
"author": "sonnet"
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions src/ui/components/panes/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function AgentCard({
summary,
theme,
width,
author,
}: {
locationLabel: string;
noteCount?: number;
Expand All @@ -21,6 +22,7 @@ export function AgentCard({
summary: string;
theme: AppTheme;
width: number;
author?: string;
}) {
const popover = buildAgentPopoverContent({
summary,
Expand All @@ -29,6 +31,7 @@ export function AgentCard({
noteIndex,
noteCount,
width,
author,
});
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));

Expand Down
4 changes: 2 additions & 2 deletions src/ui/components/panes/AgentInlineNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { TextareaRenderable } from "@opentui/core";
import { flushSync } from "@opentui/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types";
import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard";
import { wrapText } from "../../lib/agentPopover";
import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations";
import { wrapText } from "../../lib/agentPopover";
import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";

Expand Down
141 changes: 141 additions & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1634,6 +1634,147 @@ describe("UI components", () => {
expect(saveLineIndex).toBeGreaterThan(5);
});

test("AgentInlineNote shows author name in title when author is set", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "sonnet",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
100,
5,
);

const lines = frame.split("\n");
expect(lines[0]).toContain("sonnet");
expect(lines[0]).not.toContain("AI note");
});

test("AgentInlineNote falls back to 'Agent note' when author is absent", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
100,
5,
);

const lines = frame.split("\n");
expect(lines[0]).toContain("Agent note");
});

test("AgentInlineNote includes index when multiple notes share a hunk", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "sonnet",
}}
anchorSide="new"
layout="split"
noteCount={2}
noteIndex={0}
theme={theme}
width={96}
onClose={() => {}}
/>,
100,
5,
);

const lines = frame.split("\n");
expect(lines[0]).toContain("sonnet");
expect(lines[0]).toContain("1/2");
});

test("AgentInlineNote preserves special characters in author", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentInlineNote
annotation={{
newRange: [2, 4],
summary: "Summary line",
author: "prism (arbiter)",
}}
anchorSide="new"
layout="split"
theme={theme}
width={96}
onClose={() => {}}
/>,
100,
5,
);

const lines = frame.split("\n");
expect(lines[0]).toContain("prism (arbiter)");
});
Comment thread
sdougbrown marked this conversation as resolved.

test("AgentCard shows author in title when set", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentCard
locationLabel="alpha.ts +2"
rationale="Why alpha.ts changed"
summary="Annotation for alpha.ts"
author="sonnet"
theme={theme}
width={34}
onClose={() => {}}
/>,
40,
12,
);

const lines = frame
.split("\n")
.slice(0, 8)
.map((line) => line.trimEnd());
expect(lines[1]).toContain("sonnet");
expect(lines[1]).not.toContain("AI note");
});

test("AgentCard falls back to 'AI note' when author absent", async () => {
const theme = resolveTheme("midnight", null);
const frame = await captureFrame(
<AgentCard
locationLabel="alpha.ts +2"
rationale="Why alpha.ts changed"
summary="Annotation for alpha.ts"
theme={theme}
width={34}
onClose={() => {}}
/>,
40,
12,
);

const lines = frame
.split("\n")
.slice(0, 8)
.map((line) => line.trimEnd());
expect(lines[1]).toContain("AI note");
});

test("DiffPane renders all visible hunk notes across the review stream", async () => {
const bootstrap = createBootstrap();
bootstrap.changeset.files[1]!.agent = {
Expand Down
11 changes: 8 additions & 3 deletions src/ui/lib/agentPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ export function wrapText(text: string, width: number) {
return lines.length > 0 ? lines : [""];
}

/** Build the framed agent-popover title shown in the card header. */
function agentPopoverTitle(noteIndex: number, noteCount: number) {
/** Title shown above an agent note — author name if present, otherwise "AI note", with optional "i/n" suffix. */
export function formatAgentNoteTitle(noteIndex: number, noteCount: number, author?: string) {
if (author) {
return noteCount > 1 ? `${author} ${noteIndex + 1}/${noteCount}` : author;
}
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
}

Expand All @@ -62,13 +65,15 @@ export function buildAgentPopoverContent({
rationale,
summary,
width,
author,
}: {
locationLabel: string;
noteCount: number;
noteIndex: number;
rationale?: string;
summary: string;
width: number;
author?: string;
}) {
const innerWidth = Math.max(1, width - 4);
const summaryLines = wrapText(summary, innerWidth);
Expand All @@ -78,7 +83,7 @@ export function buildAgentPopoverContent({
1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1;

return {
title: agentPopoverTitle(noteIndex, noteCount),
title: formatAgentNoteTitle(noteIndex, noteCount, author),
summaryLines,
rationaleLines,
footer,
Expand Down
Loading