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 ``;
@@ -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(``);
}
@@ -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);
});