@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 +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 +157 -116
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
|
}
|
|
@@ -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
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
const
|
|
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 !== '')
|
|
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
|
-
|
|
589
|
+
processGroup.push(
|
|
557
590
|
color(theme, resourceColor, `\u{F035B} ${formatDeckCpu(resources.cpuPercent)}`),
|
|
558
591
|
);
|
|
559
|
-
|
|
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
|
-
|
|
601
|
+
processGroup.push(
|
|
602
|
+
color(theme, 'accent', `${state.mcp.healthy}/${state.mcp.total}`),
|
|
603
|
+
);
|
|
569
604
|
}
|
|
570
|
-
|
|
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
|
-
|
|
616
|
+
contextGroup.push(
|
|
581
617
|
color(theme, ctxColor, `\u{F0F86} ${formatDeckPercent(state.contextPercent)}`),
|
|
582
618
|
);
|
|
583
619
|
}
|
|
584
620
|
if (state.compactionCount > 0) {
|
|
585
|
-
|
|
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 !== '')
|
|
589
|
-
return
|
|
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
|
|
643
|
+
function machineCornerLadder(
|
|
593
644
|
state: HeaderDeckState,
|
|
594
645
|
theme: Theme,
|
|
595
646
|
width: number,
|
|
596
647
|
nowMs = Date.now(),
|
|
597
648
|
): 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
|
-
}
|
|
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
|
|
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
|
-
|
|
854
|
+
activityTile(state, theme),
|
|
813
855
|
turnCostTile(state, theme),
|
|
814
856
|
boundedWidth,
|
|
815
857
|
);
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
|
834
|
-
//
|
|
835
|
-
|
|
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
|
}
|