@aemonge-dev/pi-stardeck 0.8.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,17 +1,21 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
 
3
3
  import { activeQuotaProviders } from './src/quota.ts';
4
+ import { startZaiQuotaProvider } from './src/quota-zai.ts';
4
5
  import { FOOTER_TELEMETRY_CHANNEL } from './src/protocol.ts';
5
6
  import { buildFooterTelemetryMessage, collectTotalCost } from './src/telemetry.ts';
6
7
 
7
8
  /**
8
9
  * Footer telemetry producer for the pi-stardeck deck. Renders nothing,
9
- * configures nothing, polls nothing: it walks the session branch for usage
10
- * cost and publishes deck-consumable snapshots on the footer-telemetry
11
- * channel after the events that can change them.
10
+ * configures nothing: it walks the session branch for usage cost and
11
+ * publishes deck-consumable snapshots on the footer-telemetry channel
12
+ * after the events that can change them. The z.ai quota provider rides
13
+ * the same cadence — session start, model change, and its own 5-minute
14
+ * interval — with snapshots cached between ticks.
12
15
  */
13
16
  export default function footerTelemetry(pi: ExtensionAPI): void {
14
17
  let context: ExtensionContext | undefined;
18
+ const zai = startZaiQuotaProvider();
15
19
 
16
20
  const publish = () => {
17
21
  if (!context) return;
@@ -24,14 +28,26 @@ export default function footerTelemetry(pi: ExtensionAPI): void {
24
28
  );
25
29
  };
26
30
 
31
+ // Fire-and-forget refresh then republish: the fetch resolves between
32
+ // events, and the publish lands the lit gauge without waiting for the
33
+ // next message.
34
+ const refreshQuota = () => {
35
+ void zai.refresh().then(() => publish());
36
+ };
37
+
27
38
  pi.on('session_start', (_event, ctx) => {
28
39
  context = ctx;
40
+ refreshQuota();
29
41
  publish();
30
42
  });
31
43
  pi.on('message_end', publish);
32
- pi.on('model_select', publish);
44
+ pi.on('model_select', () => {
45
+ refreshQuota();
46
+ publish();
47
+ });
33
48
  pi.on('session_compact', publish);
34
49
  pi.on('session_shutdown', () => {
35
50
  context = undefined;
51
+ zai.stop();
36
52
  });
37
53
  }
@@ -1,3 +1,5 @@
1
+ import type { QuotaWindow } from './quota.ts';
2
+
1
3
  export const FOOTER_TELEMETRY_PROTOCOL_VERSION = 1 as const;
2
4
 
3
5
  export const FOOTER_TELEMETRY_CHANNEL = 'pi-stardeck:footer-telemetry/v1';
@@ -7,4 +9,6 @@ export interface FooterTelemetryMessage {
7
9
  type: 'snapshot';
8
10
  totalCost: number;
9
11
  quotaPercent?: number;
12
+ /** Windowed quota usage (5h/7d); additive, protocol stays 1. */
13
+ quotaWindows?: QuotaWindow[];
10
14
  }
@@ -0,0 +1,228 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import {
6
+ registerQuotaProvider,
7
+ type QuotaSnapshot,
8
+ type QuotaWindow,
9
+ } from './quota.ts';
10
+
11
+ /**
12
+ * z.ai/GLM coding-plan QuotaProvider — the first registration on the
13
+ * quota seam. Clean-room envelope facts (recovered from git history,
14
+ * not copied): Bearer-authenticated GET on the quota-limit endpoint,
15
+ * `{ data: { limits: [...] } }` with usage as `percentage` or
16
+ * `usage`+`currentValue`, `nextResetTime` in unix seconds, and `TIME*`
17
+ * limits being the monthly MCP allowance (skipped — the token/credit
18
+ * pair gates coding sessions).
19
+ *
20
+ * Fail-soft contract: no key, unreachable API, or malformed envelope →
21
+ * `snapshot()` returns `undefined` and the deck stays dark. The last
22
+ * successful snapshot is cached between ticks so a transient failure
23
+ * never blanks a lit gauge; at most one debug line per distinct failure
24
+ * mode.
25
+ */
26
+
27
+ const ZAI_QUOTA_ENDPOINT = 'https://api.z.ai/api/monitor/usage/quota/limit';
28
+ const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
29
+ const SECONDS_PER_HOUR = 3600;
30
+
31
+ export interface ZaiQuotaOptions {
32
+ /** Injectable for tests; defaults to global fetch. */
33
+ fetchImpl?: typeof fetch;
34
+ /** Injectable for tests; defaults to ~/.pi/agent/auth.json. */
35
+ authPath?: string;
36
+ /** Injectable for tests; unix seconds. */
37
+ now?: () => number;
38
+ /** Debug sink; at most one line per distinct failure mode. */
39
+ debug?: (message: string) => void;
40
+ }
41
+
42
+ export interface ZaiQuotaHandle {
43
+ readonly id: 'zai';
44
+ refresh(): Promise<void>;
45
+ stop(): void;
46
+ }
47
+
48
+ const AUTH_REFERENCE_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
49
+
50
+ /**
51
+ * pi auth convention: a key is either a literal or a `${ENV}` reference.
52
+ * Unresolvable references yield undefined (fail-soft, no guesses).
53
+ */
54
+ export function expandAuthReference(value: string): string | undefined {
55
+ const match = AUTH_REFERENCE_PATTERN.exec(value.trim());
56
+ if (match === null) return value;
57
+ const expanded = process.env[match[1]];
58
+ return typeof expanded === 'string' && expanded.length > 0 ? expanded : undefined;
59
+ }
60
+
61
+ /** Reads the z.ai key from the pi auth file: `{ zai: { key } }`. */
62
+ export async function readZaiKey(
63
+ authPath: string,
64
+ readFileImpl: typeof readFile = readFile,
65
+ ): Promise<string | undefined> {
66
+ let text: string;
67
+ try {
68
+ text = await readFileImpl(authPath, 'utf8');
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ try {
73
+ const parsed = JSON.parse(text) as { zai?: { key?: unknown } };
74
+ const key = parsed?.zai?.key;
75
+ return typeof key === 'string' && key.length > 0
76
+ ? expandAuthReference(key)
77
+ : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ interface ZaiLimitLike {
84
+ type?: unknown;
85
+ percentage?: unknown;
86
+ usage?: unknown;
87
+ currentValue?: unknown;
88
+ nextResetTime?: unknown;
89
+ }
90
+
91
+ function clampPercent(value: number): number {
92
+ return Math.min(100, Math.max(0, value));
93
+ }
94
+
95
+ function windowLabel(resetAt: number, nowSeconds: number): string {
96
+ const hours = (resetAt - nowSeconds) / SECONDS_PER_HOUR;
97
+ if (hours <= 48) return '5h';
98
+ if (hours <= 360) return '7d';
99
+ return `${Math.max(1, Math.ceil(hours / 24))}d`;
100
+ }
101
+
102
+ /**
103
+ * Normalizes the z.ai envelope into reset-sorted windows: nearest reset
104
+ * is the coding window (`5h`), next the weekly cap (`7d`), labels fall
105
+ * back by reset distance. Returns undefined when nothing usable remains.
106
+ */
107
+ export function normalizeZaiEnvelope(
108
+ envelope: unknown,
109
+ nowSeconds: number,
110
+ ): QuotaWindow[] | undefined {
111
+ const limits = (envelope as { data?: { limits?: unknown } })?.data?.limits;
112
+ if (!Array.isArray(limits) || limits.length === 0) return undefined;
113
+ const windows: Array<QuotaWindow & { resetAt: number }> = [];
114
+ for (const raw of limits as ZaiLimitLike[]) {
115
+ if (typeof raw !== 'object' || raw === null) continue;
116
+ // TIME* limits are the monthly MCP allowance — not coding sessions.
117
+ if (typeof raw.type === 'string' && raw.type.startsWith('TIME')) continue;
118
+ let percent: number | undefined;
119
+ if (typeof raw.percentage === 'number' && Number.isFinite(raw.percentage)) {
120
+ percent = clampPercent(raw.percentage);
121
+ } else if (
122
+ typeof raw.usage === 'number' &&
123
+ Number.isFinite(raw.usage) &&
124
+ raw.usage > 0 &&
125
+ typeof raw.currentValue === 'number' &&
126
+ Number.isFinite(raw.currentValue)
127
+ ) {
128
+ percent = clampPercent((raw.currentValue / raw.usage) * 100);
129
+ }
130
+ if (percent === undefined) continue;
131
+ if (
132
+ typeof raw.nextResetTime !== 'number' ||
133
+ !Number.isFinite(raw.nextResetTime) ||
134
+ raw.nextResetTime <= 0
135
+ ) {
136
+ continue; // sort and labels ride on the reset deadline
137
+ }
138
+ // Live envelope correction (2026-10-19, real key): z.ai reports
139
+ // nextResetTime in MILLISECONDS — seconds values ride an order of
140
+ // magnitude lower, so anything above 1e11 is ms and gets floored
141
+ // to seconds. Both epochs keep working.
142
+ const resetRaw =
143
+ raw.nextResetTime > 1e11
144
+ ? Math.floor(raw.nextResetTime / 1000)
145
+ : Math.floor(raw.nextResetTime);
146
+ const resetAt = resetRaw;
147
+ windows.push({ label: windowLabel(resetAt, nowSeconds), percent, resetAt });
148
+ }
149
+ if (windows.length === 0) return undefined;
150
+ windows.sort((a, b) => a.resetAt - b.resetAt);
151
+ return windows;
152
+ }
153
+
154
+ let started: ZaiQuotaHandle | undefined;
155
+ let cached: QuotaSnapshot | undefined;
156
+
157
+ // Registered once per process; reads module state so a stop/restart
158
+ // cycle never leaves the registry holding a dead closure.
159
+ registerQuotaProvider({ id: 'zai', snapshot: () => cached });
160
+
161
+ /**
162
+ * Registers the z.ai provider and starts the cadence: refresh on start,
163
+ * on every `refresh()` call (the footer triggers session start and model
164
+ * change), and on a 5-minute interval. Idempotent per process.
165
+ */
166
+ export function startZaiQuotaProvider(options: ZaiQuotaOptions = {}): ZaiQuotaHandle {
167
+ if (started !== undefined) return started;
168
+ const fetchImpl = options.fetchImpl ?? fetch;
169
+ const authPath = options.authPath ?? join(homedir(), '.pi', 'agent', 'auth.json');
170
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000));
171
+ const reported = new Set<string>();
172
+ const debug = (mode: string) => {
173
+ if (reported.has(mode)) return;
174
+ reported.add(mode);
175
+ options.debug?.(`zai quota: ${mode}`);
176
+ };
177
+
178
+ const refresh = async (): Promise<void> => {
179
+ const key = await readZaiKey(authPath);
180
+ if (key === undefined) {
181
+ debug('no key in ~/.pi/agent/auth.json (zai.key)');
182
+ return;
183
+ }
184
+ let response: Response;
185
+ try {
186
+ response = await fetchImpl(ZAI_QUOTA_ENDPOINT, {
187
+ headers: { authorization: `Bearer ${key}` },
188
+ });
189
+ } catch {
190
+ debug('unreachable');
191
+ return;
192
+ }
193
+ if (!response.ok) {
194
+ debug(`http ${response.status}`);
195
+ return;
196
+ }
197
+ let envelope: unknown;
198
+ try {
199
+ envelope = await response.json();
200
+ } catch {
201
+ debug('unparseable envelope');
202
+ return;
203
+ }
204
+ const windows = normalizeZaiEnvelope(envelope, now());
205
+ if (windows === undefined) {
206
+ debug('no usable windows');
207
+ return;
208
+ }
209
+ reported.clear(); // a success re-arms distinct-mode reporting
210
+ cached = { percent: windows[0]!.percent, windows };
211
+ };
212
+
213
+ void refresh();
214
+ const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
215
+ timer.unref?.();
216
+
217
+ started = {
218
+ id: 'zai',
219
+ refresh,
220
+ stop() {
221
+ clearInterval(timer);
222
+ cached = undefined;
223
+ reported.clear();
224
+ started = undefined;
225
+ },
226
+ };
227
+ return started;
228
+ }
@@ -1,12 +1,26 @@
1
1
  /**
2
- * Quota seam. The provider ships with no implementations: every quota source
3
- * this cockpit had (Codex OAuth, Claude OAuth, z.ai coding plan) is gone with
4
- * the previous footer module. A future slice registers a provider here
5
- * and the deck row gains its gauge back without any renderer change.
2
+ * Quota seam. The registry ships empty by design: providers land here as
3
+ * sibling registrations (z.ai first; Codex/Claude later) and the deck row
4
+ * gains its gauge back without any renderer change.
6
5
  */
6
+ export interface QuotaWindow {
7
+ /** Human label for the window, e.g. `5h`, `7d`. */
8
+ label: string;
9
+ /** Window usage, 0–100. */
10
+ percent: number;
11
+ /** Reset deadline, unix seconds, when the source reports one. */
12
+ resetAt?: number;
13
+ }
14
+
7
15
  export interface QuotaSnapshot {
8
16
  /** Whole-session quota usage, 0–100. */
9
17
  percent: number;
18
+ /**
19
+ * Windowed usage when the source distinguishes them (z.ai: the 5h
20
+ * coding window and the 7d weekly cap). Additive and optional: the
21
+ * deck keeps rendering `percent` alone when absent.
22
+ */
23
+ windows?: readonly QuotaWindow[];
10
24
  }
11
25
 
12
26
  export interface QuotaProvider {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
 
3
- import type { QuotaSnapshot } from './quota.ts';
3
+ import type { QuotaSnapshot, QuotaWindow } from './quota.ts';
4
4
  import {
5
5
  FOOTER_TELEMETRY_PROTOCOL_VERSION,
6
6
  type FooterTelemetryMessage,
@@ -36,6 +36,31 @@ export function collectTotalCost(ctx: ExtensionContext): number {
36
36
  return totalCostFromEntries(ctx.sessionManager.getBranch());
37
37
  }
38
38
 
39
+ function boundedPercent(value: unknown): number | undefined {
40
+ return typeof value === 'number' && Number.isFinite(value)
41
+ ? Math.min(100, Math.max(0, value))
42
+ : undefined;
43
+ }
44
+
45
+ function sanitizeWindow(value: unknown): QuotaWindow | undefined {
46
+ if (typeof value !== 'object' || value === null) return undefined;
47
+ const { label, percent, resetAt } = value as {
48
+ label?: unknown;
49
+ percent?: unknown;
50
+ resetAt?: unknown;
51
+ };
52
+ if (typeof label !== 'string' || label.length === 0) return undefined;
53
+ const bounded = boundedPercent(percent);
54
+ if (bounded === undefined) return undefined;
55
+ const deadline =
56
+ typeof resetAt === 'number' && Number.isFinite(resetAt) && resetAt > 0
57
+ ? Math.floor(resetAt)
58
+ : undefined;
59
+ return deadline === undefined
60
+ ? { label, percent: bounded }
61
+ : { label, percent: bounded, resetAt: deadline };
62
+ }
63
+
39
64
  export function buildFooterTelemetryMessage(
40
65
  totalCost: number,
41
66
  quota?: QuotaSnapshot,
@@ -44,16 +69,15 @@ export function buildFooterTelemetryMessage(
44
69
  typeof totalCost === 'number' && Number.isFinite(totalCost)
45
70
  ? Math.max(0, totalCost)
46
71
  : 0;
47
- const percent =
48
- quota !== undefined &&
49
- typeof quota.percent === 'number' &&
50
- Number.isFinite(quota.percent)
51
- ? Math.min(100, Math.max(0, quota.percent))
52
- : undefined;
72
+ const percent = quota === undefined ? undefined : boundedPercent(quota.percent);
73
+ const windows = quota?.windows
74
+ ?.map(sanitizeWindow)
75
+ .filter((window): window is QuotaWindow => window !== undefined);
53
76
  return {
54
77
  protocol: FOOTER_TELEMETRY_PROTOCOL_VERSION,
55
78
  type: 'snapshot',
56
79
  totalCost: cost,
57
80
  ...(percent !== undefined ? { quotaPercent: percent } : {}),
81
+ ...(windows !== undefined && windows.length > 0 ? { quotaWindows: windows } : {}),
58
82
  };
59
83
  }
@@ -173,7 +173,12 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
173
173
  const title = state.header?.title ?? null;
174
174
  if (title) {
175
175
  const subject = safeText(title.subject ?? '');
176
- if (!subject) return [clock];
176
+ if (!subject) {
177
+ // Human amendment 2026-10-19: the empty slot renders its dim
178
+ // placeholder — mirroring the Work side's `— no change owns the
179
+ // moment` — instead of leaving the clock leading alone.
180
+ return focusWrapLines('— no subject', clock, width, theme, 'dim');
181
+ }
177
182
  return focusWrapLines(subject, clock, width, theme, 'accent');
178
183
  }
179
184
  // Pre-title publishers: the legacy selection keeps the band alive.
@@ -182,7 +187,15 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
182
187
  .map((value) => safeText(value))
183
188
  .filter(Boolean);
184
189
  if (!selection || titles.length === 0) {
185
- return [clock]; // frozen contract: no selection means no slot, clock stays
190
+ if (!state.header) {
191
+ // Frozen contract: a publisher that reports nothing at all keeps
192
+ // the collapsed frame — clock alone, no placeholder dashes.
193
+ return [clock];
194
+ }
195
+ // Amendment: a live session (header present, Work rendering) that
196
+ // simply has no subject gets the dim placeholder — the title slot
197
+ // never renders empty when the session is talking to us.
198
+ return focusWrapLines('— no subject', clock, width, theme, 'dim');
186
199
  }
187
200
  const selectionColor = ['accent', 'success'].includes(selection.color)
188
201
  ? 'accent'
@@ -385,40 +398,73 @@ function globalLeft(state: HeaderDeckState, theme: Theme): string {
385
398
  return `${color(theme, 'accent', `󰚩 ${safeText(state.model, 80)}`)} ${minorSeparator(theme)} ${color(theme, state.thinking ? 'thinkingHigh' : 'dim', safeText(state.thinking || 'off', 16))}`;
386
399
  }
387
400
 
388
- function projectDeckLeft(state: HeaderDeckState, theme: Theme, width: number): string {
389
- // Line 1 left: the path; the git segment yields below 79 columns before
390
- // the path does. The devbox marker moved to the line-1 corner with the
391
- // companion chips (deck-machine-corner); the identity cluster never
392
- // drops, so the path never carries it again.
393
- const path = color(theme, 'success', ` \u{F07B} ${safeText(state.cwd)}`);
401
+ function projectDeckLeft(
402
+ state: HeaderDeckState,
403
+ theme: Theme,
404
+ width: number,
405
+ identity: { text: string; trailingBare: boolean },
406
+ ): string {
407
+ // Line 1 left (deck-reshape): the identity cluster LEADS —
408
+ // `identity ⟩ path › git` — never the reverse; the cluster never
409
+ // drops, so truncation eats the git segment first, then the path.
410
+ const path = color(theme, 'success', `\u{F07B} ${safeText(state.cwd)}`);
394
411
  const gitSegment =
395
412
  width >= 79 ? truncateToWidth(gitRight(state, theme), 52, '…') : '';
396
- return gitSegment ? `${path} ${semanticSeparator(theme)} ${gitSegment}` : path;
413
+ const left = gitSegment ? `${path} ${semanticSeparator(theme)} ${gitSegment}` : path;
414
+ return `${identity.text} ${semanticSeparator(theme)} ${left}`;
397
415
  }
398
416
 
399
417
  type CompanionSpec = {
400
418
  keys: readonly string[];
401
419
  icons: readonly string[];
402
- /** Render the chip bracketed even at one icon (engram identity). */
403
- bracket?: boolean;
404
420
  };
405
421
 
422
+ // Human icon swap 2026-10-19: every chip is a Nerd Font PUA glyph with
423
+ // identical single-cell metrics (verified via font cmap + advance in two
424
+ // NF families) — the base-font symbols (↻ ⌬ ⛨) rendered two cells in
425
+ // some terminals and no spacing rule could compensate. md-sync keeps
426
+ // engram's loop semantics; md-graph reads knowledge-graph; md-shield
427
+ // keeps appa's shield — all md- family, matching visual weight.
406
428
  const KNOWN_COMPANION_ICONS: ReadonlyArray<CompanionSpec> = [
407
- { keys: ['engram'], icons: ['\u{21BB}'], bracket: true },
408
- { keys: ['graphify'], icons: ['\u{232C}'] },
409
- { keys: ['appa', 'openappa'], icons: ['\u{26E8}'] },
429
+ { keys: ['engram'], icons: ['\u{F04E6}'] },
430
+ { keys: ['graphify'], icons: ['\u{F1049}'] },
431
+ { keys: ['appa', 'openappa'], icons: ['\u{F0498}'] },
410
432
  ];
411
433
 
412
- const COMPANION_WARNING_PATTERN =
413
- /(warn|error|stale|unreachable|down|fail|update recommended|needs attention|unhealthy)/iu;
434
+ // Severity-shaped chips: shape is the grayscale-proof channel — [] wraps
435
+ // error-or-off, () wraps degraded, bare is healthy — while color keeps the
436
+ // fine grain underneath (off muted, degraded warning, error error,
437
+ // healthy accent/success). Mnemonic: the more enclosed, the worse.
438
+ const COMPANION_DEGRADE_PATTERN = /(warn|stale|update recommended|needs attention)/iu;
439
+ const COMPANION_ERROR_PATTERN = /(error|fail|down|unreachable|unhealthy)/iu;
440
+
441
+ type ChipSeverity = 'healthy' | 'degraded' | 'error' | 'off';
414
442
 
415
- function companionIconColor(text: string | undefined, active: boolean): string {
416
- // Color is the only state signal: symbols never hide.
417
- // On = accent, warn/error = warning, off = muted (visible gray).
418
- if (!active) return 'muted';
419
- return text !== undefined && COMPANION_WARNING_PATTERN.test(text)
420
- ? 'warning'
421
- : 'accent';
443
+ function companionSeverity(text: string | undefined): ChipSeverity {
444
+ // Error-grade words outrank degrade-grade when both appear.
445
+ if (text === undefined) return 'off';
446
+ if (COMPANION_ERROR_PATTERN.test(text)) return 'error';
447
+ if (COMPANION_DEGRADE_PATTERN.test(text)) return 'degraded';
448
+ return 'healthy';
449
+ }
450
+
451
+ function renderChip(
452
+ theme: Theme,
453
+ severity: ChipSeverity,
454
+ healthyColor: string,
455
+ icons: string,
456
+ ): string {
457
+ // Human verdict 2026-10-19 — variant C of scripts/chips-preview.mjs:
458
+ // bold foreground colors alone. No enclosures, no reverse-video.
459
+ // Blue = healthy/OK, yellow = degraded, red = error/off/KO; the
460
+ // notice slot carries the why (off vs error read identically here).
461
+ const name =
462
+ severity === 'healthy'
463
+ ? healthyColor
464
+ : severity === 'degraded'
465
+ ? 'warning'
466
+ : 'error';
467
+ return color(theme, name, icons, true);
422
468
  }
423
469
 
424
470
  function identityCluster(
@@ -435,39 +481,32 @@ function identityCluster(
435
481
  if (item.key === 'goal' || texts.has(item.key)) continue;
436
482
  texts.set(item.key, item.text);
437
483
  }
438
- const parts: Array<{ bare: boolean; text: string }> = [];
439
- if (state.devbox) {
440
- // Corner lead chip (deck-machine-corner): the devbox marker joins
441
- // the identity cluster ahead of the companions, success-colored as
442
- // it was as a path prefix.
443
- parts.push({ bare: false, text: color(theme, 'success', '[\u{F01A7}]', true) });
484
+ const parts: string[] = [];
485
+ {
486
+ // Human amendment 2026-10-19: the devbox chip never appears or
487
+ // disappears — in-devbox renders bold blue OK, outside renders
488
+ // bold red KO. DEVBOX_PROJECT_ROOT at pi launch decides.
489
+ parts.push(
490
+ renderChip(theme, state.devbox ? 'healthy' : 'error', 'accent', '\u{F01A7}'),
491
+ );
444
492
  }
445
493
  for (const companion of KNOWN_COMPANION_ICONS) {
446
494
  const published = companion.keys
447
495
  .map((key) => texts.get(key))
448
496
  .find((text) => text !== undefined);
449
497
  for (const key of companion.keys) texts.delete(key);
450
- const joined = companion.icons.join(' ');
451
- const bracketed = companion.bracket === true || companion.icons.length > 1;
452
- const chip = bracketed ? `[${joined}]` : joined;
453
- parts.push({
454
- bare: !bracketed,
455
- text: color(
498
+ parts.push(
499
+ renderChip(
456
500
  theme,
457
- companionIconColor(published, published !== undefined),
458
- chip,
459
- true,
501
+ companionSeverity(published),
502
+ 'accent',
503
+ companion.icons.join(' '),
460
504
  ),
461
- });
462
- }
463
- let text = '';
464
- let trailingBare = false;
465
- for (const part of parts) {
466
- text +=
467
- text === '' ? part.text : `${trailingBare && part.bare ? ' ' : ' '}${part.text}`;
468
- trailingBare = part.bare;
505
+ );
469
506
  }
470
- return { text, trailingBare };
507
+ // Uniform single-cell icons: plain single-space join, single gap
508
+ // before the major separator (trailingBare false).
509
+ return { text: parts.join(' '), trailingBare: false };
471
510
  }
472
511
 
473
512
  const BLOCKED_NOTICE = '\u26d4 blocked';
@@ -486,7 +525,8 @@ function companionWarningTexts(state: HeaderDeckState): string[] {
486
525
  const published = companion.keys
487
526
  .map((key) => texts.get(key))
488
527
  .find((text) => text !== undefined);
489
- if (published !== undefined && COMPANION_WARNING_PATTERN.test(published)) {
528
+ const severity = companionSeverity(published);
529
+ if (published !== undefined && (severity === 'degraded' || severity === 'error')) {
490
530
  warnings.push(safeText(published, 180));
491
531
  }
492
532
  }
@@ -521,42 +561,40 @@ export function collectDeckNotices(state: HeaderDeckState): string[] {
521
561
 
522
562
  function noticeTile(state: HeaderDeckState, theme: Theme): string {
523
563
  const notices = collectDeckNotices(state);
524
- if (notices.length === 0) return '';
564
+ // Healthy sentinel (Human-directed 2026-10-19, reversing the retirement):
565
+ // when the ladder is silent the dim ladybug holds the right end — the
566
+ // deck's signature glyph and a reserved one (safeText strips 󰠭 from
567
+ // published text), so the status row always speaks: ⚠ with notices,
568
+ // the quiet ladybug when all is well.
569
+ if (notices.length === 0) return color(theme, 'dim', '󰠭');
525
570
  // Blocked carries the error severity it wore on the deleted lifecycle
526
571
  // tile; every other notice renders at warning.
527
572
  const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
528
573
  return color(theme, semantic, `⚠ ${notices.join(' · ')}`);
529
574
  }
530
575
 
531
- function telemetryRowLeft(state: HeaderDeckState, theme: Theme): string {
532
- // Line 4 left (deck-corner-context amendment): the global group —
533
- // engine › thinking, then the long-horizon quota — owns the row's left
534
- // end; the notice slot owns the right end (Task 3). Context and
535
- // compaction live in the line-1 corner now.
536
- return modelGroupLeft(state, theme);
537
- }
538
-
539
576
  export function machineCornerSegments(
540
577
  state: HeaderDeckState,
541
578
  theme: Theme,
542
579
  nowMs = Date.now(),
543
- ): string[] {
544
- // Machine corner (deck-machine-corner; deck-corner-context): icon-led
545
- // machine readings for the line-1 corner — coding quota flame with its
546
- // countdown, CPU, memory, MCP availability, context percent, summary
547
- // (compaction) count, global cost — in that frozen order, joined by
548
- // minor separators. Segments yield right-to-left on narrow frames.
549
- const segments: string[] = [];
580
+ ): string[][] {
581
+ // Machine tail (deck-machine-corner; deck-corner-context; swap
582
+ // amendment): GROUPED icon-led readings — quota (flame + countdown),
583
+ // process (cpu › mem › mcp), context (ctx › sums), cost — minor
584
+ // separators inside a group, big separators between groups; groups
585
+ // yield right-to-left on narrow frames.
586
+ const quotaGroup: string[] = [];
550
587
  const flame = windowQuotaTiles(state, theme, nowMs);
551
- if (flame !== '') segments.push(flame);
588
+ if (flame !== '') quotaGroup.push(flame);
589
+ const processGroup: string[] = [];
552
590
  const resources = state.resources;
553
591
  if (resources) {
554
592
  const resourceColor =
555
593
  resources.cpuWarning || resources.memoryWarning ? 'warning' : 'accent';
556
- segments.push(
594
+ processGroup.push(
557
595
  color(theme, resourceColor, `\u{F035B} ${formatDeckCpu(resources.cpuPercent)}`),
558
596
  );
559
- segments.push(
597
+ processGroup.push(
560
598
  color(
561
599
  theme,
562
600
  resourceColor,
@@ -565,9 +603,12 @@ export function machineCornerSegments(
565
603
  );
566
604
  }
567
605
  if (state.mcp) {
568
- segments.push(color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`));
606
+ processGroup.push(
607
+ color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`),
608
+ );
569
609
  }
570
- // deck-corner-context: context and summary counts ride the corner.
610
+ const contextGroup: string[] = [];
611
+ // deck-corner-context: context and summary counts ride together.
571
612
  // Context wears warning at ≥ 85% (the threshold it carried engine-left);
572
613
  // the summary count follows the context color as before.
573
614
  const ctxColor =
@@ -577,45 +618,51 @@ export function machineCornerSegments(
577
618
  ? 'dim'
578
619
  : 'accent';
579
620
  if (state.contextPercent !== undefined) {
580
- segments.push(
621
+ contextGroup.push(
581
622
  color(theme, ctxColor, `\u{F0F86} ${formatDeckPercent(state.contextPercent)}`),
582
623
  );
583
624
  }
584
625
  if (state.compactionCount > 0) {
585
- segments.push(color(theme, ctxColor, `\u{F039E} ${state.compactionCount}`));
626
+ contextGroup.push(color(theme, ctxColor, `\u{F039E} ${state.compactionCount}`));
586
627
  }
628
+ const costGroup: string[] = [];
587
629
  const cost = costTile(state, theme);
588
- if (cost !== '') segments.push(cost);
589
- return segments;
630
+ if (cost !== '') costGroup.push(cost);
631
+ return [quotaGroup, processGroup, contextGroup, costGroup].filter(
632
+ (group) => group.length > 0,
633
+ );
634
+ }
635
+
636
+ function deckCornerRight(state: HeaderDeckState, theme: Theme, width: number): string {
637
+ // Line 1 right corner (deck-reshape, swap amendment): the ENGINE
638
+ // group — short and predictable (`󰚩 model › thinking › 󰃰 weekly?`) —
639
+ // so a growing PWD/branch never fights the long machine tail for
640
+ // width. It fits beside the path floor or yields wholesale. The
641
+ // machine tail lives on the bottom row now; the identity cluster
642
+ // leads line 1 LEFT and never yields.
643
+ const engine = modelGroupLeft(state, theme);
644
+ if (engine === '') return '';
645
+ return width - visibleWidth(engine) - 1 >= 20 ? engine : '';
590
646
  }
591
647
 
592
- function deckCornerRight(
648
+ function machineCornerLadder(
593
649
  state: HeaderDeckState,
594
650
  theme: Theme,
595
651
  width: number,
596
652
  nowMs = Date.now(),
597
653
  ): string {
598
- // Line 1 corner: the identity cluster (devbox lead, companion chips)
599
- // split from the machine group by one major separator. A bare trailing
600
- // chip keeps the wide gap before the separator (0.7.1 amendment);
601
- // machine segments yield right-to-left (cost first) before the path
602
- // truncates below a 20-column floor; the identity cluster never drops
603
- // (always-visible contract).
604
- const identity = identityCluster(state, theme);
605
- const segments = machineCornerSegments(state, theme, nowMs);
606
- const pathFloor = 20;
607
- const cornerGap = identity.trailingBare ? ' ' : ' ';
608
- for (let keep = segments.length; keep >= 0; keep -= 1) {
609
- const tail = segments.slice(0, keep).join(` ${minorSeparator(theme)} `);
610
- const corner =
611
- tail === ''
612
- ? identity.text
613
- : `${identity.text}${cornerGap}${semanticSeparator(theme)} ${tail}`;
614
- if (keep === 0 || width - visibleWidth(corner) - 1 >= pathFloor) {
615
- return corner;
616
- }
654
+ // Bottom row left (deck-reshape, swap amendment): the machine tail
655
+ // yields right-to-left (cost first) into whatever width remains
656
+ // beside the status right end.
657
+ const groups = machineCornerSegments(state, theme, nowMs);
658
+ for (let keep = groups.length; keep >= 1; keep -= 1) {
659
+ const tail = groups
660
+ .slice(0, keep)
661
+ .map((group) => group.join(` ${minorSeparator(theme)} `))
662
+ .join(` ${semanticSeparator(theme)} `);
663
+ if (visibleWidth(tail) <= width) return tail;
617
664
  }
618
- return identity.text;
665
+ return '';
619
666
  }
620
667
 
621
668
  function modelGroupLeft(state: HeaderDeckState, theme: Theme): string {
@@ -646,7 +693,7 @@ function simpleQuotaTile(state: HeaderDeckState, theme: Theme): string {
646
693
  return color(
647
694
  theme,
648
695
  calendar.percent >= 80 ? 'error' : calendar.percent >= 60 ? 'warning' : 'accent',
649
- `󰃰 ${formatDeckPercent(calendar.percent)}`,
696
+ `󰃰 ${calendar.label} ${formatDeckPercent(calendar.percent)}`,
650
697
  );
651
698
  }
652
699
 
@@ -809,31 +856,31 @@ export function renderHeaderDeck(
809
856
  // Activity, and the spans rows are their replacements. The spans rows
810
857
  // keep their home directly beneath this line.
811
858
  const activityRow = alignedSingleRow(
812
- ` ${activityTile(state, theme)}`,
859
+ activityTile(state, theme),
813
860
  turnCostTile(state, theme),
814
861
  boundedWidth,
815
862
  );
816
- // Line 4 (deck-machine-corner amendment): the engine group holds the
817
- // row's left end; the right end is notice-only. The machine group moved
818
- // to the line-1 corner, so notices can no longer starve machine
819
- // readings — the old wholesale yield is gone with the group itself.
820
- // 0.7.1 amendment: a clean notice slot renders the dim `healthy`
821
- // placeholder instead of trailing silence — it yields like a notice
822
- // when the engine group needs the width.
823
- const engineGroup = telemetryRowLeft(state, theme);
824
- const noticeGroup = noticeTile(state, theme);
825
- const rightGroup = noticeGroup !== '' ? noticeGroup : color(theme, 'dim', 'healthy');
826
- const telemetryRow =
827
- visibleWidth(engineGroup) + 1 + visibleWidth(rightGroup) <= boundedWidth
828
- ? alignedSingleRow(engineGroup, rightGroup, boundedWidth)
829
- : fit(engineGroup, boundedWidth);
863
+ // deck-reshape, swap amendment: the bottom row carries the MACHINE
864
+ // tail on the left (yielding right-to-left into whatever width the
865
+ // status leaves) and the status on the right — which always speaks
866
+ // now: ⚠ notices or the dim ladybug sentinel when clean. The engine
867
+ // group moved to line 1 right.
868
+ const statusRight = noticeTile(state, theme);
869
+ const machineRow = machineCornerLadder(
870
+ state,
871
+ theme,
872
+ statusRight === '' ? boundedWidth : boundedWidth - visibleWidth(statusRight) - 1,
873
+ );
874
+ const engineRow =
875
+ statusRight === '' && machineRow === ''
876
+ ? ''
877
+ : alignedSingleRow(machineRow, statusRight, boundedWidth);
830
878
 
831
879
  // Band adjacency (Human-directed, accepted mockups; third 2.4
832
880
  // amendment): the deck opens with a full ─ rule directly beneath the
833
- // line-1 strip and ends at the telemetry row — nothing renders beneath
834
- // line 4; pi's own prompt chrome below closes the visual block — while
835
- // the internal seams stay dotted ┈ between lines 2 and 3 and between
836
- // line 3 (spans rows included) and line 4.
881
+ // line-1 strip; the internal seams stay dotted ┈ between lines 2 and
882
+ // 3 and between line 3 (spans rows included) and the status row.
883
+ const identity = identityCluster(state, theme);
837
884
  const corner = deckCornerRight(state, theme, boundedWidth);
838
885
  const alert = statusAlertTile(
839
886
  state,
@@ -843,7 +890,7 @@ export function renderHeaderDeck(
843
890
  );
844
891
  const rows = [
845
892
  alignedSingleRow(
846
- projectDeckLeft(state, theme, boundedWidth),
893
+ projectDeckLeft(state, theme, boundedWidth, identity),
847
894
  alert ? `${alert} ${corner}` : corner,
848
895
  boundedWidth,
849
896
  ),
@@ -853,8 +900,7 @@ export function renderHeaderDeck(
853
900
  dottedRule,
854
901
  activityRow,
855
902
  ...spanTreeRows(state, boundedWidth, theme),
856
- dottedRule,
857
- telemetryRow,
903
+ ...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
858
904
  ];
859
905
  return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
860
906
  }