@gonrocca/zero-pi 0.1.38 → 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.
- package/extensions/zero-statusline.ts +270 -0
- package/package.json +3 -1
|
@@ -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.
|
|
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",
|