@gonrocca/zero-pi 0.1.28 → 0.1.30

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
@@ -59,11 +59,12 @@ into `/forge` for you.
59
59
 
60
60
  | Feature | What it does |
61
61
  | ------- | ------------ |
62
- | **`/zero-models`** | Pick the model + provider for each SDD phase — interactive or direct. |
62
+ | **`/zero-models`** | Pick the model + provider for each SDD phase — a boxed-window picker, or set one directly. |
63
63
  | **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
64
64
  | **`/zero-sync`** | Folds each run's spec delta into a canonical, project-wide spec store. |
65
65
  | **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
66
66
  | **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
67
+ | **Startup banner** | The violet ANSI-Shadow `ZERO` wordmark, drawn once at pi startup — `ZERO_HEADER=off` to disable. |
67
68
  | **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
68
69
  | **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
69
70
  | **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
@@ -0,0 +1,151 @@
1
+ // zero-pi — static ZERO SDD startup banner.
2
+ //
3
+ // Renders the ZERO wordmark ONCE, at extension load, as an "ANSI Shadow" 3D
4
+ // block in ceroclawd.com violet — deep-violet faces, a lit top edge, dark
5
+ // shadow strokes and a cast shadow for depth.
6
+ //
7
+ // It writes a single block to stdout before pi's UI takes over. There is
8
+ // deliberately NO setHeader and NO animation timer: an animated header that
9
+ // re-renders on a timer spammed the terminal on pi 0.75.x and could crash the
10
+ // session. A one-time stdout write cannot re-render, so it cannot spam.
11
+ //
12
+ // Disable with the ZERO_HEADER=off environment variable.
13
+
14
+ type RGB = [number, number, number];
15
+
16
+ const WORD = "ZERO";
17
+ const ROWS = 6;
18
+
19
+ // Cast-shadow offset — light from the top-left, shadow falls bottom-right.
20
+ const SHADOW_DX = 2;
21
+ const SHADOW_DY = 1;
22
+
23
+ // ANSI Shadow glyphs — six rows each, glued edge to edge like figlet output.
24
+ const FONT: Record<string, string[]> = {
25
+ Z: ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"],
26
+ E: ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
27
+ R: ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
28
+ O: [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
29
+ " ": [" ", " ", " ", " ", " ", " "],
30
+ };
31
+
32
+ // ceroclawd.com palette — violet, glow and darkness.
33
+ const VIOLET_DEEP: RGB = [124, 58, 237];
34
+ const LAVENDER: RGB = [205, 188, 255];
35
+ const PEAK: RGB = [248, 244, 255];
36
+ const SHADOW: RGB = [38, 24, 66];
37
+ const INK: RGB = [20, 13, 34];
38
+ const VIOLET: RGB = [167, 139, 250];
39
+ const MUTED: RGB = [120, 110, 150];
40
+
41
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
42
+
43
+ function fg([r, g, b]: RGB, text: string): string {
44
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
45
+ }
46
+
47
+ function lerp(a: number, b: number, t: number): number {
48
+ return a + (b - a) * t;
49
+ }
50
+
51
+ function mix(a: RGB, b: RGB, t: number): RGB {
52
+ const k = Math.max(0, Math.min(1, t));
53
+ return [
54
+ Math.round(lerp(a[0], b[0], k)),
55
+ Math.round(lerp(a[1], b[1], k)),
56
+ Math.round(lerp(a[2], b[2], k)),
57
+ ];
58
+ }
59
+
60
+ /** Printable width of a string, ignoring ANSI colour escapes. */
61
+ export function visibleWidth(text: string): number {
62
+ return text.replace(ANSI_RE, "").length;
63
+ }
64
+
65
+ function center(text: string, width: number): string {
66
+ const pad = Math.max(0, Math.floor((width - visibleWidth(text)) / 2));
67
+ return " ".repeat(pad) + text;
68
+ }
69
+
70
+ function matrixFor(text: string): { rows: string[]; width: number } {
71
+ const rows = Array.from({ length: ROWS }, () => "");
72
+ for (const raw of Array.from(text)) {
73
+ const glyph = FONT[raw.toUpperCase()] ?? FONT[" "];
74
+ for (let row = 0; row < ROWS; row++) rows[row] += glyph[row];
75
+ }
76
+ return { rows, width: rows[0]?.length ?? 0 };
77
+ }
78
+
79
+ /** Front-face colour: violet vertical gradient with a lit top edge. */
80
+ function faceColor(row: number, topEdge: boolean): RGB {
81
+ const vt = Math.pow(row / (ROWS - 1), 0.85);
82
+ const base = mix(LAVENDER, VIOLET_DEEP, vt);
83
+ return topEdge ? mix(base, PEAK, 0.5) : base;
84
+ }
85
+
86
+ /** Extrusion side: a darker, shaded version of the face it belongs to. */
87
+ function sideColor(row: number): RGB {
88
+ const vt = Math.pow(row / (ROWS - 1), 0.85);
89
+ return mix(mix(LAVENDER, VIOLET_DEEP, vt), SHADOW, 0.66);
90
+ }
91
+
92
+ function renderLogo(width: number): string[] {
93
+ const matrix = matrixFor(WORD);
94
+ const W = matrix.width;
95
+ const cellAt = (r: number, c: number): string =>
96
+ r >= 0 && r < ROWS && c >= 0 && c < W ? matrix.rows[r][c] ?? " " : " ";
97
+ const isFill = (r: number, c: number): boolean => cellAt(r, c) !== " ";
98
+
99
+ const lines: string[] = [];
100
+ for (let gr = 0; gr < ROWS + SHADOW_DY; gr++) {
101
+ let out = "";
102
+ for (let gc = 0; gc < W + SHADOW_DX; gc++) {
103
+ const ch = cellAt(gr, gc);
104
+ if (ch !== " ") {
105
+ out += ch === "█" ? fg(faceColor(gr, !isFill(gr - 1, gc)), ch) : fg(sideColor(gr), ch);
106
+ } else if ((gr >= ROWS || gc >= W) && isFill(gr - SHADOW_DY, gc - SHADOW_DX)) {
107
+ out += fg(INK, "█");
108
+ } else {
109
+ out += " ";
110
+ }
111
+ }
112
+ lines.push(center(out, width));
113
+ }
114
+ return lines;
115
+ }
116
+
117
+ /** A thin violet rule, brightest in the middle. */
118
+ function ornament(width: number): string {
119
+ const length = Math.min(46, Math.max(22, Math.floor(width * 0.5)));
120
+ let line = "";
121
+ for (let i = 0; i < length; i++) {
122
+ const t = i / Math.max(1, length - 1);
123
+ line += fg(mix(VIOLET_DEEP, VIOLET, Math.sin(t * Math.PI)), "─");
124
+ }
125
+ return center(line, width);
126
+ }
127
+
128
+ /** The full banner block, centered to the given width. */
129
+ export function bannerBlock(width: number): string[] {
130
+ if (width < 64) {
131
+ return [center(fg(VIOLET, "ZERO SDD"), width), center(fg(MUTED, "pi.dev · spec-driven work"), width)];
132
+ }
133
+ const tag = fg(VIOLET, "ZERO SDD") + fg(MUTED, " explore → plan → build → veredicto");
134
+ return [ornament(width), "", ...renderLogo(width), "", center(tag, width), ornament(width)];
135
+ }
136
+
137
+ /**
138
+ * The pi extension entry point. Renders the banner exactly once, synchronously,
139
+ * at load time. Any failure is swallowed — a banner must never break a session.
140
+ */
141
+ export default function register(_pi?: unknown): void {
142
+ try {
143
+ if ((process.env.ZERO_HEADER ?? "").trim().toLowerCase() === "off") return;
144
+ const stream = process.stdout;
145
+ if (!stream || !stream.isTTY || process.env.NO_COLOR) return;
146
+ const width = stream.columns && stream.columns > 0 ? stream.columns : 80;
147
+ stream.write("\n" + bannerBlock(width).join("\n") + "\n\n");
148
+ } catch {
149
+ // A banner failure must never break a pi session.
150
+ }
151
+ }
@@ -19,8 +19,28 @@ import { readFileSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
21
 
22
+ // `@earendil-works/pi-tui` is provided by the pi runtime as an ambient bare
23
+ // specifier — it is NOT a `package.json` dependency. It is loaded lazily, via
24
+ // a dynamic `import()` inside the no-arg handler branch, NOT a top-level value
25
+ // import: a static `import { Box } from "@earendil-works/pi-tui"` would be
26
+ // evaluated whenever any consumer imports this module — including
27
+ // `zero-models.test.ts`, which imports the deterministic helpers — and would
28
+ // crash `node --test` with `ERR_MODULE_NOT_FOUND`. `import type` below is
29
+ // fully erased by `--experimental-strip-types`, so it never triggers
30
+ // resolution. The runtime classes are reached only from inside pi.
31
+ import type { Component } from "@earendil-works/pi-tui";
32
+
22
33
  import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
23
34
  import type { AutotunePending } from "./autotune-extension.ts";
35
+ import {
36
+ back,
37
+ createPickerState,
38
+ enter,
39
+ navigate,
40
+ submitText,
41
+ type EnterResult,
42
+ type PickerState,
43
+ } from "./zero-models-picker.ts";
24
44
 
25
45
  /** The SDD phases, in pipeline order. */
26
46
  export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
@@ -181,11 +201,41 @@ export function groupByProvider(models: readonly PiModel[]): Map<string, string[
181
201
  return map;
182
202
  }
183
203
 
204
+ /** The pi-TUI host handed to a `ctx.ui.custom()` factory — only the one
205
+ * method the picker shell uses. */
206
+ interface PiTui {
207
+ requestRender(): void;
208
+ }
209
+ /** A pi-TUI component that groups children vertically and renders a frame. */
210
+ interface PiBox extends Component {
211
+ addChild(child: Component): void;
212
+ }
213
+ /** The slice of `@earendil-works/pi-tui` the boxed picker constructs at
214
+ * runtime — `Box`/`Text`/`Spacer` constructors, captured from the lazy
215
+ * dynamic `import()` so this module never statically resolves the specifier. */
216
+ interface PiTuiModule {
217
+ Box: new (paddingX?: number, paddingY?: number) => PiBox;
218
+ Text: new (content: string, paddingX?: number, paddingY?: number) => Component;
219
+ Spacer: new (lines: number) => Component;
220
+ }
221
+ /** A pi theme — only the foreground-color helper the picker uses. */
222
+ interface PiTheme {
223
+ fg(color: string, text: string): string;
224
+ }
225
+ /** The factory `ctx.ui.custom()` invokes to build the boxed component. */
226
+ type PiCustomFactory<T> = (
227
+ tui: PiTui,
228
+ theme: PiTheme,
229
+ keybindings: unknown,
230
+ done: (result: T) => void,
231
+ ) => Component;
232
+
184
233
  /** The slice of pi's extension API this command uses. */
185
234
  interface PiUI {
186
235
  select(prompt: string, options: string[]): Promise<string | undefined>;
187
236
  input(prompt: string, placeholder?: string): Promise<string | undefined>;
188
237
  notify(message: string, type?: "info" | "warning" | "error"): void;
238
+ custom<T>(factory: PiCustomFactory<T>): Promise<T>;
189
239
  }
190
240
  /** pi's model registry — the source of every provider's model list. */
191
241
  interface PiModelRegistry {
@@ -206,10 +256,6 @@ interface PiExtensionAPI {
206
256
  ): void;
207
257
  }
208
258
 
209
- const SAVE_AND_EXIT = "— guardar y salir —";
210
- const CUSTOM_MODEL = "— otro modelo (escribir) —";
211
- const CUSTOM_PROVIDER = "— otro provider (escribir) —";
212
-
213
259
  /**
214
260
  * Group models by provider for the picker.
215
261
  *
@@ -249,6 +295,192 @@ function resolveProvider(
249
295
  return undefined;
250
296
  }
251
297
 
298
+ /** The boxed-panel title row. */
299
+ const PICKER_TITLE = "zero · modelos SDD";
300
+ /** The dim help line shown at the foot of the boxed panel. */
301
+ const PICKER_HELP = "↑↓ navegar · enter elegir · esc volver";
302
+
303
+ /** ANSI arrow-key escape sequences. */
304
+ const KEY_UP = "\x1b[A";
305
+ const KEY_DOWN = "\x1b[B";
306
+ const KEY_ESC = "\x1b";
307
+ /** Enter — CR or LF, depending on the terminal. */
308
+ const KEY_ENTER = new Set(["\r", "\n", "\r\n"]);
309
+ /** Backspace — DEL or BS. */
310
+ const KEY_BACKSPACE = new Set(["\x7f", "\x08"]);
311
+
312
+ /**
313
+ * Clamp a rendered line to `width` columns so it never overflows the box
314
+ * frame (tui.md "Line Width" is a hard rule). A plain slice is enough here —
315
+ * the picker emits no ANSI inside its row strings except whole-line theming,
316
+ * and `theme.fg` is applied *after* truncation by the caller where it matters.
317
+ */
318
+ function clampLine(line: string, width: number): string {
319
+ if (width <= 0) return "";
320
+ return line.length > width ? line.slice(0, width) : line;
321
+ }
322
+
323
+ /**
324
+ * Build the inline pi-TUI component for the no-arg picker.
325
+ *
326
+ * The component owns no navigation logic — it holds one mutable `PickerState`,
327
+ * forwards keystrokes to the pure transition functions of
328
+ * `zero-models-picker.ts`, and re-renders. A `save`/`quit` `EnterResult` ends
329
+ * the component via `done(result)`.
330
+ *
331
+ * Custom typed provider/model values use an inline character buffer handled
332
+ * directly in `handleInput` (the design's documented fallback for the embedded
333
+ * `Input`): pi-tui's `Input` is not shipped with type definitions in this pi
334
+ * build and an embedded-input + `Focusable` wiring is the design's named
335
+ * highest-risk path — the inline buffer is self-contained, needs no ambient
336
+ * class beyond `Box`/`Text`/`Spacer`, and keeps the pure module's `submitText`
337
+ * contract untouched.
338
+ *
339
+ * The whole `handleInput` body is wrapped in a `try/catch` that closes the
340
+ * component cleanly via `done({ type: "quit" })` on any error (Req 9).
341
+ */
342
+ function createPickerComponent(
343
+ initial: PickerState,
344
+ pitui: PiTuiModule,
345
+ theme: PiTheme,
346
+ tui: PiTui,
347
+ done: (result: EnterResult) => void,
348
+ ): Component {
349
+ const { Box, Text, Spacer } = pitui;
350
+ // The single mutable state reference; reassigned from the pure functions.
351
+ let state = initial;
352
+ // Inline text buffer — non-null only while `state.textPrompt` is open.
353
+ let buffer: string | null = null;
354
+
355
+ /** Render the boxed panel for the current state. */
356
+ function render(width: number): string[] {
357
+ const box = new Box(1, 1);
358
+ const inner = Math.max(1, width - 4); // account for the box's paddingX
359
+
360
+ box.addChild(new Text(clampLine(PICKER_TITLE, inner), 0, 0));
361
+ box.addChild(new Spacer(1));
362
+
363
+ if (state.textPrompt) {
364
+ // Inline text-entry mode: show the prompt label and the typed buffer.
365
+ box.addChild(
366
+ new Text(clampLine(state.textPrompt.label, inner), 0, 0),
367
+ );
368
+ const typed = `> ${buffer ?? ""}`;
369
+ box.addChild(
370
+ new Text(theme.fg("accent", clampLine(typed, inner)), 0, 0),
371
+ );
372
+ box.addChild(new Spacer(1));
373
+ box.addChild(
374
+ new Text(
375
+ theme.fg("dim", clampLine("enter confirmar · esc volver", inner)),
376
+ 0,
377
+ 0,
378
+ ),
379
+ );
380
+ return box.render(width);
381
+ }
382
+
383
+ state.entries.forEach((entry, index) => {
384
+ if (index === state.cursor) {
385
+ const row = clampLine(`> ${entry.label}`, inner);
386
+ box.addChild(new Text(theme.fg("accent", row), 0, 0));
387
+ } else {
388
+ const row = clampLine(` ${entry.label}`, inner);
389
+ box.addChild(new Text(theme.fg("dim", row), 0, 0));
390
+ }
391
+ });
392
+
393
+ box.addChild(new Spacer(1));
394
+ box.addChild(
395
+ new Text(theme.fg("dim", clampLine(PICKER_HELP, inner)), 0, 0),
396
+ );
397
+ return box.render(width);
398
+ }
399
+
400
+ /** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
401
+ function applyResult(result: EnterResult): void {
402
+ if (result.type === "state") {
403
+ state = result.state;
404
+ tui.requestRender();
405
+ } else {
406
+ done(result);
407
+ }
408
+ }
409
+
410
+ /** Route a keystroke while the inline text buffer is open. */
411
+ function handleTextInput(data: string): void {
412
+ if (data === KEY_ESC) {
413
+ // Esc abandons the typed value and returns to the current list screen
414
+ // unchanged. `submitText` with an empty string is exactly that no-op:
415
+ // it clears `textPrompt` and rebuilds the list without committing.
416
+ state = submitText(state, "");
417
+ buffer = null;
418
+ tui.requestRender();
419
+ return;
420
+ }
421
+ if (KEY_ENTER.has(data)) {
422
+ state = submitText(state, buffer ?? "");
423
+ buffer = null;
424
+ tui.requestRender();
425
+ return;
426
+ }
427
+ if (KEY_BACKSPACE.has(data)) {
428
+ buffer = (buffer ?? "").slice(0, -1);
429
+ tui.requestRender();
430
+ return;
431
+ }
432
+ // Append printable characters only (skip control sequences).
433
+ if (data.length >= 1 && data.charCodeAt(0) >= 32 && !data.startsWith("\x1b")) {
434
+ buffer = (buffer ?? "") + data;
435
+ tui.requestRender();
436
+ }
437
+ }
438
+
439
+ /** Receive a keystroke; never throws out — a failure closes the picker. */
440
+ function handleInput(data: string): void {
441
+ try {
442
+ // Inline text-entry mode takes input until it submits or escapes.
443
+ if (state.textPrompt) {
444
+ handleTextInput(data);
445
+ return;
446
+ }
447
+
448
+ if (data === KEY_UP) {
449
+ state = navigate(state, -1);
450
+ tui.requestRender();
451
+ return;
452
+ }
453
+ if (data === KEY_DOWN) {
454
+ state = navigate(state, 1);
455
+ tui.requestRender();
456
+ return;
457
+ }
458
+ if (KEY_ENTER.has(data)) {
459
+ const result = enter(state);
460
+ // `enter` on a custom-* row opens `textPrompt`; arm the buffer.
461
+ if (result.type === "state" && result.state.textPrompt) buffer = "";
462
+ applyResult(result);
463
+ return;
464
+ }
465
+ if (data === KEY_ESC) {
466
+ applyResult(back(state));
467
+ return;
468
+ }
469
+ } catch {
470
+ // A render/transition bug must never wedge the pi session — close.
471
+ done({ type: "quit" });
472
+ }
473
+ }
474
+
475
+ return {
476
+ render,
477
+ invalidate(): void {
478
+ /* stateless render — nothing cached to clear */
479
+ },
480
+ handleInput,
481
+ };
482
+ }
483
+
252
484
  /**
253
485
  * The pi extension entry point — registers the `/zero-models` command.
254
486
  */
@@ -315,115 +547,68 @@ export default function register(pi?: PiExtensionAPI): void {
315
547
  return;
316
548
  }
317
549
 
318
- // Interactive form: pick a phase, a provider, a model until saved.
319
- let changed = false;
320
- let autotuneMode = readAutotuneMode(data);
321
- let autotuneChanged = false;
322
- let pending = readAutotunePending(data);
323
- let pendingApplied = false;
324
- for (;;) {
325
- const applyEntry =
326
- pending.length > 0
327
- ? `★ aplicar sugerencia: ${pending
328
- .map((p) => `${p.phase} → ${p.to}`)
329
- .join(", ")}`
330
- : null;
331
- const autotuneEntry = `autotune → ${autotuneMode}`;
332
-
333
- const phasePick = await ctx.ui.select("zero · modelos SDD elegí una fase", [
334
- ...(applyEntry ? [applyEntry] : []),
335
- ...PHASES.map(
336
- (p) => `${p} → ${providers[p] ? `${providers[p]}/` : ""}${models[p]}`,
337
- ),
338
- autotuneEntry,
339
- SAVE_AND_EXIT,
340
- ]);
341
- if (!phasePick || phasePick === SAVE_AND_EXIT) break;
342
-
343
- // Apply the pending autotune suggestion.
344
- if (applyEntry && phasePick === applyEntry) {
345
- for (const adj of pending) models[adj.phase] = adj.to;
346
- changed = true;
347
- pendingApplied = true;
348
- pending = [];
349
- continue;
350
- }
351
-
352
- // Change the autotune mode.
353
- if (phasePick === autotuneEntry) {
354
- const modePick = await ctx.ui.select(
355
- "Modo de autotune",
356
- AUTOTUNE_MODES.map((m) => formatAutotune(m)),
357
- );
358
- if (!modePick) continue;
359
- const picked = parseAutotuneArg(modePick.split(/\s/)[0]);
360
- if (picked && picked !== autotuneMode) {
361
- autotuneMode = picked;
362
- autotuneChanged = true;
363
- }
364
- continue;
365
- }
366
-
367
- const phase = phasePick.split(/\s/)[0];
368
- if (!isPhase(phase)) break;
369
-
370
- // Pick a provider — from the registry, or typed when unknown.
371
- let provider = providers[phase];
372
- let modelChoices = FALLBACK_MODELS;
373
- if (groups.size > 0) {
374
- const providerPick = await ctx.ui.select(`Provider para «${phase}»`, [
375
- ...[...groups.keys()].sort(),
376
- CUSTOM_PROVIDER,
377
- ]);
378
- if (!providerPick) continue;
379
- if (providerPick === CUSTOM_PROVIDER) {
380
- const typed = await ctx.ui.input(
381
- `Provider para «${phase}»`,
382
- providers[phase] || "",
383
- );
384
- if (!typed || typed.trim() === "") continue;
385
- provider = typed.trim();
386
- modelChoices = groups.get(provider) ?? [];
387
- } else {
388
- provider = providerPick;
389
- modelChoices = groups.get(providerPick) ?? [];
390
- }
391
- }
392
-
393
- // Pick a model within that provider — or type one.
394
- const label = provider ? `Modelo para «${phase}» (${provider})` : `Modelo para «${phase}»`;
395
- const modelPick = await ctx.ui.select(label, [...modelChoices, CUSTOM_MODEL]);
396
- if (!modelPick) continue;
550
+ // Interactive form: open the boxed picker, then persist on save.
551
+ const autotuneMode = readAutotuneMode(data);
552
+ const pending = readAutotunePending(data);
553
+
554
+ const initialState = createPickerState({
555
+ models,
556
+ providers,
557
+ autotuneMode,
558
+ pending,
559
+ groups,
560
+ fallbackModels: FALLBACK_MODELS,
561
+ });
562
+
563
+ // Load pi-tui lazily — the bare specifier resolves only inside pi.
564
+ // This `import()` lives in the handler (already inside the swallowing
565
+ // `try/catch`), so a resolution failure becomes an error notification
566
+ // and never escapes (Req 9).
567
+ const pitui = (await import(
568
+ "@earendil-works/pi-tui"
569
+ )) as unknown as PiTuiModule;
570
+
571
+ const result = await ctx.ui.custom<EnterResult>((tui, theme, _kb, done) =>
572
+ createPickerComponent(initialState, pitui, theme, tui, done),
573
+ );
397
574
 
398
- let model = modelPick;
399
- if (modelPick === CUSTOM_MODEL) {
400
- const typed = await ctx.ui.input(label, models[phase]);
401
- if (!typed || typed.trim() === "") continue;
402
- model = typed.trim();
403
- }
404
- models[phase] = model;
405
- providers[phase] = provider || resolveProvider(ctx.modelRegistry, model) || "";
406
- changed = true;
575
+ if (result.type !== "save") {
576
+ // Esc / quit (or a contained UI failure): write nothing, leaving
577
+ // `zero.json` byte-for-byte unchanged, and report the leave-as-is
578
+ // state the existing "sin cambios" notification text.
579
+ ctx.ui.notify(
580
+ `zero · modelos SDD (sin cambios):\n${formatPhases(models, providers)}\n` +
581
+ ` autotune ${autotuneMode}`,
582
+ "info",
583
+ );
584
+ return;
407
585
  }
408
586
 
409
- if (changed || autotuneChanged) {
587
+ // Save: pull the accumulated edits off the final picker state.
588
+ const edits = result.state.edits;
589
+ if (edits.changed || edits.autotuneChanged) {
410
590
  // Build the patch, preserving every other key via the spread. When
411
591
  // the pending suggestion was applied, clear the `autotunePending` key.
412
- const patch: Record<string, unknown> = { models, providers };
413
- if (autotuneChanged) patch.autotune = autotuneMode;
592
+ const patch: Record<string, unknown> = {
593
+ models: edits.models,
594
+ providers: edits.providers,
595
+ };
596
+ if (edits.autotuneChanged) patch.autotune = edits.autotuneMode;
414
597
 
415
598
  const merged = { ...data, ...patch };
416
- if (pendingApplied) delete merged.autotunePending;
599
+ if (edits.pendingApplied) delete merged.autotunePending;
417
600
  writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
418
601
 
419
- const summary = [`zero · modelos SDD guardados:\n${formatPhases(models, providers)}`];
420
- summary.push(` autotune ${autotuneMode}`);
421
- if (pendingApplied) summary.push("sugerencia aplicada");
602
+ const summary = [
603
+ `zero · modelos SDD guardados:\n${formatPhases(edits.models, edits.providers)}`,
604
+ ];
605
+ summary.push(` autotune ${edits.autotuneMode}`);
606
+ if (edits.pendingApplied) summary.push("sugerencia aplicada");
422
607
  ctx.ui.notify(summary.join("\n"), "info");
423
608
  } else {
424
609
  ctx.ui.notify(
425
- `zero · modelos SDD (sin cambios):\n${formatPhases(models, providers)}\n` +
426
- ` autotune ${autotuneMode}`,
610
+ `zero · modelos SDD (sin cambios):\n${formatPhases(edits.models, edits.providers)}\n` +
611
+ ` autotune ${edits.autotuneMode}`,
427
612
  "info",
428
613
  );
429
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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": [
@@ -23,6 +23,7 @@
23
23
  "./themes"
24
24
  ],
25
25
  "extensions": [
26
+ "./extensions/zero-banner.ts",
26
27
  "./extensions/working-phrases.ts",
27
28
  "./extensions/win-tree-kill.ts",
28
29
  "./extensions/sdd-agents.ts",
@@ -37,6 +38,7 @@
37
38
  "prompts",
38
39
  "skills",
39
40
  "themes",
41
+ "extensions/zero-banner.ts",
40
42
  "extensions/working-phrases.ts",
41
43
  "extensions/win-tree-kill.ts",
42
44
  "extensions/sdd-agents.ts",