@gonrocca/zero-pi 0.1.64 → 0.1.66

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/README.md CHANGED
@@ -113,7 +113,10 @@ into `/forge` for you.
113
113
  | **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
114
114
  | **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
115
115
  | **Startup banner** | The sunset ANSI-Shadow `ZERO` wordmark, drawn once at pi startup — `ZERO_HEADER=off` to disable. |
116
- | **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
116
+ | **ZERO HUD** | OMP-inspired segmented footer for SDD work: phase, model, tokens, cache, cost, diff, context %, and branch. Switch presets with `/zero-hud compact\|minimal\|full\|ascii\|off` or `ZERO_HUD_PRESET`. |
117
+ | **Pretty input box** | OMP-style prompt chrome with rounded corners, side borders, ZERO chips, and compact keyboard hints. |
118
+ | **Pretty code fences** | Markdown code blocks render as bordered panels with language badges instead of raw ```txt / ```json fence lines. |
119
+ | **Working-phrase ticker** | Swaps pi's static `Working...` / `Procesando...` for a large pool of context-aware Spanish/ZERO/SDD phrases + spinner. |
117
120
  | **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
118
121
  | **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
119
122
  | **SDD routing skill** | Natural-language requests that say "hacelo con sdd" route into `/forge` without remembering the slash command. |
@@ -131,6 +134,7 @@ into `/forge` for you.
131
134
  | `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
132
135
  | `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
133
136
  | `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
137
+ | `/zero-hud [compact\|minimal\|full\|ascii\|off\|on\|preview]` | Preview or switch the segmented ZERO footer for this pi session. Default preset: `compact`. |
134
138
  | `/zero-cost [<slug>]` | Report tokens (in/out/cache), USD cost, duration, and tool-count per SDD phase for a run — reads native pi session metas and project-local `.pi-subagents/artifacts`; `<slug>` for a specific run, no argument for the most recent. |
135
139
  | `/zero-checkpoint [<slug>] [--json]` | Save `diff.patch`, `status.txt`, `head.txt`, `meta.json`, and a review-before-running `restore.sh` under `.sdd/<slug>/checkpoints/<id>/`. |
136
140
  | `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
@@ -40,15 +40,39 @@ const THINKING_PLAYFUL = [
40
40
  "Maquinando",
41
41
  "Rumiando",
42
42
  "Pensando",
43
- "Cocinando",
44
- "Tramando",
45
- "Conjurando",
46
- "Cavilando",
43
+ "Cocinando una idea",
44
+ "Tramando el próximo paso",
45
+ "Conjurando contexto",
46
+ "Cavilando fino",
47
47
  "Hilando ideas",
48
- "Procesando",
48
+ "Procesando señales",
49
49
  "Razonando",
50
50
  "Atando cabos",
51
- "Calibrando",
51
+ "Calibrando criterio",
52
+ "Afilando el lápiz",
53
+ "Leyendo entre líneas",
54
+ "Buscando el camino corto",
55
+ "Separando ruido de señal",
56
+ "Ordenando el quilombo",
57
+ "Mirando el diff mental",
58
+ "Midiendo impacto",
59
+ "Puliendo la respuesta",
60
+ "Armando la jugada",
61
+ "Pensando como reviewer",
62
+ "Evitando overengineering",
63
+ "Cuidando tus tokens",
64
+ "Chequeando supuestos",
65
+ "Bajando a tierra",
66
+ "Buscando el bug escondido",
67
+ "Haciendo magia responsable",
68
+ "Tirando del hilo",
69
+ "Cruzando evidencia",
70
+ "Afinando el plan",
71
+ "Sacando filo",
72
+ "Cargando mate cognitivo",
73
+ "No rompiendo producción",
74
+ "Haciendo que compile en la cabeza",
75
+ "Respirando antes de tocar código",
52
76
  ];
53
77
 
54
78
  /** Thinking verbs biased toward SDD work — used once an SDD run is detected. */
@@ -59,6 +83,21 @@ const THINKING_SDD = [
59
83
  "Revisando supuestos",
60
84
  "Ordenando las tareas",
61
85
  "Cuadrando el spec",
86
+ "Alineando requisitos",
87
+ "Buscando criterios de éxito",
88
+ "Separando explore de build",
89
+ "Guardando el contexto útil",
90
+ "Preparando el veredicto",
91
+ "Pensando tests primero",
92
+ "Revisando riesgos",
93
+ "Midiendo alcance",
94
+ "Evitando scope creep",
95
+ "Diseñando el camino mínimo",
96
+ "Armando evidencia TDD",
97
+ "Verificando invariantes",
98
+ "Preguntando lo justo",
99
+ "Cerrando ambigüedades",
100
+ "Forjando el spec",
62
101
  "Maquinando",
63
102
  "Razonando",
64
103
  ];
@@ -79,9 +118,15 @@ const TOOL_RULES: Array<[RegExp, string]> = [
79
118
  [/(multi.?edit|str.?replace|edit|patch|apply)/i, "Aplicando cambios"],
80
119
  [/(write|create)/i, "Escribiendo archivos"],
81
120
  [/(bash|shell|exec|run|terminal|cmd)/i, "Ejecutando comandos"],
121
+ [/(test|vitest|jest|mocha|node:test|coverage)/i, "Corriendo pruebas"],
122
+ [/(npm|pnpm|bun|yarn|publish|pack)/i, "Preparando paquete"],
123
+ [/(git|commit|push|branch|diff|status)/i, "Hablando con git"],
82
124
  [/(grep|search|ripgrep|^rg)/i, "Buscando en el código"],
83
125
  [/(glob|find|^ls$|list|tree)/i, "Rastreando archivos"],
84
126
  [/(web|fetch|http|url|browse|curl)/i, "Navegando la web"],
127
+ [/(browser|click|type|screenshot|tab)/i, "Moviendo el navegador"],
128
+ [/(cortex|memoria|memory|recall)/i, "Consultando memoria"],
129
+ [/(image|screenshot|vision)/i, "Mirando la imagen"],
85
130
  [/(subagent|custom-agent|^task$|agent)/i, "Coordinando sub-agentes"],
86
131
  [/(todo|plan)/i, "Ordenando el plan"],
87
132
  ];
@@ -0,0 +1,453 @@
1
+ // zero-pi — ZERO HUD statusline.
2
+ //
3
+ // A segmented footer inspired by omp's status-line presets, adapted to ZERO SDD:
4
+ // phase-aware, cost-aware, compact by default, and fully defensive. This
5
+ // replaces the older one-piece `zero-statusline` package extension while keeping
6
+ // that file around for backwards-compatible imports/tests.
7
+ //
8
+ // Runtime controls:
9
+ // ZERO_HUD_PRESET=compact|minimal|full|ascii|off
10
+ // /zero-hud compact|minimal|full|ascii|off|on|preview
11
+
12
+ import { exec } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+
15
+ const execAsync = promisify(exec);
16
+
17
+ type RGB = [number, number, number];
18
+ type HudPreset = "minimal" | "compact" | "full" | "ascii" | "off";
19
+ type Phase = "clarify" | "explore" | "plan" | "analyze" | "build" | "veredicto" | "forge";
20
+
21
+ const CORAL: RGB = [255, 124, 92];
22
+ const GOLD: RGB = [255, 214, 130];
23
+ const PEACH: RGB = [255, 168, 99];
24
+ const ROSE: RGB = [255, 106, 122];
25
+ const ORCHID: RGB = [176, 106, 179];
26
+ const MINT: RGB = [79, 221, 171];
27
+ const AMBER: RGB = [238, 190, 92];
28
+ const SKY: RGB = [109, 184, 230];
29
+ const STEEL: RGB = [176, 152, 142];
30
+ const DIM: RGB = [120, 104, 98];
31
+
32
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
33
+ const STATUS_ID = "zero-hud";
34
+ const BRAND = "ZERO";
35
+
36
+ function fg([r, g, b]: RGB, text: string): string {
37
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
38
+ }
39
+
40
+ function plain(text: string): string {
41
+ return text.replace(ANSI_RE, "");
42
+ }
43
+
44
+ function dim(text: string): string {
45
+ return fg(DIM, text);
46
+ }
47
+
48
+ function phaseColor(phase: Phase | undefined): RGB {
49
+ switch (phase) {
50
+ case "clarify": return GOLD;
51
+ case "explore": return SKY;
52
+ case "plan": return PEACH;
53
+ case "analyze": return ORCHID;
54
+ case "build": return CORAL;
55
+ case "veredicto": return MINT;
56
+ case "forge": return GOLD;
57
+ default: return DIM;
58
+ }
59
+ }
60
+
61
+ export function normalizePreset(value: string | undefined): HudPreset | null {
62
+ const raw = (value ?? "").trim().toLowerCase();
63
+ if (raw === "minimal" || raw === "compact" || raw === "full" || raw === "ascii" || raw === "off") return raw;
64
+ if (raw === "on") return "compact";
65
+ return raw === "" ? null : null;
66
+ }
67
+
68
+ export function formatTokenCount(n: number): string {
69
+ if (!Number.isFinite(n) || n < 0) return "0";
70
+ if (n < 1000) return `${Math.floor(n)}`;
71
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
72
+ return `${(n / 1_000_000).toFixed(1)}M`;
73
+ }
74
+
75
+ export function formatUsd(n: number | undefined): string | undefined {
76
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return undefined;
77
+ if (n < 0.01) return `$${n.toFixed(4)}`;
78
+ if (n < 1) return `$${n.toFixed(3)}`;
79
+ return `$${n.toFixed(2)}`;
80
+ }
81
+
82
+ export function ctxColor(percent: number): RGB {
83
+ if (!Number.isFinite(percent) || percent < 50) return MINT;
84
+ if (percent < 80) return AMBER;
85
+ return ROSE;
86
+ }
87
+
88
+ export function shortModel(id: string | undefined): string | undefined {
89
+ if (!id) return undefined;
90
+ const slash = id.lastIndexOf("/");
91
+ const bare = slash >= 0 ? id.slice(slash + 1) : id;
92
+ return bare || undefined;
93
+ }
94
+
95
+ function shortBranch(value: string | undefined): string | undefined {
96
+ if (!value) return undefined;
97
+ return value.length <= 24 ? value : `…${value.slice(-23)}`;
98
+ }
99
+
100
+ export interface SessionUsage {
101
+ input: number;
102
+ output: number;
103
+ cacheRead: number;
104
+ cacheWrite: number;
105
+ costUsd: number;
106
+ }
107
+
108
+ interface PiSessionEntry {
109
+ type?: string;
110
+ message?: {
111
+ role?: string;
112
+ usage?: {
113
+ input?: number;
114
+ output?: number;
115
+ cacheRead?: number;
116
+ cacheWrite?: number;
117
+ cost?: number | { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
118
+ };
119
+ };
120
+ }
121
+ interface PiSessionManager {
122
+ getEntries?(): PiSessionEntry[];
123
+ }
124
+
125
+ function positive(n: unknown): number {
126
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
127
+ }
128
+
129
+ function usageCost(usage: PiSessionEntry["message"] extends infer M ? M extends { usage?: infer U } ? U : never : never): number {
130
+ if (!usage || typeof usage !== "object") return 0;
131
+ const cost = (usage as { cost?: unknown }).cost;
132
+ if (typeof cost === "number") return positive(cost);
133
+ if (cost && typeof cost === "object") {
134
+ const c = cost as { total?: unknown; input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown };
135
+ return positive(c.total) || positive(c.input) + positive(c.output) + positive(c.cacheRead) + positive(c.cacheWrite);
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ export function computeSessionUsage(sessionManager: PiSessionManager | undefined): SessionUsage {
141
+ const totals: SessionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
142
+ if (!sessionManager || typeof sessionManager.getEntries !== "function") return totals;
143
+ try {
144
+ for (const entry of sessionManager.getEntries()) {
145
+ if (entry?.type !== "message") continue;
146
+ if (entry.message?.role !== "assistant") continue;
147
+ const u = entry.message.usage;
148
+ if (!u) continue;
149
+ totals.input += positive(u.input);
150
+ totals.output += positive(u.output);
151
+ totals.cacheRead += positive(u.cacheRead);
152
+ totals.cacheWrite += positive(u.cacheWrite);
153
+ totals.costUsd += usageCost(u);
154
+ }
155
+ } catch {
156
+ // Return what we collected so far.
157
+ }
158
+ totals.costUsd = Math.round((totals.costUsd + Number.EPSILON) * 1_000_000) / 1_000_000;
159
+ return totals;
160
+ }
161
+
162
+ const SDD_AGENT_TO_PHASE: Record<string, Phase> = {
163
+ "zero-clarify": "clarify",
164
+ "zero-explore": "explore",
165
+ "zero-plan": "plan",
166
+ "zero-analyze": "analyze",
167
+ "zero-build": "build",
168
+ "zero-veredicto": "veredicto",
169
+ };
170
+
171
+ export function phaseFromSubagentArgs(args: unknown): Phase | undefined {
172
+ const names = new Set<string>();
173
+ const collect = (value: unknown): void => {
174
+ if (typeof value === "string" && value.trim()) names.add(value.trim());
175
+ };
176
+ const walk = (value: unknown): void => {
177
+ if (!value || typeof value !== "object") return;
178
+ const record = value as Record<string, unknown>;
179
+ collect(record.agent);
180
+ for (const key of ["chain", "tasks", "parallel"] as const) {
181
+ const list = record[key];
182
+ if (Array.isArray(list)) for (const item of list) walk(item);
183
+ }
184
+ };
185
+ walk(args);
186
+ for (const name of names) {
187
+ const key = name.toLowerCase().split(".").pop() ?? name.toLowerCase();
188
+ if (SDD_AGENT_TO_PHASE[key]) return SDD_AGENT_TO_PHASE[key];
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ export function phaseFromInput(text: string): Phase | undefined {
194
+ return /\/forge\b|\bforge\b|zero[\s-]?sdd|\bsdd\b/i.test(text ?? "") ? "forge" : undefined;
195
+ }
196
+
197
+ export interface HudParts {
198
+ phase?: Phase;
199
+ model?: string;
200
+ tokensIn?: number;
201
+ tokensOut?: number;
202
+ cacheRead?: number;
203
+ costUsd?: number;
204
+ diffAdded?: number;
205
+ diffRemoved?: number;
206
+ ctxPercent?: number;
207
+ branch?: string;
208
+ preset?: HudPreset;
209
+ }
210
+
211
+ function separator(preset: HudPreset): string {
212
+ if (preset === "ascii") return dim(" | ");
213
+ if (preset === "minimal") return dim(" / ");
214
+ return dim(" ▸ ");
215
+ }
216
+
217
+ function segment(label: string, value: string, color: RGB, preset: HudPreset): string {
218
+ if (preset === "ascii") return `${label}:${plain(value)}`;
219
+ return `${dim(label)} ${fg(color, value)}`;
220
+ }
221
+
222
+ export function composeHud(parts: HudParts): string {
223
+ const preset = parts.preset ?? "compact";
224
+ if (preset === "off") return "";
225
+
226
+ const out: string[] = [];
227
+ const sep = separator(preset);
228
+
229
+ if (preset !== "minimal") {
230
+ out.push(preset === "ascii" ? BRAND : fg(GOLD, BRAND));
231
+ }
232
+
233
+ if (parts.phase) {
234
+ const value = preset === "ascii" ? parts.phase : `◆ ${parts.phase}`;
235
+ out.push(segment("phase", value, phaseColor(parts.phase), preset));
236
+ }
237
+
238
+ if (parts.model && preset !== "minimal") {
239
+ out.push(fg(CORAL, parts.model));
240
+ }
241
+
242
+ if (parts.tokensIn != null || parts.tokensOut != null) {
243
+ const tokenText = `↑${formatTokenCount(parts.tokensIn ?? 0)} ↓${formatTokenCount(parts.tokensOut ?? 0)}`;
244
+ out.push(segment("tok", tokenText, PEACH, preset));
245
+ }
246
+
247
+ if (preset === "full" && parts.cacheRead && parts.cacheRead > 0) {
248
+ out.push(segment("cache", `↺${formatTokenCount(parts.cacheRead)}`, SKY, preset));
249
+ }
250
+
251
+ const cost = formatUsd(parts.costUsd);
252
+ if (cost && preset !== "minimal") {
253
+ out.push(segment("cost", cost, ORCHID, preset));
254
+ }
255
+
256
+ if (parts.diffAdded != null || parts.diffRemoved != null) {
257
+ const diff = `+${parts.diffAdded ?? 0}/-${parts.diffRemoved ?? 0}`;
258
+ out.push(segment("diff", diff, (parts.diffAdded ?? 0) || (parts.diffRemoved ?? 0) ? MINT : DIM, preset));
259
+ }
260
+
261
+ if (parts.ctxPercent != null && Number.isFinite(parts.ctxPercent)) {
262
+ out.push(segment("ctx", `${Math.round(parts.ctxPercent)}%`, ctxColor(parts.ctxPercent), preset));
263
+ }
264
+
265
+ const branch = shortBranch(parts.branch);
266
+ if (branch && preset !== "minimal") {
267
+ out.push(segment("git", branch, STEEL, preset));
268
+ }
269
+
270
+ return out.join(sep);
271
+ }
272
+
273
+ interface PiUI {
274
+ setStatus(id: string, text: string | undefined): void;
275
+ notify?(message: string, level?: "info" | "warning" | "error"): void;
276
+ }
277
+ interface PiModel {
278
+ id?: string;
279
+ name?: string;
280
+ contextWindow?: number;
281
+ }
282
+ interface PiCtx {
283
+ ui?: PiUI;
284
+ model?: PiModel;
285
+ cwd?: string;
286
+ sessionManager?: PiSessionManager;
287
+ getContextUsage?(): { tokens?: number } | null | undefined;
288
+ }
289
+ interface PiAPI {
290
+ on(event: string, handler: (event: unknown, ctx: PiCtx) => void): void;
291
+ registerCommand?(name: string, options: { description: string; handler: (args: string, ctx: PiCtx) => unknown }): void;
292
+ }
293
+
294
+ let branch: string | undefined;
295
+ let added = 0;
296
+ let removed = 0;
297
+ let lastCtx: PiCtx | undefined;
298
+ let gitInFlight = false;
299
+ let preset: HudPreset = normalizePreset(process.env.ZERO_HUD_PRESET) ?? "compact";
300
+ let activePhase: Phase | undefined;
301
+
302
+ function resetSessionState(): void {
303
+ branch = undefined;
304
+ added = 0;
305
+ removed = 0;
306
+ activePhase = undefined;
307
+ }
308
+
309
+ async function readGit(cwdHint: string | undefined): Promise<void> {
310
+ const cwd = cwdHint || process.cwd();
311
+ if (!cwd || gitInFlight) return;
312
+ gitInFlight = true;
313
+ try {
314
+ try {
315
+ const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd, timeout: 1500, windowsHide: true });
316
+ branch = stdout.trim() || undefined;
317
+ } catch {
318
+ branch = undefined;
319
+ }
320
+ try {
321
+ const { stdout } = await execAsync("git diff --shortstat", { cwd, timeout: 1500, windowsHide: true });
322
+ const ins = stdout.match(/(\d+)\s+insertions?\(\+\)/);
323
+ const del = stdout.match(/(\d+)\s+deletions?\(-\)/);
324
+ added = ins ? parseInt(ins[1], 10) : 0;
325
+ removed = del ? parseInt(del[1], 10) : 0;
326
+ } catch {
327
+ added = 0;
328
+ removed = 0;
329
+ }
330
+ } finally {
331
+ gitInFlight = false;
332
+ }
333
+ }
334
+
335
+ function render(ctx: PiCtx): void {
336
+ try {
337
+ if (!ctx?.ui || typeof ctx.ui.setStatus !== "function") return;
338
+ if (preset === "off") {
339
+ ctx.ui.setStatus(STATUS_ID, undefined);
340
+ return;
341
+ }
342
+ const window = ctx.model?.contextWindow && ctx.model.contextWindow > 0 ? ctx.model.contextWindow : 200_000;
343
+ const used = ctx.getContextUsage?.()?.tokens;
344
+ const ctxPercent = typeof used === "number" && used >= 0 ? Math.min(100, (used / window) * 100) : undefined;
345
+ const usage = computeSessionUsage(ctx.sessionManager);
346
+ const text = composeHud({
347
+ preset,
348
+ phase: activePhase,
349
+ model: shortModel(ctx.model?.id ?? ctx.model?.name),
350
+ tokensIn: usage.input,
351
+ tokensOut: usage.output,
352
+ cacheRead: usage.cacheRead,
353
+ costUsd: usage.costUsd,
354
+ diffAdded: added,
355
+ diffRemoved: removed,
356
+ ctxPercent,
357
+ branch,
358
+ });
359
+ ctx.ui.setStatus(STATUS_ID, text || undefined);
360
+ } catch {
361
+ // HUD failures must never break a pi session.
362
+ }
363
+ }
364
+
365
+ function preview(nextPreset: HudPreset): string {
366
+ return plain(composeHud({
367
+ preset: nextPreset,
368
+ phase: "build",
369
+ model: "claude-opus-4-7",
370
+ tokensIn: 128_400,
371
+ tokensOut: 8_120,
372
+ cacheRead: 512_000,
373
+ costUsd: 0.042,
374
+ diffAdded: 120,
375
+ diffRemoved: 8,
376
+ ctxPercent: 42,
377
+ branch: "sdd/zero-hud",
378
+ }));
379
+ }
380
+
381
+ export default function register(pi?: unknown): void {
382
+ try {
383
+ if (!pi || typeof (pi as PiAPI).on !== "function") return;
384
+ const api = pi as PiAPI;
385
+
386
+ api.registerCommand?.("zero-hud", {
387
+ description: "Preview or switch the ZERO HUD footer (minimal|compact|full|ascii|off|on|preview).",
388
+ handler: (args, ctx) => {
389
+ const raw = (args ?? "").trim().toLowerCase();
390
+ const next = raw === "preview" || raw === "" ? preset : normalizePreset(raw);
391
+ if (!next) {
392
+ ctx.ui?.notify?.("Uso: /zero-hud minimal|compact|full|ascii|off|on|preview", "warning");
393
+ return;
394
+ }
395
+ if (raw !== "preview" && raw !== "") preset = next;
396
+ render(ctx);
397
+ ctx.ui?.notify?.(`ZERO HUD ${raw === "preview" || raw === "" ? "preview" : "preset"}: ${next}\n${preview(next)}`, "info");
398
+ },
399
+ });
400
+
401
+ api.on("session_start", (_event, ctx) => {
402
+ lastCtx = ctx;
403
+ resetSessionState();
404
+ render(ctx);
405
+ void readGit(ctx?.cwd).then(() => render(ctx));
406
+ });
407
+
408
+ api.on("input", (event, ctx) => {
409
+ lastCtx = ctx;
410
+ const phase = phaseFromInput((event as { text?: string })?.text ?? "");
411
+ if (phase) activePhase = phase;
412
+ render(ctx);
413
+ });
414
+
415
+ api.on("model_select", (_event, ctx) => {
416
+ lastCtx = ctx;
417
+ render(ctx);
418
+ });
419
+
420
+ api.on("message_update", (_event, ctx) => {
421
+ lastCtx = ctx;
422
+ render(ctx);
423
+ });
424
+
425
+ api.on("tool_execution_start", (event, ctx) => {
426
+ lastCtx = ctx;
427
+ const e = event as { toolName?: string; args?: unknown };
428
+ const phase = /subagent|task/i.test(e.toolName ?? "") ? phaseFromSubagentArgs(e.args) : undefined;
429
+ if (phase) activePhase = phase;
430
+ render(ctx);
431
+ });
432
+
433
+ api.on("tool_execution_end", (_event, ctx) => {
434
+ lastCtx = ctx;
435
+ void readGit(ctx?.cwd).then(() => render(ctx));
436
+ });
437
+
438
+ api.on("agent_end", (_event, ctx) => {
439
+ lastCtx = ctx;
440
+ render(ctx);
441
+ });
442
+
443
+ api.on("session_shutdown", () => {
444
+ try {
445
+ lastCtx?.ui?.setStatus?.(STATUS_ID, undefined);
446
+ } catch {
447
+ // ignore
448
+ }
449
+ });
450
+ } catch {
451
+ // Registration itself must never break a pi session.
452
+ }
453
+ }
@@ -0,0 +1,76 @@
1
+ import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ type MarkdownInstance = {
5
+ theme: {
6
+ codeBlock: (text: string) => string;
7
+ codeBlockBorder: (text: string) => string;
8
+ codeBlockIndent?: string;
9
+ highlightCode?: (code: string, lang?: string) => string[];
10
+ };
11
+ renderToken: (token: { type?: string; text?: string; lang?: string }, width: number, nextTokenType?: string, styleContext?: unknown) => string[];
12
+ };
13
+
14
+ const PATCHED = Symbol.for("gon.pi.pretty-code-fences.patched");
15
+
16
+ function displayLang(lang?: string): string {
17
+ const raw = (lang ?? "").trim().toLowerCase();
18
+ if (!raw || raw === "txt" || raw === "text" || raw === "plain") return "";
19
+ if (raw === "typescript") return "ts";
20
+ if (raw === "javascript") return "js";
21
+ if (raw === "shell" || raw === "bash") return "sh";
22
+ return raw;
23
+ }
24
+
25
+ function border(theme: MarkdownInstance["theme"], width: number, position: "top" | "bottom", lang = ""): string {
26
+ const maxWidth = Math.max(10, Math.min(width, 96));
27
+ if (position === "bottom") {
28
+ return theme.codeBlockBorder(`╰${"─".repeat(Math.max(1, maxWidth - 2))}╯`);
29
+ }
30
+
31
+ const label = lang ? ` ${lang} ` : "";
32
+ const prefix = `╭─${label}`;
33
+ const suffix = "╮";
34
+ const fill = "─".repeat(Math.max(1, maxWidth - visibleWidth(prefix) - visibleWidth(suffix)));
35
+ return theme.codeBlockBorder(`${prefix}${fill}${suffix}`);
36
+ }
37
+
38
+ function patchMarkdownRenderer(): void {
39
+ const proto = Markdown.prototype as unknown as MarkdownInstance & { [PATCHED]?: boolean };
40
+ if (proto[PATCHED]) return;
41
+ proto[PATCHED] = true;
42
+
43
+ const original = proto.renderToken;
44
+ proto.renderToken = function prettyRenderToken(token, width, nextTokenType, styleContext): string[] {
45
+ if (token?.type !== "code") {
46
+ return original.call(this, token, width, nextTokenType, styleContext);
47
+ }
48
+
49
+ const lang = displayLang(token.lang);
50
+ const lines: string[] = [];
51
+ const gutter = this.theme.codeBlockBorder("│ ");
52
+ lines.push(border(this.theme, width, "top", lang));
53
+
54
+ if (this.theme.highlightCode) {
55
+ const highlightedLines = this.theme.highlightCode(token.text ?? "", token.lang);
56
+ for (const hlLine of highlightedLines) {
57
+ lines.push(`${gutter}${hlLine}`);
58
+ }
59
+ } else {
60
+ const codeLines = (token.text ?? "").split("\n");
61
+ for (const codeLine of codeLines) {
62
+ lines.push(`${gutter}${this.theme.codeBlock(codeLine)}`);
63
+ }
64
+ }
65
+
66
+ lines.push(border(this.theme, width, "bottom"));
67
+ if (nextTokenType && nextTokenType !== "space") {
68
+ lines.push("");
69
+ }
70
+ return lines;
71
+ };
72
+ }
73
+
74
+ export default function (_pi: ExtensionAPI) {
75
+ patchMarkdownRenderer();
76
+ }
@@ -0,0 +1,93 @@
1
+ import { Editor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ const PATCHED = Symbol.for("gon.pi.pretty-input-box.patched");
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+
7
+ function rgb(hex: string, text: string): string {
8
+ const clean = hex.replace(/^#/, "");
9
+ const r = Number.parseInt(clean.slice(0, 2), 16);
10
+ const g = Number.parseInt(clean.slice(2, 4), 16);
11
+ const b = Number.parseInt(clean.slice(4, 6), 16);
12
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
13
+ }
14
+
15
+ const c = {
16
+ border: (s: string) => rgb("#ff6b5f", s),
17
+ borderDim: (s: string) => rgb("#8b514a", s),
18
+ cyan: (s: string) => rgb("#00d7ff", s),
19
+ green: (s: string) => rgb("#00ff8a", s),
20
+ gold: (s: string) => rgb("#f6b85a", s),
21
+ muted: (s: string) => rgb("#7a6d62", s),
22
+ };
23
+
24
+ function stripAnsi(text: string): string {
25
+ return text.replace(ANSI_RE, "");
26
+ }
27
+
28
+ function padAnsi(text: string, width: number): string {
29
+ const clipped = truncateToWidth(text, width, "");
30
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
31
+ }
32
+
33
+ function line(width: number, side: "top" | "bottom", original = ""): string {
34
+ if (width < 20) return c.border("─".repeat(width));
35
+
36
+ const isTop = side === "top";
37
+ const leftCorner = isTop ? "╭" : "╰";
38
+ const rightCorner = isTop ? "╮" : "╯";
39
+ const strippedOriginal = stripAnsi(original);
40
+ const scrollMatch = strippedOriginal.match(/[↑↓] \d+ more/);
41
+
42
+ const leftLabel = isTop
43
+ ? `${c.border(leftCorner + "─")} ${c.cyan("π")} ${c.muted("•")} ${c.green("ZERO")} ${c.muted("•")} ${c.gold("prompt")} `
44
+ : `${c.border(leftCorner + "─")} ${c.muted("ctrl+j newline")} `;
45
+ const rightLabel = isTop
46
+ ? ` ${scrollMatch ? c.gold(scrollMatch[0]) : c.muted("Enter ↵")} ${c.border("─" + rightCorner)}`
47
+ : ` ${c.muted("esc stop")} ${c.border("─" + rightCorner)}`;
48
+
49
+ const fill = Math.max(1, width - visibleWidth(leftLabel) - visibleWidth(rightLabel));
50
+ return leftLabel + c.borderDim("─".repeat(fill)) + rightLabel;
51
+ }
52
+
53
+ function isHorizontalEditorLine(text: string): boolean {
54
+ const stripped = stripAnsi(text).trim();
55
+ return /^─+$/.test(stripped) || /^─── [↑↓] \d+ more/.test(stripped);
56
+ }
57
+
58
+ function patchEditor(): void {
59
+ const proto = Editor.prototype as unknown as {
60
+ render: (width: number) => string[];
61
+ [PATCHED]?: boolean;
62
+ };
63
+ if (proto[PATCHED]) return;
64
+ proto[PATCHED] = true;
65
+
66
+ const originalRender = proto.render;
67
+ proto.render = function prettyEditorRender(width: number): string[] {
68
+ if (width < 24) return originalRender.call(this, width);
69
+
70
+ const innerWidth = Math.max(1, width - 2);
71
+ const raw = originalRender.call(this, innerWidth);
72
+ if (raw.length < 3) return originalRender.call(this, width);
73
+
74
+ const bottomIndex = raw.findIndex((rawLine, index) => index > 0 && isHorizontalEditorLine(rawLine));
75
+ const resolvedBottomIndex = bottomIndex === -1 ? raw.length - 1 : bottomIndex;
76
+ const result: string[] = [];
77
+
78
+ result.push(line(width, "top", raw[0]));
79
+ for (let i = 1; i < resolvedBottomIndex; i++) {
80
+ result.push(`${c.border("│")} ${padAnsi(raw[i], innerWidth - 2)} ${c.border("│")}`);
81
+ }
82
+ result.push(line(width, "bottom", raw[resolvedBottomIndex]));
83
+ for (let i = resolvedBottomIndex + 1; i < raw.length; i++) {
84
+ result.push(`${c.border("│")} ${padAnsi(raw[i], innerWidth - 2)} ${c.border("│")}`);
85
+ }
86
+
87
+ return result;
88
+ };
89
+ }
90
+
91
+ export default function (_pi: ExtensionAPI) {
92
+ patchEditor();
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (clarify → explore → plan → analyze → build → veredicto) with automatic clarify/analyze gates, per-phase model autotune, and token-efficient batched builds. Adds capability to pi without modifying pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -25,7 +25,9 @@
25
25
  "image": "https://raw.githubusercontent.com/gonzalonicolasr/zero-pi/main/assets/preview.png",
26
26
  "extensions": [
27
27
  "./extensions/zero-banner.ts",
28
- "./extensions/zero-statusline.ts",
28
+ "./extensions/zero-hud.ts",
29
+ "./extensions/zero-pretty-code-fences.ts",
30
+ "./extensions/zero-pretty-input-box.ts",
29
31
  "./extensions/working-phrases.ts",
30
32
  "./extensions/win-tree-kill.ts",
31
33
  "./extensions/sdd-agents.ts",
@@ -54,6 +56,9 @@
54
56
  "themes",
55
57
  "assets/preview.png",
56
58
  "extensions/zero-banner.ts",
59
+ "extensions/zero-hud.ts",
60
+ "extensions/zero-pretty-code-fences.ts",
61
+ "extensions/zero-pretty-input-box.ts",
57
62
  "extensions/zero-statusline.ts",
58
63
  "extensions/working-phrases.ts",
59
64
  "extensions/win-tree-kill.ts",