@agentdeck/shared 0.1.0 → 0.2.3

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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapter.d.ts +18 -3
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js +62 -0
  5. package/dist/adapter.js.map +1 -1
  6. package/dist/command-builders.d.ts +35 -0
  7. package/dist/command-builders.d.ts.map +1 -0
  8. package/dist/command-builders.js +75 -0
  9. package/dist/command-builders.js.map +1 -0
  10. package/dist/connection-status.d.ts +42 -0
  11. package/dist/connection-status.d.ts.map +1 -0
  12. package/dist/connection-status.js +52 -0
  13. package/dist/connection-status.js.map +1 -0
  14. package/dist/d200h-layout.d.ts +131 -0
  15. package/dist/d200h-layout.d.ts.map +1 -0
  16. package/dist/d200h-layout.js +627 -0
  17. package/dist/d200h-layout.js.map +1 -0
  18. package/dist/design-tokens.d.ts +259 -0
  19. package/dist/design-tokens.d.ts.map +1 -0
  20. package/dist/design-tokens.js +156 -0
  21. package/dist/design-tokens.js.map +1 -0
  22. package/dist/eval-schema.d.ts +235 -0
  23. package/dist/eval-schema.d.ts.map +1 -0
  24. package/dist/eval-schema.js +23 -0
  25. package/dist/eval-schema.js.map +1 -0
  26. package/dist/format-utils.d.ts +52 -0
  27. package/dist/format-utils.d.ts.map +1 -0
  28. package/dist/format-utils.js +165 -0
  29. package/dist/format-utils.js.map +1 -0
  30. package/dist/gateway-protocol.d.ts +391 -0
  31. package/dist/gateway-protocol.d.ts.map +1 -0
  32. package/dist/gateway-protocol.js +19 -0
  33. package/dist/gateway-protocol.js.map +1 -0
  34. package/dist/index.d.ts +19 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +19 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/llm-settings.d.ts +52 -0
  39. package/dist/llm-settings.d.ts.map +1 -0
  40. package/dist/llm-settings.js +118 -0
  41. package/dist/llm-settings.js.map +1 -0
  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 +496 -2
  51. package/dist/protocol.d.ts.map +1 -1
  52. package/dist/protocol.js +30 -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 +142 -0
  59. package/dist/session-utils.d.ts.map +1 -0
  60. package/dist/session-utils.js +304 -0
  61. package/dist/session-utils.js.map +1 -0
  62. package/dist/state-colors.d.ts +16 -0
  63. package/dist/state-colors.d.ts.map +1 -0
  64. package/dist/state-colors.js +48 -0
  65. package/dist/state-colors.js.map +1 -0
  66. package/dist/states.d.ts +2 -0
  67. package/dist/states.d.ts.map +1 -1
  68. package/dist/states.js +6 -1
  69. package/dist/states.js.map +1 -1
  70. package/dist/svg-renderers/agent-logos.d.ts +45 -0
  71. package/dist/svg-renderers/agent-logos.d.ts.map +1 -0
  72. package/dist/svg-renderers/agent-logos.js +238 -0
  73. package/dist/svg-renderers/agent-logos.js.map +1 -0
  74. package/dist/svg-renderers/index.d.ts +4 -0
  75. package/dist/svg-renderers/index.d.ts.map +1 -0
  76. package/dist/svg-renderers/index.js +4 -0
  77. package/dist/svg-renderers/index.js.map +1 -0
  78. package/dist/svg-renderers/session-slot-renderer.d.ts +78 -0
  79. package/dist/svg-renderers/session-slot-renderer.d.ts.map +1 -0
  80. package/dist/svg-renderers/session-slot-renderer.js +506 -0
  81. package/dist/svg-renderers/session-slot-renderer.js.map +1 -0
  82. package/dist/svg-renderers/text-utils.d.ts +29 -0
  83. package/dist/svg-renderers/text-utils.d.ts.map +1 -0
  84. package/dist/svg-renderers/text-utils.js +122 -0
  85. package/dist/svg-renderers/text-utils.js.map +1 -0
  86. package/dist/telemetry-envelope.d.ts +108 -0
  87. package/dist/telemetry-envelope.d.ts.map +1 -0
  88. package/dist/telemetry-envelope.js +34 -0
  89. package/dist/telemetry-envelope.js.map +1 -0
  90. package/dist/timeline-icons.d.ts +70 -0
  91. package/dist/timeline-icons.d.ts.map +1 -0
  92. package/dist/timeline-icons.js +161 -0
  93. package/dist/timeline-icons.js.map +1 -0
  94. package/dist/timeline-label.d.ts +38 -0
  95. package/dist/timeline-label.d.ts.map +1 -0
  96. package/dist/timeline-label.js +95 -0
  97. package/dist/timeline-label.js.map +1 -0
  98. package/dist/timeline-markdown.d.ts +80 -0
  99. package/dist/timeline-markdown.d.ts.map +1 -0
  100. package/dist/timeline-markdown.js +221 -0
  101. package/dist/timeline-markdown.js.map +1 -0
  102. package/dist/timeline-summarizer.d.ts +37 -0
  103. package/dist/timeline-summarizer.d.ts.map +1 -0
  104. package/dist/timeline-summarizer.js +125 -0
  105. package/dist/timeline-summarizer.js.map +1 -0
  106. package/dist/timeline.d.ts +135 -0
  107. package/dist/timeline.d.ts.map +1 -0
  108. package/dist/timeline.js +633 -0
  109. package/dist/timeline.js.map +1 -0
  110. package/dist/voice-paths.d.ts +4 -0
  111. package/dist/voice-paths.d.ts.map +1 -1
  112. package/dist/voice-paths.js +5 -0
  113. package/dist/voice-paths.js.map +1 -1
  114. package/package.json +9 -3
@@ -0,0 +1,627 @@
1
+ /**
2
+ * D200H / deck layout engine — SHARED between the direct-HID renderer
3
+ * (bridge/src/d200h/image-renderer.ts) and the Ulanzi Studio plugin
4
+ * (plugin-ulanzi). Given the current agent state it computes, for a 5×3 key
5
+ * grid, what each key shows (SVG) and does (command) — reflowing dynamically:
6
+ * idle → sessions; a focused awaiting session → its options + ESC; processing →
7
+ * STOP; plus mode / model / usage / tokens / cost.
8
+ *
9
+ * Key position is addressed as `col_row` (e.g. "0_0", "3_2") — the same scheme
10
+ * the D200H firmware and Ulanzi Studio both use for keys.
11
+ */
12
+ import { renderSessionSlot, renderEmptySlot, renderOptionButton, renderStopButton, renderEscButton, renderDetailInfo, renderBackButton, renderNextPageButton, renderInfoSlot, svgFrame, escSvgText, } from './svg-renderers/index.js';
13
+ import { sortSessions, foldCodexSessionsForDisplay } from './session-utils.js';
14
+ import { Brand } from './design-tokens.js';
15
+ import { PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL } from './connection-status.js';
16
+ import { CLAUDE_LOGO_PATH, CODEX_LOGO_PATH } from './svg-renderers/agent-logos.js';
17
+ /** 5 columns × 3 rows. Physical key index == row * GRID_COLS + col. */
18
+ export const GRID_COLS = 5;
19
+ /** D200H usage placement: the 2×2 block directly above the wide bottom-right
20
+ * button (cols 3–4, rows 0–1), in reading order. Cleaner than the trailing keys
21
+ * — Claude 5H/7D fill the top row, Codex 5H/7D the row below. */
22
+ const USAGE_PREFERRED_POS = ['3_0', '4_0', '3_1', '4_1'];
23
+ export function parseState(evt) {
24
+ return {
25
+ state: evt?.state ?? 'DISCONNECTED',
26
+ projectName: evt?.projectName ?? '',
27
+ modelName: evt?.modelName ?? '',
28
+ mode: evt?.mode ?? 'default',
29
+ agentType: evt?.agentType ?? 'claude-code',
30
+ fiveHourPercent: evt?.fiveHourPercent ?? 0,
31
+ sevenDayPercent: evt?.sevenDayPercent ?? 0,
32
+ totalTokens: evt?.totalTokens ?? 0,
33
+ totalCost: evt?.totalCost ?? 0,
34
+ options: (evt?.options ?? []).map((o) => typeof o === 'string' ? { label: o } : { label: o?.label ?? '', shortcut: o?.shortcut ?? '' }),
35
+ currentTool: evt?.currentTool ?? '',
36
+ allSessions: Array.isArray(evt?.allSessions) ? evt.allSessions : [],
37
+ navigable: Boolean(evt?.navigable),
38
+ // Prefer an explicit flag; otherwise infer from the presence of a real percent.
39
+ usageKnown: typeof evt?.usageKnown === 'boolean'
40
+ ? evt.usageKnown
41
+ : evt?.fiveHourPercent != null || evt?.sevenDayPercent != null,
42
+ fiveHourResetsAt: typeof evt?.fiveHourResetsAt === 'string' ? evt.fiveHourResetsAt : undefined,
43
+ sevenDayResetsAt: typeof evt?.sevenDayResetsAt === 'string' ? evt.sevenDayResetsAt : undefined,
44
+ subscriptions: Array.isArray(evt?.subscriptions) ? evt.subscriptions : undefined,
45
+ codexRateLimits: evt?.codexRateLimits && typeof evt.codexRateLimits === 'object'
46
+ ? evt.codexRateLimits
47
+ : undefined,
48
+ };
49
+ }
50
+ // --- SVG helpers for info / usage / mode / offline tiles ---
51
+ // Shared sanitizer: strips ANSI/control chars (resvg rejects the whole SVG on
52
+ // any raw control char → blank tile) before entity-escaping.
53
+ const escXml = escSvgText;
54
+ function gaugeBar(pct, width = 8) {
55
+ const filled = Math.round(Math.min(pct, 100) / 100 * width);
56
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
57
+ }
58
+ function gaugeColor(pct) {
59
+ return pct > 80 ? '#ef4444' : pct > 50 ? '#eab308' : '#22c55e';
60
+ }
61
+ export function renderUsageButton(label, percent, color, known = true) {
62
+ // When the subscription quota is unknown (no OAuth data / stale hub), draw a
63
+ // muted "—" instead of a confident 0% that would read as "fully available".
64
+ if (!known) {
65
+ const dim = '#475569';
66
+ const elements = [
67
+ `<text x="72" y="36" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#94a3b8">${escXml(label)}</text>`,
68
+ `<text x="72" y="60" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="14" fill="${dim}">${escXml('░'.repeat(8))}</text>`,
69
+ `<text x="72" y="90" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="${dim}">—</text>`,
70
+ `<rect x="16" y="110" width="112" height="2" rx="1" fill="#1e293b"/>`,
71
+ ].join('');
72
+ return svgFrame('#0f172a', elements);
73
+ }
74
+ const gBar = gaugeBar(percent, 8);
75
+ const elements = [
76
+ `<text x="72" y="36" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#94a3b8">${escXml(label)}</text>`,
77
+ `<text x="72" y="60" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="14" fill="${color}">${escXml(gBar)}</text>`,
78
+ `<text x="72" y="90" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="#ffffff">${Math.round(percent)}%</text>`,
79
+ `<rect x="16" y="110" width="112" height="2" rx="1" fill="#1e293b"/>`,
80
+ `<rect x="16" y="110" width="${Math.round(112 * Math.min(percent, 100) / 100)}" height="2" rx="1" fill="${color}"/>`,
81
+ ].join('');
82
+ return svgFrame('#0f172a', elements);
83
+ }
84
+ /** Wide merged slot (3_2) — 288×144 SVG. Two columns: 5H | 7D. Direct-HID only. */
85
+ export function renderUsageWideSlot(fiveHourPct, sevenDayPct, known = true) {
86
+ const c5 = gaugeColor(fiveHourPct);
87
+ const c7 = gaugeColor(sevenDayPct);
88
+ // Unknown quota → "—" instead of a confident 0% (mirrors renderUsageButton).
89
+ const pct5 = known ? `${Math.round(fiveHourPct)}%` : '—';
90
+ const pct7 = known ? `${Math.round(sevenDayPct)}%` : '—';
91
+ const w5 = known ? Math.round(120 * Math.min(fiveHourPct, 100) / 100) : 0;
92
+ const w7 = known ? Math.round(120 * Math.min(sevenDayPct, 100) / 100) : 0;
93
+ const valColor = known ? '#ffffff' : '#475569';
94
+ const elements = [
95
+ `<rect x="0" y="0" width="144" height="144" fill="#0f172a"/>`,
96
+ `<rect x="144" y="0" width="144" height="144" fill="#0f172a"/>`,
97
+ `<rect x="8" y="8" width="128" height="128" rx="8" fill="#1e293b" opacity="0.3"/>`,
98
+ `<rect x="152" y="8" width="128" height="128" rx="8" fill="#1e293b" opacity="0.3"/>`,
99
+ `<text x="72" y="26" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold" fill="#94a3b8">5H</text>`,
100
+ `<text x="216" y="26" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold" fill="#94a3b8">7D</text>`,
101
+ `<text x="72" y="70" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" font-weight="bold" fill="${valColor}">${pct5}</text>`,
102
+ `<text x="216" y="70" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" font-weight="bold" fill="${valColor}">${pct7}</text>`,
103
+ `<rect x="12" y="132" width="120" height="2" rx="1" fill="#1e293b"/>`,
104
+ `<rect x="156" y="132" width="120" height="2" rx="1" fill="#1e293b"/>`,
105
+ `<rect x="12" y="132" width="${w5}" height="2" rx="1" fill="${c5}"/>`,
106
+ `<rect x="156" y="132" width="${w7}" height="2" rx="1" fill="${c7}"/>`,
107
+ ].join('');
108
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="288" height="144" viewBox="0 0 288 144">${elements}</svg>`;
109
+ }
110
+ // --- Full-bleed level-fill usage gauge (canonical D200H/Ulanzi usage tile) -----
111
+ // Mirrors the Stream Deck redesign (plugin/src/renderers/usage-gauge.ts)
112
+ // replicated here so shared has no plugin/ dependency. The ENTIRE tile is the
113
+ // gauge: a full-width band rises from the bottom by `usedPercent` so the tile
114
+ // fills as the agent burns its window. The fill colour is a vivid SEVERITY ramp
115
+ // (green → amber → red); agent identity rides the provider's BRAND LOGO (Claude
116
+ // terracotta / Codex blue) in the top-right corner, NOT the fill colour. The
117
+ // headline used% + reset countdown sit on a solid dark chip (painted over the
118
+ // fill) so they stay legible at any fill height — no reliance on a halo.
119
+ /** Reset countdown ("2h13m" / "6d4h") from an ISO instant; "" when unknown. */
120
+ function formatResetCountdown(iso) {
121
+ if (!iso)
122
+ return '';
123
+ const t = new Date(iso).getTime();
124
+ if (Number.isNaN(t))
125
+ return '';
126
+ const diff = t - Date.now();
127
+ if (diff <= 0)
128
+ return 'now';
129
+ const totalH = Math.floor(diff / 3600000);
130
+ const m = Math.floor((diff % 3600000) / 60000);
131
+ if (totalH >= 24) {
132
+ const days = Math.floor(totalH / 24);
133
+ const remainH = totalH % 24;
134
+ return remainH > 0 ? `${days}d${remainH}h` : `${days}d`;
135
+ }
136
+ return totalH > 0 ? `${totalH}h${m}m` : `${m}m`;
137
+ }
138
+ /** Agent brand colours (Brand tokens) used to tint the provider logo. */
139
+ const USAGE_BRAND_COLOR = {
140
+ claude: Brand.claudeCode,
141
+ codex: Brand.codex,
142
+ };
143
+ /** Canonical provider brand mark (viewBox 0 0 24 24) per agent. */
144
+ const USAGE_BRAND_LOGO = {
145
+ claude: CLAUDE_LOGO_PATH,
146
+ codex: CODEX_LOGO_PATH,
147
+ };
148
+ /**
149
+ * Provider brand mark for the top-right corner (the agent identity). 24-unit
150
+ * path scaled to `size`, centred on (cx,cy), filled with the brand colour, over
151
+ * a subtle dark scrim circle so it survives a ~100% fill. `dim` greys it.
152
+ */
153
+ function usageBrandLogo(agent, cx, cy, size, dim) {
154
+ const s = size / 24;
155
+ const color = dim ? '#64748b' : USAGE_BRAND_COLOR[agent];
156
+ return `<circle cx="${cx}" cy="${cy}" r="${(size / 2 + 3).toFixed(1)}" fill="#0b1220" opacity="0.55"/>`
157
+ + `<g transform="translate(${cx},${cy}) scale(${s.toFixed(3)}) translate(-12,-12)">`
158
+ + `<path d="${USAGE_BRAND_LOGO[agent]}" fill="${color}" fill-rule="evenodd"/></g>`;
159
+ }
160
+ /** Severity ramp by USED percent: <=50 green, 50–80 amber, >80 red. */
161
+ function usageRampColor(used, stale = false) {
162
+ if (stale)
163
+ return { fill: '#64748b', hi: '#64748b' };
164
+ if (used > 80)
165
+ return { fill: '#ef4444', hi: '#fca5a5' };
166
+ if (used > 50)
167
+ return { fill: '#eab308', hi: '#fde047' };
168
+ return { fill: '#22c55e', hi: '#86efac' };
169
+ }
170
+ export function renderUsageGauge(data) {
171
+ const W = 144, H = 144, RX = 12;
172
+ const known = data.known !== false;
173
+ const agent = data.agent === 'codex' ? 'codex' : 'claude';
174
+ const label = data.label || data.window.toUpperCase();
175
+ const BG = '#0f172a', LABEL_DIM = '#64748b', TEXT_DIM = '#475569';
176
+ const HEADLINE = '#ffffff', COUNTDOWN = '#ffffff';
177
+ const clipId = `ugauge-${agent}-${data.window}`;
178
+ const clip = `<defs><clipPath id="${clipId}"><rect x="0" y="0" width="${W}" height="${H}" rx="${RX}"/></clipPath></defs>`;
179
+ const bg = `<rect width="${W}" height="${H}" rx="${RX}" fill="${BG}"/>`;
180
+ const logo = usageBrandLogo(agent, 124, 22, 26, !known);
181
+ if (!known) {
182
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
183
+ + clip + bg
184
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="26" font-weight="bold" fill="${LABEL_DIM}">${escXml(label)}</text>`
185
+ + logo
186
+ + `<text x="72" y="94" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="bold" fill="${TEXT_DIM}">—</text></svg>`;
187
+ }
188
+ const stale = data.stale === true;
189
+ const used = Math.max(0, Math.min(100, data.usedPercent));
190
+ const ramp = usageRampColor(used, stale);
191
+ const fillH = Math.round((H * used) / 100);
192
+ const fillY = H - fillH;
193
+ // Subtle level tint (low opacity) + crisp 3px level line — no dark overlay.
194
+ // Stale = extra-faint tint so it reads as "not current".
195
+ const fillOpacity = stale ? 0.22 : 0.38;
196
+ const fill = fillH > 0
197
+ ? `<g clip-path="url(#${clipId})">`
198
+ + `<rect x="0" y="${fillY}" width="${W}" height="${fillH}" fill="${ramp.fill}" opacity="${fillOpacity}"/>`
199
+ + `<rect x="0" y="${fillY}" width="${W}" height="3" fill="${ramp.fill}"/>`
200
+ + `</g>`
201
+ : '';
202
+ // Expired window: muted "stale" marker instead of the (absent) countdown; the
203
+ // % stays last-known but dims so it doesn't read as live.
204
+ const reset = stale ? 'stale' : formatResetCountdown(data.resetsAt);
205
+ const pctColor = stale ? LABEL_DIM : HEADLINE;
206
+ const resetColor = stale ? LABEL_DIM : COUNTDOWN;
207
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
208
+ + clip + bg + fill
209
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="26" font-weight="bold" fill="${stale ? LABEL_DIM : HEADLINE}">${escXml(label)}</text>`
210
+ + logo
211
+ + `<text x="72" y="92" text-anchor="middle" font-family="Arial,sans-serif" font-size="46" font-weight="bold" fill="${pctColor}">${Math.round(used)}<tspan font-size="24">%</tspan></text>`
212
+ + (reset ? `<text x="72" y="122" text-anchor="middle" font-family="Arial,sans-serif" font-size="17" font-weight="bold" fill="${resetColor}">${escXml(reset)}</text>` : '')
213
+ + `</svg>`;
214
+ }
215
+ /**
216
+ * Codex credit-based plans (e.g. `limit_id: "premium"`) report null 5h/7d
217
+ * windows and convey usage via a credits balance instead. Render a flat readout
218
+ * tile — limit label + balance (or ∞ when unlimited) — matching the gauge frame
219
+ * so it sits alongside the Claude gauges without a false "%" affordance.
220
+ */
221
+ export function renderCreditsTile(data) {
222
+ const W = 144, H = 144, RX = 12;
223
+ const BG = '#0f172a', HEADLINE = '#ffffff';
224
+ const label = (data.limitId || 'CREDITS').toUpperCase();
225
+ const value = data.unlimited ? '∞' : (data.balance ?? '—');
226
+ const valueSize = value.length > 5 ? 32 : value.length > 3 ? 40 : 50;
227
+ const logo = usageBrandLogo('codex', 124, 22, 26, false);
228
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
229
+ + `<rect width="${W}" height="${H}" rx="${RX}" fill="${BG}"/>`
230
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="20" font-weight="bold" fill="${HEADLINE}">${escXml(label)}</text>`
231
+ + logo
232
+ + `<text x="72" y="100" text-anchor="middle" font-family="Arial,sans-serif" font-size="${valueSize}" font-weight="bold" fill="${HEADLINE}">${escXml(value)}</text>`
233
+ + `<text x="72" y="126" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="bold" fill="#94a3b8">CREDITS</text>`
234
+ + `</svg>`;
235
+ }
236
+ /**
237
+ * Usage tiles for the session deck, in placement order. Every tile is
238
+ * hide-if-absent: Claude 5H/7D appear only when that window's quota is actually
239
+ * known, and Codex 5H (primary) / 7D (secondary) only when present in
240
+ * `codexRateLimits`. An unlinked or partial usage state therefore emits fewer
241
+ * (or zero) tiles, so `buildList` reserves fewer keys and the freed slots flow
242
+ * to session tiles instead of leaving reserved "—" ghost gauges behind.
243
+ * Credit-based plans (null windows) get a single credits readout tile instead.
244
+ * Each tile re-fetches quota on press.
245
+ */
246
+ function buildUsageTiles(state) {
247
+ const action = { kind: 'command', command: { type: 'query_usage' } };
248
+ const known = state.usageKnown !== false;
249
+ const tiles = [];
250
+ if (known && state.fiveHourPercent != null) {
251
+ tiles.push({ svg: renderUsageGauge({ agent: 'claude', window: '5h', label: '5H', usedPercent: state.fiveHourPercent, resetsAt: state.fiveHourResetsAt, known: true }), action });
252
+ }
253
+ if (known && state.sevenDayPercent != null) {
254
+ tiles.push({ svg: renderUsageGauge({ agent: 'claude', window: '7d', label: '7D', usedPercent: state.sevenDayPercent, resetsAt: state.sevenDayResetsAt, known: true }), action });
255
+ }
256
+ const cx = state.codexRateLimits;
257
+ // Codex windows carry the same short "5H"/"7D" labels — the brand dot conveys
258
+ // the agent, not a "CX " prefix.
259
+ if (cx?.primary) {
260
+ tiles.push({ svg: renderUsageGauge({ agent: 'codex', window: '5h', label: '5H', usedPercent: cx.primary.usedPercent, resetsAt: cx.primary.resetsAt, known: true, stale: cx.primary.stale === true }), action });
261
+ }
262
+ if (cx?.secondary) {
263
+ tiles.push({ svg: renderUsageGauge({ agent: 'codex', window: '7d', label: '7D', usedPercent: cx.secondary.usedPercent, resetsAt: cx.secondary.resetsAt, known: true, stale: cx.secondary.stale === true }), action });
264
+ }
265
+ // Credit-based Codex plan: no windows, show the credits balance instead so the
266
+ // Codex usage doesn't silently vanish.
267
+ if (!cx?.primary && !cx?.secondary && (cx?.credits || cx?.limitId)) {
268
+ tiles.push({ svg: renderCreditsTile({ limitId: cx.limitId, balance: cx.credits?.balance, unlimited: cx.credits?.unlimited }), action });
269
+ }
270
+ return tiles;
271
+ }
272
+ /**
273
+ * Display-only readout tile (TOKENS / COST / MODEL). Deliberately FLAT — no
274
+ * rounded button bezel/panel — so the user reads it as status, not a pressable
275
+ * key. (These tiles carry `command: null`; the old bezeled `svgFrame` look gave
276
+ * them a false "press me" affordance.) A thin baseline rule + small dim caption
277
+ * mark it as a label.
278
+ */
279
+ function renderInfoButton(title, value, titleColor = '#7c8596', valueColor = '#e5e7eb') {
280
+ const valueFontSize = value.length > 8 ? 18 : value.length > 5 ? 22 : 26;
281
+ const elements = [
282
+ `<rect width="144" height="144" fill="#0b0c10"/>`,
283
+ `<text x="72" y="56" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold" letter-spacing="1.5" fill="${titleColor}">${escXml(title.toUpperCase())}</text>`,
284
+ `<text x="72" y="92" text-anchor="middle" font-family="Arial,sans-serif" font-size="${valueFontSize}" font-weight="bold" fill="${valueColor}">${escXml(value)}</text>`,
285
+ `<rect x="44" y="108" width="56" height="2" rx="1" fill="#1e2330"/>`,
286
+ ].join('');
287
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144">${elements}</svg>`;
288
+ }
289
+ function renderModeButton(mode) {
290
+ const elements = [
291
+ `<text x="72" y="52" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="bold" fill="#94a3b8">MODE</text>`,
292
+ `<text x="72" y="88" text-anchor="middle" font-family="Arial,sans-serif" font-size="18" font-weight="bold" fill="#a78bfa">${escXml(mode.toUpperCase())}</text>`,
293
+ ].join('');
294
+ return svgFrame('#1C1C1E', elements);
295
+ }
296
+ function renderOfflineSlot(hero = false) {
297
+ // Route through the shared aquarium-tide OFFLINE card so the legacy single-page
298
+ // D200H grid shows the same dome-over-deck brand mark as the session-deck path
299
+ // and the native connection overlays.
300
+ if (hero)
301
+ return renderInfoSlot(PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL, 'agentdeck', 'brand');
302
+ return renderEmptySlot();
303
+ }
304
+ /**
305
+ * Compute the dynamic 5×3 grid for the current state. Returns slots 0–12
306
+ * (the merged wide usage slot 3_2 is rendered by the direct-HID consumer
307
+ * separately; see `usageTileForGrid` for the per-key variant).
308
+ *
309
+ * `animFrame` advances the shared renderer's session-tile animation; pass a
310
+ * fixed value (e.g. 0) for a static frame.
311
+ */
312
+ /**
313
+ * One row-of-actions key for the legacy single-page D200H layout. Renders the
314
+ * i-th real option Claude reported (PTY-managed session). When there are no
315
+ * options the slot stays empty — observed (hook-only) sessions can't expose the
316
+ * real choices, so we never fabricate an Allow/Deny that doesn't match the
317
+ * actual prompt.
318
+ */
319
+ function awaitingActionSlot(state, isAwaiting, i, col, row) {
320
+ if (isAwaiting) {
321
+ const opt = state.options[i];
322
+ if (opt) {
323
+ return { col, row, svg: renderOptionButton(opt, i), label: '', command: { type: 'select_option', index: i } };
324
+ }
325
+ }
326
+ return { col, row, svg: renderEmptySlot(), label: '', command: null };
327
+ }
328
+ export function computeLayout(state, animFrame = 0, animated = false) {
329
+ const isDisconnected = state.state === 'DISCONNECTED' || state.state === 'disconnected';
330
+ if (isDisconnected) {
331
+ const slots = [];
332
+ const heroCol = 2, heroRow = 1;
333
+ for (let row = 0; row < 3; row++) {
334
+ for (let col = 0; col < 5; col++) {
335
+ slots.push({ col, row, svg: renderOfflineSlot(col === heroCol && row === heroRow), label: '', command: null });
336
+ }
337
+ }
338
+ return slots;
339
+ }
340
+ const slots = [];
341
+ const isAwaiting = state.state.startsWith('AWAITING') || state.state.startsWith('awaiting');
342
+ const isProcessing = state.state === 'PROCESSING' || state.state === 'processing';
343
+ const activeSession = {
344
+ id: 'local',
345
+ agentType: state.agentType,
346
+ projectName: state.projectName,
347
+ modelName: state.modelName,
348
+ state: state.state.toLowerCase(),
349
+ alive: true,
350
+ port: 0,
351
+ };
352
+ const sessionsToDisplay = state.allSessions.length > 0 ? state.allSessions.slice(0, 4) : [activeSession];
353
+ const isMultiSession = sessionsToDisplay.length > 1;
354
+ if (isMultiSession) {
355
+ slots.push({ col: 0, row: 0, svg: renderModeButton(state.mode), label: '', command: { type: 'mode_toggle' } });
356
+ for (let i = 0; i < 4; i++) {
357
+ const col = i + 1;
358
+ const sess = sessionsToDisplay[i];
359
+ if (sess) {
360
+ const isActive = sess.projectName === activeSession.projectName && sess.agentType === activeSession.agentType;
361
+ slots.push({ col, row: 0, svg: renderSessionSlot(sess, isActive, animFrame, undefined, { animated }), label: '', command: { type: 'focus_session', sessionId: sess.id } });
362
+ }
363
+ else {
364
+ slots.push({ col, row: 0, svg: renderEmptySlot(), label: '', command: null });
365
+ }
366
+ }
367
+ for (let i = 0; i < 4; i++) {
368
+ const col = i;
369
+ slots.push(awaitingActionSlot(state, isAwaiting, i, col, 1));
370
+ }
371
+ slots.push({ col: 4, row: 1, svg: renderInfoButton('MODEL', state.modelName.slice(0, 12) || 'N/A'), label: '', command: null });
372
+ }
373
+ else {
374
+ const heroSession = state.allSessions.length > 0 ? sessionsToDisplay[0] : null;
375
+ slots.push({ col: 0, row: 0, svg: renderModeButton(state.mode), label: '', command: { type: 'mode_toggle' } });
376
+ slots.push({ col: 1, row: 0, svg: renderSessionSlot(sessionsToDisplay[0], true, animFrame, undefined, { animated }), label: '', command: heroSession ? { type: 'focus_session', sessionId: heroSession.id } : null });
377
+ slots.push({ col: 2, row: 0, svg: renderDetailInfo(sessionsToDisplay[0], state.state.toLowerCase(), state.currentTool, state.modelName, state.mode), label: '', command: null });
378
+ for (let i = 0; i < 4; i++) {
379
+ const col = (i + 3) % 5;
380
+ const row = Math.floor((i + 3) / 5);
381
+ slots.push(awaitingActionSlot(state, isAwaiting, i, col, row));
382
+ }
383
+ slots.push({ col: 2, row: 1, svg: renderInfoButton('MODEL', state.modelName.slice(0, 12) || 'N/A'), label: '', command: null });
384
+ slots.push({ col: 3, row: 1, svg: renderUsageButton('5H', state.fiveHourPercent, '#28a0b4', state.usageKnown !== false), label: '', command: { type: 'usage_toggle' } });
385
+ slots.push({ col: 4, row: 1, svg: renderUsageButton('7D', state.sevenDayPercent, '#2850a0', state.usageKnown !== false), label: '', command: { type: 'usage_toggle' } });
386
+ }
387
+ // Row 2 shared actions: STOP/ESC, TOKENS, COST
388
+ if (isProcessing) {
389
+ slots.push({ col: 0, row: 2, svg: renderStopButton(true), label: '', command: { type: 'interrupt' } });
390
+ }
391
+ else if (isAwaiting) {
392
+ slots.push({ col: 0, row: 2, svg: renderEscButton(true), label: '', command: { type: 'interrupt' } });
393
+ }
394
+ else {
395
+ slots.push({ col: 0, row: 2, svg: renderStopButton(false), label: '', command: { type: 'interrupt' } });
396
+ }
397
+ const tk = state.totalTokens > 1000 ? `${(state.totalTokens / 1000).toFixed(0)}K` : `${state.totalTokens}`;
398
+ slots.push({ col: 1, row: 2, svg: renderInfoButton('TOKENS', tk), label: '', command: null });
399
+ slots.push({ col: 2, row: 2, svg: renderInfoButton('COST', `$${state.totalCost.toFixed(2)}`), label: '', command: null });
400
+ return slots;
401
+ }
402
+ /** Physical-key-index → command map (index = row*GRID_COLS+col). Direct-HID input. */
403
+ export function buildButtonCommandMap(stateEvt) {
404
+ const layout = computeLayout(parseState(stateEvt));
405
+ const map = new Map();
406
+ for (const slot of layout) {
407
+ if (slot.command)
408
+ map.set(slot.row * GRID_COLS + slot.col, slot.command);
409
+ }
410
+ return map;
411
+ }
412
+ /**
413
+ * `col_row` → {svg, command} map for the WHOLE grid, for plugins that address
414
+ * keys by position (Ulanzi Studio). Unlike direct-HID, slot 3_2 is a normal
415
+ * per-key usage tile (no hardware merge), and 4_2 mirrors it.
416
+ */
417
+ export function buildLayoutMap(stateEvt, animFrame = 0, animated = false) {
418
+ const state = parseState(stateEvt);
419
+ const map = new Map();
420
+ for (const slot of computeLayout(state, animFrame, animated)) {
421
+ map.set(`${slot.col}_${slot.row}`, { svg: slot.svg, command: slot.command ?? null });
422
+ }
423
+ // Per-key usage tiles for the right side of row 2 (direct-HID merges these).
424
+ if (!map.has('3_2')) {
425
+ map.set('3_2', { svg: renderUsageButton('5H', state.fiveHourPercent, '#28a0b4', state.usageKnown !== false), command: { type: 'usage_toggle' } });
426
+ }
427
+ if (!map.has('4_2')) {
428
+ map.set('4_2', { svg: renderUsageButton('7D', state.sevenDayPercent, '#2850a0', state.usageKnown !== false), command: { type: 'usage_toggle' } });
429
+ }
430
+ return map;
431
+ }
432
+ /** Row-major position order ("0_0","1_0",…,"4_2"). */
433
+ function sortPositions(positions) {
434
+ return [...positions].sort((a, b) => {
435
+ const [ac, ar] = a.split('_').map(Number);
436
+ const [bc, br] = b.split('_').map(Number);
437
+ return ar !== br ? ar - br : ac - bc;
438
+ });
439
+ }
440
+ const awaitingState = (s) => !!s && s.toLowerCase().startsWith('awaiting');
441
+ const processingState = (s) => s?.toLowerCase() === 'processing';
442
+ /** Small colored action tile (Allow/Deny/Always/quick-action). */
443
+ function actionTile(label, color, subtitle) {
444
+ const els = [
445
+ `<text x="72" y="${subtitle ? 70 : 80}" text-anchor="middle" font-family="Arial,sans-serif" font-size="22" font-weight="bold" fill="${color}">${label}</text>`,
446
+ subtitle ? `<text x="72" y="98" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" fill="#94a3b8">${subtitle}</text>` : '',
447
+ ].join('');
448
+ return svgFrame('#16181d', els);
449
+ }
450
+ export function buildSessionDeck(stateEvt, view, positions) {
451
+ const state = parseState(stateEvt);
452
+ const slots = sortPositions(positions);
453
+ const out = new Map();
454
+ const animFrame = view.animFrame ?? 0;
455
+ const animated = view.animated ?? false;
456
+ if (slots.length === 0)
457
+ return out;
458
+ // Daemon down → OFFLINE hero on the center key, rest dim. Every key launches
459
+ // the companion app on press (parity with the SD/SD+ keypad). If AgentDeck
460
+ // isn't installed yet, the hero shows the install command so a marketplace-only
461
+ // user knows the daemon is the missing piece.
462
+ //
463
+ // Gate on an EMPTY session list, not the top-level state alone: the daemon
464
+ // reports `state:'disconnected'` whenever no managed/focused session is active
465
+ // — which is the normal case when only passively-observed sessions exist (e.g.
466
+ // after a managed PTY session ends on sleep). Those still arrive via
467
+ // `sessions_list`, so showing OFFLINE while sessions are present would hide a
468
+ // live deck. Genuine link-down funnels through the store as DISCONNECTED with
469
+ // an empty list, so this still fires for a truly absent daemon.
470
+ if ((state.state === 'DISCONNECTED' || state.state === 'disconnected') && state.allSessions.length === 0) {
471
+ const hero = Math.floor(slots.length / 2);
472
+ slots.forEach((pos, i) => out.set(pos, {
473
+ svg: i === hero ? renderInfoSlot(PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL, 'agentdeck', 'brand', 'npx @agentdeck/setup') : renderEmptySlot(),
474
+ action: { kind: 'launch' },
475
+ }));
476
+ return out;
477
+ }
478
+ if (view.mode === 'detail' && view.openSessionId) {
479
+ return buildDetail(state, stateEvt, view, slots, animFrame, animated, out);
480
+ }
481
+ return buildList(state, view, slots, animFrame, animated, out);
482
+ }
483
+ function buildList(state, view, slots, animFrame, animated, out) {
484
+ const sessions = sortSessions(foldCodexSessionsForDisplay(state.allSessions));
485
+ // Pin the trailing keys to global usage gauges (opt-in, water-tank style). On
486
+ // the D200H these land just left of the native clock widget; on classic Stream
487
+ // Deck they replace the encoder LCD this surface lacks. We reserve as many keys
488
+ // as we have usage tiles (Claude 5H/7D + any Codex windows), but NEVER more
489
+ // than `slots.length - 1` so at least one key stays for sessions — this is the
490
+ // fix for the old `slots.length >= 6` gate that silently dropped ALL usage when
491
+ // the user placed only a few AgentDeck keys. Codex tiles drop first on tiny
492
+ // decks (Claude prioritised). Reserved keys are pinned on EVERY page (usage is
493
+ // global), and paging math below treats them as unavailable for sessions.
494
+ const usageHere = new Map();
495
+ if (view.showUsage) {
496
+ const usageTiles = buildUsageTiles(state);
497
+ const maxReserve = Math.max(0, slots.length - 1);
498
+ const reserveCount = Math.min(usageTiles.length, maxReserve);
499
+ // Prefer the 2×2 block directly ABOVE the wide bottom-right button
500
+ // (cols 3–4, rows 0–1) — cleaner on the D200H than the trailing keys.
501
+ // Reading order (top→bottom) puts Claude 5H/7D on the top row and any
502
+ // Codex 5H/7D below. Falls back to trailing positions for tiles whose
503
+ // preferred slot wasn't placed by the user.
504
+ const preferred = USAGE_PREFERRED_POS.filter((p) => slots.includes(p));
505
+ const rest = slots.filter((p) => !preferred.includes(p));
506
+ const fallback = rest.slice(rest.length - Math.max(0, reserveCount - preferred.length));
507
+ const reserved = sortPositions([...preferred, ...fallback]).slice(0, reserveCount);
508
+ reserved.forEach((pos, i) => usageHere.set(pos, usageTiles[i]));
509
+ }
510
+ // Positions left for sessions / NEXT after carving out usage.
511
+ const freeSlots = slots.filter((pos) => !usageHere.has(pos));
512
+ if (sessions.length === 0) {
513
+ freeSlots.forEach((pos, i) => out.set(pos, {
514
+ svg: i === 0 ? renderInfoSlot('NO SESSION', 'waiting', 'activity', 'info') : renderEmptySlot(),
515
+ action: null,
516
+ }));
517
+ for (const [pos, cell] of usageHere)
518
+ out.set(pos, cell);
519
+ return out;
520
+ }
521
+ const overflow = sessions.length > freeSlots.length;
522
+ const sessionSlots = overflow ? freeSlots.length - 1 : freeSlots.length;
523
+ const pages = Math.max(1, Math.ceil(sessions.length / Math.max(1, sessionSlots)));
524
+ const page = ((view.page ?? 0) % pages + pages) % pages;
525
+ const pageSessions = sessions.slice(page * sessionSlots, page * sessionSlots + sessionSlots);
526
+ freeSlots.forEach((pos, i) => {
527
+ if (overflow && i === freeSlots.length - 1) {
528
+ out.set(pos, { svg: renderNextPageButton(`${page + 1}/${pages}`), action: { kind: 'page', delta: 1 } });
529
+ return;
530
+ }
531
+ const sess = pageSessions[i];
532
+ if (sess) {
533
+ out.set(pos, {
534
+ svg: renderSessionSlot(sess, false, animFrame, undefined, { animated }),
535
+ action: { kind: 'open', sessionId: sess.id },
536
+ });
537
+ }
538
+ else {
539
+ out.set(pos, { svg: renderEmptySlot(), action: null });
540
+ }
541
+ });
542
+ for (const [pos, cell] of usageHere)
543
+ out.set(pos, cell);
544
+ return out;
545
+ }
546
+ function buildDetail(state, stateEvt, view, slots, animFrame, animated, out) {
547
+ const sid = view.openSessionId;
548
+ const sess = state.allSessions.find((s) => s.id === sid);
549
+ // Focused-session detail comes from the top-level state_update when it relays
550
+ // this session (focusedSessionId/sessionId match); else fall back to SessionInfo.
551
+ const focused = stateEvt?.focusedSessionId === sid || stateEvt?.sessionId === sid;
552
+ const sState = (focused ? state.state : (sess?.state ?? 'idle')).toLowerCase();
553
+ const options = (focused ? state.options : (sess?.options ?? [])) ?? [];
554
+ const tool = focused ? state.currentTool : sess?.currentTool;
555
+ const model = sess?.modelName ?? state.modelName;
556
+ const heroSess = sess ?? {
557
+ id: sid, port: 0, alive: true, projectName: state.projectName,
558
+ agentType: state.agentType, state: sState, modelName: model,
559
+ };
560
+ const first = slots[0];
561
+ const last = slots[slots.length - 1];
562
+ out.set(first, { svg: renderBackButton(), action: { kind: 'back' } });
563
+ out.set(slots[1] ?? first, {
564
+ svg: renderDetailInfo(heroSess, sState, tool, model, undefined),
565
+ action: null,
566
+ });
567
+ if (processingState(sState)) {
568
+ out.set(last, { svg: renderStopButton(true), action: { kind: 'command', command: { type: 'interrupt' } } });
569
+ }
570
+ else if (awaitingState(sState)) {
571
+ out.set(last, { svg: renderEscButton(true), action: { kind: 'command', command: { type: 'escape' } } });
572
+ }
573
+ else {
574
+ out.set(last, { svg: renderStopButton(false), action: { kind: 'command', command: { type: 'interrupt' } } });
575
+ }
576
+ // Content slots between INFO and STOP.
577
+ const content = slots.slice(2, slots.length - 1);
578
+ const cells = [];
579
+ if (awaitingState(sState)) {
580
+ // A focused PTY session reports `navigable` on the live state_update; a
581
+ // non-focused SessionInfo never carries it (and rarely carries options).
582
+ const navigable = Boolean(focused ? stateEvt?.navigable : false);
583
+ if (options.length > 0) {
584
+ // Render the REAL option set (permission OR multi-select) regardless of
585
+ // promptType — the parser already extracted the actual labels (e.g.
586
+ // "Yes" / "Yes, and don't ask again" / "No, tell Claude"). Navigable TUI
587
+ // (❯ cursor) → select_option so the daemon drives arrows+Enter;
588
+ // non-navigable inline prompts → respond with the option's shortcut.
589
+ options.forEach((opt, i) => {
590
+ const command = navigable
591
+ ? { type: 'select_option', index: i, sessionId: sid }
592
+ : { type: 'respond', value: opt.shortcut || opt.label?.charAt(0)?.toLowerCase() || String(i + 1) };
593
+ cells.push({ svg: renderOptionButton(opt, i), action: { kind: 'command', command } });
594
+ });
595
+ }
596
+ else {
597
+ // Awaiting but no real options to render — only PTY-managed sessions expose
598
+ // Claude's actual choices. Don't fabricate Allow/Deny that may not match the
599
+ // real prompt; guide the user to the terminal instead.
600
+ cells.push({ svg: renderInfoSlot('PERMIT?', 'answer in terminal', 'status', 'warning'), action: null });
601
+ }
602
+ }
603
+ else if (processingState(sState)) {
604
+ cells.push({ svg: renderInfoSlot('RUNNING', tool || 'working', 'activity', 'info'), action: null });
605
+ }
606
+ else {
607
+ // idle quick-actions
608
+ const presets = [['GO ON', 'continue'], ['REVIEW', 'review the changes'], ['COMMIT', 'commit the changes'], ['CLEAR', '/clear']];
609
+ presets.forEach(([label, text]) => cells.push({ svg: actionTile(label, '#cbd5e1'), action: { kind: 'command', command: { type: 'send_prompt', text } } }));
610
+ }
611
+ // Paginate cells into content slots; reserve last content slot for MORE if overflow.
612
+ const cap = content.length;
613
+ const overflow = cells.length > cap;
614
+ const perPage = overflow ? cap - 1 : cap;
615
+ const pages = Math.max(1, Math.ceil(cells.length / Math.max(1, perPage)));
616
+ const page = ((view.page ?? 0) % pages + pages) % pages;
617
+ const pageCells = cells.slice(page * perPage, page * perPage + perPage);
618
+ content.forEach((pos, i) => {
619
+ if (overflow && i === content.length - 1) {
620
+ out.set(pos, { svg: renderNextPageButton(`${page + 1}/${pages}`), action: { kind: 'page', delta: 1 } });
621
+ return;
622
+ }
623
+ out.set(pos, pageCells[i] ?? { svg: renderEmptySlot(), action: null });
624
+ });
625
+ return out;
626
+ }
627
+ //# sourceMappingURL=d200h-layout.js.map