diff --git a/CHANGELOG.md b/CHANGELOG.md
index 712d8b44..7c0cb52b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable user-visible changes to Hunk are documented in this file.
### Added
+- 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.
+- Tinted inline agent notes per author with a stable per-theme accent — borders, the title-row right-edge bar, the guide cap below multi-row ranges, and a soft body/title background — so notes from different agents are visually distinct on the same diff.
+
### Changed
### Fixed
diff --git a/examples/3-agent-review-demo/agent-context.json b/examples/3-agent-review-demo/agent-context.json
index 583bb031..d484be52 100644
--- a/examples/3-agent-review-demo/agent-context.json
+++ b/examples/3-agent-review-demo/agent-context.json
@@ -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": "llama"
}
]
},
@@ -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": "grok"
+ },
+ {
+ "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": "gemini"
}
]
},
@@ -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": "phi"
}
]
},
@@ -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"
}
]
}
diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx
index b996059d..86cc9e97 100644
--- a/src/ui/components/panes/AgentCard.tsx
+++ b/src/ui/components/panes/AgentCard.tsx
@@ -1,5 +1,6 @@
import { buildAgentPopoverContent } from "../../lib/agentPopover";
import { fitText, padText } from "../../lib/text";
+import { resolveAuthorAccent } from "../../lib/agentColor";
import type { AppTheme } from "../../themes";
/** Render one framed floating agent note popover. */
@@ -12,6 +13,7 @@ export function AgentCard({
summary,
theme,
width,
+ author,
}: {
locationLabel: string;
noteCount?: number;
@@ -21,6 +23,7 @@ export function AgentCard({
summary: string;
theme: AppTheme;
width: number;
+ author?: string;
}) {
const popover = buildAgentPopoverContent({
summary,
@@ -29,7 +32,9 @@ export function AgentCard({
noteIndex,
noteCount,
width,
+ author,
});
+ const accent = resolveAuthorAccent(author, theme.noteAccentPalette) ?? theme.accent;
const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0));
return (
@@ -38,7 +43,7 @@ export function AgentCard({
width,
height: popover.height,
border: true,
- borderColor: theme.accent,
+ borderColor: accent,
backgroundColor: theme.panel,
paddingLeft: 1,
paddingRight: 1,
diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx
index a14be446..f235239f 100644
--- a/src/ui/components/panes/AgentInlineNote.tsx
+++ b/src/ui/components/panes/AgentInlineNote.tsx
@@ -1,13 +1,14 @@
import type { AgentAnnotation, LayoutMode } from "../../../core/types";
-import { wrapText } from "../../lib/agentPopover";
+import { formatAgentNoteTitle, wrapText } from "../../lib/agentPopover";
import { annotationRangeLabel } from "../../lib/agentAnnotations";
import { fitText, padText } from "../../lib/text";
+import {
+ deriveAuthorBackground,
+ deriveAuthorTitleBackground,
+ resolveAuthorAccent,
+} from "../../lib/agentColor";
import type { AppTheme } from "../../themes";
-function inlineNoteTitle(noteIndex: number, noteCount: number) {
- return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
-}
-
interface AgentInlineNoteLine {
kind: "summary" | "rationale";
text: string;
@@ -83,7 +84,15 @@ export function AgentInlineNote({
width: number;
}) {
const closeText = onClose ? "[x]" : "";
- const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`;
+ const titleText = `${formatAgentNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`;
+ const resolvedAccent = resolveAuthorAccent(annotation.author, theme.noteAccentPalette);
+ const accent = resolvedAccent ?? theme.noteBorder;
+ const noteBackground = resolvedAccent
+ ? (deriveAuthorBackground(resolvedAccent, theme.appearance) ?? theme.noteBackground)
+ : theme.noteBackground;
+ const noteTitleBackground = resolvedAccent
+ ? (deriveAuthorTitleBackground(resolvedAccent, theme.appearance) ?? theme.noteTitleBackground)
+ : theme.noteTitleBackground;
const splitWidths = splitColumnWidths(width);
const canDockRight = layout === "split" && anchorSide === "new" && width >= 84;
const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84;
@@ -125,7 +134,7 @@ export function AgentInlineNote({
{" ".repeat(boxLeft)}
-
+
{topBorder}
@@ -136,12 +145,12 @@ export function AgentInlineNote({
{" ".repeat(boxLeft)}
-
+
│
-
+
{padText(fitText(titleText, titleWidth), titleWidth)}
@@ -150,11 +159,11 @@ export function AgentInlineNote({
onMouseUp={onClose}
style={{ width: closeText.length + 1, height: 1, backgroundColor: theme.panel }}
>
- {` ${closeText}`}
+ {` ${closeText}`}
) : null}
-
+
│
@@ -169,17 +178,17 @@ export function AgentInlineNote({
{" ".repeat(boxLeft)}
-
+
│
-
+
{padText(line.text, bodyWidth)}
-
+
│
@@ -191,7 +200,7 @@ export function AgentInlineNote({
{" ".repeat(boxLeft)}
-
+
{bottomBorder}
@@ -205,17 +214,20 @@ export function AgentInlineNoteGuideCap({
side,
theme,
width,
+ accent,
}: {
side: "old" | "new";
theme: AppTheme;
width: number;
+ accent?: string;
}) {
+ const borderColor = accent ?? theme.noteBorder;
return (
{side === "old" ? (
<>
- ╵
+ ╵
{" ".repeat(Math.max(0, width - 1))}
@@ -227,7 +239,7 @@ export function AgentInlineNoteGuideCap({
{" ".repeat(Math.max(0, width - 1))}
- ╵
+ ╵
>
)}
diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx
index 668a3f15..b2d45cc2 100644
--- a/src/ui/components/ui-components.test.tsx
+++ b/src/ui/components/ui-components.test.tsx
@@ -1285,6 +1285,234 @@ describe("UI components", () => {
expect(lines[4]?.trimStart().startsWith("└")).toBe(true);
});
+ test("AgentInlineNote shows author name in title when author is set", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ const lines = frame.split("\n");
+ expect(lines[1]).toContain("sonnet");
+ expect(lines[1]).not.toContain("AI note");
+ });
+
+ test("AgentInlineNote falls back to 'AI note' when author is absent", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ const lines = frame.split("\n");
+ expect(lines[1]).toContain("AI note");
+ });
+
+ test("AgentInlineNote includes index when multiple notes share a hunk", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ const lines = frame.split("\n");
+ expect(lines[1]).toContain("sonnet");
+ expect(lines[1]).toContain("1/2");
+ });
+
+ test("AgentInlineNote preserves special characters in author", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ const lines = frame.split("\n");
+ expect(lines[1]).toContain("prism (arbiter)");
+ });
+
+ test("AgentCard shows author in title when set", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 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(
+ {}}
+ />,
+ 40,
+ 12,
+ );
+
+ const lines = frame
+ .split("\n")
+ .slice(0, 8)
+ .map((line) => line.trimEnd());
+ expect(lines[1]).toContain("AI note");
+ });
+
+ test("AgentInlineNote uses theme.noteBorder when author is absent", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ // Verify it renders without error; the border color should be theme.noteBorder
+ expect(frame).toContain("AI note · ▶ new 2-4");
+ expect(frame).toContain("Summary line");
+ });
+
+ test("AgentInlineNote derives border color from palette when author is set", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame = await captureFrame(
+ {}}
+ />,
+ 100,
+ 5,
+ );
+
+ // Verify it renders without error with author set
+ expect(frame).toContain("sonnet · ▶ new 2-4");
+ expect(frame).toContain("Summary line");
+ });
+
+ test("AgentInlineNote renders different border colors for different authors", async () => {
+ const theme = resolveTheme("midnight", null);
+ const frame1 = await captureFrame(
+ ,
+ 100,
+ 5,
+ );
+
+ const frame2 = await captureFrame(
+ ,
+ 100,
+ 5,
+ );
+
+ // Both frames should render successfully; the internal accent colors differ
+ expect(frame1).toContain("sonnet · ▶ new 2-4");
+ expect(frame2).toContain("prism · ▶ new 2-4");
+ });
+
test("DiffPane renders all visible hunk notes across the review stream", async () => {
const bootstrap = createBootstrap();
bootstrap.changeset.files[1]!.agent = {
diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx
index 0060525d..c7b4e212 100644
--- a/src/ui/diff/PierreDiffView.tsx
+++ b/src/ui/diff/PierreDiffView.tsx
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import type { DiffFile, LayoutMode } from "../../core/types";
import { AgentInlineNote, AgentInlineNoteGuideCap } from "../components/panes/AgentInlineNote";
import type { VisibleAgentNote } from "../lib/agentAnnotations";
+import { resolveAuthorAccent } from "../lib/agentColor";
import type { DiffSectionGeometry } from "../lib/diffSectionGeometry";
import { reviewRowId } from "../lib/ids";
import type { AppTheme } from "../themes";
@@ -170,9 +171,16 @@ export function PierreDiffView({
}
if (plannedRow.kind === "note-guide-cap") {
+ const capAccent =
+ resolveAuthorAccent(plannedRow.author, theme.noteAccentPalette) ?? undefined;
return (
-
+
);
}
diff --git a/src/ui/diff/reviewRenderPlan.test.ts b/src/ui/diff/reviewRenderPlan.test.ts
index 39c3bc56..6f0a211b 100644
--- a/src/ui/diff/reviewRenderPlan.test.ts
+++ b/src/ui/diff/reviewRenderPlan.test.ts
@@ -118,6 +118,40 @@ describe("review render plan", () => {
expect(cap?.kind).toBe("note-guide-cap");
if (cap?.kind === "note-guide-cap") {
expect(cap.side).toBe("new");
+ expect(cap.author).toBeUndefined();
+ }
+ });
+
+ test("propagates annotation author onto the matching guide cap row", () => {
+ const theme = resolveTheme("midnight", null);
+ const file = createDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\nexport const beta = 3;\nexport const gamma = 4;\n",
+ );
+ const rows = buildSplitRows(file, null, theme);
+ const plannedRows = buildReviewRenderPlan({
+ fileId: file.id,
+ rows,
+ selectedHunkIndex: 0,
+ showHunkHeaders: true,
+ visibleAgentNotes: [
+ {
+ id: "annotation:alpha:0:0",
+ annotation: {
+ newRange: [2, 3],
+ summary: "Authored note",
+ author: "sonnet",
+ },
+ },
+ ],
+ });
+
+ const cap = plannedRows.find((row) => row.kind === "note-guide-cap");
+ expect(cap?.kind).toBe("note-guide-cap");
+ if (cap?.kind === "note-guide-cap") {
+ expect(cap.author).toBe("sonnet");
}
});
diff --git a/src/ui/diff/reviewRenderPlan.ts b/src/ui/diff/reviewRenderPlan.ts
index ba2e89ed..10ce0c0c 100644
--- a/src/ui/diff/reviewRenderPlan.ts
+++ b/src/ui/diff/reviewRenderPlan.ts
@@ -50,6 +50,7 @@ export type PlannedReviewRow =
fileId: string;
hunkIndex: number;
side: "old" | "new";
+ author?: string;
};
function lineRows(rows: DiffRow[]) {
@@ -292,7 +293,10 @@ function buildNoteGuideSideByRowKey(placementsByAnchor: Map) {
- const guideCapsByRowKey = new Map>();
+ // Track the first author seen per (row, side) so the cap glyph can adopt the
+ // matching per-author accent. Two notes ending on the same row+side is rare;
+ // if it happens, first-author-wins keeps the colour stable.
+ const guideCapsByRowKey = new Map>();
for (const placements of placementsByAnchor.values()) {
for (const placement of placements) {
@@ -300,8 +304,12 @@ function buildGuideCapsByRowKey(placementsByAnchor: Map();
- rowCaps.add(placement.anchorSide);
+ const rowCaps =
+ guideCapsByRowKey.get(placement.endGuideAfterKey) ??
+ new Map<"old" | "new", string | undefined>();
+ if (!rowCaps.has(placement.anchorSide)) {
+ rowCaps.set(placement.anchorSide, placement.note.annotation.author);
+ }
guideCapsByRowKey.set(placement.endGuideAfterKey, rowCaps);
}
}
@@ -383,7 +391,7 @@ export function buildReviewRenderPlan({
const guideCaps = guideCapsByRowKey.get(row.key);
if (guideCaps) {
- Array.from(guideCaps).forEach((side) => {
+ Array.from(guideCaps.entries()).forEach(([side, author]) => {
plannedRows.push({
kind: "note-guide-cap",
key: `note-guide-cap:${row.key}:${side}`,
@@ -391,6 +399,7 @@ export function buildReviewRenderPlan({
fileId,
hunkIndex: row.hunkIndex,
side,
+ author,
});
});
}
diff --git a/src/ui/lib/agentColor.test.ts b/src/ui/lib/agentColor.test.ts
new file mode 100644
index 00000000..568f4095
--- /dev/null
+++ b/src/ui/lib/agentColor.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, test } from "bun:test";
+import {
+ deriveAuthorBackground,
+ deriveAuthorTitleBackground,
+ resolveAuthorAccent,
+} from "./agentColor";
+
+describe("agentColor helpers", () => {
+ test("resolveAuthorAccent returns null for undefined author", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"];
+ const result = resolveAuthorAccent(undefined, palette);
+ expect(result).toBe(null);
+ });
+
+ test("resolveAuthorAccent returns null for empty string author", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"];
+ const result = resolveAuthorAccent("", palette);
+ expect(result).toBe(null);
+ });
+
+ test("resolveAuthorAccent returns null for whitespace-only author", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"];
+ const result = resolveAuthorAccent(" ", palette);
+ expect(result).toBe(null);
+ });
+
+ test("resolveAuthorAccent returns null for empty palette", () => {
+ const result = resolveAuthorAccent("sonnet", []);
+ expect(result).toBe(null);
+ });
+
+ test("same author and palette yield same color when called twice", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"];
+ const author = "sonnet";
+ const color1 = resolveAuthorAccent(author, palette);
+ const color2 = resolveAuthorAccent(author, palette);
+ expect(color1).toBe(color2);
+ });
+
+ test("different authors yield different colors with 5-color palette", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"];
+ const color1 = resolveAuthorAccent("sonnet", palette);
+ const color2 = resolveAuthorAccent("prism", palette);
+ expect(color1).not.toBe(color2);
+ });
+
+ test("returned color is always a member of the supplied palette", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"];
+ const authors = ["alice", "bob", "charlie", "david", "eve"];
+ for (const author of authors) {
+ const color = resolveAuthorAccent(author, palette);
+ expect(color).not.toBe(null);
+ if (color !== null) {
+ expect(palette).toContain(color);
+ }
+ }
+ });
+
+ test("whitespace is trimmed from author before hashing", () => {
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"];
+ const color1 = resolveAuthorAccent("sonnet", palette);
+ const color2 = resolveAuthorAccent(" sonnet ", palette);
+ expect(color1).toBe(color2);
+ });
+
+ test("deriveAuthorBackground returns null for unparseable hex", () => {
+ expect(deriveAuthorBackground("not-a-color", "dark")).toBe(null);
+ expect(deriveAuthorBackground("#abc", "dark")).toBe(null);
+ expect(deriveAuthorBackground("#zzzzzz", "dark")).toBe(null);
+ });
+
+ test("deriveAuthorBackground is deterministic for same input", () => {
+ const a = deriveAuthorBackground("#c6a0ff", "dark");
+ const b = deriveAuthorBackground("#c6a0ff", "dark");
+ expect(a).toBe(b);
+ });
+
+ test("deriveAuthorBackground produces dark output for dark appearance", () => {
+ const result = deriveAuthorBackground("#c6a0ff", "dark");
+ expect(result).not.toBe(null);
+ if (result === null) return;
+ const value = Number.parseInt(result.slice(1), 16);
+ const r = (value >> 16) & 0xff;
+ const g = (value >> 8) & 0xff;
+ const b = value & 0xff;
+ const lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255;
+ expect(lightness).toBeLessThan(0.25);
+ });
+
+ test("deriveAuthorBackground produces light output for light appearance", () => {
+ const result = deriveAuthorBackground("#7d5bc4", "light");
+ expect(result).not.toBe(null);
+ if (result === null) return;
+ const value = Number.parseInt(result.slice(1), 16);
+ const r = (value >> 16) & 0xff;
+ const g = (value >> 8) & 0xff;
+ const b = value & 0xff;
+ const lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255;
+ expect(lightness).toBeGreaterThan(0.85);
+ });
+
+ test("deriveAuthorBackground differs across hues", () => {
+ const purple = deriveAuthorBackground("#c6a0ff", "dark");
+ const blue = deriveAuthorBackground("#7fd1ff", "dark");
+ const green = deriveAuthorBackground("#88d39b", "dark");
+ expect(purple).not.toBe(blue);
+ expect(blue).not.toBe(green);
+ expect(purple).not.toBe(green);
+ });
+
+ test("deriveAuthorTitleBackground is lighter than body for dark appearance", () => {
+ const body = deriveAuthorBackground("#c6a0ff", "dark");
+ const title = deriveAuthorTitleBackground("#c6a0ff", "dark");
+ expect(body).not.toBe(null);
+ expect(title).not.toBe(null);
+ if (body === null || title === null) return;
+ const lightnessOf = (hex: string) => {
+ const v = Number.parseInt(hex.slice(1), 16);
+ const r = (v >> 16) & 0xff;
+ const g = (v >> 8) & 0xff;
+ const b = v & 0xff;
+ return (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255;
+ };
+ expect(lightnessOf(title)).toBeGreaterThan(lightnessOf(body));
+ });
+
+ test("deriveAuthorTitleBackground is darker than body for light appearance", () => {
+ const body = deriveAuthorBackground("#7d5bc4", "light");
+ const title = deriveAuthorTitleBackground("#7d5bc4", "light");
+ expect(body).not.toBe(null);
+ expect(title).not.toBe(null);
+ if (body === null || title === null) return;
+ const lightnessOf = (hex: string) => {
+ const v = Number.parseInt(hex.slice(1), 16);
+ const r = (v >> 16) & 0xff;
+ const g = (v >> 8) & 0xff;
+ const b = v & 0xff;
+ return (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255;
+ };
+ expect(lightnessOf(title)).toBeLessThan(lightnessOf(body));
+ });
+
+ test("derived background keeps WCAG contrast above 4.5 against light text", () => {
+ // Dark theme body text (#f2f4f6, near white) on derived dark bg.
+ const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"];
+ const lightText = "#f2f4f6";
+ for (const accent of palette) {
+ const bg = deriveAuthorBackground(accent, "dark");
+ expect(bg).not.toBe(null);
+ if (bg === null) continue;
+ expect(contrastRatio(lightText, bg)).toBeGreaterThan(4.5);
+ }
+ });
+
+ test("derived background keeps WCAG contrast above 4.5 against dark text", () => {
+ // Light theme body text (#2f2417, near black) on derived light bg.
+ const palette = ["#7d5bc4", "#4a6890", "#3f8d58", "#9f6c1f", "#b4545b"];
+ const darkText = "#2f2417";
+ for (const accent of palette) {
+ const bg = deriveAuthorBackground(accent, "light");
+ expect(bg).not.toBe(null);
+ if (bg === null) continue;
+ expect(contrastRatio(darkText, bg)).toBeGreaterThan(4.5);
+ }
+ });
+});
+
+function relativeLuminance(hex: string): number {
+ const v = Number.parseInt(hex.slice(1), 16);
+ const r = ((v >> 16) & 0xff) / 255;
+ const g = ((v >> 8) & 0xff) / 255;
+ const b = (v & 0xff) / 255;
+ const channel = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+}
+
+function contrastRatio(a: string, b: string): number {
+ const la = relativeLuminance(a);
+ const lb = relativeLuminance(b);
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
+ return (hi + 0.05) / (lo + 0.05);
+}
diff --git a/src/ui/lib/agentColor.ts b/src/ui/lib/agentColor.ts
new file mode 100644
index 00000000..6cdcc262
--- /dev/null
+++ b/src/ui/lib/agentColor.ts
@@ -0,0 +1,123 @@
+/** Stable, order-independent hash of an author string → palette index.
+ * Uses FNV-1a (32-bit) for low collisions on short identifiers. */
+function hashAuthor(author: string): number {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < author.length; i++) {
+ h ^= author.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return h >>> 0;
+}
+
+/** Resolve the accent color a given author should use against a palette.
+ * Returns null when the author is absent or the palette is empty so callers
+ * can fall back to theme defaults. */
+export function resolveAuthorAccent(
+ author: string | undefined,
+ palette: readonly string[],
+): string | null {
+ if (!author || palette.length === 0) return null;
+ const trimmed = author.trim();
+ if (trimmed.length === 0) return null;
+ const index = hashAuthor(trimmed) % palette.length;
+ return palette[index] ?? null;
+}
+
+type Hsl = readonly [number, number, number];
+type Rgb = readonly [number, number, number];
+
+function hexToRgb(hex: string): Rgb | null {
+ const cleaned = hex.startsWith("#") ? hex.slice(1) : hex;
+ if (cleaned.length !== 6) return null;
+ const num = Number.parseInt(cleaned, 16);
+ if (Number.isNaN(num)) return null;
+ return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff];
+}
+
+function rgbToHsl(r: number, g: number, b: number): Hsl {
+ const rN = r / 255;
+ const gN = g / 255;
+ const bN = b / 255;
+ const max = Math.max(rN, gN, bN);
+ const min = Math.min(rN, gN, bN);
+ const l = (max + min) / 2;
+ if (max === min) return [0, 0, l * 100];
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ let h = 0;
+ if (max === rN) h = (gN - bN) / d + (gN < bN ? 6 : 0);
+ else if (max === gN) h = (bN - rN) / d + 2;
+ else h = (rN - gN) / d + 4;
+ h *= 60;
+ return [h, s * 100, l * 100];
+}
+
+function hslToRgb(h: number, s: number, l: number): Rgb {
+ const sN = s / 100;
+ const lN = l / 100;
+ const c = (1 - Math.abs(2 * lN - 1)) * sN;
+ const hN = h / 60;
+ const x = c * (1 - Math.abs((hN % 2) - 1));
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ if (hN >= 0 && hN < 1) [r, g, b] = [c, x, 0];
+ else if (hN < 2) [r, g, b] = [x, c, 0];
+ else if (hN < 3) [r, g, b] = [0, c, x];
+ else if (hN < 4) [r, g, b] = [0, x, c];
+ else if (hN < 5) [r, g, b] = [x, 0, c];
+ else [r, g, b] = [c, 0, x];
+ const m = lN - c / 2;
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
+}
+
+function rgbToHex(r: number, g: number, b: number): string {
+ const toHex = (n: number) =>
+ Math.max(0, Math.min(255, Math.round(n)))
+ .toString(16)
+ .padStart(2, "0");
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+}
+
+function hexToHsl(hex: string): Hsl | null {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return null;
+ return rgbToHsl(rgb[0], rgb[1], rgb[2]);
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ const [r, g, b] = hslToRgb(h, s, l);
+ return rgbToHex(r, g, b);
+}
+
+/** Derive a body background that carries the author's accent hue but stays
+ * legible against `theme.text` and `theme.muted`. Dark themes get a deep,
+ * lightly-tinted backdrop; light themes get a near-white tint. Returns null
+ * for unparseable hex so callers can fall back to `theme.noteBackground`. */
+export function deriveAuthorBackground(
+ accent: string,
+ appearance: "light" | "dark",
+): string | null {
+ const hsl = hexToHsl(accent);
+ if (!hsl) return null;
+ const [h, s] = hsl;
+ if (appearance === "dark") {
+ return hslToHex(h, Math.min(s, 32), 15);
+ }
+ return hslToHex(h, Math.min(s, 90), 95);
+}
+
+/** Derive the title-strip background. Slightly more saturated and one shade
+ * away from the body backdrop so the title row reads as its own band. */
+export function deriveAuthorTitleBackground(
+ accent: string,
+ appearance: "light" | "dark",
+): string | null {
+ const hsl = hexToHsl(accent);
+ if (!hsl) return null;
+ const [h, s] = hsl;
+ if (appearance === "dark") {
+ return hslToHex(h, Math.min(s, 40), 22);
+ }
+ return hslToHex(h, Math.min(s, 95), 91);
+}
diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts
index 9b7ea8c1..c25f8821 100644
--- a/src/ui/lib/agentPopover.ts
+++ b/src/ui/lib/agentPopover.ts
@@ -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";
}
@@ -62,6 +65,7 @@ export function buildAgentPopoverContent({
rationale,
summary,
width,
+ author,
}: {
locationLabel: string;
noteCount: number;
@@ -69,6 +73,7 @@ export function buildAgentPopoverContent({
rationale?: string;
summary: string;
width: number;
+ author?: string;
}) {
const innerWidth = Math.max(1, width - 4);
const summaryLines = wrapText(summary, innerWidth);
@@ -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,
diff --git a/src/ui/themes.ts b/src/ui/themes.ts
index 38ad3883..cf306a2a 100644
--- a/src/ui/themes.ts
+++ b/src/ui/themes.ts
@@ -35,6 +35,7 @@ export interface AppTheme {
noteBackground: string;
noteTitleBackground: string;
noteTitleText: string;
+ noteAccentPalette: readonly string[];
syntaxColors: SyntaxColors;
syntaxStyle: SyntaxStyle;
}
@@ -124,6 +125,7 @@ export const THEMES: AppTheme[] = [
noteBackground: "#241c31",
noteTitleBackground: "#322446",
noteTitleText: "#f5edff",
+ noteAccentPalette: ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"],
},
{
default: "#f2f4f6",
@@ -173,6 +175,7 @@ export const THEMES: AppTheme[] = [
noteBackground: "#211a36",
noteTitleBackground: "#30234f",
noteTitleText: "#f5eeff",
+ noteAccentPalette: ["#c49bff", "#7fd1ff", "#5ad188", "#ffd883", "#ff8b8b"],
},
{
default: "#e8f1ff",
@@ -222,6 +225,7 @@ export const THEMES: AppTheme[] = [
noteBackground: "#efe6ff",
noteTitleBackground: "#e3d7ff",
noteTitleText: "#462b74",
+ noteAccentPalette: ["#7d5bc4", "#4a6890", "#3f8d58", "#9f6c1f", "#b4545b"],
},
{
default: "#2f2417",
@@ -271,6 +275,7 @@ export const THEMES: AppTheme[] = [
noteBackground: "#311d36",
noteTitleBackground: "#452650",
noteTitleText: "#fff0ff",
+ noteAccentPalette: ["#e1a3ff", "#ffb07a", "#83d99d", "#ffd08f", "#ff9d8f"],
},
{
default: "#fff0e6",