diff --git a/docs/assets/agent-status-preview-dark.svg b/docs/assets/agent-status-preview-dark.svg index 368579e..f0e993a 100644 --- a/docs/assets/agent-status-preview-dark.svg +++ b/docs/assets/agent-status-preview-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/assets/agent-status-preview.svg b/docs/assets/agent-status-preview.svg index ef68cb1..539bbbf 100644 --- a/docs/assets/agent-status-preview.svg +++ b/docs/assets/agent-status-preview.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/render.ts b/src/render.ts index e27eb4a..e2ccf2a 100644 --- a/src/render.ts +++ b/src/render.ts @@ -45,19 +45,22 @@ export function renderAgentKey(slot: number, title: string, status: AgentVisualS return toDataUrl(renderAgentSvg(slot, title, status, selected, phase, theme, hostBadge, hostHealth, contextUsedPercent, showContextRing)); } -export function renderAgentSvg(_slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light", hostBadge?: string, hostHealth: HostHealthState = "ready", contextUsedPercent?: number, showContextRing = true): string { +export function renderAgentSvg(slot: number, title: string, status: AgentVisualStatus, selected = false, phase = 0, theme: ThemeMode = "light", hostBadge?: string, hostHealth: HostHealthState = "ready", contextUsedPercent?: number, showContextRing = true): string { const surface = SURFACES[theme]; const color = SIGNAL_COLORS[theme][status]; - const [line1, line2] = splitTitle(title); - const pulse = 0.70 + 0.30 * ((Math.sin((phase / 12) * Math.PI * 2) + 1) / 2); + const [line1, line2, line3] = splitTitle(title); + const statusPhase = status === "thinking" ? (phase + (slot * 7) % 12) % 12 : phase; + const pulse = 0.70 + 0.30 * ((Math.sin((statusPhase / 12) * Math.PI * 2) + 1) / 2); const glowColor = status === "idle" ? (theme === "dark" ? "#D5D9DC" : "#AAB4BB") : color; const themeBoost = theme === "dark" ? .08 : 0; const glowOpacity = Math.min(1, (status === "empty" ? .12 : status === "idle" ? .18 : status === "thinking" ? .50 + pulse * .16 : status === "input" ? .42 + pulse * .12 : .52) + themeBoost); const surfaceOpacity = (status === "empty" ? .04 : status === "idle" ? .06 : status === "thinking" ? .30 + pulse * .12 : status === "input" ? .24 + pulse * .08 : .28) + (theme === "dark" && status !== "empty" ? .06 : 0); - const statusMark = renderAgentStatusMark(status, glowColor, phase, pulse); - const titleMarkup = line2 - ? `${escapeXml(line1)}${escapeXml(line2)}` - : `${escapeXml(line1)}`; + const statusMark = hostHealth === "ready" ? renderAgentStatusMark(status, glowColor, statusPhase, pulse, theme) : ""; + const titleMarkup = line3 + ? `${escapeXml(line1)}${escapeXml(line2)}${escapeXml(line3)}` + : line2 + ? `${escapeXml(line1)}${escapeXml(line2)}` + : `${escapeXml(line1)}`; return ` @@ -79,7 +82,7 @@ export function renderAgentSvg(_slot: number, title: string, status: AgentVisual ${renderHostHealthMark(hostHealth, theme)} ${hostHealth === "ready" && status !== "empty" && showContextRing ? renderContextRing(contextUsedPercent, theme, surface) : ""} - ${hostBadge ? `${escapeXml(hostBadge)}` : ""} + ${hostBadge ? `${escapeXml(hostBadge)}` : ""} ${titleMarkup} ${statusMark} `; @@ -101,10 +104,10 @@ export function renderImportedKeycap(svg: string, theme: ThemeMode = "light"): s const surface = SURFACES[theme]; const glyphColor = theme === "dark" ? "#F2F2EE" : "#24292D"; - const size = 90; + const size = 108; const scale = Math.min(size / width, size / height); - const x = 27 + (size - width * scale) / 2 - minX * scale; - const y = 27 + (size - height * scale) / 2 - minY * scale; + const x = 18 + (size - width * scale) / 2 - minX * scale; + const y = 18 + (size - height * scale) / 2 - minY * scale; const glyph = body .replaceAll("currentColor", glyphColor) .replace(/#(?:000000|000|ffffff|fff)\b/gi, glyphColor) @@ -142,12 +145,12 @@ export function renderBuiltinKeycap(name: BuiltinIconName, theme: ThemeMode = "l export function renderFallbackKeycap(keycapId: string, theme: ThemeMode = "light"): string { const surface = SURFACES[theme]; const label = escapeXml(keycapId); - const fontSize = keycapId.length > 5 ? 17 : 21; + const fontSize = keycapId.length <= 4 ? 34 : keycapId.length === 5 ? 29 : 24; return toDataUrl(` - ${label} + ${label} `); } @@ -272,24 +275,30 @@ export function escapeXml(value: string): string { })[character] ?? character); } -function splitTitle(value: string): [string, string] { +function splitTitle(value: string): [string, string, string] { const clean = value.replace(/\s+/g, " ").trim(); - if (clean.length <= 16) return [clean, ""]; - const words = clean.split(" "); - let first = ""; - let second = ""; - for (const word of words) { - if (!second && `${first} ${word}`.trim().length <= 16) first = `${first} ${word}`.trim(); - else if (`${second} ${word}`.trim().length <= 16) second = `${second} ${word}`.trim(); - else break; + if (clean.length <= 10) return [clean, "", ""]; + + const lines: string[] = []; + let remaining = clean; + while (remaining && lines.length < 3) { + if (remaining.length <= 10) { + lines.push(remaining); + remaining = ""; + break; + } + const candidate = remaining.slice(0, 11); + const lastSpace = candidate.lastIndexOf(" "); + const nextSpace = remaining.indexOf(" "); + const breakAt = lastSpace > 0 ? lastSpace : nextSpace > 0 && nextSpace <= 12 ? nextSpace : 10; + lines.push(remaining.slice(0, breakAt).trim()); + remaining = remaining.slice(breakAt).trim(); } - if (!first) first = clean.slice(0, 15); - const used = `${first}${second ? ` ${second}` : ""}`.length; - if (used < clean.length) second = `${(second || clean.slice(first.length).trim()).slice(0, 15)}…`; - return [first, second]; + if (remaining && lines.length === 3) lines[2] = `${lines[2]!.slice(0, 9)}…`; + return [lines[0] ?? "", lines[1] ?? "", lines[2] ?? ""]; } -function fitTitleFont(value: string, maximum: number): string { +function fitTitleFont(value: string, maximum: number, minimum = 15.5): string { let units = 0; for (const character of value) { if (/\s/.test(character)) units += .32; @@ -298,19 +307,20 @@ function fitTitleFont(value: string, maximum: number): string { else if (/[A-ZÄÖÜ]/.test(character)) units += .63; else units += .54; } - return Math.max(12.5, Math.min(maximum, 112 / Math.max(units, 1))).toFixed(2); + return Math.max(minimum, Math.min(maximum, 108 / Math.max(units, 1))).toFixed(2); } -function renderAgentStatusMark(status: AgentVisualStatus, color: string, phase: number, pulse: number): string { +function renderAgentStatusMark(status: AgentVisualStatus, color: string, phase: number, pulse: number, theme: ThemeMode): string { + const contrastInk = theme === "dark" ? "#FFFFFF" : "#15202A"; if (status === "thinking") { - const x = 45 + (phase % 12) * 2.75; - return ``; + const x = 15 + (phase % 12) * 1.2; + return ``; } - if (status === "input") return ``; - if (status === "complete") return ``; - if (status === "error") return ``; - if (status === "empty") return ``; - return ``; + if (status === "input") return ``; + if (status === "complete") return ``; + if (status === "error") return ``; + if (status === "empty") return ``; + return ``; } function renderHostHealthMark(health: HostHealthState, theme: ThemeMode): string { @@ -333,7 +343,7 @@ function renderContextRing( ): string { if (value == null || !Number.isFinite(value)) { return ` - + `; } const percent = Math.max(0, Math.min(100, value)); @@ -344,7 +354,7 @@ function renderContextRing( ? SIGNAL_COLORS[theme].error : percent >= 80 ? SIGNAL_COLORS[theme].input : surface.title; return ` - - + + `; } diff --git a/test/render-theme.test.ts b/test/render-theme.test.ts index fad5b1f..05a1fd2 100644 --- a/test/render-theme.test.ts +++ b/test/render-theme.test.ts @@ -24,6 +24,7 @@ test("light and dark agent themes remain visually distinct", () => { test("agent context ring is bounded and can be hidden globally", () => { const visible = renderAgentSvg(0, "Context test", "thinking", false, 0, "dark", "M", "ready", 84, true); assert.match(visible, /data-context-used="84"/); + assert.match(visible, /cx="116" cy="25"/); assert.match(visible, new RegExp(SIGNAL_COLORS.dark.input, "i")); const hidden = renderAgentSvg(0, "Context test", "thinking", false, 0, "dark", "M", "ready", 84, false); @@ -36,12 +37,59 @@ test("agent context ring is bounded and can be hidden globally", () => { assert.doesNotMatch(empty, /data-context-used=/); }); +test("agent titles are enlarged and all state indicators occupy the upper-left slot", () => { + const complete = renderAgentSvg(0, "Review changes", "complete", false, 0, "dark", undefined, "ready", 42, true); + assert.match(complete, /font-size="26\.00"/); + assert.match(complete, /data-agent-motion="complete"[\s\S]*cx="25" cy="25" r="11"/); + assert.match(complete, /data-context-used="42"[\s\S]*cx="116" cy="25"/); + + const idle = renderAgentSvg(0, "Ready", "idle", false, 0, "dark"); + assert.match(idle, /font-size="27\.00"/); + assert.match(idle, /data-agent-motion="idle" cx="25" cy="25" r="6" fill="#FFFFFF"/); + + const working = renderAgentSvg(0, "Building UI", "thinking", false, 4, "dark"); + assert.match(working, /data-agent-motion="working"[\s\S]*x="13" y="19"/); + assert.match(working, /fill="#FFFFFF" fill-opacity="\.98"/); + + const lightWorking = renderAgentSvg(0, "Building UI", "thinking", false, 4, "light"); + assert.match(lightWorking, /data-agent-motion="working"[\s\S]*fill="#15202A" fill-opacity="\.98"/); + + const nextWorking = renderAgentSvg(1, "Building API", "thinking", false, 4, "dark"); + assert.match(working, /data-agent-motion="working"[\s\S]* { + const longTitle = renderAgentSvg(0, "Investigate yellow triangle", "thinking", false, 0, "dark"); + assert.match(longTitle, /y="62"[\s\S]*>Investigate<\/text>/); + assert.match(longTitle, /y="91"[\s\S]*>yellow<\/text>/); + assert.match(longTitle, /y="120"[\s\S]*>triangle<\/text>/); + assert.match(longTitle, /font-size="24\.00"/); +}); + +test("agent titles wrap before reaching the horizontal key edges", () => { + const title = renderAgentSvg(0, "Building UI", "thinking", false, 0, "dark"); + assert.match(title, /x="72" y="73"[\s\S]*>Building<\/text>/); + assert.match(title, /x="72" y="107"[\s\S]*>UI<\/text>/); + assert.doesNotMatch(title, />Building UI<\/text>/); +}); + test("user-local monochrome SVGs normalize to an off-white dark glyph", () => { const input = ''; const output = decodeURIComponent(renderImportedKeycap(input, "dark").replace(/^data:image\/svg\+xml;charset=utf8,/, "")); assert.match(output, /data-theme="dark"/); assert.match(output, /fill="#F2F2EE"/); assert.match(output, /stroke="#F2F2EE"/); + assert.match(output, /translate\(18\.000 18\.000\) scale\(4\.50000\)/); assert.doesNotMatch(output, /#000(?:000)?\b/i); }); @@ -69,6 +117,8 @@ test("dark title contrast stays above WCAG AA for small text", () => { test("missing local assets receive a readable themed fallback", () => { const output = decodeURIComponent(renderFallbackKeycap("TERM", "dark").replace(/^data:image\/svg\+xml;charset=utf8,/, "")); assert.match(output, /data-icon-source="fallback-label"/); + assert.match(output, /font-size="34"/); + assert.match(output, /font-weight="700"/); assert.match(output, />TERM<\/text>/); assert.doesNotMatch(output, /#000(?:000)?\b/i); });