@gonrocca/zero-pi 0.1.29 → 0.1.31
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 +1 -1
- package/extensions/zero-models-picker.ts +488 -0
- package/extensions/zero-models.ts +286 -101
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ 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 —
|
|
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. |
|
|
@@ -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,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:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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> = {
|
|
413
|
-
|
|
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 = [
|
|
420
|
-
|
|
421
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.31",
|
|
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",
|