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",