@gonrocca/zero-pi 0.1.38 → 0.1.40

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.
@@ -0,0 +1,303 @@
1
+ // zero-pi — colored statusline footer.
2
+ //
3
+ // Updates pi's footer (`ctx.ui.setStatus`) with a powerline-style line:
4
+ //
5
+ // claude-opus-4-7 · tok ↑12.3K ↓4.1K · diff +50/-12 · ctx 45% · master · www.ceroclawd.com
6
+ //
7
+ // Themed: model violet, tokens cyan/blue, diff mint/rose, ctx mint→amber→rose
8
+ // by load, branch steel, brand amber. Pure 24-bit ANSI, no runtime deps.
9
+ //
10
+ // Refreshes on `session_start`, `model_select`, `message_update`
11
+ // (accumulates tokens), and `tool_execution_end` (re-reads git, since tools
12
+ // edit files). No timer — the 140 ms re-render loop that crashed pi 0.75.x
13
+ // is the cautionary tale; event-driven updates are safe.
14
+
15
+ import { exec } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ // ─── Color palette (matches the zero-sdd theme `vars`) ─────────────────────
21
+
22
+ type RGB = [number, number, number];
23
+ const VIOLET: RGB = [175, 138, 255];
24
+ const CYAN: RGB = [80, 210, 255];
25
+ const BLUE: RGB = [116, 151, 255];
26
+ const MINT: RGB = [79, 221, 171];
27
+ const AMBER: RGB = [238, 190, 92];
28
+ const ROSE: RGB = [255, 106, 122];
29
+ const STEEL: RGB = [143, 152, 168];
30
+ const DIM: RGB = [95, 104, 120];
31
+
32
+ function fg([r, g, b]: RGB, text: string): string {
33
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
34
+ }
35
+
36
+ // ─── Pure formatters (unit-tested) ─────────────────────────────────────────
37
+
38
+ /** Compact a token count: `500` → `500`, `1500` → `1.5K`, `2_400_000` → `2.4M`. */
39
+ export function formatTokenCount(n: number): string {
40
+ if (!Number.isFinite(n) || n < 0) return "0";
41
+ if (n < 1000) return `${Math.floor(n)}`;
42
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
43
+ return `${(n / 1_000_000).toFixed(1)}M`;
44
+ }
45
+
46
+ /** Pick a load-based color for context-usage display. */
47
+ export function ctxColor(percent: number): RGB {
48
+ if (!Number.isFinite(percent) || percent < 50) return MINT;
49
+ if (percent < 80) return AMBER;
50
+ return ROSE;
51
+ }
52
+
53
+ /** Strip a `provider/` prefix from a model id for compact display. */
54
+ export function shortModel(id: string | undefined): string | undefined {
55
+ if (!id) return undefined;
56
+ const slash = id.lastIndexOf("/");
57
+ return slash >= 0 ? id.slice(slash + 1) : id;
58
+ }
59
+
60
+ /** The shape of one statusline render. All fields optional — missing parts skip. */
61
+ export interface StatuslineParts {
62
+ model?: string;
63
+ tokensIn?: number;
64
+ tokensOut?: number;
65
+ diffAdded?: number;
66
+ diffRemoved?: number;
67
+ ctxPercent?: number;
68
+ branch?: string;
69
+ brand?: string;
70
+ }
71
+
72
+ const SEP = fg(DIM, " · ");
73
+
74
+ /**
75
+ * Compose the themed statusline string from its parts. Missing parts are
76
+ * skipped — an empty parts object yields the empty string.
77
+ */
78
+ export function composeStatusline(p: StatuslineParts): string {
79
+ const parts: string[] = [];
80
+
81
+ if (p.model) parts.push(fg(VIOLET, p.model));
82
+
83
+ if (p.tokensIn != null || p.tokensOut != null) {
84
+ const inS = fg(CYAN, `↑${formatTokenCount(p.tokensIn ?? 0)}`);
85
+ const outS = fg(BLUE, `↓${formatTokenCount(p.tokensOut ?? 0)}`);
86
+ parts.push(`${fg(DIM, "tok")} ${inS} ${outS}`);
87
+ }
88
+
89
+ if (p.diffAdded != null || p.diffRemoved != null) {
90
+ const added = fg(MINT, `+${p.diffAdded ?? 0}`);
91
+ const removed = fg(ROSE, `-${p.diffRemoved ?? 0}`);
92
+ parts.push(`${fg(DIM, "diff")} ${added}/${removed}`);
93
+ }
94
+
95
+ if (p.ctxPercent != null && Number.isFinite(p.ctxPercent)) {
96
+ parts.push(fg(ctxColor(p.ctxPercent), `ctx ${Math.round(p.ctxPercent)}%`));
97
+ }
98
+
99
+ if (p.branch) parts.push(fg(STEEL, p.branch));
100
+
101
+ if (p.brand) parts.push(fg(AMBER, p.brand));
102
+
103
+ return parts.join(SEP);
104
+ }
105
+
106
+ // ─── pi API surfaces (minimal slices) ──────────────────────────────────────
107
+
108
+ interface PiUI {
109
+ setStatus(id: string, text: string | undefined): void;
110
+ }
111
+ interface PiModel {
112
+ id?: string;
113
+ name?: string;
114
+ contextWindow?: number;
115
+ }
116
+ interface PiSessionEntry {
117
+ type?: string;
118
+ message?: {
119
+ role?: string;
120
+ usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
121
+ };
122
+ }
123
+ interface PiSessionManager {
124
+ getEntries?(): PiSessionEntry[];
125
+ }
126
+ interface PiCtx {
127
+ ui?: PiUI;
128
+ model?: PiModel;
129
+ cwd?: string;
130
+ sessionManager?: PiSessionManager;
131
+ getContextUsage?(): { tokens?: number } | null | undefined;
132
+ }
133
+ interface PiAPI {
134
+ on(event: string, handler: (event: unknown, ctx: PiCtx) => void): void;
135
+ }
136
+
137
+ const STATUS_ID = "zero-statusline";
138
+ const BRAND = "www.ceroclawd.com";
139
+
140
+ // ─── Session-scoped state ──────────────────────────────────────────────────
141
+ // Only git stays in state (it's expensive to re-shell on every render);
142
+ // tokens are computed fresh from sessionManager.getEntries() per render —
143
+ // the same pattern pi's native footer uses, so it can never double-count a
144
+ // streamed message_update.
145
+
146
+ let branch: string | undefined;
147
+ let added = 0;
148
+ let removed = 0;
149
+ let lastCtx: PiCtx | undefined;
150
+ let gitInFlight = false;
151
+
152
+ function resetSessionState(): void {
153
+ branch = undefined;
154
+ added = 0;
155
+ removed = 0;
156
+ }
157
+
158
+ /**
159
+ * Sum assistant input/output tokens across the full session, matching pi's
160
+ * own footer logic. Defensive: a missing sessionManager or entries iteration
161
+ * failure yields zero, never throws.
162
+ */
163
+ export function computeSessionTokens(
164
+ sessionManager: PiSessionManager | undefined,
165
+ ): { input: number; output: number } {
166
+ if (!sessionManager || typeof sessionManager.getEntries !== "function") {
167
+ return { input: 0, output: 0 };
168
+ }
169
+ let input = 0;
170
+ let output = 0;
171
+ try {
172
+ for (const entry of sessionManager.getEntries()) {
173
+ if (entry?.type !== "message") continue;
174
+ if (entry.message?.role !== "assistant") continue;
175
+ const u = entry.message.usage;
176
+ if (typeof u?.input === "number" && u.input > 0) input += u.input;
177
+ if (typeof u?.output === "number" && u.output > 0) output += u.output;
178
+ }
179
+ } catch {
180
+ // session iteration failure — return what we summed so far.
181
+ }
182
+ return { input, output };
183
+ }
184
+
185
+ // ─── Git read (best-effort, async, deduped) ────────────────────────────────
186
+
187
+ async function readGit(cwdHint: string | undefined): Promise<void> {
188
+ const cwd = cwdHint || process.cwd();
189
+ if (!cwd || gitInFlight) return;
190
+ gitInFlight = true;
191
+ try {
192
+ try {
193
+ const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
194
+ cwd,
195
+ timeout: 1500,
196
+ windowsHide: true,
197
+ });
198
+ branch = stdout.trim() || undefined;
199
+ } catch {
200
+ branch = undefined;
201
+ }
202
+ try {
203
+ const { stdout } = await execAsync("git diff --shortstat", {
204
+ cwd,
205
+ timeout: 1500,
206
+ windowsHide: true,
207
+ });
208
+ // "1 file changed, 12 insertions(+), 3 deletions(-)"
209
+ const ins = stdout.match(/(\d+)\s+insertions?\(\+\)/);
210
+ const del = stdout.match(/(\d+)\s+deletions?\(-\)/);
211
+ added = ins ? parseInt(ins[1], 10) : 0;
212
+ removed = del ? parseInt(del[1], 10) : 0;
213
+ } catch {
214
+ // not a git repo or no diff
215
+ }
216
+ } finally {
217
+ gitInFlight = false;
218
+ }
219
+ }
220
+
221
+ // ─── Render: read everything from ctx + state, push to footer ──────────────
222
+
223
+ function render(ctx: PiCtx): void {
224
+ try {
225
+ if (!ctx?.ui || typeof ctx.ui.setStatus !== "function") return;
226
+ const window = ctx.model?.contextWindow && ctx.model.contextWindow > 0 ? ctx.model.contextWindow : 200_000;
227
+ const used = ctx.getContextUsage?.()?.tokens;
228
+ const ctxPercent = typeof used === "number" && used >= 0 ? Math.min(100, (used / window) * 100) : undefined;
229
+ const tokens = computeSessionTokens(ctx.sessionManager);
230
+ const text = composeStatusline({
231
+ model: shortModel(ctx.model?.id ?? ctx.model?.name),
232
+ tokensIn: tokens.input,
233
+ tokensOut: tokens.output,
234
+ diffAdded: added,
235
+ diffRemoved: removed,
236
+ ctxPercent,
237
+ branch,
238
+ brand: BRAND,
239
+ });
240
+ ctx.ui.setStatus(STATUS_ID, text);
241
+ } catch {
242
+ // setStatus failure must never break a pi session.
243
+ }
244
+ }
245
+
246
+ // ─── Registration ──────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * The pi extension entry point. Wires four events: `session_start` (reset +
250
+ * initial git read), `model_select` (re-render with the new model name),
251
+ * `message_update` (accumulate token usage), `tool_execution_end` (re-read git
252
+ * since the tool may have edited files). Defensive: every callback is wrapped,
253
+ * and registration itself never throws.
254
+ */
255
+ export default function register(pi?: unknown): void {
256
+ try {
257
+ if (!pi || typeof (pi as PiAPI).on !== "function") return;
258
+ const api = pi as PiAPI;
259
+
260
+ api.on("session_start", (_event, ctx) => {
261
+ try {
262
+ lastCtx = ctx;
263
+ resetSessionState();
264
+ render(ctx);
265
+ void readGit(ctx?.cwd).then(() => render(ctx));
266
+ } catch {
267
+ // never break a session
268
+ }
269
+ });
270
+
271
+ api.on("model_select", (_event, ctx) => {
272
+ try {
273
+ lastCtx = ctx;
274
+ render(ctx);
275
+ } catch {
276
+ // never break a session
277
+ }
278
+ });
279
+
280
+ api.on("message_update", (_event, ctx) => {
281
+ try {
282
+ lastCtx = ctx;
283
+ // Tokens are computed from sessionManager.getEntries() in render() —
284
+ // no event-side accumulation, so a streamed message_update repeat
285
+ // can never double-count.
286
+ render(ctx);
287
+ } catch {
288
+ // never break a session
289
+ }
290
+ });
291
+
292
+ api.on("tool_execution_end", (_event, ctx) => {
293
+ try {
294
+ lastCtx = ctx;
295
+ void readGit(ctx?.cwd).then(() => render(ctx));
296
+ } catch {
297
+ // never break a session
298
+ }
299
+ });
300
+ } catch {
301
+ // Registration itself must never break a pi session.
302
+ }
303
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow, per-phase model autotune, and skill auto-learning. Adds capability to pi without modifying pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "extensions": [
26
26
  "./extensions/zero-banner.ts",
27
+ "./extensions/zero-statusline.ts",
27
28
  "./extensions/working-phrases.ts",
28
29
  "./extensions/win-tree-kill.ts",
29
30
  "./extensions/sdd-agents.ts",
@@ -39,6 +40,7 @@
39
40
  "skills",
40
41
  "themes",
41
42
  "extensions/zero-banner.ts",
43
+ "extensions/zero-statusline.ts",
42
44
  "extensions/working-phrases.ts",
43
45
  "extensions/win-tree-kill.ts",
44
46
  "extensions/sdd-agents.ts",