@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -2
  3. package/demo/pi-stardeck.tape +59 -0
  4. package/docs/architecture.md +103 -0
  5. package/docs/assets/pi-stardeck.gif +0 -0
  6. package/docs/development.md +128 -0
  7. package/licenses/README.md +17 -0
  8. package/licenses/pi-fancy-footer-LICENSE +21 -0
  9. package/licenses/pi-vim-LICENSE +21 -0
  10. package/package.json +81 -4
  11. package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
  12. package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
  13. package/packages/pi-fancy-footer-full-palette/README.md +468 -0
  14. package/packages/pi-fancy-footer-full-palette/package.json +52 -0
  15. package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
  16. package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
  17. package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
  18. package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
  19. package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
  20. package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
  21. package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
  22. package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
  23. package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
  24. package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
  25. package/packages/pi-stardeck/README.md +219 -0
  26. package/packages/pi-stardeck/index.ts +691 -0
  27. package/packages/pi-stardeck/package.json +32 -0
  28. package/packages/pi-stardeck/src/activity-age.ts +184 -0
  29. package/packages/pi-stardeck/src/capabilities.ts +105 -0
  30. package/packages/pi-stardeck/src/deck.ts +807 -0
  31. package/packages/pi-stardeck/src/gauge.ts +1323 -0
  32. package/packages/pi-stardeck/src/git-refresh.ts +5 -0
  33. package/packages/pi-stardeck/src/process-resources.ts +428 -0
  34. package/packages/pi-stardeck/src/title-state.ts +195 -0
  35. package/packages/pi-stardeck/src/title.ts +443 -0
  36. package/packages/pi-vim-top-border/LICENSE +21 -0
  37. package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
  38. package/packages/pi-vim-top-border/README.md +577 -0
  39. package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
  40. package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
  41. package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
  42. package/packages/pi-vim-top-border/index.ts +4195 -0
  43. package/packages/pi-vim-top-border/input-keys.ts +49 -0
  44. package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
  45. package/packages/pi-vim-top-border/mode-colors.ts +97 -0
  46. package/packages/pi-vim-top-border/mode-label.ts +62 -0
  47. package/packages/pi-vim-top-border/motions.ts +297 -0
  48. package/packages/pi-vim-top-border/package.json +47 -0
  49. package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
  50. package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
  51. package/packages/pi-vim-top-border/prompt-status.ts +131 -0
  52. package/packages/pi-vim-top-border/settings.ts +287 -0
  53. package/packages/pi-vim-top-border/text-objects.ts +389 -0
  54. package/packages/pi-vim-top-border/types.ts +51 -0
  55. package/packages/pi-vim-top-border/visual.ts +65 -0
  56. package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
  57. package/packages/status/README.md +398 -0
  58. package/packages/status/index.ts +1717 -0
  59. package/packages/status/package.json +33 -0
  60. package/packages/status/src/active-work.ts +235 -0
  61. package/packages/status/src/config.ts +218 -0
  62. package/packages/status/src/diagnostics.ts +238 -0
  63. package/packages/status/src/goal-block.ts +377 -0
  64. package/packages/status/src/goal.ts +170 -0
  65. package/packages/status/src/ledger.ts +101 -0
  66. package/packages/status/src/openspec.ts +394 -0
  67. package/packages/status/src/orchestration.ts +237 -0
  68. package/packages/status/src/publisher.ts +1294 -0
  69. package/packages/status/src/reducer.ts +190 -0
  70. package/packages/status/src/runtime-runs.ts +267 -0
  71. package/packages/status/src/state.ts +178 -0
  72. package/packages/status/src/title-activity.ts +224 -0
  73. package/packages/status/src/title-state.ts +218 -0
  74. package/packages/status/src/turn-cost.ts +63 -0
  75. package/packages/status/src/types.ts +228 -0
  76. package/src/deck-surface.ts +46 -0
  77. package/src/health.ts +228 -0
  78. package/src/index.ts +28 -0
  79. package/src/pi-status.ts +334 -0
@@ -0,0 +1,807 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent';
2
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
3
+
4
+ import type { LspCapability, McpCapability } from './capabilities.ts';
5
+ import type { HeaderSelection } from './gauge.ts';
6
+ import type {
7
+ FooterTelemetrySnapshot,
8
+ GitStatusSummary,
9
+ HeaderActivity,
10
+ HeaderSnapshot,
11
+ PiStatusSnapshot,
12
+ VimMode,
13
+ } from './gauge.ts';
14
+ import type { ResourceTelemetry } from './process-resources.ts';
15
+ import { formatDeckCountdown } from './gauge.ts';
16
+ import { formatDeckWorkLabel } from './title-state.ts';
17
+
18
+ export interface HeaderDeckModeRail {
19
+ plain: string;
20
+ styled: string;
21
+ }
22
+
23
+ export interface HeaderDeckState {
24
+ elapsedMs: number | null;
25
+ idleMs?: number;
26
+ header: HeaderSnapshot | null;
27
+ cwd: string;
28
+ devbox: boolean;
29
+ branch: string;
30
+ gitAvailable: boolean;
31
+ git: GitStatusSummary;
32
+ model: string;
33
+ thinking?: string;
34
+ contextPercent?: number;
35
+ contextTokens?: number;
36
+ contextWindow?: number;
37
+ compactionCount: number;
38
+ resources?: ResourceTelemetry;
39
+ footerTelemetry?: FooterTelemetrySnapshot;
40
+ piStatus?: PiStatusSnapshot;
41
+ lsp: LspCapability | null;
42
+ mcp: McpCapability | null;
43
+ mode: VimMode;
44
+ }
45
+
46
+ const ANSI_ESCAPE = new RegExp(
47
+ '\\u001b(?:\\][\\s\\S]*?(?:\\u0007|\\u001b\\\\)|\\[[0-?]*[ -/]*[@-~]|[@-_])',
48
+ 'gu',
49
+ );
50
+ const CONTROL_CHARACTER = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f]', 'gu');
51
+ const TRAILING_ANSI_ESCAPE =
52
+ /\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~]|[@-_])\s*$/u;
53
+
54
+ function safeText(value: string, maximum = 180): string {
55
+ return Array.from(
56
+ value
57
+ .replace(ANSI_ESCAPE, '')
58
+ .replace(CONTROL_CHARACTER, ' ')
59
+ .replaceAll('󰠭', '')
60
+ .replace(/\s+/gu, ' ')
61
+ .trim(),
62
+ )
63
+ .slice(0, maximum)
64
+ .join('');
65
+ }
66
+
67
+ function color(theme: Theme, name: string, text: string, bold = false): string {
68
+ const content = bold ? theme.bold(text) : text;
69
+ return theme.fg(name as Parameters<Theme['fg']>[0], content);
70
+ }
71
+
72
+ function fit(line: string, width: number): string {
73
+ return truncateToWidth(line, Math.max(0, width), '');
74
+ }
75
+
76
+ function trimTrailingFormatting(line: string): string {
77
+ let trimmed = line.trimEnd();
78
+ while (TRAILING_ANSI_ESCAPE.test(trimmed)) {
79
+ trimmed = trimmed.replace(TRAILING_ANSI_ESCAPE, '').trimEnd();
80
+ }
81
+ return trimmed;
82
+ }
83
+
84
+ function fitWithoutDanglingSeparator(line: string, width: number): string {
85
+ let fitted = fit(line, width);
86
+ while (true) {
87
+ const visible = fitted.replace(ANSI_ESCAPE, '').trimEnd();
88
+ const separator = visible.at(-1);
89
+ if (separator !== '›' && separator !== '⟩') return fitted;
90
+ fitted = trimTrailingFormatting(fitted.slice(0, fitted.lastIndexOf(separator)));
91
+ }
92
+ }
93
+
94
+ export function formatDeckElapsed(elapsedMs: number | null): string {
95
+ const maximum = (999 * 60 + 59) * 1_000 + 999;
96
+ const observed = elapsedMs ?? 0;
97
+ const finite = Number.isFinite(observed)
98
+ ? observed
99
+ : observed === Number.POSITIVE_INFINITY
100
+ ? maximum
101
+ : 0;
102
+ const normalized = Math.min(maximum, Math.max(0, Math.floor(finite)));
103
+ const minutes = Math.floor(normalized / 60_000);
104
+ const seconds = Math.floor((normalized % 60_000) / 1_000);
105
+ const centiseconds = Math.floor((normalized % 1_000) / 10);
106
+ return `${String(minutes).padStart(3, '0')}:${String(seconds).padStart(2, '0')}'${String(centiseconds).padStart(2, '0')}`;
107
+ }
108
+
109
+ export function formatDeckPercent(value: number): string {
110
+ const normalized = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
111
+ return `${Math.round(normalized)}%`;
112
+ }
113
+
114
+ export function formatDeckCpu(value: number): string {
115
+ const normalized = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
116
+ return `${normalized.toFixed(1)}%`;
117
+ }
118
+
119
+ export function formatDeckBytes(value: number): string {
120
+ const units = ['', 'K', 'M', 'G', 'T'];
121
+ let normalized = Number.isFinite(value) ? Math.max(0, value) : 0;
122
+ let unit = 0;
123
+ while (normalized >= 1024 && unit < units.length - 1) {
124
+ normalized /= 1024;
125
+ unit += 1;
126
+ }
127
+ const decimals = unit > 0 && normalized < 10 ? 1 : 0;
128
+ return `${normalized.toFixed(decimals).replace(/\.0$/u, '')}${units[unit]}`;
129
+ }
130
+
131
+ function formatCost(value: number): string {
132
+ const normalized = Number.isFinite(value) ? Math.max(0, value) : 0;
133
+ if (normalized === 0) return '$0';
134
+ const decimals =
135
+ normalized >= 0.01
136
+ ? 2
137
+ : Math.min(6, Math.max(3, Math.ceil(-Math.log10(normalized)) + 1));
138
+ return `$${normalized.toFixed(decimals).replace(/\.?0+$/u, '')}`;
139
+ }
140
+
141
+ function wrapFocus(text: string, width: number, maximumLines: number): string[] {
142
+ const words = safeText(text, 360).split(' ').filter(Boolean);
143
+ const lines: string[] = [];
144
+ let current = '';
145
+ for (const word of words) {
146
+ const candidate = current ? `${current} ${word}` : word;
147
+ if (visibleWidth(candidate) <= width) {
148
+ current = candidate;
149
+ continue;
150
+ }
151
+ if (current) lines.push(current);
152
+ current = word;
153
+ if (lines.length >= maximumLines) break;
154
+ }
155
+ if (lines.length < maximumLines && current) lines.push(current);
156
+ if (words.length > 0 && lines.length === maximumLines) {
157
+ lines[maximumLines - 1] = truncateToWidth(
158
+ lines[maximumLines - 1] ?? '',
159
+ width,
160
+ '…',
161
+ );
162
+ }
163
+ return lines.slice(0, maximumLines);
164
+ }
165
+
166
+ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): string[] {
167
+ // Line 2 anchor: the clock always leads the band with its icon; the
168
+ // Subject follows when the title state carries one, and a fresh session
169
+ // (subject null) leaves the clock leading alone — no dangling separator.
170
+ // Task 3.1: the unfocused open-goal hint left this band for the notice
171
+ // slot (design: "notices absorb the unfocused open-goal hint").
172
+ const clock = `${color(theme, 'customMessageLabel', '󰠭')} ${timerTile(state, theme)}`;
173
+ const title = state.header?.title ?? null;
174
+ if (title) {
175
+ const subject = safeText(title.subject ?? '');
176
+ if (!subject) return [clock];
177
+ return focusWrapLines(subject, clock, width, theme, 'accent');
178
+ }
179
+ // Pre-title publishers: the legacy selection keeps the band alive.
180
+ const selection = state.header?.selection;
181
+ const titles = (selection?.titles ?? [])
182
+ .map((value) => safeText(value))
183
+ .filter(Boolean);
184
+ if (!selection || titles.length === 0) {
185
+ return [clock]; // frozen contract: no selection means no slot, clock stays
186
+ }
187
+ const selectionColor = ['accent', 'success'].includes(selection.color)
188
+ ? 'accent'
189
+ : selection.color;
190
+ return focusWrapLines(titles.join(' › '), clock, width, theme, selectionColor);
191
+ }
192
+
193
+ function focusWrapLines(
194
+ content: string,
195
+ clock: string,
196
+ width: number,
197
+ theme: Theme,
198
+ contentColor: string,
199
+ ): string[] {
200
+ const lead = `${clock} ${semanticSeparator(theme)} `;
201
+ const indent = ' '.repeat(visibleWidth(lead));
202
+ const contentWidth = Math.max(1, width - visibleWidth(lead));
203
+ return wrapFocus(content, contentWidth, 2).map(
204
+ (line, index) =>
205
+ `${index === 0 ? lead : indent}${color(theme, contentColor, line)}`,
206
+ );
207
+ }
208
+
209
+ function semanticSeparator(theme: Theme): string {
210
+ return color(theme, 'thinkingHigh', '⟩', true);
211
+ }
212
+
213
+ function minorSeparator(theme: Theme, semanticColor = 'accent'): string {
214
+ return color(theme, semanticColor, '›');
215
+ }
216
+ function loud(theme: Theme, name: string, text: string): string {
217
+ return `\u001b[7m${color(theme, name, text, true)}\u001b[27m`;
218
+ }
219
+
220
+ function alignedSingleRow(left: string, right: string, width: number): string {
221
+ if (!right) return fit(left, width);
222
+ const rightWidth = visibleWidth(right);
223
+ if (rightWidth >= width) return fit(right, width);
224
+ const fittedLeft = truncateToWidth(left, width - rightWidth - 1, '…');
225
+ const gap = Math.max(1, width - visibleWidth(fittedLeft) - rightWidth);
226
+ return `${fittedLeft}${' '.repeat(gap)}${right}`;
227
+ }
228
+
229
+ function workTile(state: HeaderDeckState, theme: Theme): string {
230
+ // Line 2 right: Work — derived from OpenSpec focus, never authored here.
231
+ // A fresh session keeps the slot with the accepted dim hint; pre-title
232
+ // publishers leave it silent (absent facts remove their slots).
233
+ const title = state.header?.title ?? null;
234
+ if (!title) return '';
235
+ const work = title.work;
236
+ if (!work) return color(theme, 'dim', '— no change owns the moment');
237
+ return color(
238
+ theme,
239
+ work.total > 0 && work.done >= work.total ? 'success' : 'text',
240
+ formatDeckWorkLabel(work),
241
+ );
242
+ }
243
+
244
+ // Relocation phase (Human-directed): the clock leads the title band beside
245
+ // the anchor, runs ride the lifecycle row, and Work keeps the title's
246
+ // right end — real values when live, the dim hint when idle.
247
+ function timerTile(state: HeaderDeckState, theme: Theme): string {
248
+ const elapsed = state.elapsedMs;
249
+ const idle = state.idleMs;
250
+ return elapsed != null
251
+ ? color(theme, 'accent', formatDeckElapsed(elapsed))
252
+ : color(theme, 'dim', formatDeckElapsed(idle ?? 0));
253
+ }
254
+
255
+ function spanElapsed(ms: number): string {
256
+ const totalSeconds = Math.floor(ms / 1_000);
257
+ const minutes = Math.floor(totalSeconds / 60);
258
+ const seconds = totalSeconds % 60;
259
+ return `${String(minutes).padStart(2, '0')}'${String(seconds).padStart(2, '0')}`;
260
+ }
261
+
262
+ function spanTreeRows(state: HeaderDeckState, width: number, theme: Theme): string[] {
263
+ // Frozen contract runs fact: one elastic row per live child run, naming its
264
+ // agent and stage; the rows collapse the moment the spans settle.
265
+ if (width < 60) return [];
266
+ const spans = (state.header?.activeRunSpans ?? []).filter(
267
+ (span) => span.agent !== undefined || span.kind === 'bash',
268
+ );
269
+ return spans.map((span, index) => {
270
+ const branch = index === spans.length - 1 ? '└' : '├';
271
+ const who = span.agent ?? span.label ?? span.kind;
272
+ // Gruvbox brown (dim in this theme), never bare black nor violet.
273
+ // Relocation: the age rides inline beside its branch glyph.
274
+ const row = color(
275
+ theme,
276
+ 'dim',
277
+ ` ${branch} (${spanElapsed(span.elapsedMs)}) ${who} ${span.stage}`,
278
+ );
279
+ return truncateToWidth(row, Math.max(1, width), '…');
280
+ });
281
+ }
282
+
283
+ const ACTIVITY_LABELS: Record<HeaderActivity['kind'], string> = {
284
+ understanding: 'interpreting',
285
+ inspection: 'inspecting',
286
+ synthesis: 'synthesizing',
287
+ planning: 'planning',
288
+ 'skill-proposal': 'proposing skill',
289
+ mutation: 'updating',
290
+ implementation: 'implementing',
291
+ 'change-review': 'reviewing change',
292
+ regression: 'testing',
293
+ verification: 'checking',
294
+ 'result-review': 'reviewing result',
295
+ recovery: 'recovering',
296
+ 'awaiting-validation': 'validation',
297
+ 'awaiting-input': 'input',
298
+ 'operation-aborted': 'stopped',
299
+ };
300
+
301
+ function activityLabel(state: HeaderDeckState): string {
302
+ const pathActivity = state.header?.work?.activityPath?.at(-1);
303
+ const pathLabel = safeText(pathActivity?.compact || pathActivity?.label || '');
304
+ if (pathLabel) return pathLabel;
305
+ const activity = state.header?.work?.activity;
306
+ return activity ? ACTIVITY_LABELS[activity.kind] : '';
307
+ }
308
+
309
+ /** `editing deck.ts` / `reading schema.yaml` / `subagent: scout`. */
310
+ function titleActivityLabel(activity: { class: string; target: string }): string {
311
+ return activity.class === 'subagent'
312
+ ? `subagent: ${activity.target}`
313
+ : `${activity.class} ${activity.target}`;
314
+ }
315
+
316
+ function activityTile(state: HeaderDeckState, theme: Theme): string {
317
+ // Line 3 left: the observed activity owns the slot — `awaiting Human`
318
+ // when idle, the legacy observed label for pre-title publishers.
319
+ const title = state.header?.title ?? null;
320
+ if (title) {
321
+ return title.activity
322
+ ? color(theme, 'text', titleActivityLabel(title.activity))
323
+ : color(theme, 'dim', 'awaiting Human');
324
+ }
325
+ const legacy = activityLabel(state);
326
+ return legacy ? color(theme, 'text', legacy) : color(theme, 'dim', 'awaiting Human');
327
+ }
328
+
329
+ function formatTurnElapsed(ms: number): string {
330
+ // Matches the turn meter's span-idle ceiling: 99m59s999ms → `99:59`.
331
+ const bounded = Math.min(
332
+ (99 * 60 + 59) * 1_000 + 999,
333
+ Math.max(0, Math.floor(Number.isFinite(ms) ? ms : 0)),
334
+ );
335
+ const totalSeconds = Math.floor(bounded / 1_000);
336
+ return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
337
+ }
338
+
339
+ function turnCostTile(state: HeaderDeckState, theme: Theme): string {
340
+ // Line 3 right: the per-turn cost meter. Idle renders the accepted
341
+ // `— · 0 tok`; pre-title publishers (no meter) stay silent.
342
+ const turn = state.header?.turn ?? null;
343
+ if (!turn) return '';
344
+ if (turn.tokens <= 0 && turn.elapsedMs <= 0) {
345
+ return color(theme, 'dim', '— · 0 tok');
346
+ }
347
+ return color(
348
+ theme,
349
+ 'accent',
350
+ `this turn ${formatTokens(turn.tokens)} · ${formatTurnElapsed(turn.elapsedMs)}`,
351
+ );
352
+ }
353
+
354
+ function gitRight(state: HeaderDeckState, theme: Theme): string {
355
+ // Gray-out amendment (Human-directed): absent Git shows a dim marker.
356
+ if (!state.branch && !state.gitAvailable) {
357
+ return color(theme, 'dim', ` (no Git)`);
358
+ }
359
+ const branch = color(
360
+ theme,
361
+ state.branch ? 'success' : 'dim',
362
+ ` ${safeText(state.branch || '(detached)', 32)}`,
363
+ );
364
+ const entries = [
365
+ [state.git.staged, 'success', ''],
366
+ [state.git.modified, 'warning', ''],
367
+ [state.git.untracked, 'accent', ''],
368
+ [state.git.conflicts, 'error', ''],
369
+ ] as const;
370
+ const dirty = entries
371
+ .filter(([count]) => count > 0)
372
+ .map(([count, semanticColor, icon]) =>
373
+ color(theme, semanticColor, `${icon} ${count}`),
374
+ );
375
+ const status = !state.gitAvailable
376
+ ? ''
377
+ : dirty.length > 0
378
+ ? dirty.join(` ${minorSeparator(theme)} `)
379
+ : color(theme, 'success', ' clean');
380
+ if (!status) return branch;
381
+ return `${branch} ${minorSeparator(theme)} ${status}`;
382
+ }
383
+
384
+ function globalLeft(state: HeaderDeckState, theme: Theme): string {
385
+ return `${color(theme, 'accent', `󰚩 ${safeText(state.model, 80)}`)} ${minorSeparator(theme)} ${color(theme, state.thinking ? 'thinkingHigh' : 'dim', safeText(state.thinking || 'off', 16))}`;
386
+ }
387
+
388
+ function projectDeckLeft(state: HeaderDeckState, theme: Theme, width: number): string {
389
+ // Line 1 left: the devbox marker is a path prefix, then the path; the git
390
+ // segment yields below 79 columns before the path does. The companion
391
+ // chips ride line 1's right end and never drop (always-visible contract).
392
+ const devbox = state.devbox ? `${color(theme, 'success', '[󰆧]', true)} ` : '';
393
+ const path = `${devbox}${color(theme, 'success', ` ${safeText(state.cwd)}`)}`;
394
+ const gitSegment =
395
+ width >= 79 ? truncateToWidth(gitRight(state, theme), 52, '…') : '';
396
+ return gitSegment ? `${path} ${semanticSeparator(theme)} ${gitSegment}` : path;
397
+ }
398
+
399
+ const KNOWN_COMPANION_ICONS: ReadonlyArray<{
400
+ keys: readonly string[];
401
+ icons: readonly string[];
402
+ }> = [
403
+ { keys: ['engram'], icons: ['\u{21BB}', '\u{25A4}'] },
404
+ { keys: ['graphify'], icons: ['\u{232C}'] },
405
+ { keys: ['appa', 'openappa'], icons: ['\u{26E8}'] },
406
+ ];
407
+
408
+ const COMPANION_WARNING_PATTERN =
409
+ /(warn|error|stale|unreachable|down|fail|update recommended|needs attention|unhealthy)/iu;
410
+
411
+ function companionIconColor(text: string | undefined, active: boolean): string {
412
+ // Color is the only state signal: symbols never hide.
413
+ // On = accent, warn/error = warning, off = muted (visible gray).
414
+ if (!active) return 'muted';
415
+ return text !== undefined && COMPANION_WARNING_PATTERN.test(text)
416
+ ? 'warning'
417
+ : 'accent';
418
+ }
419
+
420
+ function extensionStatusCodes(state: HeaderDeckState, theme: Theme): string {
421
+ const texts = new Map<string, string>();
422
+ for (const item of state.piStatus?.nativeStatuses ?? []) {
423
+ if (item.key === 'goal' || texts.has(item.key)) continue;
424
+ texts.set(item.key, item.text);
425
+ }
426
+ const parts: Array<{ bare: boolean; text: string }> = [];
427
+ for (const companion of KNOWN_COMPANION_ICONS) {
428
+ const published = companion.keys
429
+ .map((key) => texts.get(key))
430
+ .find((text) => text !== undefined);
431
+ for (const key of companion.keys) texts.delete(key);
432
+ const joined = companion.icons.join(' ');
433
+ const chip = companion.icons.length > 1 ? `[${joined}]` : joined;
434
+ parts.push({
435
+ bare: companion.icons.length === 1,
436
+ text: color(
437
+ theme,
438
+ companionIconColor(published, published !== undefined),
439
+ chip,
440
+ true,
441
+ ),
442
+ });
443
+ }
444
+ let out = '';
445
+ let previousBare = false;
446
+ for (const part of parts) {
447
+ out +=
448
+ out === '' ? part.text : `${previousBare && part.bare ? ' ' : ' '}${part.text}`;
449
+ previousBare = part.bare;
450
+ }
451
+ return out;
452
+ }
453
+
454
+ const BLOCKED_NOTICE = '\u26d4 blocked';
455
+ const APPROVAL_NOTICE = 'approval required';
456
+
457
+ function companionWarningTexts(state: HeaderDeckState): string[] {
458
+ // Same sources as the capability-health chips: the known companions'
459
+ // native-status texts, goal key filtered, ranked after blocked/approval.
460
+ const texts = new Map<string, string>();
461
+ for (const item of state.piStatus?.nativeStatuses ?? []) {
462
+ if (item.key === 'goal' || texts.has(item.key)) continue;
463
+ texts.set(item.key, item.text);
464
+ }
465
+ const warnings: string[] = [];
466
+ for (const companion of KNOWN_COMPANION_ICONS) {
467
+ const published = companion.keys
468
+ .map((key) => texts.get(key))
469
+ .find((text) => text !== undefined);
470
+ if (published !== undefined && COMPANION_WARNING_PATTERN.test(published)) {
471
+ warnings.push(safeText(published, 180));
472
+ }
473
+ }
474
+ return warnings;
475
+ }
476
+
477
+ /**
478
+ * Aggregate the deck's scattered warning signals into one notice slot
479
+ * (Task 3.1). Ranked per the spec's accepted mixed render: the unfocused
480
+ * open-goal hint, then the companion group (blocked, approval, warning
481
+ * texts), then the diagnostics count — `·`-joined, empty when clean.
482
+ */
483
+ export function collectDeckNotices(state: HeaderDeckState): string[] {
484
+ const header = state.header;
485
+ const openGoals = header?.selection?.source === 'goal' ? 0 : (header?.openGoals ?? 0);
486
+ const notices: string[] = [];
487
+ if (openGoals > 0) {
488
+ notices.push(`${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`);
489
+ }
490
+ if (header?.blocked) notices.push(BLOCKED_NOTICE);
491
+ if (header?.approvalRequired) notices.push(APPROVAL_NOTICE);
492
+ notices.push(...companionWarningTexts(state));
493
+ const diagnostics = header?.diagnostics?.count;
494
+ if (diagnostics !== undefined && diagnostics > 0) {
495
+ notices.push(`${diagnostics} diagnostic${diagnostics === 1 ? '' : 's'}`);
496
+ }
497
+ // Task 3.2: a set waiver suppresses exactly its matching notice text
498
+ // (suppression-only pending Human review); absence restores the notice.
499
+ const waiver = header?.title?.waiver ?? null;
500
+ return waiver ? notices.filter((notice) => notice !== waiver.notice) : notices;
501
+ }
502
+
503
+ function noticeTile(state: HeaderDeckState, theme: Theme): string {
504
+ const notices = collectDeckNotices(state);
505
+ if (notices.length === 0) return '';
506
+ // Blocked carries the error severity it wore on the deleted lifecycle
507
+ // tile; every other notice renders at warning.
508
+ const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
509
+ return color(theme, semantic, `⚠ ${notices.join(' · ')}`);
510
+ }
511
+
512
+ function telemetryRowLeft(state: HeaderDeckState, theme: Theme): string {
513
+ // Line 4 left (2.4 second amendment): the engine group — engine ›
514
+ // thinking, then context, compaction, and the calendar quota — owns
515
+ // the row's left end; the notice slot owns the right end (Task 3).
516
+ return modelGroupLeft(state, theme);
517
+ }
518
+
519
+ function telemetryRowRight(
520
+ state: HeaderDeckState,
521
+ theme: Theme,
522
+ nowMs = Date.now(),
523
+ ): string {
524
+ // Line 4 right (2.4 second amendment): the machine group — the coding
525
+ // quota flame with its countdown, machine readings, global cost — in
526
+ // that order, anchoring the row's right end.
527
+ const machineSegments: string[] = [];
528
+ const resources = state.resources;
529
+ if (resources) {
530
+ const resourceColor =
531
+ resources.cpuWarning || resources.memoryWarning ? 'warning' : 'accent';
532
+ const parts = [
533
+ color(theme, resourceColor, `${formatDeckCpu(resources.cpuPercent)}`),
534
+ color(theme, resourceColor, `${formatDeckBytes(resources.memoryBytes)}`),
535
+ ];
536
+ machineSegments.push(parts.join(` ${minorSeparator(theme, resourceColor)} `));
537
+ }
538
+ if (state.mcp) {
539
+ machineSegments.push(
540
+ color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`),
541
+ );
542
+ }
543
+ return [
544
+ windowQuotaTiles(state, theme, nowMs),
545
+ machineSegments.join(` ${semanticSeparator(theme)} `),
546
+ costTile(state, theme),
547
+ ]
548
+ .filter((group) => group !== '')
549
+ .join(` ${semanticSeparator(theme)} `);
550
+ }
551
+
552
+ function modelGroupLeft(state: HeaderDeckState, theme: Theme): string {
553
+ const ctxColor =
554
+ state.contextPercent !== undefined && state.contextPercent >= 85
555
+ ? 'warning'
556
+ : state.contextPercent === undefined
557
+ ? 'dim'
558
+ : 'accent';
559
+ const segments: string[] = [];
560
+ if (state.contextPercent !== undefined) {
561
+ segments.push(
562
+ color(theme, 'accent', `󰾆 ${formatDeckPercent(state.contextPercent)}`),
563
+ );
564
+ }
565
+ if (state.compactionCount > 0) {
566
+ segments.push(color(theme, ctxColor, `󰎞 ${state.compactionCount}`));
567
+ }
568
+ const quota = simpleQuotaTile(state, theme);
569
+ if (quota) segments.push(quota);
570
+ return segments.length === 0
571
+ ? globalLeft(state, theme)
572
+ : [
573
+ globalLeft(state, theme),
574
+ semanticSeparator(theme),
575
+ segments.join(` ${minorSeparator(theme, ctxColor)} `),
576
+ ].join(' ');
577
+ }
578
+
579
+ function simpleQuotaTile(state: HeaderDeckState, theme: Theme): string {
580
+ const telemetry = state.footerTelemetry;
581
+ if (!telemetry) return '';
582
+ const windows = telemetry.quotaWindows ?? [];
583
+ if (windows.length === 0) {
584
+ const quota = telemetry.quotaPercent;
585
+ if (quota === undefined) return '';
586
+ const quotaColor = quota >= 80 ? 'error' : quota >= 60 ? 'warning' : 'accent';
587
+ return color(theme, quotaColor, ` ${formatDeckPercent(quota)}`);
588
+ }
589
+ // With windows, the long-range calendar tile joins the left group; the
590
+ // coding flame stays with the live resources on the right.
591
+ const calendar = windows.find((window) => /^[\d.]+d$/u.test(window.label));
592
+ if (!calendar) return '';
593
+ return color(
594
+ theme,
595
+ calendar.percent >= 80 ? 'error' : calendar.percent >= 60 ? 'warning' : 'accent',
596
+ `󰃰 ${formatDeckPercent(calendar.percent)}`,
597
+ );
598
+ }
599
+
600
+ function windowQuotaTiles(
601
+ state: HeaderDeckState,
602
+ theme: Theme,
603
+ nowMs = Date.now(),
604
+ ): string {
605
+ const telemetry = state.footerTelemetry;
606
+ const windows = telemetry?.quotaWindows ?? [];
607
+ if (windows.length === 0) return '';
608
+ // Icons carry window identity: minute/hour windows are the coding window and
609
+ // wear the permanent flame with its reset countdown; day windows are the long
610
+ // window on the calendar. Telemetry lists windows shortest-first, so the
611
+ // first of each class is the tightest one.
612
+ const coding = windows.find((window) => /^[\d.]+[mh]$/u.test(window.label));
613
+ const tiles: string[] = [];
614
+ if (coding) {
615
+ tiles.push(
616
+ color(
617
+ theme,
618
+ coding.percent >= 80 ? 'error' : coding.percent >= 60 ? 'warning' : 'accent',
619
+ coding.resetAt !== undefined
620
+ ? `󰈸 ${formatDeckPercent(coding.percent)} › 󰅐 ${formatDeckCountdown(coding.resetAt, nowMs)}`
621
+ : `󰈸 ${formatDeckPercent(coding.percent)}`,
622
+ ),
623
+ );
624
+ }
625
+ return tiles.join(` ${semanticSeparator(theme)} `);
626
+ }
627
+
628
+ function costTile(state: HeaderDeckState, theme: Theme): string {
629
+ const telemetry = state.footerTelemetry;
630
+ if (!telemetry) return '';
631
+ return color(theme, 'accent', `󰜦 ${formatCost(telemetry.totalCost)}`);
632
+ }
633
+
634
+ function statusAlertTile(
635
+ state: HeaderDeckState,
636
+ width: number,
637
+ theme: Theme,
638
+ lineColor: string,
639
+ ): string {
640
+ // Line 1 rider (2.4 amendment): the severity counts formerly opened the
641
+ // deleted line-1 rule; they now ride line 1's right end beside the
642
+ // companion chips, compacting — drop the command, then merge to one
643
+ // count, then yield — before the always-visible chips ever do.
644
+ const errors = state.piStatus?.errors ?? 0;
645
+ const warnings = state.piStatus?.warnings ?? 0;
646
+ if (errors === 0 && warnings === 0) return '';
647
+
648
+ const groups = [
649
+ errors > 0 ? loud(theme, 'error', `󰅙 ${errors}`) : '',
650
+ warnings > 0 ? loud(theme, 'warning', ` ${warnings}`) : '',
651
+ ].filter(Boolean);
652
+ const summary = groups.join(color(theme, lineColor, ' › '));
653
+ const full = `${summary}${color(theme, lineColor, ' ⟩ ')}${color(theme, 'dim', '/pi-status')}`;
654
+ if (visibleWidth(full) < width) return full;
655
+ if (visibleWidth(summary) < width) return summary;
656
+
657
+ const total = errors + warnings;
658
+ const severity = errors > 0 ? 'error' : 'warning';
659
+ const icon = errors > 0 ? '󰅙' : '';
660
+ const count = loud(theme, severity, `${icon} ${total}`);
661
+ return visibleWidth(count) < width ? count : '';
662
+ }
663
+
664
+ function formatGoalElapsed(seconds: number): string {
665
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
666
+ const totalMinutes = Math.floor(seconds / 60);
667
+ if (totalMinutes < 60) return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
668
+ return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
669
+ }
670
+
671
+ function formatTokens(tokens: number): string {
672
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
673
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
674
+ return `${Math.floor(tokens)}`;
675
+ }
676
+
677
+ const GOAL_STATUS_COLORS: Record<string, string> = {
678
+ active: 'accent',
679
+ complete: 'success',
680
+ paused: 'warning',
681
+ blocked: 'error',
682
+ usage_limited: 'warning',
683
+ budget_limited: 'warning',
684
+ queued: 'dim',
685
+ };
686
+
687
+ function goalBadgeTile(
688
+ goal: NonNullable<HeaderSelection['goal']>,
689
+ theme: Theme,
690
+ ): string {
691
+ const semantic = GOAL_STATUS_COLORS[goal.status] ?? 'text';
692
+ const elapsed =
693
+ goal.elapsedSeconds !== undefined
694
+ ? ` · ${formatGoalElapsed(goal.elapsedSeconds)}`
695
+ : '';
696
+ return `${color(theme, semantic, `● ${goal.status}`)}${color(theme, 'dim', elapsed)}`;
697
+ }
698
+
699
+ function goalTreeLines(
700
+ goal: NonNullable<HeaderSelection['goal']>,
701
+ width: number,
702
+ theme: Theme,
703
+ ): string[] {
704
+ if (width < 56) return [];
705
+ const rows: string[] = [];
706
+ if (goal.tasksTotal !== undefined && goal.tasksDone !== undefined) {
707
+ const active =
708
+ goal.tasksActive !== undefined && goal.tasksActive > 0
709
+ ? ` · ${goal.tasksActive} active`
710
+ : '';
711
+ rows.push(` ├ tasks ${goal.tasksDone}/${goal.tasksTotal} done${active}`);
712
+ }
713
+ if (goal.tokensUsed !== undefined) {
714
+ const budget =
715
+ goal.tokenBudget !== undefined && goal.tokenBudget > 0
716
+ ? ` · budget ${Math.min(999, Math.round((goal.tokensUsed / goal.tokenBudget) * 100))}%`
717
+ : '';
718
+ rows.push(` └ usage ${formatTokens(goal.tokensUsed)} tokens${budget}`);
719
+ }
720
+ return rows.map((line) => color(theme, 'dim', line));
721
+ }
722
+
723
+ export function renderHeaderDeck(
724
+ state: HeaderDeckState,
725
+ width: number,
726
+ theme: Theme,
727
+ ): string[] {
728
+ const boundedWidth = Math.max(0, Math.floor(width));
729
+ if (boundedWidth === 0) return [];
730
+
731
+ const lineColor = 'thinkingHigh';
732
+ const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
733
+ const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
734
+ // Line 2 (four-line deck): the clock and Subject lead with Work on the
735
+ // right end; goal progress rows keep their home beneath this line.
736
+ // Narrow frames: the Work slot yields before the clock anchor does.
737
+ const goalDetail =
738
+ state.header?.selection?.source === 'goal'
739
+ ? state.header.selection.goal
740
+ : undefined;
741
+ const titleRight =
742
+ boundedWidth >= 40
743
+ ? goalDetail
744
+ ? goalBadgeTile(goalDetail, theme)
745
+ : workTile(state, theme)
746
+ : '';
747
+ const focusLines = titleBandLines(state, boundedWidth, theme);
748
+ const titleRows = [
749
+ alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
750
+ ...focusLines.slice(1),
751
+ ];
752
+ const goalRows = goalDetail ? goalTreeLines(goalDetail, boundedWidth, theme) : [];
753
+
754
+ // Line 3 (four-line deck): Activity left with the per-turn cost right —
755
+ // the superseded lifecycle/suggestion/runs tiles are gone; Work,
756
+ // Activity, and the spans rows are their replacements. The spans rows
757
+ // keep their home directly beneath this line.
758
+ const activityRow = alignedSingleRow(
759
+ ` ${activityTile(state, theme)}`,
760
+ turnCostTile(state, theme),
761
+ boundedWidth,
762
+ );
763
+ // Line 4 (2.4 second amendment + Task 3 placement default): the engine
764
+ // group holds the row's left end. Its right end belongs to the machine
765
+ // group — until notices land: a non-empty notice slot owns the right end
766
+ // and the machine group yields wholesale, riding the same ladder as the
767
+ // narrow-frame yield (the right group renders only when it fits beside
768
+ // the engine group at full width; the engine group never yields to it).
769
+ const engineGroup = telemetryRowLeft(state, theme);
770
+ const noticeGroup = noticeTile(state, theme);
771
+ const rightGroup = noticeGroup !== '' ? noticeGroup : telemetryRowRight(state, theme);
772
+ const telemetryRow =
773
+ rightGroup !== '' &&
774
+ visibleWidth(engineGroup) + 1 + visibleWidth(rightGroup) <= boundedWidth
775
+ ? alignedSingleRow(engineGroup, rightGroup, boundedWidth)
776
+ : fit(engineGroup, boundedWidth);
777
+
778
+ // Band adjacency (Human-directed, accepted mockups; third 2.4
779
+ // amendment): the deck opens with a full ─ rule directly beneath the
780
+ // line-1 strip and ends at the telemetry row — nothing renders beneath
781
+ // line 4; pi's own prompt chrome below closes the visual block — while
782
+ // the internal seams stay dotted ┈ between lines 2 and 3 and between
783
+ // line 3 (spans rows included) and line 4.
784
+ const chips = extensionStatusCodes(state, theme);
785
+ const alert = statusAlertTile(
786
+ state,
787
+ Math.max(0, boundedWidth - visibleWidth(chips) - 2),
788
+ theme,
789
+ lineColor,
790
+ );
791
+ const rows = [
792
+ alignedSingleRow(
793
+ projectDeckLeft(state, theme, boundedWidth),
794
+ alert ? `${alert} ${chips}` : chips,
795
+ boundedWidth,
796
+ ),
797
+ solidRule,
798
+ ...titleRows,
799
+ ...goalRows,
800
+ dottedRule,
801
+ activityRow,
802
+ ...spanTreeRows(state, boundedWidth, theme),
803
+ dottedRule,
804
+ telemetryRow,
805
+ ];
806
+ return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
807
+ }