@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.
- package/LICENSE +21 -0
- package/dist/adapter.d.ts +18 -3
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +62 -0
- package/dist/adapter.js.map +1 -1
- package/dist/command-builders.d.ts +35 -0
- package/dist/command-builders.d.ts.map +1 -0
- package/dist/command-builders.js +75 -0
- package/dist/command-builders.js.map +1 -0
- package/dist/connection-status.d.ts +42 -0
- package/dist/connection-status.d.ts.map +1 -0
- package/dist/connection-status.js +52 -0
- package/dist/connection-status.js.map +1 -0
- package/dist/d200h-layout.d.ts +131 -0
- package/dist/d200h-layout.d.ts.map +1 -0
- package/dist/d200h-layout.js +627 -0
- package/dist/d200h-layout.js.map +1 -0
- package/dist/design-tokens.d.ts +259 -0
- package/dist/design-tokens.d.ts.map +1 -0
- package/dist/design-tokens.js +156 -0
- package/dist/design-tokens.js.map +1 -0
- package/dist/eval-schema.d.ts +235 -0
- package/dist/eval-schema.d.ts.map +1 -0
- package/dist/eval-schema.js +23 -0
- package/dist/eval-schema.js.map +1 -0
- package/dist/format-utils.d.ts +52 -0
- package/dist/format-utils.d.ts.map +1 -0
- package/dist/format-utils.js +165 -0
- package/dist/format-utils.js.map +1 -0
- package/dist/gateway-protocol.d.ts +391 -0
- package/dist/gateway-protocol.d.ts.map +1 -0
- package/dist/gateway-protocol.js +19 -0
- package/dist/gateway-protocol.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/llm-settings.d.ts +52 -0
- package/dist/llm-settings.d.ts.map +1 -0
- package/dist/llm-settings.js +118 -0
- package/dist/llm-settings.js.map +1 -0
- package/dist/net-utils.d.ts +2 -1
- package/dist/net-utils.d.ts.map +1 -1
- package/dist/net-utils.js +100 -3
- package/dist/net-utils.js.map +1 -1
- package/dist/pricing.d.ts +45 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +93 -0
- package/dist/pricing.js.map +1 -0
- package/dist/protocol.d.ts +496 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +30 -1
- package/dist/protocol.js.map +1 -1
- package/dist/sample.d.ts +152 -0
- package/dist/sample.d.ts.map +1 -0
- package/dist/sample.js +22 -0
- package/dist/sample.js.map +1 -0
- package/dist/session-utils.d.ts +142 -0
- package/dist/session-utils.d.ts.map +1 -0
- package/dist/session-utils.js +304 -0
- package/dist/session-utils.js.map +1 -0
- package/dist/state-colors.d.ts +16 -0
- package/dist/state-colors.d.ts.map +1 -0
- package/dist/state-colors.js +48 -0
- package/dist/state-colors.js.map +1 -0
- package/dist/states.d.ts +2 -0
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +6 -1
- package/dist/states.js.map +1 -1
- package/dist/svg-renderers/agent-logos.d.ts +45 -0
- package/dist/svg-renderers/agent-logos.d.ts.map +1 -0
- package/dist/svg-renderers/agent-logos.js +238 -0
- package/dist/svg-renderers/agent-logos.js.map +1 -0
- package/dist/svg-renderers/index.d.ts +4 -0
- package/dist/svg-renderers/index.d.ts.map +1 -0
- package/dist/svg-renderers/index.js +4 -0
- package/dist/svg-renderers/index.js.map +1 -0
- package/dist/svg-renderers/session-slot-renderer.d.ts +78 -0
- package/dist/svg-renderers/session-slot-renderer.d.ts.map +1 -0
- package/dist/svg-renderers/session-slot-renderer.js +506 -0
- package/dist/svg-renderers/session-slot-renderer.js.map +1 -0
- package/dist/svg-renderers/text-utils.d.ts +29 -0
- package/dist/svg-renderers/text-utils.d.ts.map +1 -0
- package/dist/svg-renderers/text-utils.js +122 -0
- package/dist/svg-renderers/text-utils.js.map +1 -0
- package/dist/telemetry-envelope.d.ts +108 -0
- package/dist/telemetry-envelope.d.ts.map +1 -0
- package/dist/telemetry-envelope.js +34 -0
- package/dist/telemetry-envelope.js.map +1 -0
- package/dist/timeline-icons.d.ts +70 -0
- package/dist/timeline-icons.d.ts.map +1 -0
- package/dist/timeline-icons.js +161 -0
- package/dist/timeline-icons.js.map +1 -0
- package/dist/timeline-label.d.ts +38 -0
- package/dist/timeline-label.d.ts.map +1 -0
- package/dist/timeline-label.js +95 -0
- package/dist/timeline-label.js.map +1 -0
- package/dist/timeline-markdown.d.ts +80 -0
- package/dist/timeline-markdown.d.ts.map +1 -0
- package/dist/timeline-markdown.js +221 -0
- package/dist/timeline-markdown.js.map +1 -0
- package/dist/timeline-summarizer.d.ts +37 -0
- package/dist/timeline-summarizer.d.ts.map +1 -0
- package/dist/timeline-summarizer.js +125 -0
- package/dist/timeline-summarizer.js.map +1 -0
- package/dist/timeline.d.ts +135 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +633 -0
- package/dist/timeline.js.map +1 -0
- package/dist/voice-paths.d.ts +4 -0
- package/dist/voice-paths.d.ts.map +1 -1
- package/dist/voice-paths.js +5 -0
- package/dist/voice-paths.js.map +1 -1
- 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
|