@agentdeck/shared 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/adapter.d.ts +3 -1
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adapter.js +24 -0
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/command-builders.d.ts +8 -2
  6. package/dist/command-builders.d.ts.map +1 -1
  7. package/dist/command-builders.js +20 -2
  8. package/dist/command-builders.js.map +1 -1
  9. package/dist/connection-status.d.ts +42 -0
  10. package/dist/connection-status.d.ts.map +1 -0
  11. package/dist/connection-status.js +52 -0
  12. package/dist/connection-status.js.map +1 -0
  13. package/dist/creature-layout.d.ts +18 -0
  14. package/dist/creature-layout.d.ts.map +1 -0
  15. package/dist/creature-layout.js +131 -0
  16. package/dist/creature-layout.js.map +1 -0
  17. package/dist/d200h-layout.d.ts +146 -0
  18. package/dist/d200h-layout.d.ts.map +1 -0
  19. package/dist/d200h-layout.js +808 -0
  20. package/dist/d200h-layout.js.map +1 -0
  21. package/dist/design-tokens.d.ts +261 -0
  22. package/dist/design-tokens.d.ts.map +1 -0
  23. package/dist/design-tokens.js +157 -0
  24. package/dist/design-tokens.js.map +1 -0
  25. package/dist/eval-schema.d.ts +32 -2
  26. package/dist/eval-schema.d.ts.map +1 -1
  27. package/dist/eval-schema.js.map +1 -1
  28. package/dist/format-utils.d.ts +38 -0
  29. package/dist/format-utils.d.ts.map +1 -1
  30. package/dist/format-utils.js +70 -0
  31. package/dist/format-utils.js.map +1 -1
  32. package/dist/gateway-protocol.d.ts +1 -1
  33. package/dist/gateway-protocol.js +1 -1
  34. package/dist/index.d.ts +11 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +11 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/llm-settings.d.ts +3 -3
  39. package/dist/llm-settings.d.ts.map +1 -1
  40. package/dist/llm-settings.js +3 -3
  41. package/dist/llm-settings.js.map +1 -1
  42. package/dist/net-utils.d.ts +2 -1
  43. package/dist/net-utils.d.ts.map +1 -1
  44. package/dist/net-utils.js +100 -3
  45. package/dist/net-utils.js.map +1 -1
  46. package/dist/pricing.d.ts +45 -0
  47. package/dist/pricing.d.ts.map +1 -0
  48. package/dist/pricing.js +93 -0
  49. package/dist/pricing.js.map +1 -0
  50. package/dist/protocol.d.ts +220 -5
  51. package/dist/protocol.d.ts.map +1 -1
  52. package/dist/protocol.js +11 -1
  53. package/dist/protocol.js.map +1 -1
  54. package/dist/sample.d.ts +152 -0
  55. package/dist/sample.d.ts.map +1 -0
  56. package/dist/sample.js +22 -0
  57. package/dist/sample.js.map +1 -0
  58. package/dist/session-utils.d.ts +82 -2
  59. package/dist/session-utils.d.ts.map +1 -1
  60. package/dist/session-utils.js +205 -10
  61. package/dist/session-utils.js.map +1 -1
  62. package/dist/state-colors.d.ts.map +1 -1
  63. package/dist/state-colors.js +2 -0
  64. package/dist/state-colors.js.map +1 -1
  65. package/dist/states.d.ts +1 -0
  66. package/dist/states.d.ts.map +1 -1
  67. package/dist/states.js +6 -1
  68. package/dist/states.js.map +1 -1
  69. package/dist/svg-renderers/agent-logos.d.ts +20 -12
  70. package/dist/svg-renderers/agent-logos.d.ts.map +1 -1
  71. package/dist/svg-renderers/agent-logos.js +154 -46
  72. package/dist/svg-renderers/agent-logos.js.map +1 -1
  73. package/dist/svg-renderers/session-slot-renderer.d.ts +71 -6
  74. package/dist/svg-renderers/session-slot-renderer.d.ts.map +1 -1
  75. package/dist/svg-renderers/session-slot-renderer.js +429 -209
  76. package/dist/svg-renderers/session-slot-renderer.js.map +1 -1
  77. package/dist/svg-renderers/text-utils.d.ts +14 -0
  78. package/dist/svg-renderers/text-utils.d.ts.map +1 -1
  79. package/dist/svg-renderers/text-utils.js +34 -0
  80. package/dist/svg-renderers/text-utils.js.map +1 -1
  81. package/dist/telemetry-envelope.d.ts +1 -1
  82. package/dist/telemetry-envelope.d.ts.map +1 -1
  83. package/dist/terrarium-rules.d.ts +46 -0
  84. package/dist/terrarium-rules.d.ts.map +1 -0
  85. package/dist/terrarium-rules.js +63 -0
  86. package/dist/terrarium-rules.js.map +1 -0
  87. package/dist/timeline-icons.d.ts +80 -0
  88. package/dist/timeline-icons.d.ts.map +1 -0
  89. package/dist/timeline-icons.js +174 -0
  90. package/dist/timeline-icons.js.map +1 -0
  91. package/dist/timeline-label.d.ts +38 -0
  92. package/dist/timeline-label.d.ts.map +1 -0
  93. package/dist/timeline-label.js +95 -0
  94. package/dist/timeline-label.js.map +1 -0
  95. package/dist/timeline-markdown.d.ts +80 -0
  96. package/dist/timeline-markdown.d.ts.map +1 -0
  97. package/dist/timeline-markdown.js +221 -0
  98. package/dist/timeline-markdown.js.map +1 -0
  99. package/dist/timeline-summarizer.d.ts +24 -0
  100. package/dist/timeline-summarizer.d.ts.map +1 -1
  101. package/dist/timeline-summarizer.js +55 -13
  102. package/dist/timeline-summarizer.js.map +1 -1
  103. package/dist/timeline-task-display.d.ts +66 -0
  104. package/dist/timeline-task-display.d.ts.map +1 -0
  105. package/dist/timeline-task-display.js +94 -0
  106. package/dist/timeline-task-display.js.map +1 -0
  107. package/dist/timeline.d.ts +101 -2
  108. package/dist/timeline.d.ts.map +1 -1
  109. package/dist/timeline.js +249 -11
  110. package/dist/timeline.js.map +1 -1
  111. package/dist/trmnl-layout.d.ts.map +1 -0
  112. package/dist/trmnl-layout.js.map +1 -0
  113. package/package.json +2 -1
@@ -1,12 +1,31 @@
1
1
  import { stateColor } from '../state-colors.js';
2
+ import { PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL } from '../connection-status.js';
2
3
  import { agentLogoIcon } from './agent-logos.js';
3
- import { wrapTextByWidth } from './text-utils.js';
4
+ import { wrapTextByWidth, escSvgText } from './text-utils.js';
4
5
  const SIZE = 144;
5
- const MAX_TEXT_PX = 124; // 144 - 10px padding each side
6
+ const BORDER_PERIMETER = 512;
7
+ // Shared sanitizer: strips ANSI/control chars before entity-escaping (a raw
8
+ // control char in interpolated text makes resvg reject the entire SVG).
9
+ const escXml = escSvgText;
10
+ function truncate(s, max) {
11
+ return s.length <= max ? s : s.slice(0, max - 1) + '\u2026';
12
+ }
13
+ /**
14
+ * Compact, readable model strings for narrow surfaces (StreamDeck 144\u00d7144 keys).
15
+ * - claude-sonnet-4-6 \u2192 "sonnet 4.6"
16
+ * - claude-opus-4-7 \u2192 "opus 4.7"
17
+ * - claude-haiku-4-5-20251001 \u2192 "haiku 4.5" (date suffix dropped)
18
+ * - gpt-5-codex / gpt-5 / others \u2192 unchanged (caller still truncates if needed)
19
+ */
20
+ export function aliasModelName(name) {
21
+ const claude = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d+)?$/i.exec(name);
22
+ if (claude)
23
+ return `${claude[1].toLowerCase()} ${claude[2]}.${claude[3]}`;
24
+ return name;
25
+ }
6
26
  function stateLabel(state, agentType) {
7
27
  if (!state)
8
- return 'OFFLINE';
9
- // OpenClaw-specific labels
28
+ return PASSIVE_OFFLINE_LABEL;
10
29
  if (agentType === 'openclaw') {
11
30
  if (state === 'idle')
12
31
  return 'STANDBY';
@@ -23,295 +42,496 @@ function stateLabel(state, agentType) {
23
42
  default: return state.toUpperCase();
24
43
  }
25
44
  }
26
- function escXml(s) {
27
- return s
28
- .replace(/&/g, '&amp;')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;')
32
- .replace(/'/g, '&apos;');
33
- }
34
- function truncate(s, max) {
35
- if (s.length <= max)
36
- return s;
37
- return s.slice(0, max - 1) + '\u2026';
38
- }
39
- /**
40
- * Format "Model · effort" suffix for session tile / detail view.
41
- * Skips neutral efforts ("medium" legacy, "default" is Claude Code 2.1+
42
- * per-model default) so they don't clutter the tile. Truncates the model
43
- * name preferentially so the effort label survives in tight pixel budgets.
44
- */
45
45
  export function formatModelEffort(modelName, effortLevel, maxLen = 14) {
46
46
  if (!modelName)
47
47
  return '';
48
+ const aliased = aliasModelName(modelName);
48
49
  const showEffort = effortLevel && effortLevel !== 'medium' && effortLevel !== 'default';
49
50
  if (!showEffort)
50
- return truncate(modelName, maxLen);
51
- const combined = `${modelName} · ${effortLevel}`;
51
+ return truncate(aliased, maxLen);
52
+ const combined = `${aliased} · ${effortLevel}`;
52
53
  if (combined.length <= maxLen)
53
54
  return combined;
54
55
  const effortSuffix = ` · ${effortLevel}`;
55
56
  const modelBudget = Math.max(4, maxLen - effortSuffix.length);
56
- return truncate(modelName, modelBudget) + effortSuffix;
57
+ return truncate(aliased, modelBudget) + effortSuffix;
58
+ }
59
+ function toneColors(tone = 'info') {
60
+ switch (tone) {
61
+ case 'ready':
62
+ return { bg: '#06160d', panel: '#12331f', icon: '#86efac', accent: '#22c55e', text: '#dcfce7', sub: '#86efac' };
63
+ case 'idle':
64
+ return { bg: '#0b1320', panel: '#172033', icon: '#93c5fd', accent: '#60a5fa', text: '#dbeafe', sub: '#93c5fd' };
65
+ case 'warning':
66
+ return { bg: '#17110a', panel: '#2a1b0c', icon: '#fbbf24', accent: '#f59e0b', text: '#fde68a', sub: '#fbbf24' };
67
+ case 'danger':
68
+ return { bg: '#1c0c0c', panel: '#341312', icon: '#fca5a5', accent: '#ef4444', text: '#fee2e2', sub: '#fca5a5' };
69
+ case 'muted':
70
+ return { bg: '#0a0a0c', panel: '#17171a', icon: '#a1a1aa', accent: '#52525b', text: '#e4e4e7', sub: '#a1a1aa' };
71
+ case 'agent':
72
+ return { bg: '#141016', panel: '#241a2b', icon: '#f0abfc', accent: '#c084fc', text: '#fae8ff', sub: '#d8b4fe' };
73
+ case 'action':
74
+ return { bg: '#07170f', panel: '#12331f', icon: '#bbf7d0', accent: '#22c55e', text: '#dcfce7', sub: '#86efac' };
75
+ case 'brand':
76
+ // Aquarium-tide OFFLINE tone — matches the dark-scrim + cyan AgentDeck mark
77
+ // the macOS/iOS/Android connection overlays and the ESP32 splash draw.
78
+ return { bg: '#071a1e', panel: '#0e2e33', icon: '#3ED6E8', accent: '#3ED6E8', text: '#e6f7fa', sub: '#7fb2bc' };
79
+ case 'purple':
80
+ return { bg: '#120d1d', panel: '#24163a', icon: '#d8b4fe', accent: '#a78bfa', text: '#f3e8ff', sub: '#d8b4fe' };
81
+ case 'info':
82
+ default:
83
+ return { bg: '#0b1626', panel: '#12223a', icon: '#bfdbfe', accent: '#60a5fa', text: '#dbeafe', sub: '#93c5fd' };
84
+ }
85
+ }
86
+ function orbitOffset(animFrame, speedPx, phasePx = 0) {
87
+ return -((animFrame * speedPx + phasePx) % BORDER_PERIMETER);
88
+ }
89
+ function renderOrbitingRect(params) {
90
+ const { x, y, width, height, rx, color, animFrame, speedPx = 18, phasePx = 0, dashPx = 80, gapPx = BORDER_PERIMETER - dashPx, railOpacity = 0.18, glowOpacity = 0.58, coreOpacity = 0.96, railWidth = 1.2, glowWidth = 4.8, coreWidth = 2.2, filterId, } = params;
91
+ const dashOffset = orbitOffset(animFrame, speedPx, phasePx);
92
+ const filterAttr = filterId ? ` filter="url(#${filterId})"` : '';
93
+ const common = `x="${x}" y="${y}" width="${width}" height="${height}" rx="${rx}" fill="none" stroke="${color}" stroke-linecap="round" stroke-linejoin="round"`;
94
+ return [
95
+ `<rect ${common} stroke-width="${railWidth}" opacity="${railOpacity.toFixed(2)}"/>`,
96
+ `<rect ${common} stroke-width="${glowWidth}" stroke-dasharray="${dashPx} ${gapPx}" stroke-dashoffset="${dashOffset}" opacity="${glowOpacity.toFixed(2)}"${filterAttr}/>`,
97
+ `<rect ${common} stroke-width="${coreWidth}" stroke-dasharray="${dashPx} ${gapPx}" stroke-dashoffset="${dashOffset}" opacity="${coreOpacity.toFixed(2)}"/>`,
98
+ ].join('');
99
+ }
100
+ /**
101
+ * AgentDeck product mark — the aquarium dome over a hardware deck (glass dome +
102
+ * waterline + highlight + rounded deck base + three keys + two bubbles). Ported
103
+ * unit-for-unit (0..24 space) from the SwiftUI `AgentDeckLogo` / Compose
104
+ * `AgentDeckMark` so the Stream Deck / D200H offline hero shows the SAME brand
105
+ * mark that the macOS/iOS/Android connection overlays and the ESP32 splash draw.
106
+ * Centered on (x,y); `size` is the mark's width in px. `color` draws the dome/deck
107
+ * strokes + keys; `accent` tints the waterline + bubbles.
108
+ */
109
+ export function renderAgentDeckMark(x, y, size, color, accent = color) {
110
+ const u = size / 24; // unit-space (0..24) → px
111
+ const ax = (p) => +(x + (p - 12) * u).toFixed(2);
112
+ const ay = (p) => +(y + (p - 12) * u).toFixed(2);
113
+ const w = (n) => +(n * u).toFixed(2);
114
+ const cap = 'stroke-linecap="round" stroke-linejoin="round" fill="none"';
115
+ return [
116
+ // glass dome
117
+ `<path d="M${ax(4.7)} ${ay(12.8)} C${ax(5.3)} ${ay(4.9)} ${ax(18.7)} ${ay(4.9)} ${ax(19.3)} ${ay(12.8)}" stroke="${color}" stroke-width="${w(1.6)}" ${cap}/>`,
118
+ // waterline
119
+ `<path d="M${ax(6.1)} ${ay(11.2)} C${ax(8.8)} ${ay(12.5)} ${ax(15.2)} ${ay(12.5)} ${ax(17.9)} ${ay(11.2)}" stroke="${accent}" stroke-width="${w(1.15)}" opacity="0.6" ${cap}/>`,
120
+ // dome highlight
121
+ `<path d="M${ax(8.0)} ${ay(7.7)} C${ax(10.0)} ${ay(5.7)} ${ax(13.2)} ${ay(5.4)} ${ax(15.8)} ${ay(6.1)}" stroke="${color}" stroke-width="${w(0.9)}" opacity="0.34" ${cap}/>`,
122
+ // deck base
123
+ `<rect x="${ax(3.4)}" y="${ay(12.2)}" width="${w(17.2)}" height="${w(7.8)}" rx="${w(2.2)}" stroke="${color}" stroke-width="${w(1.6)}" opacity="0.88" fill="none"/>`,
124
+ // three deck keys
125
+ `<rect x="${ax(6.5)}" y="${ay(15.4)}" width="${w(3.1)}" height="${w(2.0)}" rx="${w(1.0)}" fill="${color}" opacity="0.70"/>`,
126
+ `<rect x="${ax(10.4)}" y="${ay(15.4)}" width="${w(3.1)}" height="${w(2.0)}" rx="${w(1.0)}" fill="${color}" opacity="0.92"/>`,
127
+ `<rect x="${ax(14.3)}" y="${ay(15.4)}" width="${w(3.1)}" height="${w(2.0)}" rx="${w(1.0)}" fill="${color}" opacity="0.70"/>`,
128
+ // interior bubbles
129
+ `<circle cx="${ax(9.6)}" cy="${ay(9.0)}" r="${w(0.95)}" fill="${accent}" opacity="0.62"/>`,
130
+ `<circle cx="${ax(14.8)}" cy="${ay(8.2)}" r="${w(0.6)}" fill="${accent}" opacity="0.42"/>`,
131
+ ].join('');
132
+ }
133
+ /**
134
+ * Small-size reduction of {@link renderAgentDeckMark}.
135
+ *
136
+ * The full mark carries a waterline, a dome highlight and interior bubbles at
137
+ * 0.34–0.62 opacity. Those read at 72px+ but collapse into a grey blob by 20px,
138
+ * which is the size Stream Deck draws action-list icons at. This keeps the
139
+ * mark's three identifying features — dome arc, deck body, three keys — at full
140
+ * opacity and drops everything that cannot survive the downsample.
141
+ *
142
+ * Keep the two in sync: if the mark's silhouette changes, change this too.
143
+ */
144
+ export function renderAgentDeckMarkCompact(x, y, size, color) {
145
+ const u = size / 24;
146
+ const ax = (p) => +(x + (p - 12) * u).toFixed(2);
147
+ const ay = (p) => +(y + (p - 12) * u).toFixed(2);
148
+ const w = (n) => +(n * u).toFixed(2);
149
+ const cap = 'stroke-linecap="round" stroke-linejoin="round" fill="none"';
150
+ const key = (kx) => `<rect x="${ax(kx)}" y="${ay(14.6)}" width="${w(3.4)}" height="${w(2.4)}" rx="${w(1.2)}" fill="${color}"/>`;
151
+ return [
152
+ // dome arc, seated on the deck lip
153
+ `<path d="M${ax(4.6)} ${ay(11.6)} C${ax(5.3)} ${ay(3.9)} ${ax(18.7)} ${ay(3.9)} ${ax(19.4)} ${ay(11.6)}" stroke="${color}" stroke-width="${w(1.7)}" ${cap}/>`,
154
+ // deck body
155
+ `<rect x="${ax(3.2)}" y="${ay(11.6)}" width="${w(17.6)}" height="${w(8.6)}" rx="${w(2.3)}" stroke="${color}" stroke-width="${w(1.7)}" fill="none"/>`,
156
+ key(6.0), key(10.3), key(14.6),
157
+ ].join('');
158
+ }
159
+ function renderGlyphIcon(kind, color, accent, x = 72, y = 43, scale = 1) {
160
+ const s = scale;
161
+ const sx = (n) => x + n * s;
162
+ const sy = (n) => y + n * s;
163
+ const common = `stroke="${color}" stroke-linecap="round" stroke-linejoin="round"`;
164
+ const accentStroke = `stroke="${accent}" stroke-linecap="round" stroke-linejoin="round"`;
165
+ switch (kind) {
166
+ case 'hub':
167
+ return [
168
+ `<circle cx="${x}" cy="${y}" r="${9 * s}" fill="${accent}" opacity="0.18" stroke="${color}" stroke-width="${2.4 * s}"/>`,
169
+ `<circle cx="${sx(-24)}" cy="${sy(17)}" r="${5 * s}" fill="${color}" opacity="0.9"/>`,
170
+ `<circle cx="${sx(24)}" cy="${sy(17)}" r="${5 * s}" fill="${color}" opacity="0.9"/>`,
171
+ `<circle cx="${x}" cy="${sy(-25)}" r="${5 * s}" fill="${color}" opacity="0.9"/>`,
172
+ `<path d="M${sx(-7)} ${sy(7)} L${sx(-20)} ${sy(14)} M${sx(7)} ${sy(7)} L${sx(20)} ${sy(14)} M${x} ${sy(-9)} L${x} ${sy(-20)}" ${accentStroke} stroke-width="${2.6 * s}" opacity="0.72" fill="none"/>`,
173
+ ].join('');
174
+ case 'no-session':
175
+ return [
176
+ `<path d="M${sx(-25)} ${sy(-3)} H${sx(25)} V${sy(21)} Q${sx(25)} ${sy(27)} ${sx(19)} ${sy(27)} H${sx(-19)} Q${sx(-25)} ${sy(27)} ${sx(-25)} ${sy(21)} Z" fill="${accent}" opacity="0.14" stroke="${color}" stroke-width="${2.4 * s}"/>`,
177
+ `<path d="M${sx(-14)} ${sy(-14)} H${sx(14)} M${sx(-7)} ${sy(-24)} H${sx(7)}" ${common} stroke-width="${2.6 * s}" opacity="0.82"/>`,
178
+ `<circle cx="${sx(-10)}" cy="${sy(13)}" r="${2.8 * s}" fill="${color}" opacity="0.55"/><circle cx="${x}" cy="${sy(13)}" r="${2.8 * s}" fill="${color}" opacity="0.38"/><circle cx="${sx(10)}" cy="${sy(13)}" r="${2.8 * s}" fill="${color}" opacity="0.24"/>`,
179
+ ].join('');
180
+ case 'agentdeck':
181
+ // The canonical dome-over-deck brand mark (parity with the native overlays).
182
+ return renderAgentDeckMark(x, y, 54 * s, color, accent);
183
+ case 'tool':
184
+ return `<circle cx="${sx(-18)}" cy="${sy(-13)}" r="${7 * s}" fill="none" stroke="${color}" stroke-width="${2.6 * s}"/><path d="M${sx(-12)} ${sy(-7)} L${sx(4)} ${sy(9)}" ${common} stroke-width="${3.8 * s}" fill="none"/><rect x="${sx(4)}" y="${sy(5)}" width="${22 * s}" height="${12 * s}" rx="${5 * s}" transform="rotate(45 ${sx(15)} ${sy(11)})" fill="${accent}" opacity="0.82"/>`;
185
+ case 'model':
186
+ return `<path d="M${x} ${sy(-27)} L${sx(24)} ${sy(-13)} V${sy(15)} L${x} ${sy(29)} L${sx(-24)} ${sy(15)} V${sy(-13)} Z" fill="${accent}" opacity="0.12" stroke="${color}" stroke-width="${2.4 * s}"/><path d="M${sx(-24)} ${sy(-13)} L${x} ${sy(1)} L${sx(24)} ${sy(-13)} M${x} ${sy(1)} V${sy(29)}" ${common} stroke-width="${2 * s}" opacity="0.7" fill="none"/>`;
187
+ case 'mode':
188
+ return `<path d="M${sx(-24)} ${sy(-18)} H${sx(24)} M${sx(-24)} ${y} H${sx(24)} M${sx(-24)} ${sy(18)} H${sx(24)}" ${common} stroke-width="${2.8 * s}" opacity="0.72"/><circle cx="${sx(-8)}" cy="${sy(-18)}" r="${5 * s}" fill="${accent}" stroke="${color}" stroke-width="${2 * s}"/><circle cx="${sx(13)}" cy="${y}" r="${5 * s}" fill="${accent}" stroke="${color}" stroke-width="${2 * s}"/><circle cx="${sx(-15)}" cy="${sy(18)}" r="${5 * s}" fill="${accent}" stroke="${color}" stroke-width="${2 * s}"/>`;
189
+ case 'ready':
190
+ case 'allow':
191
+ return `<circle cx="${x}" cy="${y}" r="${27 * s}" fill="${accent}" opacity="0.15" stroke="${color}" stroke-width="${2.6 * s}"/><path d="M${sx(-13)} ${sy(0)} L${sx(-3)} ${sy(11)} L${sx(16)} ${sy(-12)}" ${common} stroke-width="${5 * s}" fill="none"/>`;
192
+ case 'activity':
193
+ return `<path d="M${sx(-29)} ${y} H${sx(-18)} L${sx(-10)} ${sy(-16)} L${sx(3)} ${sy(18)} L${sx(12)} ${sy(-4)} H${sx(29)}" ${common} stroke-width="${4 * s}" fill="none"/><circle cx="${sx(29)}" cy="${y}" r="${4 * s}" fill="${accent}"/>`;
194
+ case 'open-app':
195
+ case 'play':
196
+ return `<rect x="${sx(-25)}" y="${sy(-22)}" width="${50 * s}" height="${44 * s}" rx="${9 * s}" fill="${accent}" opacity="0.16" stroke="${color}" stroke-width="${2.2 * s}"/><polygon points="${sx(-7)},${sy(-13)} ${sx(-7)},${sy(13)} ${sx(15)},${y}" fill="${color}"/>`;
197
+ case 'retry':
198
+ return `<path d="M${sx(20)} ${sy(-12)} A${26 * s} ${26 * s} 0 1 0 ${sx(23)} ${sy(13)}" fill="none" ${common} stroke-width="${4.5 * s}"/><path d="M${sx(20)} ${sy(-28)} V${sy(-10)} H${sx(2)}" fill="none" ${common} stroke-width="${4.5 * s}"/>`;
199
+ case 'offline':
200
+ return `<circle cx="${x}" cy="${y}" r="${25 * s}" fill="${accent}" opacity="0.14" stroke="${color}" stroke-width="${2.4 * s}"/><circle cx="${x}" cy="${y}" r="${4.5 * s}" fill="${color}"/><path d="M${x} ${sy(5)} V${sy(17)} M${sx(-16)} ${sy(18)} H${sx(16)}" ${accentStroke} stroke-width="${2.6 * s}" opacity="0.68"/>`;
201
+ case 'back':
202
+ return `<path d="M${sx(21)} ${y} H${sx(-18)} M${sx(-18)} ${y} L${sx(-2)} ${sy(-16)} M${sx(-18)} ${y} L${sx(-2)} ${sy(16)}" ${common} stroke-width="${5 * s}" fill="none"/>`;
203
+ case 'more':
204
+ return `<path d="M${sx(-18)} ${sy(-15)} L${sx(0)} ${y} L${sx(-18)} ${sy(15)} M${sx(3)} ${sy(-15)} L${sx(21)} ${y} L${sx(3)} ${sy(15)}" ${common} stroke-width="${4.5 * s}" fill="none"/>`;
205
+ case 'esc':
206
+ case 'deny':
207
+ return `<circle cx="${x}" cy="${y}" r="${27 * s}" fill="${accent}" opacity="0.14" stroke="${color}" stroke-width="${2.2 * s}"/><path d="M${sx(-12)} ${sy(-12)} L${sx(12)} ${sy(12)} M${sx(12)} ${sy(-12)} L${sx(-12)} ${sy(12)}" ${common} stroke-width="${4.8 * s}" fill="none"/>`;
208
+ case 'stop':
209
+ return `<rect x="${sx(-22)}" y="${sy(-22)}" width="${44 * s}" height="${44 * s}" rx="${8 * s}" fill="${accent}" opacity="0.16" stroke="${color}" stroke-width="${2.2 * s}"/><rect x="${sx(-11)}" y="${sy(-11)}" width="${22 * s}" height="${22 * s}" rx="${3 * s}" fill="${color}"/>`;
210
+ case 'review':
211
+ case 'diff':
212
+ return `<rect x="${sx(-18)}" y="${sy(-25)}" width="${36 * s}" height="${50 * s}" rx="${5 * s}" fill="${accent}" opacity="0.13" stroke="${color}" stroke-width="${2.4 * s}"/><path d="M${sx(-9)} ${sy(-10)} H${sx(10)} M${sx(-9)} ${sy(1)} H${sx(6)} M${sx(-9)} ${sy(12)} H${sx(2)}" ${common} stroke-width="${2.2 * s}" opacity="0.76"/>`;
213
+ case 'commit':
214
+ return `<circle cx="${x}" cy="${sy(-15)}" r="${7 * s}" fill="${color}"/><circle cx="${sx(-21)}" cy="${sy(18)}" r="${7 * s}" fill="${color}"/><circle cx="${sx(21)}" cy="${sy(18)}" r="${7 * s}" fill="${color}"/><path d="M${x} ${sy(-8)} V${sy(7)} M${x} ${sy(7)} H${sx(-21)} V${sy(11)} M${x} ${sy(7)} H${sx(21)} V${sy(11)}" ${accentStroke} stroke-width="${3 * s}" fill="none"/>`;
215
+ case 'clear':
216
+ return `<path d="M${sx(-16)} ${sy(-16)} L${sx(16)} ${sy(16)} M${sx(16)} ${sy(-16)} L${sx(-16)} ${sy(16)}" ${common} stroke-width="${4.5 * s}" fill="none"/>`;
217
+ case 'gateway':
218
+ return `<rect x="${sx(-28)}" y="${sy(-21)}" width="${56 * s}" height="${42 * s}" rx="${6 * s}" fill="${accent}" opacity="0.12" stroke="${color}" stroke-width="${2.2 * s}"/><path d="M${sx(-28)} ${sy(-8)} H${sx(28)} M${sx(-12)} ${sy(7)} H${sx(12)} M${x} ${sy(-5)} V${sy(19)}" ${common} stroke-width="${2.2 * s}" opacity="0.72"/>`;
219
+ case 'status':
220
+ return `<circle cx="${x}" cy="${y}" r="${27 * s}" fill="${accent}" opacity="0.14" stroke="${color}" stroke-width="${2.4 * s}"/><path d="M${x} ${sy(-15)} V${sy(4)}" ${common} stroke-width="${4.4 * s}"/><circle cx="${x}" cy="${sy(17)}" r="${4.5 * s}" fill="${color}"/>`;
221
+ case 'option':
222
+ default:
223
+ return `<rect x="${sx(-25)}" y="${sy(-20)}" width="${50 * s}" height="${40 * s}" rx="${8 * s}" fill="${accent}" opacity="0.13" stroke="${color}" stroke-width="${2.2 * s}"/><circle cx="${sx(-12)}" cy="${y}" r="${4 * s}" fill="${color}"/><circle cx="${x}" cy="${y}" r="${4 * s}" fill="${color}"/><circle cx="${sx(12)}" cy="${y}" r="${4 * s}" fill="${color}"/>`;
224
+ }
57
225
  }
58
- // ---- Session List Button ----
59
226
  export function renderSessionSlot(session, isActive, animFrame, displayName, options) {
60
227
  const isWorking = session.state === 'processing';
61
228
  const isAsking = session.state?.startsWith('awaiting') ?? false;
62
229
  const isIdle = !isWorking && !isAsking;
63
- // Stream Deck / SD+ run a 150ms tick — animated. D200H's TypeScript image
64
- // pipeline (bridge/src/d200h/image-renderer.ts) has no such loop: passing
65
- // animFrame=0 every time freezes dashed strokes and leaves the sin-based
66
- // pulse at a dim phase. Callers without a loop should pass animated:false
67
- // so we emit a motion-free visual (solid borders + badges) that still
68
- // clearly distinguishes working vs awaiting vs idle.
69
230
  const animated = options?.animated ?? true;
70
231
  const agent = session.agentType || 'claude-code';
71
232
  const nameForDisplay = displayName ?? session.projectName;
72
- // Bottom line budget: tile width ~124px at 14px font ≈ 16 chars. Leave a
73
- // little headroom so "Opus 4.7 · max" fits without running into the
74
- // watermark on the right.
75
233
  const modelText = formatModelEffort(session.modelName, session.effortLevel, 15);
76
- // Custom palette mimicking the premium demo
77
- const p1 = agent === 'claude-code' ? '#D97757' : agent === 'codex-cli' ? '#8BA4FF' : agent === 'openclaw' ? '#FF6B6B' : '#F1ECEC';
78
- const p2 = agent === 'claude-code' ? '#BE6D52' : agent === 'codex-cli' ? '#5981FF' : agent === 'openclaw' ? '#CC3333' : '#AFAFAF';
234
+ const p1 = agent === 'claude-code' ? '#D97757' : (agent === 'codex-cli' || agent === 'codex-app') ? '#8BA4FF' : agent === 'openclaw' ? '#FF6B6B' : '#F1ECEC';
79
235
  const sColor = stateColor(session.state);
236
+ // RUNNING reads as a COOL teal (calm "in progress"); PERM keeps the semantic
237
+ // amber (stateColor → #f59e0b). Previously RUNNING used a gold #F5B942 that
238
+ // collided with awaiting's amber, so the two states looked near-identical.
239
+ const WORKING_COLOR = '#2DD4BF'; // teal — cool, clearly not amber
240
+ const signalColor = isWorking ? WORKING_COLOR : sColor;
80
241
  const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
81
242
  const stateLbl = isWorking ? 'RUNNING' : isAsking ? 'PERMIT?' : 'IDLE';
82
- const colorText = isWorking ? '#FDE68A' : isAsking ? '#FECACA' : p1;
243
+ const colorText = isWorking ? '#CCFBF1' : isAsking ? '#FCD34D' : p1;
83
244
  const gradId = `sd-bg-${agent}-${session.state || 'idle'}`;
84
- let defs = `
85
- <linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%">
86
- <stop offset="0%" stop-color="#1C1C1E"/>
87
- <stop offset="100%" stop-color="#0C0C0E"/>
88
- </linearGradient>
89
- `;
90
- let glowBorder = '';
245
+ const filterId = `pg-${animFrame}`;
246
+ let defs = `<linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#1C1C1E"/><stop offset="100%" stop-color="#0C0C0E"/></linearGradient>`;
247
+ const blurDef = `<filter id="${filterId}" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur in="SourceGraphic" stdDeviation="2.4"/></filter>`;
248
+ let stateBorder = '';
249
+ let activeRing = '';
91
250
  let askDot = '';
92
251
  let runBadge = '';
93
- const filterId = `pg-${animFrame}`;
94
- const blurDef = `
95
- <filter id="${filterId}" x="-10%" y="-10%" width="120%" height="120%">
96
- <feGaussianBlur in="SourceGraphic" stdDeviation="2.4"/>
97
- </filter>
98
- `;
99
- if (isAsking) {
100
- // Animated callers pulse the opacity (0.55…1.0 over a sin wave). Static
101
- // callers (D200H) pin to a near-peak value so the frozen snapshot reads
102
- // as bright and attention-grabbing instead of dim.
103
- const pulseOpacity = animated
104
- ? 0.55 + 0.45 * Math.abs(Math.sin(animFrame * 0.15))
105
- : 0.95;
252
+ if (isWorking) {
253
+ // RUNNING: a teal border of marching dashes ORBITING the key — motion that
254
+ // reads as "work travelling around", distinct from PERM's steady breath.
106
255
  defs += blurDef;
107
- // Thick outer glow + crisp inner ring distinct "breathing" solid border.
108
- glowBorder =
109
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="4.5" opacity="${pulseOpacity.toFixed(2)}" filter="url(#${filterId})"/>` +
110
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="1.5" opacity="${(pulseOpacity * 0.9).toFixed(2)}"/>`;
111
- askDot = `
112
- <circle cx="114" cy="24" r="5" fill="#F5B942" filter="url(#${filterId})"/>
113
- <circle cx="114" cy="24" r="3" fill="#ffffff" />
114
- `;
256
+ const pulseOpacity = animated ? 0.72 + 0.20 * Math.abs(Math.sin(animFrame * 0.12)) : 0.9;
257
+ const borderColor = signalColor;
258
+ const orbitSpeedPx = 22;
259
+ // Anchor the rotation to when this session entered the animated state so
260
+ // sibling buttons that started later orbit out of phase, not in lockstep.
261
+ const startFrame = options?.processingStartFrame ?? animFrame;
262
+ const processingPhasePx = -((startFrame * orbitSpeedPx) % BORDER_PERIMETER);
263
+ stateBorder = animated
264
+ ? renderOrbitingRect({
265
+ x: 8, y: 8, width: 128, height: 128, rx: 12, color: borderColor, animFrame,
266
+ speedPx: orbitSpeedPx, dashPx: 92,
267
+ phasePx: processingPhasePx,
268
+ glowOpacity: pulseOpacity * 0.72, coreOpacity: Math.min(1, pulseOpacity + 0.06), filterId,
269
+ })
270
+ : `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${borderColor}" stroke-width="4.5" opacity="${pulseOpacity.toFixed(2)}" filter="url(#${filterId})"/><rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${borderColor}" stroke-width="1.5" opacity="${(pulseOpacity * 0.9).toFixed(2)}"/>`;
271
+ runBadge = `<rect x="99" y="14" width="30" height="16" rx="8" fill="${signalColor}" opacity="0.9" /><text x="114" y="25" font-size="9" font-weight="800" text-anchor="middle" fill="#0C0C0E" font-family="${fontFam}">RUN</text>`;
115
272
  }
116
- else if (isWorking) {
117
- if (animated) {
118
- // Flowing-light dashed border SD/SD+ 150ms tick advances animFrame
119
- // dashoffset drifts chasing light.
120
- const perimeter = 128 * 4;
121
- const dashOffset = ((animFrame * 4) % perimeter).toFixed(0);
122
- defs += blurDef;
123
- glowBorder =
124
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="4.5" stroke-dasharray="22 14" stroke-dashoffset="-${dashOffset}" opacity="0.85" filter="url(#${filterId})"/>` +
125
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="1.5" stroke-dasharray="22 14" stroke-dashoffset="-${dashOffset}" opacity="0.95"/>`;
126
- }
127
- else {
128
- // Non-animated callers (D200H still-image pipeline): render a solid
129
- // glowing border + a play-chevron badge so WORKING is obvious without
130
- // motion. The amber border is still the primary signal; the ▶ badge
131
- // distinguishes this from the red awaiting state at a glance.
273
+ else if (isAsking) {
274
+ // PERM / AWAITING: a SOLID amber border that BREATHES (full perimeter, no
275
+ // marching dashes)a deliberately different motion + hue from RUNNING so
276
+ // "needs you" is unmistakable at a glance. Plus a bold amber PERM badge.
277
+ defs += blurDef;
278
+ const amber = sColor; // #f59e0b
279
+ const breathe = animated ? 0.45 + 0.55 * Math.abs(Math.sin(animFrame * 0.14)) : 1;
280
+ stateBorder = [
281
+ `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${amber}" stroke-width="7" opacity="${(breathe * 0.6).toFixed(2)}" filter="url(#${filterId})"/>`,
282
+ `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${amber}" stroke-width="3" opacity="0.97"/>`,
283
+ ].join('');
284
+ // Bold filled amber pill — far more legible than the old 5px dot.
285
+ askDot = `<rect x="90" y="12" width="42" height="19" rx="9.5" fill="${amber}"/><text x="111" y="26" font-size="11" font-weight="800" text-anchor="middle" fill="#221500" font-family="${fontFam}">PERM</text>`;
286
+ }
287
+ if (isActive) {
288
+ if (animated && isIdle)
132
289
  defs += blurDef;
133
- glowBorder =
134
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="4.5" opacity="0.9" filter="url(#${filterId})"/>` +
135
- `<rect x="8" y="8" width="128" height="128" rx="12" fill="none" stroke="${sColor}" stroke-width="1.5" opacity="0.95"/>`;
136
- runBadge = `
137
- <rect x="100" y="14" width="28" height="16" rx="8" fill="${sColor}" opacity="0.9" />
138
- <text x="114" y="26" font-size="11" font-weight="800" text-anchor="middle" fill="#0C0C0E" font-family="${fontFam}">▶ RUN</text>
139
- `;
140
- }
290
+ activeRing = animated
291
+ ? renderOrbitingRect({
292
+ x: 10.5, y: 10.5, width: 123, height: 123, rx: 10.5, color: '#60A5FA', animFrame,
293
+ speedPx: 16, phasePx: 170, dashPx: isIdle ? 72 : 52,
294
+ railOpacity: isIdle ? 0.20 : 0.10, glowOpacity: isIdle ? 0.42 : 0.24,
295
+ coreOpacity: isIdle ? 0.95 : 0.58, railWidth: 1, glowWidth: isIdle ? 3.6 : 2.6,
296
+ coreWidth: isIdle ? 1.8 : 1.3, filterId: isIdle ? filterId : undefined,
297
+ })
298
+ : `<rect x="10.5" y="10.5" width="123" height="123" rx="10.5" fill="none" stroke="#60A5FA" stroke-width="1.5" opacity="${isIdle ? '0.72' : '0.36'}"/>`;
141
299
  }
142
- // Simplified Agent watermark at bottom-right
143
- // agentLogoIcon renders at x,y with given width/height
144
- const watermark = `<g transform="translate(90, 86)" opacity="${isIdle ? '0.46' : '0.34'}">
145
- ${agentLogoIcon(agent, 40, 1, 0, 0)}
146
- </g>`;
147
- // Left Edge Color Strip
148
- const leftStrip = `<rect x="8" y="8" width="4" height="128" rx="2" fill="${isWorking ? '#F5B942' : isAsking ? '#F87171' : p1}"/>`;
149
- // Border is now the primary working/awaiting signal — no tiny spinner glyph.
150
- const spinner = '';
151
- // ACT badge only on IDLE — WORKING has the flowing border, AWAITING has the pulse.
152
- const badgeObj = isIdle ? `
153
- <rect x="100" y="14" width="28" height="16" rx="8" fill="#ffffff" opacity="0.1" />
154
- <text x="114" y="25" font-size="10" font-weight="700" text-anchor="middle" fill="#A1A1AA" font-family="${fontFam}">ACT</text>
155
- ` : '';
300
+ const watermark = `<g transform="translate(92, 80)" opacity="${isIdle ? '0.62' : '0.55'}">${agentLogoIcon(agent, 72, 1, 0, 0)}</g>`;
301
+ const badgeObj = isIdle ? `<rect x="100" y="14" width="28" height="16" rx="8" fill="#ffffff" opacity="0.1" /><text x="114" y="25" font-size="10" font-weight="700" text-anchor="middle" fill="#A1A1AA" font-family="${fontFam}">ACT</text>` : '';
156
302
  const toolStr = isWorking ? 'Running task' : modelText;
157
303
  const elements = [
158
304
  `<defs>${defs}</defs>`,
159
305
  `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="url(#${gradId})"/>`,
160
306
  `<rect x="8" y="8" width="128" height="128" rx="12" fill="#2C2C2E" opacity="0.8"/>`,
161
- glowBorder,
162
- leftStrip,
163
- watermark,
164
- spinner,
165
- askDot,
166
- runBadge,
167
- badgeObj,
307
+ stateBorder, activeRing, watermark, askDot, runBadge, badgeObj,
168
308
  `<text x="20" y="32" font-size="17" font-weight="800" text-anchor="start" fill="${colorText}" font-family="${fontFam}">${escXml(stateLbl)}</text>`,
169
309
  `<text x="20" y="52" font-size="13" font-weight="600" text-anchor="start" fill="#E2E8F0" font-family="${fontFam}">${escXml(truncate(nameForDisplay, 13))}</text>`,
170
- `<text x="20" y="120" font-size="${isWorking ? '13' : '14'}" font-weight="500" text-anchor="start" fill="${colorText}" opacity="0.8" font-family="${fontFam}">${escXml(toolStr)}</text>`
310
+ `<text x="20" y="120" font-size="${isWorking ? '13' : '14'}" font-weight="500" text-anchor="start" fill="${colorText}" opacity="0.8" font-family="${fontFam}">${escXml(toolStr)}</text>`,
311
+ // Stale overlay: the daemon stopped responding (no pings/state for the
312
+ // stale window) but hasn't yet hit the hard disconnect. Dim the last-known
313
+ // render and flag it so the state on screen isn't mistaken for live.
314
+ options?.isStale
315
+ ? `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="#0C0C0E" opacity="0.5"/>`
316
+ + `<rect x="20" y="62" width="50" height="17" rx="8" fill="#71717A" opacity="0.92"/>`
317
+ + `<text x="45" y="74" font-size="10" font-weight="800" text-anchor="middle" fill="#0C0C0E" font-family="${fontFam}">STALE</text>`
318
+ : '',
171
319
  ].join('');
172
320
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">${elements}</svg>`;
173
321
  }
174
- // ---- Empty Slot ----
175
322
  export function renderEmptySlot() {
176
- const label = `<text x="72" y="76" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#333333">Empty</text>`;
177
- return svgFrame('#111111', label);
323
+ return renderQuietSlot();
178
324
  }
179
- export function renderNoDaemonSlot(slot) {
180
- if (slot === 0) {
181
- // START button — launches macOS app
182
- const elements = [
183
- `<text x="72" y="56" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" fill="#94a3b8">AgentDeck</text>`,
184
- `<text x="72" y="96" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="#22c55e">\u25B6 START</text>`,
185
- ].join('');
186
- return svgFrame('#0f1a0f', elements);
325
+ export function renderQuietSlot() {
326
+ return svgFrame('#0a0a0a', '');
327
+ }
328
+ export function renderStatusCard(config) {
329
+ const colors = toneColors(config.tone);
330
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
331
+ const label = truncate(config.label, 14);
332
+ const subtitle = truncate(config.subtitle ?? '', 18);
333
+ // 22, not 17: the only detail we ship is the daemon install hint
334
+ // (`npx @agentdeck/setup`, 20 chars) on the D200H offline hero, and a 17-char
335
+ // cap rendered it as `npx @agentdeck/s…` — a truncated command is worse than
336
+ // none. At font-size 9 on the 144-wide canvas, 22 chars is ~99px, still well
337
+ // inside the frame.
338
+ const detail = truncate(config.detail ?? '', 22);
339
+ const elements = [
340
+ `<rect x="25" y="18" width="94" height="58" rx="14" fill="${colors.panel}" opacity="0.68" stroke="${colors.accent}" stroke-width="1.2" stroke-opacity="0.28"/>`,
341
+ renderGlyphIcon(config.icon, colors.icon, colors.accent, 72, 43, 0.94),
342
+ `<text x="72" y="${subtitle ? 94 : 103}" text-anchor="middle" font-family="${fontFam}" font-size="${label.length > 11 ? '14' : '16'}" font-weight="800" fill="${colors.text}">${escXml(label)}</text>`,
343
+ subtitle ? `<text x="72" y="112" text-anchor="middle" font-family="${fontFam}" font-size="11" font-weight="650" fill="${colors.sub}" opacity="0.86">${escXml(subtitle)}</text>` : '',
344
+ detail ? `<text x="72" y="127" text-anchor="middle" font-family="${fontFam}" font-size="9" font-weight="600" fill="${colors.sub}" opacity="0.52">${escXml(detail)}</text>` : '',
345
+ ].join('');
346
+ return svgFrame(colors.bg, elements);
347
+ }
348
+ export function renderDisconnectedSlot(config) {
349
+ if (config.kind === 'empty')
350
+ return renderQuietSlot();
351
+ if (config.col !== undefined &&
352
+ config.row !== undefined &&
353
+ config.cols !== undefined &&
354
+ config.rows !== undefined) {
355
+ return renderOpenAppGrid(config.col, config.row, config.cols, config.rows, config.label ?? PASSIVE_OFFLINE_LABEL, config.subtitle ?? OPEN_AGENTDECK_LABEL);
187
356
  }
188
- return svgFrame('#111111', '');
357
+ if (config.quadrant) {
358
+ return renderOpenAppQuadrant(config.quadrant, config.label ?? PASSIVE_OFFLINE_LABEL, config.subtitle ?? OPEN_AGENTDECK_LABEL);
359
+ }
360
+ return renderStatusCard({ icon: 'agentdeck', label: config.label ?? PASSIVE_OFFLINE_LABEL, subtitle: config.subtitle ?? OPEN_AGENTDECK_LABEL, detail: config.detail, tone: 'brand' });
361
+ }
362
+ /**
363
+ * Renders a single 144×144 viewport into a 288×288 cluster hero. Each of the
364
+ * four center keys (tl/tr/bl/br) emits the same logical content offset by its
365
+ * quadrant; viewBox clipping keeps only the relevant 144×144 visible. The
366
+ * cluster reads as one card visually, with the inner panel + glyph + text
367
+ * spanning all four keys while each key keeps its own outer rounded bezel.
368
+ */
369
+ export function renderOpenAppQuadrant(quadrant, label = PASSIVE_OFFLINE_LABEL, subtitle = OPEN_AGENTDECK_LABEL) {
370
+ const colors = toneColors('brand');
371
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
372
+ const offsetX = (quadrant === 'tr' || quadrant === 'br') ? -SIZE : 0;
373
+ const offsetY = (quadrant === 'bl' || quadrant === 'br') ? -SIZE : 0;
374
+ const gradId = `frame-bg-cluster-${quadrant}`;
375
+ // 288×288 cluster content. All coordinates are in the logical cluster space;
376
+ // the per-key viewBox + transform crops to one quadrant.
377
+ const cluster = [
378
+ `<rect x="16" y="16" width="256" height="256" rx="28" fill="${colors.panel}" opacity="0.68" stroke="${colors.accent}" stroke-width="2.4" stroke-opacity="0.28"/>`,
379
+ renderGlyphIcon('agentdeck', colors.icon, colors.accent, 144, 86, 1.88),
380
+ `<text x="144" y="208" text-anchor="middle" font-family="${fontFam}" font-size="36" font-weight="800" fill="${colors.text}">${escXml(truncate(label, 14))}</text>`,
381
+ `<text x="144" y="240" text-anchor="middle" font-family="${fontFam}" font-size="20" font-weight="650" fill="${colors.sub}" opacity="0.86">${escXml(truncate(subtitle, 18))}</text>`,
382
+ ].join('');
383
+ return [
384
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">`,
385
+ `<defs><linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="${colors.bg}"/><stop offset="100%" stop-color="#0A0A0E"/></linearGradient></defs>`,
386
+ `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="url(#${gradId})"/>`,
387
+ `<g transform="translate(${offsetX} ${offsetY})">${cluster}</g>`,
388
+ `</svg>`,
389
+ ].join('');
189
390
  }
190
- // ---- Navigation Buttons ----
191
391
  export function renderBackButton() {
192
- const arrow = `<text x="72" y="76" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" fill="#94a3b8">\u2190</text>`;
193
- const label = `<text x="72" y="108" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="600" fill="#94a3b8">BACK</text>`;
194
- return svgFrame('#1a1a1a', arrow + label);
392
+ return renderStatusCard({ icon: 'back', label: 'BACK', subtitle: 'sessions', tone: 'muted' });
195
393
  }
196
394
  export function renderNextPageButton(pageLabel) {
197
- const arrow = `<text x="72" y="64" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" fill="#60a5fa">\u25B6</text>`;
198
- const label = `<text x="72" y="92" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="600" fill="#60a5fa">NEXT</text>`;
199
- const page = `<text x="72" y="114" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" fill="#64748b">${escXml(pageLabel)}</text>`;
200
- return svgFrame('#1a1a2e', arrow + label + page);
395
+ return renderStatusCard({ icon: 'more', label: 'MORE', subtitle: pageLabel, tone: 'info' });
201
396
  }
202
397
  export function renderEscButton(active = true) {
203
- const c = active ? '#f97316' : '#a0855a';
204
- const bg = active ? '#2d1810' : '#1a1308';
205
- const op = active ? '' : ' opacity="0.45"';
206
- const elements = [
207
- `<text x="72" y="62" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" fill="${c}"${op}>\u2716</text>`,
208
- `<text x="72" y="100" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="${c}"${op}>ESC</text>`,
209
- ].join('');
210
- return svgFrame(bg, elements);
398
+ return renderStatusCard({ icon: 'esc', label: 'ESC', subtitle: active ? 'cancel' : 'idle', tone: active ? 'warning' : 'muted' });
211
399
  }
212
400
  export function renderStopButton(active = true) {
213
- const c = active ? '#ef4444' : '#666666';
214
- const bg = active ? '#2d1010' : '#1a0a0a';
215
- const op = active ? '' : ' opacity="0.4"';
216
- const elements = [
217
- `<text x="72" y="62" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" fill="${c}"${op}>\u25A0</text>`,
218
- `<text x="72" y="100" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="${c}"${op}>STOP</text>`,
219
- ].join('');
220
- return svgFrame(bg, elements);
401
+ return renderStatusCard({ icon: 'stop', label: 'STOP', subtitle: active ? 'interrupt' : 'idle', tone: active ? 'danger' : 'muted' });
221
402
  }
222
- // ---- Detail View: Session Info ----
223
403
  export function renderDetailInfo(session, state, tool, modelName, mode, displayName, effortLevel) {
224
404
  if (!session)
225
405
  return renderEmptySlot();
226
406
  const agent = session.agentType || 'claude-code';
227
407
  const nameForDisplay = displayName ?? session.projectName;
228
408
  const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
229
- const sColor = stateColor(session.state);
230
- const stateLbl = stateLabel(session.state, agent);
409
+ const effectiveState = state || session.state;
410
+ const sColor = stateColor(effectiveState);
411
+ const stateLbl = stateLabel(effectiveState, agent);
231
412
  const gradId = `sd-bg-detail-${agent}`;
232
- const defs = `
233
- <linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%">
234
- <stop offset="0%" stop-color="#151720"/>
235
- <stop offset="100%" stop-color="#0A0B10"/>
236
- </linearGradient>
237
- `;
238
- const watermark = `<g transform="translate(90, 86)" opacity="0.36">
239
- ${agentLogoIcon(agent, 40, 1, 0, 0)}
240
- </g>`;
241
- const leftStrip = `<rect x="8" y="8" width="4" height="128" rx="2" fill="${sColor}"/>`;
242
- const badgeObj = `
243
- <rect x="100" y="14" width="28" height="16" rx="8" fill="#ffffff" opacity="0.1" />
244
- <text x="114" y="25" font-size="10" font-weight="700" text-anchor="middle" fill="#A1A1AA" font-family="${fontFam}">INFO</text>
245
- `;
246
- const toolDisplay = tool ? `\u25B6 ${truncate(tool, 18)}` : stateLbl;
413
+ const watermark = `<g transform="translate(92, 80)" opacity="0.42">${agentLogoIcon(agent, 48, 1, 0, 0)}</g>`;
414
+ const badgeObj = `<rect x="100" y="14" width="28" height="16" rx="8" fill="#ffffff" opacity="0.1" /><text x="114" y="25" font-size="10" font-weight="700" text-anchor="middle" fill="#A1A1AA" font-family="${fontFam}">INFO</text>`;
415
+ const toolDisplay = tool ? `▶ ${truncate(tool, 18)}` : stateLbl;
247
416
  const elements = [
248
- `<defs>${defs}</defs>`,
417
+ `<defs><linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#151720"/><stop offset="100%" stop-color="#0A0B10"/></linearGradient></defs>`,
249
418
  `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="url(#${gradId})"/>`,
250
419
  `<rect x="8" y="8" width="128" height="128" rx="12" fill="#1C1F2E" opacity="0.8"/>`,
251
- leftStrip,
252
- watermark,
253
- badgeObj,
254
- // Title
420
+ watermark, badgeObj,
255
421
  `<text x="20" y="34" font-size="18" font-weight="800" text-anchor="start" fill="#ffffff" font-family="${fontFam}">${escXml(truncate(nameForDisplay, 10))}</text>`,
256
- // Model/Mode — effort renders inline with model (e.g. "Opus 4.7 · max")
257
- // so the permission-mode chip below keeps its y=74 slot.
258
422
  (modelName && agent !== 'openclaw') ? `<text x="20" y="56" font-size="12" font-weight="600" text-anchor="start" fill="#94a3b8" font-family="${fontFam}">${escXml(formatModelEffort(modelName, effortLevel, 17))}</text>` : '',
259
423
  (mode && mode !== 'default' && agent !== 'openclaw') ? `<text x="20" y="74" font-size="11" font-weight="700" text-anchor="start" fill="#a78bfa" font-family="${fontFam}">${escXml(mode.toUpperCase())}</text>` : '',
260
- // Tool/State
261
424
  `<text x="20" y="120" font-size="12" font-weight="700" text-anchor="start" fill="${tool ? '#fbbf24' : sColor}" font-family="${fontFam}">${escXml(toolDisplay)}</text>`,
262
425
  ].join('');
263
426
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">${elements}</svg>`;
264
427
  }
265
- // ---- Detail View: Option Button ----
428
+ function optionVisual(label) {
429
+ const lower = label.toLowerCase();
430
+ if (lower.includes('diff') || lower.includes('review') || lower.includes('view'))
431
+ return { icon: 'diff', tone: 'info' };
432
+ if (lower.startsWith('yes') || lower.startsWith('allow') || lower.startsWith('apply') || lower.includes("don't ask") || lower.includes('always'))
433
+ return { icon: 'allow', tone: 'ready' };
434
+ if (lower.startsWith('no') || lower.startsWith('deny') || lower.startsWith('reject') || lower.includes('cancel'))
435
+ return { icon: 'deny', tone: 'danger' };
436
+ return { icon: 'option', tone: 'purple' };
437
+ }
266
438
  export function renderOptionButton(option, index) {
267
439
  const label = option.label || `Option ${index + 1}`;
268
- const lines = wrapTextByWidth(label, MAX_TEXT_PX, 16);
269
- const lineHeight = 20;
270
- const startY = lines.length === 1 ? 76 : 76 - ((lines.length - 1) * lineHeight) / 2;
271
- // Slot number (top-right)
272
- const slotNum = `<text x="${SIZE - 10}" y="18" text-anchor="end" font-family="Arial,sans-serif" font-size="13" fill="#ffffff" opacity="0.25">${index + 1}</text>`;
273
- const textEls = lines.slice(0, 3).map((line, i) => `<text x="72" y="${startY + i * lineHeight}" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#ffffff">${escXml(line)}</text>`).join('');
274
- // Use different bg colors for visual distinction
275
- const colors = ['#1e3a5f', '#1e3a4a', '#2a1e4a', '#1e4a3a', '#3a1e2a'];
276
- const bgColor = colors[index % colors.length];
277
- return svgFrame(bgColor, slotNum + textEls);
440
+ const { icon, tone } = optionVisual(label);
441
+ const colors = toneColors(tone);
442
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
443
+ const lines = wrapTextByWidth(label, 112, 13).slice(0, 2);
444
+ const startY = lines.length === 1 ? 103 : 96;
445
+ const slotNum = `<circle cx="119" cy="23" r="12" fill="${colors.panel}" stroke="${colors.accent}" stroke-width="1" opacity="0.9"/><text x="119" y="28" text-anchor="middle" font-family="${fontFam}" font-size="12" font-weight="800" fill="${colors.text}" opacity="0.8">${index + 1}</text>`;
446
+ const textEls = lines.map((line, i) => `<text x="72" y="${startY + i * 17}" text-anchor="middle" font-family="${fontFam}" font-size="${line.length > 13 ? '12' : '13'}" font-weight="800" fill="${colors.text}">${escXml(line)}</text>`).join('');
447
+ return svgFrame(colors.bg, `<rect x="25" y="18" width="94" height="58" rx="14" fill="${colors.panel}" opacity="0.58" stroke="${colors.accent}" stroke-width="1.2" stroke-opacity="0.26"/>`
448
+ + renderGlyphIcon(icon, colors.icon, colors.accent, 72, 43, 0.84)
449
+ + slotNum
450
+ + textEls);
278
451
  }
279
- // ---- Detail View: Preset Action Button ----
280
452
  export function renderPresetButton(label, iconSvg, color, textColor, subtitle, loading) {
453
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
281
454
  if (loading) {
282
- return svgFrame(color, iconSvg);
455
+ return svgFrame(color, iconSvg + `<text x="72" y="103" text-anchor="middle" font-family="${fontFam}" font-size="13" font-weight="800" fill="${textColor}">SWITCHING</text>`);
283
456
  }
284
- const labelEl = `<text x="72" y="100" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="${textColor}">${escXml(label)}</text>`;
285
- const subEl = subtitle
286
- ? `<text x="72" y="118" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="${textColor}" opacity="0.7">${escXml(truncate(subtitle, 14))}</text>`
287
- : '';
457
+ const labelEl = `<text x="72" y="100" text-anchor="middle" font-family="${fontFam}" font-size="16" font-weight="800" fill="${textColor}">${escXml(label)}</text>`;
458
+ const subEl = subtitle ? `<text x="72" y="118" text-anchor="middle" font-family="${fontFam}" font-size="11" font-weight="650" fill="${textColor}" opacity="0.7">${escXml(truncate(subtitle, 14))}</text>` : '';
288
459
  return svgFrame(color, iconSvg + labelEl + subEl);
289
460
  }
290
- // ---- Detail View: Info slot (tool, model/mode) ----
291
- export function renderInfoSlot(label, subtitle) {
292
- const mainEl = `<text x="72" y="${subtitle ? 68 : 76}" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#94a3b8">${escXml(truncate(label, 18))}</text>`;
293
- const subEl = subtitle
294
- ? `<text x="72" y="92" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#64748b">${escXml(truncate(subtitle, 22))}</text>`
295
- : '';
296
- return svgFrame('#1a1a1a', mainEl + subEl);
461
+ export function renderInfoSlot(label, subtitle, icon = 'activity', tone = 'info', detail) {
462
+ return renderStatusCard({ icon, label, subtitle, detail, tone });
297
463
  }
298
- // ---- SVG Frame ----
299
464
  export function svgFrame(bgColor, innerElements) {
300
- // Use a random ID to prevent SVG constraint clashes
301
465
  const gradId = 'frame-bg-' + Math.floor(Math.random() * 1000000);
302
- const defs = `
303
- <linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%">
304
- <stop offset="0%" stop-color="${bgColor}"/>
305
- <stop offset="100%" stop-color="#0A0A0E"/>
306
- </linearGradient>
307
- `;
308
466
  return [
309
467
  `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">`,
310
- `<defs>${defs}</defs>`,
468
+ `<defs><linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="${bgColor}"/><stop offset="100%" stop-color="#0A0A0E"/></linearGradient></defs>`,
311
469
  `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="url(#${gradId})"/>`,
312
470
  `<rect x="8" y="8" width="128" height="128" rx="12" fill="#2C2C2E" opacity="0.6"/>`,
313
471
  innerElements,
314
472
  `</svg>`,
315
473
  ].join('');
316
474
  }
475
+ export function renderOpenAppGrid(col, row, cols, rows, label = PASSIVE_OFFLINE_LABEL, subtitle = OPEN_AGENTDECK_LABEL) {
476
+ const colors = toneColors('brand');
477
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
478
+ const offsetX = -col * SIZE;
479
+ const offsetY = -row * SIZE;
480
+ const gradId = `frame-bg-grid-${col}-${row}`;
481
+ const minDim = Math.min(cols, rows);
482
+ const baseScale = Math.max(1.0, minDim * 0.8);
483
+ const totalW = cols * SIZE;
484
+ const totalH = rows * SIZE;
485
+ const padX = Math.max(8, 16 * cols / 4);
486
+ const padY = Math.max(8, 16 * rows / 2);
487
+ const rectW = totalW - padX * 2;
488
+ const rectH = totalH - padY * 2;
489
+ const rx = Math.max(12, 28 * minDim / 2);
490
+ const glyphX = totalW / 2;
491
+ const glyphY = totalH / 2 - (24 * baseScale);
492
+ const labelY = totalH / 2 + (20 * baseScale);
493
+ const subY = labelY + (28 * baseScale);
494
+ const fontSizeLabel = Math.max(16, Math.round(36 * (minDim / 2)));
495
+ const fontSizeSub = Math.max(11, Math.round(20 * (minDim / 2)));
496
+ const cluster = [
497
+ `<rect x="${padX}" y="${padY}" width="${rectW}" height="${rectH}" rx="${rx}" fill="${colors.panel}" opacity="0.68" stroke="${colors.accent}" stroke-width="${(2.4 * baseScale).toFixed(1)}" stroke-opacity="0.28"/>`,
498
+ renderGlyphIcon('agentdeck', colors.icon, colors.accent, glyphX, glyphY, baseScale),
499
+ `<text x="${totalW / 2}" y="${labelY}" text-anchor="middle" font-family="${fontFam}" font-size="${fontSizeLabel}" font-weight="800" fill="${colors.text}">${escXml(truncate(label, 16))}</text>`,
500
+ `<text x="${totalW / 2}" y="${subY}" text-anchor="middle" font-family="${fontFam}" font-size="${fontSizeSub}" font-weight="650" fill="${colors.sub}" opacity="0.86">${escXml(truncate(subtitle, 24))}</text>`,
501
+ ].join('');
502
+ return [
503
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">`,
504
+ `<defs><linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="${colors.bg}"/><stop offset="100%" stop-color="#0A0A0E"/></linearGradient></defs>`,
505
+ `<rect width="${SIZE}" height="${SIZE}" rx="16" fill="url(#${gradId})"/>`,
506
+ `<g transform="translate(${offsetX} ${offsetY})">${cluster}</g>`,
507
+ `</svg>`,
508
+ ].join('');
509
+ }
510
+ export function renderOfflineTouchStrip(index) {
511
+ const fontFam = 'Inter, -apple-system, system-ui, Helvetica Neue, sans-serif';
512
+ const colors = toneColors('brand');
513
+ const W_total = 800;
514
+ const H_total = 100;
515
+ const offsetX = -index * 200;
516
+ const gradId = `touchstrip-offline-bg-${index}`;
517
+ // Entire 800x100 content
518
+ const content = [
519
+ // Background card panel
520
+ `<rect x="15" y="10" width="770" height="80" rx="16" fill="${colors.panel}" opacity="0.68" stroke="${colors.accent}" stroke-width="2" stroke-opacity="0.28"/>`,
521
+ // Left side: AgentDeck brand mark (x=120, y=50)
522
+ renderGlyphIcon('agentdeck', colors.icon, colors.accent, 120, 50, 1.2),
523
+ // Center: Offline text and helper instruction
524
+ `<text x="400" y="44" text-anchor="middle" font-family="${fontFam}" font-size="22" font-weight="800" fill="${colors.text}">AGENTDECK OFFLINE</text>`,
525
+ `<text x="400" y="70" text-anchor="middle" font-family="${fontFam}" font-size="12" font-weight="650" fill="${colors.sub}" opacity="0.86">Open the AgentDeck app — or install: npx @agentdeck/setup</text>`,
526
+ // Right side: offline icon (x=680, y=50)
527
+ renderGlyphIcon('offline', colors.icon, colors.accent, 680, 50, 1.2),
528
+ ].join('');
529
+ return [
530
+ `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">`,
531
+ `<defs><linearGradient id="${gradId}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="${colors.bg}"/><stop offset="100%" stop-color="#0A0A0E"/></linearGradient></defs>`,
532
+ `<rect width="200" height="100" fill="url(#${gradId})"/>`,
533
+ `<g transform="translate(${offsetX} 0)">${content}</g>`,
534
+ `</svg>`
535
+ ].join('');
536
+ }
317
537
  //# sourceMappingURL=session-slot-renderer.js.map