@gonrocca/zero-pi 0.1.30 → 0.1.32

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,488 @@
1
+ // zero-pi — the /zero-models interactive picker, pure-state module.
2
+ //
3
+ // The no-arg path of /zero-models is a single boxed-window TUI. This file holds
4
+ // every *decision* of that picker — menu-entry construction, highlighted-index
5
+ // movement, screen transitions, and the staged-edit accumulator — as pure,
6
+ // dependency-free TypeScript so it is unit-testable with `node --test`. The
7
+ // pi-TUI render+input shell lives in `zero-models.ts` and owns no navigation
8
+ // logic; it holds one `PickerState`, forwards keystrokes here, and re-renders.
9
+ //
10
+ // This file has NO `node:fs` and NO pi imports. It takes injected data
11
+ // (registry groups, current models/providers, pending suggestions) and returns
12
+ // new state. Type-only imports are `import type` so `--experimental-strip-types`
13
+ // erases them with no runtime resolution. Mirrors the `autotune.ts` precedent.
14
+
15
+ import type { Phase, PhaseModels, PhaseProviders } from "./zero-models.ts";
16
+ import type { AutotuneMode } from "./autotune.ts";
17
+ import type { AutotunePending } from "./autotune-extension.ts";
18
+
19
+ /** The SDD phases, in pipeline order — re-stated locally so the pure module
20
+ * carries no value import. Must stay in lockstep with `PHASES` in
21
+ * `zero-models.ts`. */
22
+ const PHASES = ["explore", "plan", "build", "veredicto"] as const;
23
+
24
+ /** The three autotune modes offered on the autotune screen. */
25
+ const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Screen model
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Which sub-screen the picker is currently showing. */
32
+ export type Screen = "main" | "provider" | "model" | "autotune";
33
+
34
+ /** One selectable row in the current screen. */
35
+ export interface MenuEntry {
36
+ /** Stable kind discriminator for the transition functions. */
37
+ kind:
38
+ | "apply-pending" // ★ aplicar sugerencia (main, conditional)
39
+ | "phase" // explore/plan/build/veredicto (main)
40
+ | "autotune" // autotune → <mode> (main)
41
+ | "save" // — guardar y salir — (main)
42
+ | "provider" // a concrete provider id (provider screen)
43
+ | "custom-provider" // — otro provider (escribir) —
44
+ | "model" // a concrete model id (model screen)
45
+ | "custom-model" // — otro modelo (escribir) —
46
+ | "autotune-mode"; // auto | ask | off (autotune screen)
47
+ /** The text shown for the row (Spanish, voseo). */
48
+ label: string;
49
+ /** Payload: phase name, provider id, model id, or autotune mode. */
50
+ value: string;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Staged edits (the accumulator)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Edits staged in memory; only written to zero.json on save. */
58
+ export interface StagedEdits {
59
+ /** From `readModels` — a mutated copy, never the caller's object. */
60
+ models: PhaseModels;
61
+ /** From `readProviders` — a mutated copy, parallel to {@link models}. */
62
+ providers: PhaseProviders;
63
+ /** The autotune mode, possibly changed from disk. */
64
+ autotuneMode: AutotuneMode;
65
+ /** Any phase model/provider changed. */
66
+ changed: boolean;
67
+ /** `autotuneMode` differs from disk. */
68
+ autotuneChanged: boolean;
69
+ /** A pending suggestion was applied. */
70
+ pendingApplied: boolean;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Picker state (the single value the component holds)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** The full picker state — one mutable value the component holds for its
78
+ * lifetime; the transition functions mutate-and-return it. */
79
+ export interface PickerState {
80
+ /** The sub-screen currently shown. */
81
+ screen: Screen;
82
+ /** Highlighted row index, always within `[0, entries.length)`. */
83
+ cursor: number;
84
+ /** Menu rows for the current screen — derived, never hand-mutated. */
85
+ entries: MenuEntry[];
86
+ /** Staged, unsaved edits. */
87
+ edits: StagedEdits;
88
+ /** Pending autotune suggestions still un-applied (drives the apply entry). */
89
+ pending: AutotunePending[];
90
+ /** Provider→models registry groups, captured once at open. */
91
+ groups: Map<string, string[]>;
92
+ /** Fallback model list when the registry is empty. */
93
+ fallbackModels: readonly string[];
94
+ /** Drill-down context: the phase being edited (provider/model screens). */
95
+ drillPhase: Phase | null;
96
+ /** Drill-down context: provider chosen so far (model screen). */
97
+ drillProvider: string | null;
98
+ /** When non-null, the component shows an inline text input for this. */
99
+ textPrompt: { for: "provider" | "model"; label: string } | null;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Transition results
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** Discriminated outcome of `enter()`/`back()` — lets the component act. */
107
+ export type EnterResult =
108
+ | { type: "state"; state: PickerState } // stay open, re-render
109
+ | { type: "save"; state: PickerState } // close, persist edits
110
+ | { type: "quit" }; // close, write nothing
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Initial state
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** The escape-row label for typing a custom provider id. */
117
+ const CUSTOM_PROVIDER_LABEL = "— otro provider (escribir) —";
118
+ /** The escape-row label for typing a custom model id. */
119
+ const CUSTOM_MODEL_LABEL = "— otro modelo (escribir) —";
120
+ /** The save-and-exit row label. */
121
+ const SAVE_LABEL = "— guardar y salir —";
122
+
123
+ /** Render a phase's current `provider/model` (provider omitted when empty). */
124
+ function phaseLabel(
125
+ phase: Phase,
126
+ models: PhaseModels,
127
+ providers: PhaseProviders,
128
+ ): string {
129
+ const provider = providers[phase];
130
+ const model = provider ? `${provider}/${models[phase]}` : models[phase];
131
+ return `${phase} → ${model}`;
132
+ }
133
+
134
+ /** Render the `★ aplicar sugerencia` label from the pending adjustments. */
135
+ function applyLabel(pending: readonly AutotunePending[]): string {
136
+ return `★ aplicar sugerencia: ${pending
137
+ .map((p) => `${p.phase} → ${p.to}`)
138
+ .join(", ")}`;
139
+ }
140
+
141
+ /**
142
+ * Build the initial main-screen picker state from disk-read inputs.
143
+ *
144
+ * The `models`/`providers` maps are copied so staged edits never mutate the
145
+ * caller's objects. `edits.autotuneMode` snapshots the disk value; the three
146
+ * change flags start `false`. The result is on `screen: "main"` with `cursor`
147
+ * at `0` and `entries` already built via `rebuildEntries`.
148
+ */
149
+ export function createPickerState(input: {
150
+ models: PhaseModels;
151
+ providers: PhaseProviders;
152
+ autotuneMode: AutotuneMode;
153
+ pending: AutotunePending[];
154
+ groups: Map<string, string[]>;
155
+ fallbackModels: readonly string[];
156
+ }): PickerState {
157
+ const state: PickerState = {
158
+ screen: "main",
159
+ cursor: 0,
160
+ entries: [],
161
+ edits: {
162
+ models: { ...input.models },
163
+ providers: { ...input.providers },
164
+ autotuneMode: input.autotuneMode,
165
+ changed: false,
166
+ autotuneChanged: false,
167
+ pendingApplied: false,
168
+ },
169
+ pending: [...input.pending],
170
+ groups: input.groups,
171
+ fallbackModels: input.fallbackModels,
172
+ drillPhase: null,
173
+ drillProvider: null,
174
+ textPrompt: null,
175
+ };
176
+ return rebuildEntries(state);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // rebuildEntries
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Build the rows for the `main` screen. */
184
+ function mainEntries(state: PickerState): MenuEntry[] {
185
+ const entries: MenuEntry[] = [];
186
+
187
+ // Conditional apply-pending row, prepended when a suggestion is staged.
188
+ if (state.pending.length > 0) {
189
+ entries.push({
190
+ kind: "apply-pending",
191
+ label: applyLabel(state.pending),
192
+ value: "apply",
193
+ });
194
+ }
195
+
196
+ // One row per SDD phase, in pipeline order.
197
+ for (const phase of PHASES) {
198
+ entries.push({
199
+ kind: "phase",
200
+ label: phaseLabel(phase, state.edits.models, state.edits.providers),
201
+ value: phase,
202
+ });
203
+ }
204
+
205
+ // The autotune row, then the save row.
206
+ entries.push({
207
+ kind: "autotune",
208
+ label: `autotune → ${state.edits.autotuneMode}`,
209
+ value: "autotune",
210
+ });
211
+ entries.push({ kind: "save", label: SAVE_LABEL, value: "save" });
212
+
213
+ return entries;
214
+ }
215
+
216
+ /** Build the rows for the `provider` screen — sorted provider ids + escape. */
217
+ function providerEntries(state: PickerState): MenuEntry[] {
218
+ const entries: MenuEntry[] = [];
219
+ for (const provider of [...state.groups.keys()].sort()) {
220
+ entries.push({ kind: "provider", label: provider, value: provider });
221
+ }
222
+ entries.push({
223
+ kind: "custom-provider",
224
+ label: CUSTOM_PROVIDER_LABEL,
225
+ value: "",
226
+ });
227
+ return entries;
228
+ }
229
+
230
+ /** Build the rows for the `model` screen — models for the drilled provider
231
+ * (or `fallbackModels` when the registry is empty) + custom-model escape. */
232
+ function modelEntries(state: PickerState): MenuEntry[] {
233
+ const models =
234
+ state.drillProvider !== null
235
+ ? (state.groups.get(state.drillProvider) ?? state.fallbackModels)
236
+ : state.fallbackModels;
237
+ const entries: MenuEntry[] = [];
238
+ for (const model of models) {
239
+ entries.push({ kind: "model", label: model, value: model });
240
+ }
241
+ entries.push({ kind: "custom-model", label: CUSTOM_MODEL_LABEL, value: "" });
242
+ return entries;
243
+ }
244
+
245
+ /** Build the rows for the `autotune` screen — the three modes. */
246
+ function autotuneEntries(): MenuEntry[] {
247
+ return AUTOTUNE_MODES.map((mode) => ({
248
+ kind: "autotune-mode" as const,
249
+ label: mode,
250
+ value: mode,
251
+ }));
252
+ }
253
+
254
+ /**
255
+ * Recompute `state.entries` for the current screen + drill context.
256
+ *
257
+ * Idempotent: calling it twice in a row produces the same rows. After
258
+ * rebuilding, `cursor` is clamped into `[0, entries.length)` so a transition
259
+ * that shrinks the row list never leaves the highlight out of bounds. Mutates
260
+ * and returns the same `PickerState`.
261
+ */
262
+ export function rebuildEntries(state: PickerState): PickerState {
263
+ switch (state.screen) {
264
+ case "main":
265
+ state.entries = mainEntries(state);
266
+ break;
267
+ case "provider":
268
+ state.entries = providerEntries(state);
269
+ break;
270
+ case "model":
271
+ state.entries = modelEntries(state);
272
+ break;
273
+ case "autotune":
274
+ state.entries = autotuneEntries();
275
+ break;
276
+ }
277
+
278
+ // Clamp the cursor — a shrunk list must never leave it past the last row.
279
+ const n = state.entries.length;
280
+ if (n === 0) {
281
+ state.cursor = 0;
282
+ } else if (state.cursor < 0) {
283
+ state.cursor = 0;
284
+ } else if (state.cursor >= n) {
285
+ state.cursor = n - 1;
286
+ }
287
+
288
+ return state;
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // navigate
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Move the highlighted-row index by `dir` (`-1` up, `+1` down), wrapping
297
+ * cyclically at both ends: Up at index 0 lands on the last row, Down at the
298
+ * last row lands on row 0 (resolves Open Question 1). A single-entry list is
299
+ * a fixed point — any move stays on row 0. An empty list keeps `cursor` at 0.
300
+ *
301
+ * Mutates and returns the same `PickerState`; the filesystem is never touched.
302
+ */
303
+ export function navigate(state: PickerState, dir: -1 | 1): PickerState {
304
+ const n = state.entries.length;
305
+ if (n === 0) {
306
+ state.cursor = 0;
307
+ } else {
308
+ state.cursor = (state.cursor + dir + n) % n;
309
+ }
310
+ return state;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // enter / back — Enter/Esc dispatch
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Dispatch Enter on the highlighted entry. Returns an {@link EnterResult}:
319
+ *
320
+ * - `phase` → drills into provider selection (`screen: "provider"`), or
321
+ * straight to `model` with `fallbackModels` when the registry is empty.
322
+ * - `provider` → records `drillProvider`, drills into model selection.
323
+ * - `custom-provider` / `custom-model` → opens `textPrompt`, stays on screen.
324
+ * - `model` → commits the model/provider into `edits` for `drillPhase`,
325
+ * marks `edits.changed`, returns to `main`.
326
+ * - `autotune` → drills into the autotune-mode screen.
327
+ * - `autotune-mode` → records the mode, marks `autotuneChanged` when it
328
+ * differs from the staged value, returns to `main`.
329
+ * - `apply-pending` → applies every pending `to` into `edits.models`,
330
+ * marks `changed` + `pendingApplied`, clears `state.pending`.
331
+ * - `save` → `{ type: "save" }`.
332
+ *
333
+ * Every staying-open path rebuilds `entries`. Filesystem is never touched.
334
+ */
335
+ export function enter(state: PickerState): EnterResult {
336
+ const entry = state.entries[state.cursor];
337
+ if (!entry) return { type: "state", state };
338
+
339
+ switch (entry.kind) {
340
+ case "phase": {
341
+ state.drillPhase = entry.value as Phase;
342
+ state.drillProvider = null;
343
+ // Skip the provider screen entirely when the registry is empty —
344
+ // jump straight to model selection over `fallbackModels`.
345
+ state.screen = state.groups.size === 0 ? "model" : "provider";
346
+ state.cursor = 0;
347
+ return { type: "state", state: rebuildEntries(state) };
348
+ }
349
+
350
+ case "provider": {
351
+ state.drillProvider = entry.value;
352
+ state.screen = "model";
353
+ state.cursor = 0;
354
+ return { type: "state", state: rebuildEntries(state) };
355
+ }
356
+
357
+ case "custom-provider": {
358
+ state.textPrompt = { for: "provider", label: entry.label };
359
+ return { type: "state", state: rebuildEntries(state) };
360
+ }
361
+
362
+ case "custom-model": {
363
+ state.textPrompt = { for: "model", label: entry.label };
364
+ return { type: "state", state: rebuildEntries(state) };
365
+ }
366
+
367
+ case "model": {
368
+ if (state.drillPhase !== null) {
369
+ const phase = state.drillPhase;
370
+ state.edits.models[phase] = entry.value;
371
+ // `drillProvider` is the provider picked/typed on the provider screen,
372
+ // or `null` when the empty-registry skip jumped straight here — in
373
+ // which case there is no provider, so `""`. (The design's middle
374
+ // `resolveProvider` term is unreachable in the pure module: a null
375
+ // `drillProvider` only ever co-occurs with an empty `groups`.)
376
+ state.edits.providers[phase] = state.drillProvider ?? "";
377
+ state.edits.changed = true;
378
+ }
379
+ state.screen = "main";
380
+ state.drillPhase = null;
381
+ state.drillProvider = null;
382
+ state.cursor = 0;
383
+ return { type: "state", state: rebuildEntries(state) };
384
+ }
385
+
386
+ case "autotune": {
387
+ state.screen = "autotune";
388
+ state.cursor = 0;
389
+ return { type: "state", state: rebuildEntries(state) };
390
+ }
391
+
392
+ case "autotune-mode": {
393
+ const mode = entry.value as AutotuneMode;
394
+ if (mode !== state.edits.autotuneMode) {
395
+ state.edits.autotuneMode = mode;
396
+ state.edits.autotuneChanged = true;
397
+ }
398
+ state.screen = "main";
399
+ state.cursor = 0;
400
+ return { type: "state", state: rebuildEntries(state) };
401
+ }
402
+
403
+ case "apply-pending": {
404
+ for (const adj of state.pending) {
405
+ state.edits.models[adj.phase] = adj.to;
406
+ }
407
+ state.edits.changed = true;
408
+ state.edits.pendingApplied = true;
409
+ state.pending = [];
410
+ return { type: "state", state: rebuildEntries(state) };
411
+ }
412
+
413
+ case "save":
414
+ return { type: "save", state };
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Dispatch Esc. From a drill screen (`provider`/`model`/`autotune`) it
420
+ * returns to `main` with the drill context cleared and **no** edit
421
+ * committed. From `main` it returns `{ type: "quit" }` so the handler
422
+ * closes the picker without writing `zero.json`.
423
+ */
424
+ export function back(state: PickerState): EnterResult {
425
+ if (state.screen === "main") {
426
+ return { type: "quit" };
427
+ }
428
+ state.screen = "main";
429
+ state.drillPhase = null;
430
+ state.drillProvider = null;
431
+ state.textPrompt = null;
432
+ state.cursor = 0;
433
+ return { type: "state", state: rebuildEntries(state) };
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // submitText — commit a typed custom value
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Commit a typed custom value from the inline text input opened by a
442
+ * `custom-provider` / `custom-model` escape row.
443
+ *
444
+ * - `textPrompt.for === "provider"` → records the trimmed `typed` string as
445
+ * `drillProvider` and advances to the `model` screen so a model can be
446
+ * picked under the just-typed provider.
447
+ * - `textPrompt.for === "model"` → commits the trimmed `typed` string as the
448
+ * drilled phase's model into `edits.models`, records the provider
449
+ * (`drillProvider ?? ""`, matching the model-commit semantics of `enter`)
450
+ * into `edits.providers`, sets `edits.changed`, and returns to `main`.
451
+ * - empty / whitespace `typed` → a no-op: nothing is committed, the screen is
452
+ * left unchanged, only `textPrompt` is cleared so the list shows again.
453
+ *
454
+ * `textPrompt` is cleared on every path and `rebuildEntries` is always called.
455
+ * Mutates and returns the same `PickerState`; the filesystem is never touched.
456
+ */
457
+ export function submitText(state: PickerState, typed: string): PickerState {
458
+ const prompt = state.textPrompt;
459
+ const value = typed.trim();
460
+
461
+ // Empty / whitespace input, or no prompt open: a no-op. Drop the prompt and
462
+ // re-show the current list screen unchanged — no edit is committed.
463
+ if (prompt === null || value === "") {
464
+ state.textPrompt = null;
465
+ return rebuildEntries(state);
466
+ }
467
+
468
+ if (prompt.for === "provider") {
469
+ state.drillProvider = value;
470
+ state.screen = "model";
471
+ state.cursor = 0;
472
+ } else {
473
+ // prompt.for === "model": commit the typed model into the drilled phase.
474
+ if (state.drillPhase !== null) {
475
+ const phase = state.drillPhase;
476
+ state.edits.models[phase] = value;
477
+ state.edits.providers[phase] = state.drillProvider ?? "";
478
+ state.edits.changed = true;
479
+ }
480
+ state.screen = "main";
481
+ state.drillPhase = null;
482
+ state.drillProvider = null;
483
+ state.cursor = 0;
484
+ }
485
+
486
+ state.textPrompt = null;
487
+ return rebuildEntries(state);
488
+ }
@@ -19,16 +19,25 @@ 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";
22
+ // This module has NO `@earendil-works/pi-tui` import at all not a value
23
+ // import, not an `import type`. The no-arg picker is a `ctx.ui.custom()`
24
+ // component, and `ctx.ui.custom`'s factory only needs an object that exposes
25
+ // `render(width): string[]` (plus optional `handleInput`/`invalidate`) pi's
26
+ // own docs example returns exactly such a plain object literal. So the picker
27
+ // builds its framed layout by hand with Unicode box-drawing characters and
28
+ // satisfies the contract via the small local `Component` interface below. Not
29
+ // importing the ambient `@earendil-works/pi-tui` specifier at all means even a
30
+ // stray static reference can never crash `node --test` with
31
+ // `ERR_MODULE_NOT_FOUND` when `zero-models.test.ts` imports the deterministic
32
+ // helpers.
33
+
34
+ /** The shape `ctx.ui.custom()`'s factory must return — a renderable component.
35
+ * Declared locally so this module pulls in no ambient TUI specifier. */
36
+ interface Component {
37
+ render(width: number): string[];
38
+ handleInput?(data: string): void;
39
+ invalidate?(): void;
40
+ }
32
41
 
33
42
  import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
34
43
  import type { AutotunePending } from "./autotune-extension.ts";
@@ -206,18 +215,6 @@ export function groupByProvider(models: readonly PiModel[]): Map<string, string[
206
215
  interface PiTui {
207
216
  requestRender(): void;
208
217
  }
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
218
  /** A pi theme — only the foreground-color helper the picker uses. */
222
219
  interface PiTheme {
223
220
  fg(color: string, text: string): string;
@@ -320,6 +317,60 @@ function clampLine(line: string, width: number): string {
320
317
  return line.length > width ? line.slice(0, width) : line;
321
318
  }
322
319
 
320
+ /** Unicode box-drawing characters for the picker's 4-sided frame. */
321
+ const BOX = {
322
+ topLeft: "┌",
323
+ topRight: "┐",
324
+ bottomLeft: "└",
325
+ bottomRight: "┘",
326
+ horizontal: "─",
327
+ vertical: "│",
328
+ } as const;
329
+ /** The theme color used for the picker's box frame. */
330
+ const FRAME_COLOR = "accent";
331
+ /** Below this `width` a real frame cannot be drawn — render unframed instead. */
332
+ const MIN_BOX_WIDTH = 10;
333
+
334
+ /**
335
+ * One content row of the boxed panel, given as *plain* text plus an optional
336
+ * theme color. {@link frameBox} measures the plain `text`, then sizes and
337
+ * colorizes it — so an ANSI escape is never fed to `clampLine`/`padEnd`.
338
+ */
339
+ interface BoxRow {
340
+ text: string;
341
+ color?: string;
342
+ }
343
+
344
+ /**
345
+ * Wrap content rows in a true 4-sided Unicode box.
346
+ *
347
+ * `width` is the full outer box width. Each row's *plain* text is truncated
348
+ * and space-padded to the inner width (`width - 4`: two `│` columns plus one
349
+ * space of padding on each side) — measured *before* `theme.fg` runs, so an
350
+ * ANSI escape is never measured. The frame chars and the row content are
351
+ * themed separately. When `width` is too small to frame, the plain rows are
352
+ * returned unframed (defensive — never produce garbage).
353
+ */
354
+ function frameBox(rows: readonly BoxRow[], width: number, theme: PiTheme): string[] {
355
+ if (width < MIN_BOX_WIDTH) {
356
+ return rows.map((row) => clampLine(row.text, Math.max(0, width)));
357
+ }
358
+ const inner = width - 4;
359
+ const frame = (s: string): string => theme.fg(FRAME_COLOR, s);
360
+ const horizontal = BOX.horizontal.repeat(width - 2);
361
+ const top = frame(`${BOX.topLeft}${horizontal}${BOX.topRight}`);
362
+ const bottom = frame(`${BOX.bottomLeft}${horizontal}${BOX.bottomRight}`);
363
+ const side = frame(BOX.vertical);
364
+
365
+ const body = rows.map((row) => {
366
+ // Size on plain text, then colorize — never measure an ANSI string.
367
+ const sized = clampLine(row.text, inner).padEnd(inner, " ");
368
+ const content = row.color ? theme.fg(row.color, sized) : sized;
369
+ return `${side} ${content} ${side}`;
370
+ });
371
+ return [top, ...body, bottom];
372
+ }
373
+
323
374
  /**
324
375
  * Build the inline pi-TUI component for the no-arg picker.
325
376
  *
@@ -333,68 +384,62 @@ function clampLine(line: string, width: number): string {
333
384
  * `Input`): pi-tui's `Input` is not shipped with type definitions in this pi
334
385
  * build and an embedded-input + `Focusable` wiring is the design's named
335
386
  * 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.
387
+ * TUI class at all, and keeps the pure module's `submitText` contract
388
+ * untouched.
338
389
  *
339
390
  * The whole `handleInput` body is wrapped in a `try/catch` that closes the
340
391
  * component cleanly via `done({ type: "quit" })` on any error (Req 9).
392
+ *
393
+ * `render(width)` builds the framed layout by hand with Unicode box-drawing
394
+ * characters ({@link frameBox}) — no pi-tui components are involved — so the
395
+ * picker draws as a real closed rectangle.
341
396
  */
342
397
  function createPickerComponent(
343
398
  initial: PickerState,
344
- pitui: PiTuiModule,
345
399
  theme: PiTheme,
346
400
  tui: PiTui,
347
401
  done: (result: EnterResult) => void,
348
402
  ): Component {
349
- const { Box, Text, Spacer } = pitui;
350
403
  // The single mutable state reference; reassigned from the pure functions.
351
404
  let state = initial;
352
405
  // Inline text buffer — non-null only while `state.textPrompt` is open.
353
406
  let buffer: string | null = null;
354
407
 
355
- /** Render the boxed panel for the current state. */
408
+ /**
409
+ * Render the boxed panel for the current state.
410
+ *
411
+ * Builds plain-text content rows with an optional theme color each, then
412
+ * hands them to {@link frameBox} which sizes (on plain text) and colorizes
413
+ * last. Defensive — it must never throw: rows carry plain text, theming is
414
+ * deferred to `frameBox`, and `frameBox` degrades gracefully on a tiny
415
+ * `width`.
416
+ */
356
417
  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
418
+ const rows: BoxRow[] = [];
359
419
 
360
- box.addChild(new Text(clampLine(PICKER_TITLE, inner), 0, 0));
361
- box.addChild(new Spacer(1));
420
+ rows.push({ text: PICKER_TITLE });
421
+ rows.push({ text: "" });
362
422
 
363
423
  if (state.textPrompt) {
364
424
  // 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);
425
+ rows.push({ text: state.textPrompt.label });
426
+ rows.push({ text: `> ${buffer ?? ""}`, color: "accent" });
427
+ rows.push({ text: "" });
428
+ rows.push({ text: "enter confirmar · esc volver", color: "dim" });
429
+ return frameBox(rows, width, theme);
381
430
  }
382
431
 
383
432
  state.entries.forEach((entry, index) => {
384
433
  if (index === state.cursor) {
385
- const row = clampLine(`> ${entry.label}`, inner);
386
- box.addChild(new Text(theme.fg("accent", row), 0, 0));
434
+ rows.push({ text: `> ${entry.label}`, color: "accent" });
387
435
  } else {
388
- const row = clampLine(` ${entry.label}`, inner);
389
- box.addChild(new Text(theme.fg("dim", row), 0, 0));
436
+ rows.push({ text: ` ${entry.label}`, color: "dim" });
390
437
  }
391
438
  });
392
439
 
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);
440
+ rows.push({ text: "" });
441
+ rows.push({ text: PICKER_HELP, color: "dim" });
442
+ return frameBox(rows, width, theme);
398
443
  }
399
444
 
400
445
  /** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
@@ -560,16 +605,11 @@ export default function register(pi?: PiExtensionAPI): void {
560
605
  fallbackModels: FALLBACK_MODELS,
561
606
  });
562
607
 
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
-
608
+ // The picker is a self-contained `ctx.ui.custom()` component it
609
+ // renders its own framed layout with box-drawing characters, so no
610
+ // pi-tui module is loaded here.
571
611
  const result = await ctx.ui.custom<EnterResult>((tui, theme, _kb, done) =>
572
- createPickerComponent(initialState, pitui, theme, tui, done),
612
+ createPickerComponent(initialState, theme, tui, done),
573
613
  );
574
614
 
575
615
  if (result.type !== "save") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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": [
@@ -44,6 +44,7 @@
44
44
  "extensions/sdd-agents.ts",
45
45
  "extensions/conversation-resume.ts",
46
46
  "extensions/zero-models.ts",
47
+ "extensions/zero-models-picker.ts",
47
48
  "extensions/autotune.ts",
48
49
  "extensions/autotune-extension.ts",
49
50
  "extensions/spec-merge.ts",