@aemonge-dev/pi-stardeck 0.8.0 → 0.9.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.
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.0",
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
  }
@@ -528,35 +568,28 @@ function noticeTile(state: HeaderDeckState, theme: Theme): string {
528
568
  return color(theme, semantic, `⚠ ${notices.join(' · ')}`);
529
569
  }
530
570
 
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
571
  export function machineCornerSegments(
540
572
  state: HeaderDeckState,
541
573
  theme: Theme,
542
574
  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[] = [];
575
+ ): string[][] {
576
+ // Machine tail (deck-machine-corner; deck-corner-context; swap
577
+ // amendment): GROUPED icon-led readings — quota (flame + countdown),
578
+ // process (cpu › mem › mcp), context (ctx › sums), cost — minor
579
+ // separators inside a group, big separators between groups; groups
580
+ // yield right-to-left on narrow frames.
581
+ const quotaGroup: string[] = [];
550
582
  const flame = windowQuotaTiles(state, theme, nowMs);
551
- if (flame !== '') segments.push(flame);
583
+ if (flame !== '') quotaGroup.push(flame);
584
+ const processGroup: string[] = [];
552
585
  const resources = state.resources;
553
586
  if (resources) {
554
587
  const resourceColor =
555
588
  resources.cpuWarning || resources.memoryWarning ? 'warning' : 'accent';
556
- segments.push(
589
+ processGroup.push(
557
590
  color(theme, resourceColor, `\u{F035B} ${formatDeckCpu(resources.cpuPercent)}`),
558
591
  );
559
- segments.push(
592
+ processGroup.push(
560
593
  color(
561
594
  theme,
562
595
  resourceColor,
@@ -565,9 +598,12 @@ export function machineCornerSegments(
565
598
  );
566
599
  }
567
600
  if (state.mcp) {
568
- segments.push(color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`));
601
+ processGroup.push(
602
+ color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`),
603
+ );
569
604
  }
570
- // deck-corner-context: context and summary counts ride the corner.
605
+ const contextGroup: string[] = [];
606
+ // deck-corner-context: context and summary counts ride together.
571
607
  // Context wears warning at ≥ 85% (the threshold it carried engine-left);
572
608
  // the summary count follows the context color as before.
573
609
  const ctxColor =
@@ -577,45 +613,51 @@ export function machineCornerSegments(
577
613
  ? 'dim'
578
614
  : 'accent';
579
615
  if (state.contextPercent !== undefined) {
580
- segments.push(
616
+ contextGroup.push(
581
617
  color(theme, ctxColor, `\u{F0F86} ${formatDeckPercent(state.contextPercent)}`),
582
618
  );
583
619
  }
584
620
  if (state.compactionCount > 0) {
585
- segments.push(color(theme, ctxColor, `\u{F039E} ${state.compactionCount}`));
621
+ contextGroup.push(color(theme, ctxColor, `\u{F039E} ${state.compactionCount}`));
586
622
  }
623
+ const costGroup: string[] = [];
587
624
  const cost = costTile(state, theme);
588
- if (cost !== '') segments.push(cost);
589
- return segments;
625
+ if (cost !== '') costGroup.push(cost);
626
+ return [quotaGroup, processGroup, contextGroup, costGroup].filter(
627
+ (group) => group.length > 0,
628
+ );
629
+ }
630
+
631
+ function deckCornerRight(state: HeaderDeckState, theme: Theme, width: number): string {
632
+ // Line 1 right corner (deck-reshape, swap amendment): the ENGINE
633
+ // group — short and predictable (`󰚩 model › thinking › 󰃰 weekly?`) —
634
+ // so a growing PWD/branch never fights the long machine tail for
635
+ // width. It fits beside the path floor or yields wholesale. The
636
+ // machine tail lives on the bottom row now; the identity cluster
637
+ // leads line 1 LEFT and never yields.
638
+ const engine = modelGroupLeft(state, theme);
639
+ if (engine === '') return '';
640
+ return width - visibleWidth(engine) - 1 >= 20 ? engine : '';
590
641
  }
591
642
 
592
- function deckCornerRight(
643
+ function machineCornerLadder(
593
644
  state: HeaderDeckState,
594
645
  theme: Theme,
595
646
  width: number,
596
647
  nowMs = Date.now(),
597
648
  ): 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
- }
649
+ // Bottom row left (deck-reshape, swap amendment): the machine tail
650
+ // yields right-to-left (cost first) into whatever width remains
651
+ // beside the status right end.
652
+ const groups = machineCornerSegments(state, theme, nowMs);
653
+ for (let keep = groups.length; keep >= 1; keep -= 1) {
654
+ const tail = groups
655
+ .slice(0, keep)
656
+ .map((group) => group.join(` ${minorSeparator(theme)} `))
657
+ .join(` ${semanticSeparator(theme)} `);
658
+ if (visibleWidth(tail) <= width) return tail;
617
659
  }
618
- return identity.text;
660
+ return '';
619
661
  }
620
662
 
621
663
  function modelGroupLeft(state: HeaderDeckState, theme: Theme): string {
@@ -646,7 +688,7 @@ function simpleQuotaTile(state: HeaderDeckState, theme: Theme): string {
646
688
  return color(
647
689
  theme,
648
690
  calendar.percent >= 80 ? 'error' : calendar.percent >= 60 ? 'warning' : 'accent',
649
- `󰃰 ${formatDeckPercent(calendar.percent)}`,
691
+ `󰃰 ${calendar.label} ${formatDeckPercent(calendar.percent)}`,
650
692
  );
651
693
  }
652
694
 
@@ -809,31 +851,31 @@ export function renderHeaderDeck(
809
851
  // Activity, and the spans rows are their replacements. The spans rows
810
852
  // keep their home directly beneath this line.
811
853
  const activityRow = alignedSingleRow(
812
- ` ${activityTile(state, theme)}`,
854
+ activityTile(state, theme),
813
855
  turnCostTile(state, theme),
814
856
  boundedWidth,
815
857
  );
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);
858
+ // deck-reshape, swap amendment: the bottom row carries the MACHINE
859
+ // tail on the left (yielding right-to-left into whatever width the
860
+ // status leaves) and the status on the right — present only when the
861
+ // ladder speaks (healthy means the right end is empty; the dim
862
+ // placeholder stays retired). The engine group moved to line 1 right.
863
+ const statusRight = noticeTile(state, theme);
864
+ const machineRow = machineCornerLadder(
865
+ state,
866
+ theme,
867
+ statusRight === '' ? boundedWidth : boundedWidth - visibleWidth(statusRight) - 1,
868
+ );
869
+ const engineRow =
870
+ statusRight === '' && machineRow === ''
871
+ ? ''
872
+ : alignedSingleRow(machineRow, statusRight, boundedWidth);
830
873
 
831
874
  // Band adjacency (Human-directed, accepted mockups; third 2.4
832
875
  // 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.
876
+ // line-1 strip; the internal seams stay dotted ┈ between lines 2 and
877
+ // 3 and between line 3 (spans rows included) and the status row.
878
+ const identity = identityCluster(state, theme);
837
879
  const corner = deckCornerRight(state, theme, boundedWidth);
838
880
  const alert = statusAlertTile(
839
881
  state,
@@ -843,7 +885,7 @@ export function renderHeaderDeck(
843
885
  );
844
886
  const rows = [
845
887
  alignedSingleRow(
846
- projectDeckLeft(state, theme, boundedWidth),
888
+ projectDeckLeft(state, theme, boundedWidth, identity),
847
889
  alert ? `${alert} ${corner}` : corner,
848
890
  boundedWidth,
849
891
  ),
@@ -853,8 +895,7 @@ export function renderHeaderDeck(
853
895
  dottedRule,
854
896
  activityRow,
855
897
  ...spanTreeRows(state, boundedWidth, theme),
856
- dottedRule,
857
- telemetryRow,
898
+ ...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
858
899
  ];
859
900
  return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
860
901
  }