@gonrocca/zero-pi 0.1.37 → 0.1.39

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.
@@ -142,58 +142,27 @@ export function bannerBlock(width: number): string[] {
142
142
  return [ornament(width), ...renderLogo(width), center(tag, width), ornament(width)];
143
143
  }
144
144
 
145
- /** The slice of pi's UI surface this extension uses. */
146
- interface PiUI {
147
- setWidget(id: string, lines: string[]): void;
148
- }
149
- interface PiSessionContext {
150
- ui?: PiUI;
151
- }
152
- interface PiAPI {
153
- on(event: string, handler: (event: unknown, ctx: PiSessionContext) => void): void;
154
- }
155
-
156
- /** Stable widget id — re-installing with the same id replaces the previous lines. */
157
- const WIDGET_ID = "zero-banner";
158
-
159
- /**
160
- * Install (or refresh) the banner as a pi-managed widget above the editor.
161
- *
162
- * Using `ctx.ui.setWidget` instead of a one-shot `stdout.write` means pi owns
163
- * the lines and redraws them on every resize, every reload, every session
164
- * restart — so the banner survives maximize/minimize instead of vanishing into
165
- * scrollback. The crash this file's earlier comment warned about was the
166
- * animated 140 ms re-render loop; a static widget set once per `session_start`
167
- * has no timer and re-uses the same id, so it never spams.
168
- */
169
- function installWidget(ctx: PiSessionContext): void {
170
- if (!ctx?.ui || typeof ctx.ui.setWidget !== "function") return;
171
- if (process.env.NO_COLOR) return;
172
- const cols = process.stdout?.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
173
- ctx.ui.setWidget(WIDGET_ID, bannerBlock(cols));
174
- }
175
-
176
145
  /**
177
146
  * The pi extension entry point.
178
147
  *
179
- * Hooks `session_start` (fires on startup, reload, new, resume, fork) and
180
- * installs the banner as a pi-managed widget above the editor so pi keeps it
181
- * rendered and redraws it on resize. Disabled with `ZERO_HEADER=off`.
182
- * Defensive: any failure of registration or the widget install is swallowed
183
- * a banner must never break a pi session.
148
+ * Writes the banner ONCE to stdout at extension load — pi loads extensions
149
+ * before its TUI takes over the screen, so the banner ends up at the very top
150
+ * of the terminal scrollback, above everything else. `setWidget` was tried
151
+ * (0.1.33–0.1.37) but pi positions widgets above the editor, not at the top
152
+ * of the viewport short chats showed the banner in the middle.
153
+ *
154
+ * Known tradeoff: resizing the terminal lets pi clear/reflow its TUI area, and
155
+ * the one-shot banner in scrollback can scroll out of view. That is normal
156
+ * CLI startup-banner behavior. `ZERO_HEADER=off` disables it.
184
157
  */
185
- export default function register(pi?: unknown): void {
158
+ export default function register(_pi?: unknown): void {
186
159
  try {
187
160
  if ((process.env.ZERO_HEADER ?? "").trim().toLowerCase() === "off") return;
188
- if (!pi || typeof (pi as PiAPI).on !== "function") return;
189
- (pi as PiAPI).on("session_start", (_event, ctx) => {
190
- try {
191
- installWidget(ctx);
192
- } catch {
193
- // A widget install failure must never break a pi session.
194
- }
195
- });
161
+ const stream = process.stdout;
162
+ if (!stream || !stream.isTTY || process.env.NO_COLOR) return;
163
+ const width = stream.columns && stream.columns > 0 ? stream.columns : 80;
164
+ stream.write("\n" + bannerBlock(width).join("\n") + "\n\n");
196
165
  } catch {
197
- // Registration itself must never break a pi session.
166
+ // A banner failure must never break a pi session.
198
167
  }
199
168
  }
@@ -0,0 +1,270 @@
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 PiCtx {
117
+ ui?: PiUI;
118
+ model?: PiModel;
119
+ cwd?: string;
120
+ getContextUsage?(): { tokens?: number } | null | undefined;
121
+ }
122
+ interface PiAPI {
123
+ on(event: string, handler: (event: unknown, ctx: PiCtx) => void): void;
124
+ }
125
+
126
+ const STATUS_ID = "zero-statusline";
127
+ const BRAND = "www.ceroclawd.com";
128
+
129
+ // ─── Session-scoped state ──────────────────────────────────────────────────
130
+
131
+ let tokensIn = 0;
132
+ let tokensOut = 0;
133
+ let branch: string | undefined;
134
+ let added = 0;
135
+ let removed = 0;
136
+ let lastCtx: PiCtx | undefined;
137
+ let gitInFlight = false;
138
+
139
+ function resetSessionState(): void {
140
+ tokensIn = 0;
141
+ tokensOut = 0;
142
+ branch = undefined;
143
+ added = 0;
144
+ removed = 0;
145
+ }
146
+
147
+ // ─── Git read (best-effort, async, deduped) ────────────────────────────────
148
+
149
+ async function readGit(cwd: string | undefined): Promise<void> {
150
+ if (!cwd || gitInFlight) return;
151
+ gitInFlight = true;
152
+ try {
153
+ try {
154
+ const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
155
+ cwd,
156
+ timeout: 1500,
157
+ windowsHide: true,
158
+ });
159
+ branch = stdout.trim() || undefined;
160
+ } catch {
161
+ branch = undefined;
162
+ }
163
+ try {
164
+ const { stdout } = await execAsync("git diff --shortstat", {
165
+ cwd,
166
+ timeout: 1500,
167
+ windowsHide: true,
168
+ });
169
+ // "1 file changed, 12 insertions(+), 3 deletions(-)"
170
+ const ins = stdout.match(/(\d+)\s+insertions?\(\+\)/);
171
+ const del = stdout.match(/(\d+)\s+deletions?\(-\)/);
172
+ added = ins ? parseInt(ins[1], 10) : 0;
173
+ removed = del ? parseInt(del[1], 10) : 0;
174
+ } catch {
175
+ // not a git repo or no diff
176
+ }
177
+ } finally {
178
+ gitInFlight = false;
179
+ }
180
+ }
181
+
182
+ // ─── Render: read everything from ctx + state, push to footer ──────────────
183
+
184
+ function render(ctx: PiCtx): void {
185
+ try {
186
+ if (!ctx?.ui || typeof ctx.ui.setStatus !== "function") return;
187
+ const window = ctx.model?.contextWindow && ctx.model.contextWindow > 0 ? ctx.model.contextWindow : 200_000;
188
+ const used = ctx.getContextUsage?.()?.tokens;
189
+ const ctxPercent = typeof used === "number" && used >= 0 ? Math.min(100, (used / window) * 100) : undefined;
190
+ const text = composeStatusline({
191
+ model: shortModel(ctx.model?.id ?? ctx.model?.name),
192
+ tokensIn,
193
+ tokensOut,
194
+ diffAdded: added,
195
+ diffRemoved: removed,
196
+ ctxPercent,
197
+ branch,
198
+ brand: BRAND,
199
+ });
200
+ ctx.ui.setStatus(STATUS_ID, text);
201
+ } catch {
202
+ // setStatus failure must never break a pi session.
203
+ }
204
+ }
205
+
206
+ // ─── Registration ──────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * The pi extension entry point. Wires four events: `session_start` (reset +
210
+ * initial git read), `model_select` (re-render with the new model name),
211
+ * `message_update` (accumulate token usage), `tool_execution_end` (re-read git
212
+ * since the tool may have edited files). Defensive: every callback is wrapped,
213
+ * and registration itself never throws.
214
+ */
215
+ export default function register(pi?: unknown): void {
216
+ try {
217
+ if (!pi || typeof (pi as PiAPI).on !== "function") return;
218
+ const api = pi as PiAPI;
219
+
220
+ api.on("session_start", (_event, ctx) => {
221
+ try {
222
+ lastCtx = ctx;
223
+ resetSessionState();
224
+ render(ctx);
225
+ void readGit(ctx?.cwd).then(() => render(ctx));
226
+ } catch {
227
+ // never break a session
228
+ }
229
+ });
230
+
231
+ api.on("model_select", (_event, ctx) => {
232
+ try {
233
+ lastCtx = ctx;
234
+ render(ctx);
235
+ } catch {
236
+ // never break a session
237
+ }
238
+ });
239
+
240
+ api.on("message_update", (event, ctx) => {
241
+ try {
242
+ lastCtx = ctx;
243
+ const usage = (event as { message?: { usage?: { inputTokens?: unknown; outputTokens?: unknown } } })?.message
244
+ ?.usage;
245
+ if (usage) {
246
+ if (typeof usage.inputTokens === "number" && usage.inputTokens > 0) {
247
+ tokensIn += usage.inputTokens;
248
+ }
249
+ if (typeof usage.outputTokens === "number" && usage.outputTokens > 0) {
250
+ tokensOut += usage.outputTokens;
251
+ }
252
+ }
253
+ render(ctx);
254
+ } catch {
255
+ // never break a session
256
+ }
257
+ });
258
+
259
+ api.on("tool_execution_end", (_event, ctx) => {
260
+ try {
261
+ lastCtx = ctx;
262
+ void readGit(ctx?.cwd).then(() => render(ctx));
263
+ } catch {
264
+ // never break a session
265
+ }
266
+ });
267
+ } catch {
268
+ // Registration itself must never break a pi session.
269
+ }
270
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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",