Skip to content

Commit 6f91692

Browse files
authored
Render inline notes with side-aware range guides (#62)
* feat: render inline notes with side-aware range guides * style: format inline note range guide changes
1 parent 6990172 commit 6f91692

8 files changed

Lines changed: 626 additions & 265 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { AgentAnnotation, LayoutMode } from "../../../core/types";
2+
import { wrapText } from "../../lib/agentPopover";
3+
import { annotationRangeLabel } from "../../lib/agentAnnotations";
4+
import { fitText, padText } from "../../lib/text";
5+
import type { AppTheme } from "../../themes";
6+
7+
function inlineNoteTitle(noteIndex: number, noteCount: number) {
8+
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
9+
}
10+
11+
interface AgentInlineNoteLine {
12+
kind: "summary" | "rationale";
13+
text: string;
14+
}
15+
16+
function clamp(value: number, min: number, max: number) {
17+
return Math.min(Math.max(value, min), max);
18+
}
19+
20+
function splitColumnWidths(width: number) {
21+
const markerWidth = 1;
22+
const separatorWidth = 1;
23+
const usableWidth = Math.max(0, width - markerWidth - separatorWidth);
24+
const leftWidth = Math.max(0, markerWidth + Math.floor(usableWidth / 2));
25+
const rightWidth = Math.max(0, separatorWidth + usableWidth - Math.floor(usableWidth / 2));
26+
return { leftWidth, rightWidth };
27+
}
28+
29+
/** Render the note card itself before the start of an annotated range. */
30+
export function AgentInlineNote({
31+
annotation,
32+
anchorSide,
33+
layout,
34+
noteCount = 1,
35+
noteIndex = 0,
36+
onClose,
37+
theme,
38+
width,
39+
}: {
40+
annotation: AgentAnnotation;
41+
anchorSide?: "old" | "new";
42+
layout: Exclude<LayoutMode, "auto">;
43+
noteCount?: number;
44+
noteIndex?: number;
45+
onClose?: () => void;
46+
theme: AppTheme;
47+
width: number;
48+
}) {
49+
const closeText = onClose ? "[x]" : "";
50+
const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`;
51+
const splitWidths = splitColumnWidths(width);
52+
const canDockRight = layout === "split" && anchorSide === "new" && width >= 84;
53+
const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84;
54+
const preferredDockWidth = canDockRight
55+
? splitWidths.rightWidth
56+
: canDockLeft
57+
? splitWidths.leftWidth
58+
: Math.max(34, width - 4);
59+
const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4));
60+
const boxLeft = canDockRight
61+
? Math.max(0, width - boxWidth)
62+
: canDockLeft
63+
? 0
64+
: Math.min(4, Math.max(0, width - boxWidth));
65+
const innerWidth = Math.max(1, boxWidth - 2);
66+
const titleWidth = Math.max(1, innerWidth - (closeText ? closeText.length + 1 : 0));
67+
const bodyWidth = innerWidth;
68+
const lines: AgentInlineNoteLine[] = [
69+
...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })),
70+
...(annotation.rationale
71+
? wrapText(annotation.rationale, bodyWidth).map((text) => ({
72+
kind: "rationale" as const,
73+
text,
74+
}))
75+
: []),
76+
];
77+
const topBorder = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`;
78+
const bottomBorder =
79+
anchorSide === "new" && canDockRight
80+
? `└${"─".repeat(Math.max(0, boxWidth - 2))}┤`
81+
: anchorSide === "old" && canDockLeft
82+
? `├${"─".repeat(Math.max(0, boxWidth - 2))}┘`
83+
: `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`;
84+
85+
return (
86+
<box style={{ width: "100%", flexDirection: "column", backgroundColor: theme.panel }}>
87+
<box style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}>
88+
<box style={{ width: boxLeft, height: 1, backgroundColor: theme.panel }}>
89+
<text>{" ".repeat(boxLeft)}</text>
90+
</box>
91+
<box style={{ width: boxWidth, height: 1, backgroundColor: theme.panel }}>
92+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
93+
{topBorder}
94+
</text>
95+
</box>
96+
</box>
97+
98+
<box style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}>
99+
<box style={{ width: boxLeft, height: 1, backgroundColor: theme.panel }}>
100+
<text>{" ".repeat(boxLeft)}</text>
101+
</box>
102+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
103+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
104+
105+
</text>
106+
</box>
107+
<box style={{ width: titleWidth, height: 1, backgroundColor: theme.panel }}>
108+
<text fg={theme.noteTitleText} bg={theme.noteTitleBackground}>
109+
{padText(fitText(titleText, titleWidth), titleWidth)}
110+
</text>
111+
</box>
112+
{closeText ? (
113+
<box
114+
onMouseUp={onClose}
115+
style={{ width: closeText.length + 1, height: 1, backgroundColor: theme.panel }}
116+
>
117+
<text fg={theme.noteTitleText} bg={theme.noteTitleBackground}>{` ${closeText}`}</text>
118+
</box>
119+
) : null}
120+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
121+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
122+
123+
</text>
124+
</box>
125+
</box>
126+
127+
{lines.map((line, index) => (
128+
<box
129+
key={`${line.kind}:${index}`}
130+
style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}
131+
>
132+
<box style={{ width: boxLeft, height: 1, backgroundColor: theme.panel }}>
133+
<text>{" ".repeat(boxLeft)}</text>
134+
</box>
135+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
136+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
137+
138+
</text>
139+
</box>
140+
<box style={{ width: bodyWidth, height: 1, backgroundColor: theme.panel }}>
141+
<text fg={line.kind === "summary" ? theme.text : theme.muted} bg={theme.noteBackground}>
142+
{padText(line.text, bodyWidth)}
143+
</text>
144+
</box>
145+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
146+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
147+
148+
</text>
149+
</box>
150+
</box>
151+
))}
152+
153+
<box style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}>
154+
<box style={{ width: boxLeft, height: 1, backgroundColor: theme.panel }}>
155+
<text>{" ".repeat(boxLeft)}</text>
156+
</box>
157+
<box style={{ width: boxWidth, height: 1, backgroundColor: theme.panel }}>
158+
<text fg={theme.noteBorder} bg={theme.noteBackground}>
159+
{bottomBorder}
160+
</text>
161+
</box>
162+
</box>
163+
164+
{(anchorSide === "new" || anchorSide === "old") && layout === "split" ? (
165+
<box
166+
style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}
167+
>
168+
{anchorSide === "old" ? (
169+
<>
170+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
171+
<text fg={theme.noteBorder}></text>
172+
</box>
173+
<box
174+
style={{ width: Math.max(0, width - 1), height: 1, backgroundColor: theme.panel }}
175+
>
176+
<text>{" ".repeat(Math.max(0, width - 1))}</text>
177+
</box>
178+
</>
179+
) : (
180+
<>
181+
<box
182+
style={{ width: Math.max(0, width - 1), height: 1, backgroundColor: theme.panel }}
183+
>
184+
<text>{" ".repeat(Math.max(0, width - 1))}</text>
185+
</box>
186+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
187+
<text fg={theme.noteBorder}></text>
188+
</box>
189+
</>
190+
)}
191+
</box>
192+
) : null}
193+
</box>
194+
);
195+
}
196+
197+
/** Render the small cap shown after the last diff row in a note's range. */
198+
export function AgentInlineNoteGuideCap({
199+
side,
200+
theme,
201+
width,
202+
}: {
203+
side: "old" | "new";
204+
theme: AppTheme;
205+
width: number;
206+
}) {
207+
return (
208+
<box style={{ width: "100%", height: 1, flexDirection: "row", backgroundColor: theme.panel }}>
209+
{side === "old" ? (
210+
<>
211+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
212+
<text fg={theme.noteBorder}></text>
213+
</box>
214+
<box style={{ width: Math.max(0, width - 1), height: 1, backgroundColor: theme.panel }}>
215+
<text>{" ".repeat(Math.max(0, width - 1))}</text>
216+
</box>
217+
</>
218+
) : (
219+
<>
220+
<box style={{ width: Math.max(0, width - 1), height: 1, backgroundColor: theme.panel }}>
221+
<text>{" ".repeat(Math.max(0, width - 1))}</text>
222+
</box>
223+
<box style={{ width: 1, height: 1, backgroundColor: theme.panel }}>
224+
<text fg={theme.noteBorder}></text>
225+
</box>
226+
</>
227+
)}
228+
</box>
229+
);
230+
}

src/ui/components/panes/DiffPane.tsx

Lines changed: 4 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type { ScrollBoxRenderable } from "@opentui/core";
22
import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type RefObject } from "react";
33
import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types";
4-
import { AgentCard } from "./AgentCard";
5-
import { annotationLocationLabel, type VisibleAgentNote } from "../../lib/agentAnnotations";
6-
import { buildAgentPopoverContent, resolveAgentPopoverPlacement } from "../../lib/agentPopover";
4+
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
75
import { estimateDiffBodyRows, estimateHunkAnchorRow } from "../../lib/sectionHeights";
86
import { diffHunkId, diffSectionId } from "../../lib/ids";
97
import type { AppTheme } from "../../themes";
@@ -12,26 +10,6 @@ import { DiffSectionPlaceholder } from "./DiffSectionPlaceholder";
1210

1311
const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
1412

15-
function maxLineNumber(file: DiffFile) {
16-
return Math.max(file.metadata.additionLines.length, file.metadata.deletionLines.length, 0);
17-
}
18-
19-
function noteAnchorColumn(
20-
file: DiffFile,
21-
layout: Exclude<LayoutMode, "auto">,
22-
width: number,
23-
showLineNumbers: boolean,
24-
note: VisibleAgentNote,
25-
) {
26-
if (layout === "split") {
27-
return note.annotation.oldRange && !note.annotation.newRange
28-
? 1
29-
: Math.max(2, Math.floor(width * 0.58));
30-
}
31-
32-
return showLineNumbers ? Math.max(2, String(maxLineNumber(file)).length + 4) : 2;
33-
}
34-
3513
/** Render the main multi-file review stream. */
3614
export function DiffPane({
3715
activeAnnotations,
@@ -168,85 +146,6 @@ export function DiffPane({
168146
() => files.map((file) => estimateDiffBodyRows(file, layout, showHunkHeaders)),
169147
[files, layout, showHunkHeaders],
170148
);
171-
const selectedOverlayNote = useMemo(() => {
172-
if (!selectedFileId) {
173-
return null;
174-
}
175-
176-
const selectedFileIndex = files.findIndex((file) => file.id === selectedFileId);
177-
if (selectedFileIndex < 0) {
178-
return null;
179-
}
180-
181-
const selectedFile = files[selectedFileIndex]!;
182-
const visibleNotes = visibleAgentNotesByFile.get(selectedFileId) ?? EMPTY_VISIBLE_AGENT_NOTES;
183-
const note = visibleNotes[0];
184-
if (!note) {
185-
return null;
186-
}
187-
188-
let sectionTop = 0;
189-
for (let index = 0; index < selectedFileIndex; index += 1) {
190-
sectionTop += (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0);
191-
}
192-
193-
sectionTop += (selectedFileIndex > 0 ? 1 : 0) + 1;
194-
const anchorRowTop =
195-
sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
196-
const anchorColumn = noteAnchorColumn(
197-
selectedFile,
198-
layout,
199-
diffContentWidth,
200-
showLineNumbers,
201-
note,
202-
);
203-
const noteWidth = Math.min(
204-
Math.max(34, Math.floor(diffContentWidth * 0.42)),
205-
Math.max(12, diffContentWidth - 2),
206-
);
207-
const locationLabel = annotationLocationLabel(selectedFile, note.annotation);
208-
const popover = buildAgentPopoverContent({
209-
summary: note.annotation.summary,
210-
rationale: note.annotation.rationale,
211-
locationLabel,
212-
noteIndex: 0,
213-
noteCount: visibleNotes.length,
214-
width: noteWidth,
215-
});
216-
217-
const contentHeight = files.reduce(
218-
(total, file, index) => total + (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0),
219-
0,
220-
);
221-
const placement = resolveAgentPopoverPlacement({
222-
anchorColumn,
223-
anchorRowTop,
224-
anchorRowHeight: 1,
225-
contentHeight,
226-
noteHeight: popover.height,
227-
noteWidth,
228-
viewportWidth: diffContentWidth,
229-
});
230-
231-
return {
232-
note,
233-
noteCount: visibleNotes.length,
234-
noteWidth,
235-
left: placement.left,
236-
top: placement.top,
237-
locationLabel,
238-
};
239-
}, [
240-
diffContentWidth,
241-
estimatedBodyHeights,
242-
files,
243-
layout,
244-
selectedFileId,
245-
selectedHunkIndex,
246-
showHunkHeaders,
247-
showLineNumbers,
248-
visibleAgentNotesByFile,
249-
]);
250149

251150
const visibleViewportFileIds = useMemo(() => {
252151
const overscanRows = 8;
@@ -428,7 +327,9 @@ export function DiffPane({
428327
wrapLines={wrapLines}
429328
theme={theme}
430329
viewWidth={diffContentWidth}
431-
visibleAgentNotes={EMPTY_VISIBLE_AGENT_NOTES}
330+
visibleAgentNotes={
331+
visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES
332+
}
432333
onDismissAgentNote={onDismissAgentNote}
433334
onOpenAgentNotesAtHunk={(hunkIndex) => onOpenAgentNotesAtHunk(file.id, hunkIndex)}
434335
onSelect={() => onSelectFile(file.id)}
@@ -447,26 +348,6 @@ export function DiffPane({
447348
/>
448349
);
449350
})}
450-
{selectedFileId && selectedOverlayNote ? (
451-
<box
452-
style={{
453-
position: "absolute",
454-
top: selectedOverlayNote.top,
455-
left: selectedOverlayNote.left,
456-
zIndex: 20,
457-
}}
458-
>
459-
<AgentCard
460-
locationLabel={selectedOverlayNote.locationLabel}
461-
noteCount={selectedOverlayNote.noteCount}
462-
rationale={selectedOverlayNote.note.annotation.rationale}
463-
summary={selectedOverlayNote.note.annotation.summary}
464-
theme={theme}
465-
width={selectedOverlayNote.noteWidth}
466-
onClose={() => onDismissAgentNote(selectedOverlayNote.note.id)}
467-
/>
468-
</box>
469-
) : null}
470351
</box>
471352
</scrollbox>
472353
) : (

0 commit comments

Comments
 (0)