@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
@@ -0,0 +1,808 @@
1
+ /**
2
+ * D200H / deck layout engine used by the Ulanzi Studio plugin
3
+ * (plugin-ulanzi) and Apple device previews. Given the current agent state it
4
+ * 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
+ /**
20
+ * D200H usage placement: the three bottom-row keys immediately LEFT of the wide
21
+ * bottom-right clock widget (0_2, 1_2, 2_2) — a single horizontal quota strip
22
+ * along the bottom edge, so the whole upper grid stays session tiles.
23
+ *
24
+ * The strip is filled from its RIGHT end (see `buildList`), keeping the gauges
25
+ * flush against the clock: with the usual three tiles (Claude 5H, Claude 7D,
26
+ * Codex) it fills exactly; when a tile is absent — Codex now commonly reports
27
+ * only its weekly window, and an unlinked Claude reports none — the LEFTMOST
28
+ * key falls out of the strip and flows back to sessions instead of leaving a
29
+ * hole in the middle of the row.
30
+ *
31
+ * Its length also caps usage at three keys: a fourth tile (Codex 5H *and* 7D)
32
+ * drops, Claude prioritised by `buildUsageTiles` order.
33
+ */
34
+ const USAGE_PREFERRED_POS = ['0_2', '1_2', '2_2'];
35
+ export function parseState(evt) {
36
+ return {
37
+ state: evt?.state ?? 'DISCONNECTED',
38
+ projectName: evt?.projectName ?? '',
39
+ modelName: evt?.modelName ?? '',
40
+ mode: evt?.mode ?? 'default',
41
+ agentType: evt?.agentType ?? 'claude-code',
42
+ fiveHourPercent: evt?.fiveHourPercent ?? 0,
43
+ sevenDayPercent: evt?.sevenDayPercent ?? 0,
44
+ totalTokens: evt?.totalTokens ?? 0,
45
+ totalCost: evt?.totalCost ?? 0,
46
+ options: (evt?.options ?? []).map((o) => typeof o === 'string' ? { label: o } : { label: o?.label ?? '', shortcut: o?.shortcut ?? '' }),
47
+ currentTool: evt?.currentTool ?? '',
48
+ allSessions: Array.isArray(evt?.allSessions) ? evt.allSessions : [],
49
+ navigable: Boolean(evt?.navigable),
50
+ // Prefer an explicit flag; otherwise infer from the presence of a real percent.
51
+ usageKnown: typeof evt?.usageKnown === 'boolean'
52
+ ? evt.usageKnown
53
+ : evt?.fiveHourPercent != null || evt?.sevenDayPercent != null,
54
+ fiveHourResetsAt: typeof evt?.fiveHourResetsAt === 'string' ? evt.fiveHourResetsAt : undefined,
55
+ sevenDayResetsAt: typeof evt?.sevenDayResetsAt === 'string' ? evt.sevenDayResetsAt : undefined,
56
+ subscriptions: Array.isArray(evt?.subscriptions) ? evt.subscriptions : undefined,
57
+ codexRateLimits: evt?.codexRateLimits && typeof evt.codexRateLimits === 'object'
58
+ ? evt.codexRateLimits
59
+ : undefined,
60
+ };
61
+ }
62
+ // --- SVG helpers for info / usage / mode / offline tiles ---
63
+ // Shared sanitizer: strips ANSI/control chars (resvg rejects the whole SVG on
64
+ // any raw control char → blank tile) before entity-escaping.
65
+ const escXml = escSvgText;
66
+ function gaugeBar(pct, width = 8) {
67
+ const filled = Math.round(Math.min(pct, 100) / 100 * width);
68
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
69
+ }
70
+ function gaugeColor(pct) {
71
+ return pct > 80 ? '#ef4444' : pct > 50 ? '#eab308' : '#22c55e';
72
+ }
73
+ export function renderUsageButton(label, percent, color, known = true) {
74
+ // When the subscription quota is unknown (no OAuth data / stale hub), draw a
75
+ // muted "—" instead of a confident 0% that would read as "fully available".
76
+ if (!known) {
77
+ const dim = '#475569';
78
+ const elements = [
79
+ `<text x="72" y="36" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#94a3b8">${escXml(label)}</text>`,
80
+ `<text x="72" y="60" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="14" fill="${dim}">${escXml('░'.repeat(8))}</text>`,
81
+ `<text x="72" y="90" text-anchor="middle" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="${dim}">—</text>`,
82
+ `<rect x="16" y="110" width="112" height="2" rx="1" fill="#1e293b"/>`,
83
+ ].join('');
84
+ return svgFrame('#0f172a', elements);
85
+ }
86
+ const gBar = gaugeBar(percent, 8);
87
+ const elements = [
88
+ `<text x="72" y="36" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" fill="#94a3b8">${escXml(label)}</text>`,
89
+ `<text x="72" y="60" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="14" fill="${color}">${escXml(gBar)}</text>`,
90
+ `<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>`,
91
+ `<rect x="16" y="110" width="112" height="2" rx="1" fill="#1e293b"/>`,
92
+ `<rect x="16" y="110" width="${Math.round(112 * Math.min(percent, 100) / 100)}" height="2" rx="1" fill="${color}"/>`,
93
+ ].join('');
94
+ return svgFrame('#0f172a', elements);
95
+ }
96
+ /** Wide merged slot (3_2) — 288×144 SVG. Two columns: 5H | 7D. Direct-HID only. */
97
+ export function renderUsageWideSlot(fiveHourPct, sevenDayPct, known = true) {
98
+ const c5 = gaugeColor(fiveHourPct);
99
+ const c7 = gaugeColor(sevenDayPct);
100
+ // Unknown quota → "—" instead of a confident 0% (mirrors renderUsageButton).
101
+ const pct5 = known ? `${Math.round(fiveHourPct)}%` : '—';
102
+ const pct7 = known ? `${Math.round(sevenDayPct)}%` : '—';
103
+ const w5 = known ? Math.round(120 * Math.min(fiveHourPct, 100) / 100) : 0;
104
+ const w7 = known ? Math.round(120 * Math.min(sevenDayPct, 100) / 100) : 0;
105
+ const valColor = known ? '#ffffff' : '#475569';
106
+ const elements = [
107
+ `<rect x="0" y="0" width="144" height="144" fill="#0f172a"/>`,
108
+ `<rect x="144" y="0" width="144" height="144" fill="#0f172a"/>`,
109
+ `<rect x="8" y="8" width="128" height="128" rx="8" fill="#1e293b" opacity="0.3"/>`,
110
+ `<rect x="152" y="8" width="128" height="128" rx="8" fill="#1e293b" opacity="0.3"/>`,
111
+ `<text x="72" y="26" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold" fill="#94a3b8">5H</text>`,
112
+ `<text x="216" y="26" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold" fill="#94a3b8">7D</text>`,
113
+ `<text x="72" y="70" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" font-weight="bold" fill="${valColor}">${pct5}</text>`,
114
+ `<text x="216" y="70" text-anchor="middle" font-family="Arial,sans-serif" font-size="32" font-weight="bold" fill="${valColor}">${pct7}</text>`,
115
+ `<rect x="12" y="132" width="120" height="2" rx="1" fill="#1e293b"/>`,
116
+ `<rect x="156" y="132" width="120" height="2" rx="1" fill="#1e293b"/>`,
117
+ `<rect x="12" y="132" width="${w5}" height="2" rx="1" fill="${c5}"/>`,
118
+ `<rect x="156" y="132" width="${w7}" height="2" rx="1" fill="${c7}"/>`,
119
+ ].join('');
120
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="288" height="144" viewBox="0 0 288 144">${elements}</svg>`;
121
+ }
122
+ // --- Full-bleed level-fill usage gauge (canonical D200H/Ulanzi usage tile) -----
123
+ // Mirrors the Stream Deck redesign (plugin/src/renderers/usage-gauge.ts)
124
+ // replicated here so shared has no plugin/ dependency. The ENTIRE tile is the
125
+ // gauge: a full-width band rises from the bottom by `usedPercent` so the tile
126
+ // fills as the agent burns its window. The fill colour is a vivid SEVERITY ramp
127
+ // (green → amber → red); agent identity rides the provider's BRAND LOGO (Claude
128
+ // terracotta / Codex blue) in the top-right corner, NOT the fill colour. The
129
+ // headline used% + reset countdown sit on a solid dark chip (painted over the
130
+ // fill) so they stay legible at any fill height — no reliance on a halo.
131
+ /** Reset countdown ("2h13m" / "6d4h") from an ISO instant; "" when unknown. */
132
+ function formatResetCountdown(iso) {
133
+ if (!iso)
134
+ return '';
135
+ const t = new Date(iso).getTime();
136
+ if (Number.isNaN(t))
137
+ return '';
138
+ const diff = t - Date.now();
139
+ if (diff <= 0)
140
+ return 'now';
141
+ const totalH = Math.floor(diff / 3600000);
142
+ const m = Math.floor((diff % 3600000) / 60000);
143
+ if (totalH >= 24) {
144
+ const days = Math.floor(totalH / 24);
145
+ const remainH = totalH % 24;
146
+ return remainH > 0 ? `${days}d${remainH}h` : `${days}d`;
147
+ }
148
+ return totalH > 0 ? `${totalH}h${m}m` : `${m}m`;
149
+ }
150
+ /** Agent brand colours (Brand tokens) used to tint the provider logo. */
151
+ const USAGE_BRAND_COLOR = {
152
+ claude: Brand.claudeCode,
153
+ codex: Brand.codex,
154
+ };
155
+ /** Canonical provider brand mark (viewBox 0 0 24 24) per agent. */
156
+ const USAGE_BRAND_LOGO = {
157
+ claude: CLAUDE_LOGO_PATH,
158
+ codex: CODEX_LOGO_PATH,
159
+ };
160
+ /**
161
+ * Provider brand mark for the top-right corner (the agent identity). 24-unit
162
+ * path scaled to `size`, centred on (cx,cy), filled with the brand colour, over
163
+ * a subtle dark scrim circle so it survives a ~100% fill. `dim` greys it.
164
+ */
165
+ function usageBrandLogo(agent, cx, cy, size, dim) {
166
+ const s = size / 24;
167
+ const color = dim ? '#64748b' : USAGE_BRAND_COLOR[agent];
168
+ return `<circle cx="${cx}" cy="${cy}" r="${(size / 2 + 3).toFixed(1)}" fill="#0b1220" opacity="0.55"/>`
169
+ + `<g transform="translate(${cx},${cy}) scale(${s.toFixed(3)}) translate(-12,-12)">`
170
+ + `<path d="${USAGE_BRAND_LOGO[agent]}" fill="${color}" fill-rule="evenodd"/></g>`;
171
+ }
172
+ /** Severity ramp by USED percent: <=50 green, 50–80 amber, >80 red. */
173
+ function usageRampColor(used, stale = false) {
174
+ if (stale)
175
+ return { fill: '#64748b', hi: '#64748b' };
176
+ if (used > 80)
177
+ return { fill: '#ef4444', hi: '#fca5a5' };
178
+ if (used > 50)
179
+ return { fill: '#eab308', hi: '#fde047' };
180
+ return { fill: '#22c55e', hi: '#86efac' };
181
+ }
182
+ /**
183
+ * Compact label for a rolling-window length in minutes, mirroring the Swift
184
+ * `TopologyRail.windowLabel`: whole days → "Nd" (10080 → "7D"), whole hours →
185
+ * "Nh" (300 → "5H"), else "Nm". Codex now sometimes reports the weekly window
186
+ * in the `primary` slot with `secondary` null, so usage gauges MUST label by a
187
+ * window's own length, never by its slot position — otherwise the 7D gauge
188
+ * silently vanishes and the weekly window is mislabelled "5H".
189
+ */
190
+ export function usageWindowLabel(windowMinutes) {
191
+ if (!windowMinutes || windowMinutes <= 0)
192
+ return '';
193
+ if (windowMinutes % 1440 === 0)
194
+ return `${windowMinutes / 1440}D`;
195
+ if (windowMinutes % 60 === 0)
196
+ return `${windowMinutes / 60}H`;
197
+ return `${windowMinutes}M`;
198
+ }
199
+ /**
200
+ * Which gauge bucket ('5h' short vs '7d' long) a rolling window belongs to,
201
+ * derived from its length (≥ a day → long) so the clip id / styling is right
202
+ * regardless of whether the window arrived in the primary or secondary slot.
203
+ */
204
+ export function usageWindowKind(windowMinutes) {
205
+ return (windowMinutes ?? 0) >= 1440 ? '7d' : '5h';
206
+ }
207
+ export function renderUsageGauge(data) {
208
+ const W = 144, H = 144, RX = 12;
209
+ const known = data.known !== false;
210
+ const agent = data.agent === 'codex' ? 'codex' : 'claude';
211
+ const label = data.label || data.window.toUpperCase();
212
+ const BG = '#0f172a', LABEL_DIM = '#64748b', TEXT_DIM = '#475569';
213
+ const HEADLINE = '#ffffff', COUNTDOWN = '#ffffff';
214
+ const clipId = `ugauge-${agent}-${data.window}`;
215
+ const clip = `<defs><clipPath id="${clipId}"><rect x="0" y="0" width="${W}" height="${H}" rx="${RX}"/></clipPath></defs>`;
216
+ const bg = `<rect width="${W}" height="${H}" rx="${RX}" fill="${BG}"/>`;
217
+ const logo = usageBrandLogo(agent, 124, 22, 26, !known);
218
+ if (!known) {
219
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
220
+ + clip + bg
221
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="26" font-weight="bold" fill="${LABEL_DIM}">${escXml(label)}</text>`
222
+ + logo
223
+ + `<text x="72" y="94" text-anchor="middle" font-family="Arial,sans-serif" font-size="44" font-weight="bold" fill="${TEXT_DIM}">—</text></svg>`;
224
+ }
225
+ const stale = data.stale === true;
226
+ const used = Math.max(0, Math.min(100, data.usedPercent));
227
+ const ramp = usageRampColor(used, stale);
228
+ const fillH = Math.round((H * used) / 100);
229
+ const fillY = H - fillH;
230
+ // Subtle level tint (low opacity) + crisp 3px level line — no dark overlay.
231
+ // Stale = extra-faint tint so it reads as "not current".
232
+ const fillOpacity = stale ? 0.22 : 0.38;
233
+ const fill = fillH > 0
234
+ ? `<g clip-path="url(#${clipId})">`
235
+ + `<rect x="0" y="${fillY}" width="${W}" height="${fillH}" fill="${ramp.fill}" opacity="${fillOpacity}"/>`
236
+ + `<rect x="0" y="${fillY}" width="${W}" height="3" fill="${ramp.fill}"/>`
237
+ + `</g>`
238
+ : '';
239
+ // Expired window: muted "stale" marker instead of the (absent) countdown; the
240
+ // % stays last-known but dims so it doesn't read as live.
241
+ const reset = stale ? 'stale' : formatResetCountdown(data.resetsAt);
242
+ const pctColor = stale ? LABEL_DIM : HEADLINE;
243
+ const resetColor = stale ? LABEL_DIM : COUNTDOWN;
244
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
245
+ + clip + bg + fill
246
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="26" font-weight="bold" fill="${stale ? LABEL_DIM : HEADLINE}">${escXml(label)}</text>`
247
+ + logo
248
+ + `<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>`
249
+ + (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>` : '')
250
+ + `</svg>`;
251
+ }
252
+ /**
253
+ * Codex credit-based plans (e.g. `limit_id: "premium"`) report null 5h/7d
254
+ * windows and convey usage via a credits balance instead. Render a flat readout
255
+ * tile — limit label + balance (or ∞ when unlimited) — matching the gauge frame
256
+ * so it sits alongside the Claude gauges without a false "%" affordance.
257
+ */
258
+ export function renderCreditsTile(data) {
259
+ const W = 144, H = 144, RX = 12;
260
+ const BG = '#0f172a', HEADLINE = '#ffffff';
261
+ const label = (data.limitId || 'CREDITS').toUpperCase();
262
+ const value = data.unlimited ? '∞' : (data.balance ?? '—');
263
+ const valueSize = value.length > 5 ? 32 : value.length > 3 ? 40 : 50;
264
+ const logo = usageBrandLogo('codex', 124, 22, 26, false);
265
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
266
+ + `<rect width="${W}" height="${H}" rx="${RX}" fill="${BG}"/>`
267
+ + `<text x="14" y="36" font-family="JetBrains Mono, monospace" font-size="20" font-weight="bold" fill="${HEADLINE}">${escXml(label)}</text>`
268
+ + logo
269
+ + `<text x="72" y="100" text-anchor="middle" font-family="Arial,sans-serif" font-size="${valueSize}" font-weight="bold" fill="${HEADLINE}">${escXml(value)}</text>`
270
+ + `<text x="72" y="126" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="bold" fill="#94a3b8">CREDITS</text>`
271
+ + `</svg>`;
272
+ }
273
+ /**
274
+ * Usage tiles for the session deck, in placement order. Every tile is
275
+ * hide-if-absent: Claude 5H/7D appear only when that window's quota is actually
276
+ * known, and each present Codex window (labelled by its own length, not its
277
+ * primary/secondary slot) only when present in
278
+ * `codexRateLimits`. An unlinked or partial usage state therefore emits fewer
279
+ * (or zero) tiles, so `buildList` reserves fewer keys and the freed slots flow
280
+ * to session tiles instead of leaving reserved "—" ghost gauges behind.
281
+ * Credit-based plans (null windows) get a single credits readout tile instead.
282
+ * Each tile re-fetches quota on press.
283
+ */
284
+ function buildUsageTiles(state) {
285
+ const action = { kind: 'command', command: { type: 'query_usage' } };
286
+ const known = state.usageKnown !== false;
287
+ const tiles = [];
288
+ if (known && state.fiveHourPercent != null) {
289
+ tiles.push({ svg: renderUsageGauge({ agent: 'claude', window: '5h', label: '5H', usedPercent: state.fiveHourPercent, resetsAt: state.fiveHourResetsAt, known: true }), action });
290
+ }
291
+ if (known && state.sevenDayPercent != null) {
292
+ tiles.push({ svg: renderUsageGauge({ agent: 'claude', window: '7d', label: '7D', usedPercent: state.sevenDayPercent, resetsAt: state.sevenDayResetsAt, known: true }), action });
293
+ }
294
+ const cx = state.codexRateLimits;
295
+ // Codex windows carry the same short "5H"/"7D" labels — the brand dot conveys
296
+ // the agent, not a "CX " prefix. Label each present window by its own length
297
+ // (windowMinutes), never by slot: Codex now sometimes reports the weekly
298
+ // (10080-min) window as `primary` with `secondary` null, so a slot-based "7D
299
+ // = secondary" would drop the gauge entirely.
300
+ for (const w of [cx?.primary, cx?.secondary]) {
301
+ if (!w)
302
+ continue;
303
+ tiles.push({ svg: renderUsageGauge({ agent: 'codex', window: usageWindowKind(w.windowMinutes), label: usageWindowLabel(w.windowMinutes) || '5H', usedPercent: w.usedPercent, resetsAt: w.resetsAt, known: true, stale: w.stale === true }), action });
304
+ }
305
+ // Credit-based Codex plan: no windows, show the credits balance instead so the
306
+ // Codex usage doesn't silently vanish.
307
+ if (!cx?.primary && !cx?.secondary && (cx?.credits || cx?.limitId)) {
308
+ tiles.push({ svg: renderCreditsTile({ limitId: cx.limitId, balance: cx.credits?.balance, unlimited: cx.credits?.unlimited }), action });
309
+ }
310
+ return tiles;
311
+ }
312
+ /**
313
+ * Display-only readout tile (TOKENS / COST / MODEL). Deliberately FLAT — no
314
+ * rounded button bezel/panel — so the user reads it as status, not a pressable
315
+ * key. (These tiles carry `command: null`; the old bezeled `svgFrame` look gave
316
+ * them a false "press me" affordance.) A thin baseline rule + small dim caption
317
+ * mark it as a label.
318
+ */
319
+ function renderInfoButton(title, value, titleColor = '#7c8596', valueColor = '#e5e7eb') {
320
+ const valueFontSize = value.length > 8 ? 18 : value.length > 5 ? 22 : 26;
321
+ const elements = [
322
+ `<rect width="144" height="144" fill="#0b0c10"/>`,
323
+ `<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>`,
324
+ `<text x="72" y="92" text-anchor="middle" font-family="Arial,sans-serif" font-size="${valueFontSize}" font-weight="bold" fill="${valueColor}">${escXml(value)}</text>`,
325
+ `<rect x="44" y="108" width="56" height="2" rx="1" fill="#1e2330"/>`,
326
+ ].join('');
327
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144">${elements}</svg>`;
328
+ }
329
+ function renderModeButton(mode) {
330
+ const elements = [
331
+ `<text x="72" y="52" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="bold" fill="#94a3b8">MODE</text>`,
332
+ `<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>`,
333
+ ].join('');
334
+ return svgFrame('#1C1C1E', elements);
335
+ }
336
+ function renderOfflineSlot(hero = false) {
337
+ // Route through the shared aquarium-tide OFFLINE card so the legacy single-page
338
+ // D200H grid shows the same dome-over-deck brand mark as the session-deck path
339
+ // and the native connection overlays.
340
+ if (hero)
341
+ return renderInfoSlot(PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL, 'agentdeck', 'brand');
342
+ return renderEmptySlot();
343
+ }
344
+ /**
345
+ * Compute the dynamic 5×3 grid for the current state. Returns slots 0–12
346
+ * (the merged wide usage slot 3_2 is rendered by the direct-HID consumer
347
+ * separately; see `usageTileForGrid` for the per-key variant).
348
+ *
349
+ * `animFrame` advances the shared renderer's session-tile animation; pass a
350
+ * fixed value (e.g. 0) for a static frame.
351
+ */
352
+ /**
353
+ * One row-of-actions key for the legacy single-page D200H layout. Renders the
354
+ * i-th real option Claude reported (PTY-managed session). When there are no
355
+ * options the slot stays empty — observed (hook-only) sessions can't expose the
356
+ * real choices, so we never fabricate an Allow/Deny that doesn't match the
357
+ * actual prompt.
358
+ */
359
+ function awaitingActionSlot(state, isAwaiting, i, col, row) {
360
+ if (isAwaiting) {
361
+ const opt = state.options[i];
362
+ if (opt) {
363
+ return { col, row, svg: renderOptionButton(opt, i), label: '', command: { type: 'select_option', index: i } };
364
+ }
365
+ }
366
+ return { col, row, svg: renderEmptySlot(), label: '', command: null };
367
+ }
368
+ export function computeLayout(state, animFrame = 0, animated = false) {
369
+ const isDisconnected = state.state === 'DISCONNECTED' || state.state === 'disconnected';
370
+ if (isDisconnected) {
371
+ const slots = [];
372
+ const heroCol = 2, heroRow = 1;
373
+ for (let row = 0; row < 3; row++) {
374
+ for (let col = 0; col < 5; col++) {
375
+ slots.push({ col, row, svg: renderOfflineSlot(col === heroCol && row === heroRow), label: '', command: null });
376
+ }
377
+ }
378
+ return slots;
379
+ }
380
+ const slots = [];
381
+ const isAwaiting = state.state.startsWith('AWAITING') || state.state.startsWith('awaiting');
382
+ const isProcessing = state.state === 'PROCESSING' || state.state === 'processing';
383
+ const activeSession = {
384
+ id: 'local',
385
+ agentType: state.agentType,
386
+ projectName: state.projectName,
387
+ modelName: state.modelName,
388
+ state: state.state.toLowerCase(),
389
+ alive: true,
390
+ port: 0,
391
+ };
392
+ const sessionsToDisplay = state.allSessions.length > 0 ? state.allSessions.slice(0, 4) : [activeSession];
393
+ const isMultiSession = sessionsToDisplay.length > 1;
394
+ if (isMultiSession) {
395
+ slots.push({ col: 0, row: 0, svg: renderModeButton(state.mode), label: '', command: { type: 'mode_toggle' } });
396
+ for (let i = 0; i < 4; i++) {
397
+ const col = i + 1;
398
+ const sess = sessionsToDisplay[i];
399
+ if (sess) {
400
+ const isActive = sess.projectName === activeSession.projectName && sess.agentType === activeSession.agentType;
401
+ slots.push({ col, row: 0, svg: renderSessionSlot(sess, isActive, animFrame, undefined, { animated }), label: '', command: { type: 'focus_session', sessionId: sess.id } });
402
+ }
403
+ else {
404
+ slots.push({ col, row: 0, svg: renderEmptySlot(), label: '', command: null });
405
+ }
406
+ }
407
+ for (let i = 0; i < 4; i++) {
408
+ const col = i;
409
+ slots.push(awaitingActionSlot(state, isAwaiting, i, col, 1));
410
+ }
411
+ slots.push({ col: 4, row: 1, svg: renderInfoButton('MODEL', state.modelName.slice(0, 12) || 'N/A'), label: '', command: null });
412
+ }
413
+ else {
414
+ const heroSession = state.allSessions.length > 0 ? sessionsToDisplay[0] : null;
415
+ slots.push({ col: 0, row: 0, svg: renderModeButton(state.mode), label: '', command: { type: 'mode_toggle' } });
416
+ slots.push({ col: 1, row: 0, svg: renderSessionSlot(sessionsToDisplay[0], true, animFrame, undefined, { animated }), label: '', command: heroSession ? { type: 'focus_session', sessionId: heroSession.id } : null });
417
+ slots.push({ col: 2, row: 0, svg: renderDetailInfo(sessionsToDisplay[0], state.state.toLowerCase(), state.currentTool, state.modelName, state.mode), label: '', command: null });
418
+ for (let i = 0; i < 4; i++) {
419
+ const col = (i + 3) % 5;
420
+ const row = Math.floor((i + 3) / 5);
421
+ slots.push(awaitingActionSlot(state, isAwaiting, i, col, row));
422
+ }
423
+ slots.push({ col: 2, row: 1, svg: renderInfoButton('MODEL', state.modelName.slice(0, 12) || 'N/A'), label: '', command: null });
424
+ slots.push({ col: 3, row: 1, svg: renderUsageButton('5H', state.fiveHourPercent, '#28a0b4', state.usageKnown !== false), label: '', command: { type: 'usage_toggle' } });
425
+ slots.push({ col: 4, row: 1, svg: renderUsageButton('7D', state.sevenDayPercent, '#2850a0', state.usageKnown !== false), label: '', command: { type: 'usage_toggle' } });
426
+ }
427
+ // Row 2 shared actions: STOP/ESC, TOKENS, COST
428
+ if (isProcessing) {
429
+ slots.push({ col: 0, row: 2, svg: renderStopButton(true), label: '', command: { type: 'interrupt' } });
430
+ }
431
+ else if (isAwaiting) {
432
+ slots.push({ col: 0, row: 2, svg: renderEscButton(true), label: '', command: { type: 'interrupt' } });
433
+ }
434
+ else {
435
+ slots.push({ col: 0, row: 2, svg: renderStopButton(false), label: '', command: { type: 'interrupt' } });
436
+ }
437
+ const tk = state.totalTokens > 1000 ? `${(state.totalTokens / 1000).toFixed(0)}K` : `${state.totalTokens}`;
438
+ slots.push({ col: 1, row: 2, svg: renderInfoButton('TOKENS', tk), label: '', command: null });
439
+ slots.push({ col: 2, row: 2, svg: renderInfoButton('COST', `$${state.totalCost.toFixed(2)}`), label: '', command: null });
440
+ return slots;
441
+ }
442
+ /** Physical-key-index → command map (index = row*GRID_COLS+col). Direct-HID input. */
443
+ export function buildButtonCommandMap(stateEvt) {
444
+ const layout = computeLayout(parseState(stateEvt));
445
+ const map = new Map();
446
+ for (const slot of layout) {
447
+ if (slot.command)
448
+ map.set(slot.row * GRID_COLS + slot.col, slot.command);
449
+ }
450
+ return map;
451
+ }
452
+ /**
453
+ * `col_row` → {svg, command} map for the WHOLE grid, for plugins that address
454
+ * keys by position (Ulanzi Studio). Unlike direct-HID, slot 3_2 is a normal
455
+ * per-key usage tile (no hardware merge), and 4_2 mirrors it.
456
+ */
457
+ export function buildLayoutMap(stateEvt, animFrame = 0, animated = false) {
458
+ const state = parseState(stateEvt);
459
+ const map = new Map();
460
+ for (const slot of computeLayout(state, animFrame, animated)) {
461
+ map.set(`${slot.col}_${slot.row}`, { svg: slot.svg, command: slot.command ?? null });
462
+ }
463
+ // Per-key usage tiles for the right side of row 2 (direct-HID merges these).
464
+ if (!map.has('3_2')) {
465
+ map.set('3_2', { svg: renderUsageButton('5H', state.fiveHourPercent, '#28a0b4', state.usageKnown !== false), command: { type: 'usage_toggle' } });
466
+ }
467
+ if (!map.has('4_2')) {
468
+ map.set('4_2', { svg: renderUsageButton('7D', state.sevenDayPercent, '#2850a0', state.usageKnown !== false), command: { type: 'usage_toggle' } });
469
+ }
470
+ return map;
471
+ }
472
+ /** Row-major position order ("0_0","1_0",…,"4_2"). */
473
+ function sortPositions(positions) {
474
+ return [...positions].sort((a, b) => {
475
+ const [ac, ar] = a.split('_').map(Number);
476
+ const [bc, br] = b.split('_').map(Number);
477
+ return ar !== br ? ar - br : ac - bc;
478
+ });
479
+ }
480
+ const awaitingState = (s) => !!s && s.toLowerCase().startsWith('awaiting');
481
+ const processingState = (s) => s?.toLowerCase() === 'processing';
482
+ /** Small colored action tile (Allow/Deny/Always/quick-action). */
483
+ function actionTile(label, color, subtitle) {
484
+ const els = [
485
+ `<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>`,
486
+ subtitle ? `<text x="72" y="98" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" fill="#94a3b8">${subtitle}</text>` : '',
487
+ ].join('');
488
+ return svgFrame('#16181d', els);
489
+ }
490
+ /**
491
+ * REVIEW = independent on-demand eval (review_run), NOT a prompt to the
492
+ * agent — the daemon judges the session's latest COMPLETED work with a
493
+ * separate model, so this tile is valid for EVERY session type (managed,
494
+ * observed Claude/OpenCode, even control-less observed Codex) — but only
495
+ * once the turn has ended. Shows the last verdict as a badge; REVIEWING
496
+ * while the judge runs.
497
+ */
498
+ function reviewTile(sess, sid) {
499
+ if (sess?.reviewStatus === 'running') {
500
+ return { svg: renderInfoSlot('REVIEWING', 'judge running', 'activity', 'info'), action: null };
501
+ }
502
+ const risk = sess?.reviewRisk;
503
+ const subtitle = risk
504
+ ? `risk ${risk}${sess?.reviewFindings != null ? ` · ${sess.reviewFindings}` : ''}`
505
+ : 'independent eval';
506
+ const color = risk === 'high' ? '#f87171' : risk === 'medium' ? '#fbbf24' : '#93c5fd';
507
+ return {
508
+ svg: actionTile('REVIEW', color, subtitle),
509
+ action: { kind: 'command', command: { type: 'review_run', sessionId: sid } },
510
+ };
511
+ }
512
+ /**
513
+ * Non-pressable review status for the PROCESSING branch: REVIEWING while the
514
+ * judge runs, or the last verdict as an inert badge. Null when there is
515
+ * nothing to show. Mid-turn the tile must not fire review_run — the work is
516
+ * not complete yet (the Swift daemon judges the session trajectory, which
517
+ * mid-turn has no assistant response and systematically reads as
518
+ * "incomplete/unverified"; the Node daemon would judge a half-written diff).
519
+ */
520
+ function reviewBadgeTile(sess) {
521
+ if (sess?.reviewStatus === 'running') {
522
+ return { svg: renderInfoSlot('REVIEWING', 'judge running', 'activity', 'info'), action: null };
523
+ }
524
+ const risk = sess?.reviewRisk;
525
+ if (!risk)
526
+ return null;
527
+ const subtitle = `risk ${risk}${sess?.reviewFindings != null ? ` · ${sess.reviewFindings}` : ''}`;
528
+ const color = risk === 'high' ? '#f87171' : risk === 'medium' ? '#fbbf24' : '#93c5fd';
529
+ return { svg: actionTile('REVIEW', color, subtitle), action: null };
530
+ }
531
+ export function buildSessionDeck(stateEvt, view, positions) {
532
+ const state = parseState(stateEvt);
533
+ const slots = sortPositions(positions);
534
+ const out = new Map();
535
+ const animFrame = view.animFrame ?? 0;
536
+ const animated = view.animated ?? false;
537
+ if (slots.length === 0)
538
+ return out;
539
+ // Daemon down → OFFLINE hero on the center key, rest dim. Every key launches
540
+ // the companion app on press (parity with the SD/SD+ keypad). If AgentDeck
541
+ // isn't installed yet, the hero shows the install command so a marketplace-only
542
+ // user knows the daemon is the missing piece.
543
+ //
544
+ // Gate on an EMPTY session list, not the top-level state alone: the daemon
545
+ // reports `state:'disconnected'` whenever no managed/focused session is active
546
+ // — which is the normal case when only passively-observed sessions exist (e.g.
547
+ // after a managed PTY session ends on sleep). Those still arrive via
548
+ // `sessions_list`, so showing OFFLINE while sessions are present would hide a
549
+ // live deck. Genuine link-down funnels through the store as DISCONNECTED with
550
+ // an empty list, so this still fires for a truly absent daemon.
551
+ if ((state.state === 'DISCONNECTED' || state.state === 'disconnected') && state.allSessions.length === 0) {
552
+ const hero = Math.floor(slots.length / 2);
553
+ slots.forEach((pos, i) => out.set(pos, {
554
+ svg: i === hero ? renderInfoSlot(PASSIVE_OFFLINE_LABEL, OPEN_AGENTDECK_LABEL, 'agentdeck', 'brand', 'npx @agentdeck/setup') : renderEmptySlot(),
555
+ action: { kind: 'launch' },
556
+ }));
557
+ return out;
558
+ }
559
+ if (view.mode === 'detail' && view.openSessionId) {
560
+ return buildDetail(state, stateEvt, view, slots, animFrame, animated, out);
561
+ }
562
+ return buildList(state, view, slots, animFrame, animated, out);
563
+ }
564
+ function buildList(state, view, slots, animFrame, animated, out) {
565
+ const sessions = sortSessions(foldCodexSessionsForDisplay(state.allSessions));
566
+ // Pin the bottom-row usage strip to the global quota gauges (opt-in,
567
+ // water-tank style). On the D200H the strip sits just left of the native clock
568
+ // widget; on classic Stream Deck it replaces the encoder LCD this surface
569
+ // lacks. We reserve as many keys as we have usage tiles (Claude 5H/7D + any
570
+ // Codex window), but never more than the strip is wide, and never more than
571
+ // `slots.length - 1` so at least one key stays for sessions — the latter is
572
+ // the fix for the old `slots.length >= 6` gate that silently dropped ALL usage
573
+ // when the user placed only a few AgentDeck keys. Reserved keys are pinned on
574
+ // EVERY page (usage is global), and paging math below treats them as
575
+ // unavailable for sessions.
576
+ const usageHere = new Map();
577
+ if (view.showUsage) {
578
+ const usageTiles = buildUsageTiles(state);
579
+ const maxReserve = Math.max(0, slots.length - 1);
580
+ const preferred = sortPositions(USAGE_PREFERRED_POS.filter((p) => slots.includes(p)));
581
+ const reserveCount = Math.min(usageTiles.length, USAGE_PREFERRED_POS.length, maxReserve);
582
+ // Fill the strip from its RIGHT end so a missing tile frees the LEFTMOST key
583
+ // (which flows back to sessions) and the gauges stay flush against the clock
584
+ // — never a hole mid-strip.
585
+ const pinned = preferred.slice(Math.max(0, preferred.length - reserveCount));
586
+ // Tiles whose strip key the user didn't place fall back to trailing keys.
587
+ const rest = slots.filter((p) => !pinned.includes(p));
588
+ const fallback = rest.slice(rest.length - Math.max(0, reserveCount - pinned.length));
589
+ const reserved = sortPositions([...pinned, ...fallback]).slice(0, reserveCount);
590
+ reserved.forEach((pos, i) => usageHere.set(pos, usageTiles[i]));
591
+ }
592
+ // Positions left for sessions / NEXT after carving out usage.
593
+ const freeSlots = slots.filter((pos) => !usageHere.has(pos));
594
+ if (sessions.length === 0) {
595
+ freeSlots.forEach((pos, i) => out.set(pos, {
596
+ svg: i === 0 ? renderInfoSlot('NO SESSION', 'waiting', 'activity', 'info') : renderEmptySlot(),
597
+ action: null,
598
+ }));
599
+ for (const [pos, cell] of usageHere)
600
+ out.set(pos, cell);
601
+ return out;
602
+ }
603
+ const overflow = sessions.length > freeSlots.length;
604
+ const sessionSlots = overflow ? freeSlots.length - 1 : freeSlots.length;
605
+ const pages = Math.max(1, Math.ceil(sessions.length / Math.max(1, sessionSlots)));
606
+ const page = ((view.page ?? 0) % pages + pages) % pages;
607
+ const pageSessions = sessions.slice(page * sessionSlots, page * sessionSlots + sessionSlots);
608
+ freeSlots.forEach((pos, i) => {
609
+ if (overflow && i === freeSlots.length - 1) {
610
+ out.set(pos, { svg: renderNextPageButton(`${page + 1}/${pages}`), action: { kind: 'page', delta: 1 } });
611
+ return;
612
+ }
613
+ const sess = pageSessions[i];
614
+ if (sess) {
615
+ out.set(pos, {
616
+ svg: renderSessionSlot(sess, false, animFrame, undefined, { animated }),
617
+ action: { kind: 'open', sessionId: sess.id },
618
+ });
619
+ }
620
+ else {
621
+ out.set(pos, { svg: renderEmptySlot(), action: null });
622
+ }
623
+ });
624
+ for (const [pos, cell] of usageHere)
625
+ out.set(pos, cell);
626
+ return out;
627
+ }
628
+ function buildDetail(state, stateEvt, view, slots, animFrame, animated, out) {
629
+ const sid = view.openSessionId;
630
+ const sess = state.allSessions.find((s) => s.id === sid);
631
+ // Focused-session detail comes from the top-level state_update when it relays
632
+ // this session (focusedSessionId/sessionId match); else fall back to SessionInfo.
633
+ const focused = stateEvt?.focusedSessionId === sid || stateEvt?.sessionId === sid;
634
+ const sState = (focused ? state.state : (sess?.state ?? 'idle')).toLowerCase();
635
+ const options = (focused ? state.options : (sess?.options ?? [])) ?? [];
636
+ const tool = focused ? state.currentTool : sess?.currentTool;
637
+ // A selected session with no model is UNKNOWN, not an invitation to borrow
638
+ // the daemon-global model from another agent. Only a matching focused event
639
+ // may override the sessions_list row.
640
+ const model = focused ? (state.modelName || sess?.modelName || '') : (sess?.modelName || '');
641
+ const heroSess = sess ?? {
642
+ id: sid, port: 0, alive: true, projectName: state.projectName,
643
+ agentType: state.agentType, state: sState, modelName: model,
644
+ };
645
+ const first = slots[0];
646
+ const last = slots[slots.length - 1];
647
+ out.set(first, { svg: renderBackButton(), action: { kind: 'back' } });
648
+ out.set(slots[1] ?? first, {
649
+ svg: renderDetailInfo(heroSess, sState, tool, model, undefined),
650
+ action: null,
651
+ });
652
+ // Observed (hook-only, no PTY) sessions steer through hook primitives, so
653
+ // every actionable cell must map to something the daemon can actually
654
+ // deliver: STOP → soft stop (deny at next tool call), quick actions →
655
+ // queued for turn end, ALLOW/DENY → held PreToolUse gate. Anything
656
+ // undeliverable renders inert instead of pretending to work.
657
+ const isObserved = sess?.controlMode === 'observed';
658
+ // Which observed agents actually have a delivery path: Claude = hook RPC
659
+ // (soft stop / turn-end queue / gate), OpenCode = observer-plugin command
660
+ // queue (immediate abort / prompt injection). Codex hooks are notify-only —
661
+ // no steering exists, so its buttons must stay inert.
662
+ const observedSteerable = isObserved
663
+ && (sess?.agentType === 'claude-code' || sess?.agentType === 'opencode');
664
+ const stopRequested = Boolean(sess?.stopRequested);
665
+ const gateRequestId = sess?.requestId;
666
+ if (processingState(sState)) {
667
+ if (isObserved && !observedSteerable) {
668
+ out.set(last, { svg: renderStopButton(false), action: null });
669
+ }
670
+ else if (isObserved && stopRequested) {
671
+ // Soft stop already requested — pressing again does nothing new.
672
+ out.set(last, { svg: renderInfoSlot('STOPPING', 'at next tool', 'status', 'warning'), action: null });
673
+ }
674
+ else {
675
+ const command = isObserved
676
+ ? { type: 'session_command', sessionId: sid, command: { type: 'interrupt' } }
677
+ : { type: 'interrupt' };
678
+ out.set(last, { svg: renderStopButton(true), action: { kind: 'command', command } });
679
+ }
680
+ }
681
+ else if (awaitingState(sState)) {
682
+ if (isObserved) {
683
+ // ESC can't reach an observed terminal; the gate (if any) is answered
684
+ // by the ALLOW/DENY content cells below.
685
+ out.set(last, { svg: renderEscButton(false), action: null });
686
+ }
687
+ else {
688
+ out.set(last, { svg: renderEscButton(true), action: { kind: 'command', command: { type: 'escape' } } });
689
+ }
690
+ }
691
+ else {
692
+ // Idle: an observed session has nothing to interrupt — render STOP inert
693
+ // (the old always-armed interrupt here was a silent-drop trap).
694
+ out.set(last, {
695
+ svg: renderStopButton(false),
696
+ action: isObserved ? null : { kind: 'command', command: { type: 'interrupt' } },
697
+ });
698
+ }
699
+ // Content slots between INFO and STOP.
700
+ const content = slots.slice(2, slots.length - 1);
701
+ const cells = [];
702
+ if (awaitingState(sState)) {
703
+ // A focused PTY session reports `navigable` on the live state_update; a
704
+ // non-focused SessionInfo never carries it (and rarely carries options).
705
+ const navigable = Boolean(focused ? stateEvt?.navigable : false);
706
+ if (options.length > 0) {
707
+ // Render the REAL option set (permission OR multi-select) regardless of
708
+ // promptType — the parser already extracted the actual labels (e.g.
709
+ // "Yes" / "Yes, and don't ask again" / "No, tell Claude"). Navigable TUI
710
+ // (❯ cursor) → select_option so the daemon drives arrows+Enter;
711
+ // non-navigable inline prompts → respond with the option's shortcut.
712
+ options.forEach((opt, i) => {
713
+ const command = navigable
714
+ ? { type: 'select_option', index: i, sessionId: sid }
715
+ : { type: 'respond', value: opt.shortcut || opt.label?.charAt(0)?.toLowerCase() || String(i + 1) };
716
+ cells.push({ svg: renderOptionButton(opt, i), action: { kind: 'command', command } });
717
+ });
718
+ }
719
+ else if (isObserved && gateRequestId) {
720
+ // Held PreToolUse gate: these two answers are device-native semantics
721
+ // (permit/deny THIS tool call), not a mirror of the TUI prompt — the
722
+ // daemon only holds calls it verified Claude would genuinely prompt for.
723
+ cells.push({
724
+ svg: actionTile('ALLOW', '#22c55e', 'this tool call'),
725
+ action: { kind: 'command', command: { type: 'permission_decision', requestId: gateRequestId, decision: 'allow' } },
726
+ });
727
+ cells.push({
728
+ svg: actionTile('DENY', '#f87171', 'this tool call'),
729
+ action: { kind: 'command', command: { type: 'permission_decision', requestId: gateRequestId, decision: 'deny' } },
730
+ });
731
+ }
732
+ else {
733
+ // Awaiting but no real options to render — only PTY-managed sessions expose
734
+ // Claude's actual choices. Don't fabricate Allow/Deny that may not match the
735
+ // real prompt; guide the user to the terminal instead.
736
+ cells.push({ svg: renderInfoSlot('PERMIT?', 'answer in terminal', 'status', 'warning'), action: null });
737
+ }
738
+ }
739
+ else if (processingState(sState)) {
740
+ const queued = sess?.queuedDirectives ?? 0;
741
+ cells.push({
742
+ svg: renderInfoSlot('RUNNING', queued > 0 ? `${queued} queued` : (tool || 'working'), 'activity', 'info'),
743
+ action: null,
744
+ });
745
+ // No queued task buttons mid-turn: PROCESSING is deliberately limited to
746
+ // live status + STOP so users cannot mistake a future directive for the
747
+ // agent's current work. Keep the
748
+ // REVIEWING spinner / last-verdict badge visible as inert status.
749
+ const badge = reviewBadgeTile(sess);
750
+ if (badge)
751
+ cells.push(badge);
752
+ }
753
+ else if (isObserved) {
754
+ if (sess?.agentType === 'opencode') {
755
+ // OpenCode observed injects immediately even while idle (observer
756
+ // plugin + in-process SDK) — same semantics as managed idle presets.
757
+ const inject = [
758
+ ['GO ON', 'continue'], ['COMMIT', 'commit the changes'],
759
+ ];
760
+ inject.forEach(([label, text]) => cells.push({
761
+ svg: actionTile(label, '#cbd5e1', 'inject now'),
762
+ action: { kind: 'command', command: { type: 'session_command', sessionId: sid, command: { type: 'send_prompt', text } } },
763
+ }));
764
+ cells.push(reviewTile(sess, sid));
765
+ }
766
+ else {
767
+ // Idle observed Claude/Codex: no prompt-delivery path exists, but the
768
+ // independent review needs no control at all — it is the one action
769
+ // that stays live here.
770
+ cells.push(reviewTile(sess, sid));
771
+ cells.push({ svg: renderInfoSlot('OBSERVED', 'control in terminal', 'status', 'info'), action: null });
772
+ }
773
+ }
774
+ else {
775
+ // Managed idle quick-actions. REVIEW routes to the independent eval
776
+ // (uniform semantics across every session type); the rest type into the
777
+ // PTY as before.
778
+ cells.push({
779
+ svg: actionTile('GO ON', '#cbd5e1'),
780
+ action: { kind: 'command', command: { type: 'send_prompt', text: 'continue' } },
781
+ });
782
+ cells.push(reviewTile(sess, sid));
783
+ cells.push({
784
+ svg: actionTile('COMMIT', '#cbd5e1'),
785
+ action: { kind: 'command', command: { type: 'send_prompt', text: 'commit the changes' } },
786
+ });
787
+ cells.push({
788
+ svg: actionTile('CLEAR', '#cbd5e1'),
789
+ action: { kind: 'command', command: { type: 'send_prompt', text: '/clear' } },
790
+ });
791
+ }
792
+ // Paginate cells into content slots; reserve last content slot for MORE if overflow.
793
+ const cap = content.length;
794
+ const overflow = cells.length > cap;
795
+ const perPage = overflow ? cap - 1 : cap;
796
+ const pages = Math.max(1, Math.ceil(cells.length / Math.max(1, perPage)));
797
+ const page = ((view.page ?? 0) % pages + pages) % pages;
798
+ const pageCells = cells.slice(page * perPage, page * perPage + perPage);
799
+ content.forEach((pos, i) => {
800
+ if (overflow && i === content.length - 1) {
801
+ out.set(pos, { svg: renderNextPageButton(`${page + 1}/${pages}`), action: { kind: 'page', delta: 1 } });
802
+ return;
803
+ }
804
+ out.set(pos, pageCells[i] ?? { svg: renderEmptySlot(), action: null });
805
+ });
806
+ return out;
807
+ }
808
+ //# sourceMappingURL=d200h-layout.js.map