@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 +1 -1
- package/packages/footer/index.ts +20 -4
- package/packages/footer/src/protocol.ts +4 -0
- package/packages/footer/src/quota-zai.ts +228 -0
- package/packages/footer/src/quota.ts +18 -4
- package/packages/footer/src/telemetry.ts +31 -7
- package/packages/pi-stardeck/src/deck.ts +163 -117
package/package.json
CHANGED
package/packages/footer/index.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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',
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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{
|
|
408
|
-
{ keys: ['graphify'], icons: ['\u{
|
|
409
|
-
{ keys: ['appa', 'openappa'], icons: ['\u{
|
|
429
|
+
{ keys: ['engram'], icons: ['\u{F04E6}'] },
|
|
430
|
+
{ keys: ['graphify'], icons: ['\u{F1049}'] },
|
|
431
|
+
{ keys: ['appa', 'openappa'], icons: ['\u{F0498}'] },
|
|
410
432
|
];
|
|
411
433
|
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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:
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
parts.push(
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
const chip = bracketed ? `[${joined}]` : joined;
|
|
453
|
-
parts.push({
|
|
454
|
-
bare: !bracketed,
|
|
455
|
-
text: color(
|
|
498
|
+
parts.push(
|
|
499
|
+
renderChip(
|
|
456
500
|
theme,
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
const
|
|
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 !== '')
|
|
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
|
-
|
|
594
|
+
processGroup.push(
|
|
557
595
|
color(theme, resourceColor, `\u{F035B} ${formatDeckCpu(resources.cpuPercent)}`),
|
|
558
596
|
);
|
|
559
|
-
|
|
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
|
-
|
|
606
|
+
processGroup.push(
|
|
607
|
+
color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`),
|
|
608
|
+
);
|
|
569
609
|
}
|
|
570
|
-
|
|
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
|
-
|
|
621
|
+
contextGroup.push(
|
|
581
622
|
color(theme, ctxColor, `\u{F0F86} ${formatDeckPercent(state.contextPercent)}`),
|
|
582
623
|
);
|
|
583
624
|
}
|
|
584
625
|
if (state.compactionCount > 0) {
|
|
585
|
-
|
|
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 !== '')
|
|
589
|
-
return
|
|
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
|
|
648
|
+
function machineCornerLadder(
|
|
593
649
|
state: HeaderDeckState,
|
|
594
650
|
theme: Theme,
|
|
595
651
|
width: number,
|
|
596
652
|
nowMs = Date.now(),
|
|
597
653
|
): string {
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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
|
-
|
|
859
|
+
activityTile(state, theme),
|
|
813
860
|
turnCostTile(state, theme),
|
|
814
861
|
boundedWidth,
|
|
815
862
|
);
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
|
834
|
-
//
|
|
835
|
-
|
|
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
|
}
|