@gonrocca/nodd 0.3.0 → 0.4.0

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.
@@ -1,5 +1,8 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
  import { readFileSync } from "node:fs";
4
7
  import { MECHANISM_PLACEHOLDER } from "../src/models/slots.ts";
5
8
  import register, { runModelsCommand, type ConfigIo } from "./nodd-models.ts";
@@ -164,6 +167,73 @@ test("quitting the picker produces zero writes; saving writes once", async () =>
164
167
  assert.equal(saveIo.writes[0].unrelated, true, "the picker's save preserves unrelated keys");
165
168
  });
166
169
 
170
+ test("the handler opens the picker on the saved profiles, not on an empty menu", async () => {
171
+ // The host built the picker from `models` and `groups` only, so the profiles
172
+ // on disk never reached it: a user with six saved profiles opened
173
+ // /nodd-models and the menu offered to create the first one.
174
+ const commands = new Map<string, any>();
175
+ register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
176
+
177
+ const config = {
178
+ models: { implement: "cliproxy/personal/claude-opus-5" },
179
+ thinking: { implement: "high" },
180
+ profiles: {
181
+ rapido: { models: { implement: "cliproxy/ds/deepseek-flash" }, thinking: { implement: "low" } },
182
+ lento: { models: { implement: "cliproxy/personal/claude-opus-5" }, thinking: { implement: "high" } },
183
+ },
184
+ activeProfile: "rapido",
185
+ };
186
+
187
+ const home = mkdtempSync(join(tmpdir(), "nodd-models-"));
188
+ mkdirSync(join(home, ".pi"), { recursive: true });
189
+ writeFileSync(join(home, ".pi", "nodd.json"), JSON.stringify(config));
190
+ const previousHome = process.env.HOME;
191
+ process.env.HOME = home;
192
+
193
+ let rendered: string[] = [];
194
+ try {
195
+ await commands.get("nodd-models").handler("", {
196
+ ui: {
197
+ notify() {},
198
+ // Stand in for pi's custom-UI host: build the component, render it, and
199
+ // quit without saving.
200
+ async custom(build: any) {
201
+ let result: unknown;
202
+ const component = build({ requestRender() {} }, {}, {}, (r: unknown) => { result = r; });
203
+ rendered = component.render(80);
204
+ component.handleInput?.("q");
205
+ return result ?? { type: "quit" };
206
+ },
207
+ },
208
+ modelRegistry: { getAll: () => [{ provider: "cliproxy", id: "personal/claude-opus-5" }] },
209
+ } as never);
210
+ } finally {
211
+ if (previousHome === undefined) delete process.env.HOME;
212
+ else process.env.HOME = previousHome;
213
+ }
214
+
215
+ const screen = rendered.join("\n");
216
+ assert.match(screen, /rapido/, "a saved profile is on the opening screen");
217
+ assert.match(screen, /lento/, "and so is the other one");
218
+ assert.doesNotMatch(screen, /modelos sin perfil/, "with profiles saved, the menu is not the no-profile one");
219
+ });
220
+
221
+ test("the picker input carries the profiles and the active one", () => {
222
+ const input = register.pickerInput(
223
+ {
224
+ models: { implement: "cliproxy/personal/claude-opus-5" },
225
+ thinking: { implement: "high" },
226
+ profiles: { rapido: { models: { implement: "cliproxy/ds/deepseek-flash" } } },
227
+ activeProfile: "rapido",
228
+ },
229
+ new Map([["cliproxy", ["personal/claude-opus-5"]]]),
230
+ );
231
+
232
+ assert.deepEqual(Object.keys(input.profiles), ["rapido"], "saved profiles reach the picker");
233
+ assert.equal(input.activeProfile, "rapido", "and so does the active one");
234
+ assert.deepEqual(input.thinking, { implement: "high" }, "and the levels, so editing does not wipe them");
235
+ });
236
+
167
237
  test("the command is registered with pi under its own name", () => {
168
238
  const commands = new Map<string, unknown>();
169
239
  register({ registerCommand: (name: string, options: unknown) => commands.set(name, options) } as never);
@@ -24,8 +24,8 @@ import { mergeConfig, noddConfigPath } from "../src/config.ts";
24
24
  import { SLOT_ROWS } from "../src/models/slots.ts";
25
25
  import { assignmentPatch, groupByProvider, parseAssignment, validateAssignment, type RegistryModel } from "../src/models/assign.ts";
26
26
  import { applyProfileCommand, isValidProfileName, mirrorToActiveProfile, readActiveProfile, readProfiles, type Profile } from "../src/models/profiles.ts";
27
- import { back, createPickerState, decodeKey, enter, navigate, pickerTitle, submitText, type EnterResult, type PickerState } from "../src/models/picker.ts";
28
- import { fitRows, truncateToWidth, usableRows, windowRows } from "../src/models/layout.ts";
27
+ import { back, createPickerState, decodeKey, enter, navigate, pickerTitle, previewRows, submitText, type EnterResult, type PickerState } from "../src/models/picker.ts";
28
+ import { fitRows, sideBySide, truncateToWidth, usableRows, windowRows } from "../src/models/layout.ts";
29
29
  import { isThinkingLevel, type SlotThinking } from "../src/models/thinking.ts";
30
30
 
31
31
  export type ConfigIo = {
@@ -211,7 +211,7 @@ function createComponent(
211
211
  let buffer: string | null = null;
212
212
 
213
213
  function render(width: number): string[] {
214
- const inner = Math.max(20, width - 2);
214
+ const inner = Math.max(20, width - 4);
215
215
  // pi hands the component its width but not its height, so the height comes
216
216
  // from the terminal itself, minus what pi's own chrome takes.
217
217
  const maxRows = usableRows(process.stdout?.rows);
@@ -226,19 +226,24 @@ function createComponent(
226
226
  );
227
227
  }
228
228
 
229
- // Reserve the header and footer, then window the list around the cursor so
230
- // a long list scrolls instead of overflowing the terminal.
231
- const capacity = Math.max(1, maxRows - head.length - 2);
229
+ // Reserve the header, footer and the two frame lines, then window the list
230
+ // around the cursor so a long list scrolls instead of overflowing.
231
+ const capacity = Math.max(1, maxRows - head.length - 4);
232
232
  const win = windowRows(state.entries.length, state.cursor, capacity);
233
233
  const rows = state.entries.slice(win.start, win.end).map((entry, index) => {
234
234
  const selected = win.start + index === state.cursor;
235
- return truncateToWidth(`${selected ? "❯ " : " "}${entry.label}`, inner);
235
+ return { text: `${selected ? "❯ " : " "}${entry.label}` };
236
236
  });
237
237
 
238
- return fitRows(
239
- [...head, ...rows, "", truncateToWidth("↑↓ mover · enter elegir · esc volver · q salir", inner)],
240
- maxRows,
241
- );
238
+ const menu = [
239
+ ...head.map((text) => ({ text })),
240
+ ...rows,
241
+ { text: "" },
242
+ { text: "↑↓ mover · enter elegir · esc volver · q salir" },
243
+ ];
244
+ // The preview is empty until a profile exists, and `sideBySide` drops the
245
+ // second panel for an empty preview or a narrow terminal.
246
+ return fitRows(sideBySide(menu, previewRows(state), width), maxRows);
242
247
  }
243
248
 
244
249
  /** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
@@ -334,8 +339,12 @@ function register(pi?: PiApi): void {
334
339
  // deterministic text path, which is the whole command in a headless run.
335
340
  if (args.trim() === "" && typeof ctx?.ui?.custom === "function") {
336
341
  const groups = groupsFrom(ctx.modelRegistry);
342
+ // Everything the picker needs, including the saved profiles and the
343
+ // active one. Passing only the models opened the picker as though
344
+ // nothing had ever been saved.
345
+ const input = pickerInput(io.readConfig(), groups);
337
346
  const result = await ctx.ui.custom<EnterResult>((tui, _theme, _keys, done) =>
338
- createComponent({ models: currentModels(io.readConfig()), groups }, done, () => tui.requestRender()),
347
+ createComponent(input, done, () => tui.requestRender()),
339
348
  );
340
349
  runPicker(result, io);
341
350
  notify?.(result.type === "save" ? "nodd · modelos guardados" : "nodd · sin cambios", "info");
@@ -352,5 +361,6 @@ function register(pi?: PiApi): void {
352
361
 
353
362
  register.runPicker = runPicker;
354
363
  register.createComponent = createComponent;
364
+ register.pickerInput = pickerInput;
355
365
 
356
366
  export default register;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/nodd",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Non-negotiable Organic Driven Development — the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -123,3 +123,49 @@ test("a bare model resolved to one provider is written qualified", () => {
123
123
  const patch = assignmentPatch({}, { slot: "implement", provider: "openai-codex", model: "gpt-5-codex" });
124
124
  assert.deepEqual(patch, { models: { implement: "openai-codex/gpt-5-codex" } });
125
125
  });
126
+
127
+ test("a provider whose ids carry a prefix is browsable by prefix", () => {
128
+ // cliproxy fronts several subscription pools and encodes the pool in the id:
129
+ // `personal/claude-opus-5`, `ds/deepseek-flash`. Grouping by provider alone
130
+ // put 166 models behind one row, which is not a menu anyone can use.
131
+ const groups = groupByProvider([
132
+ { provider: "cliproxy", id: "personal/claude-opus-5" },
133
+ { provider: "cliproxy", id: "personal/claude-sonnet-5" },
134
+ { provider: "cliproxy", id: "ds/deepseek-flash" },
135
+ { provider: "cliproxy", id: "glm-5.2" },
136
+ { provider: "anthropic", id: "claude-opus-4-1" },
137
+ ]);
138
+
139
+ assert.deepEqual(
140
+ [...groups.keys()].sort(),
141
+ ["anthropic", "cliproxy/ds", "cliproxy/personal", "cliproxy"].sort(),
142
+ "each prefix is its own browsable group; unprefixed ids stay under the provider",
143
+ );
144
+ assert.deepEqual(groups.get("cliproxy/personal"), ["personal/claude-opus-5", "personal/claude-sonnet-5"]);
145
+ assert.deepEqual(groups.get("cliproxy"), ["glm-5.2"], "only the unprefixed ids remain directly under it");
146
+ });
147
+
148
+ test("assigning through a prefixed group writes the provider, not the prefix", () => {
149
+ // The group is a browsing device. What reaches the agent frontmatter must be
150
+ // the real `provider/id`, or pi cannot resolve it.
151
+ const groups = groupByProvider([{ provider: "cliproxy", id: "personal/claude-opus-5" }]);
152
+ const assignment = parseAssignment("implement=cliproxy/personal/claude-opus-5");
153
+
154
+ assert.deepEqual(assignment, { slot: "implement", provider: "cliproxy", model: "personal/claude-opus-5" });
155
+ assert.deepEqual(validateAssignment(assignment!, groups), { ok: true, provider: "cliproxy" });
156
+ });
157
+
158
+ test("the unknown-provider message names providers, not browsing keys", () => {
159
+ // `cliproxy/ds` is a menu grouping, not something you can type as a provider:
160
+ // offering it as a valid value would send the user to an assignment that
161
+ // cannot resolve.
162
+ const groups = groupByProvider([
163
+ { provider: "cliproxy", id: "personal/claude-opus-5" },
164
+ { provider: "cliproxy", id: "ds/deepseek-flash" },
165
+ { provider: "anthropic", id: "claude-opus-4-1" },
166
+ ]);
167
+ const result = validateAssignment(parseAssignment("implement=nope/x")!, groups);
168
+
169
+ assert.equal(result.ok, false);
170
+ assert.match(result.message, /Usá uno de: anthropic, cliproxy$/, "the real providers, deduped");
171
+ });
@@ -31,12 +31,31 @@ export type Validation =
31
31
  | { ok: true; provider: string | null }
32
32
  | { ok: false; message: string };
33
33
 
34
+ /**
35
+ * Group the registry into browsable menus.
36
+ *
37
+ * Normally one group per provider. But a provider that fronts several pools
38
+ * encodes the pool in the id — cliproxy serves `personal/claude-opus-5`,
39
+ * `ds/deepseek-flash`, `wibond/...` — and grouping by provider alone puts every
40
+ * one of them behind a single row: measured against a live cliproxy, 166 models
41
+ * in one flat list. Those ids are split one level deeper into `provider/prefix`
42
+ * groups, so the pools are what you actually browse.
43
+ *
44
+ * The group key is a browsing device only. The id inside each group stays whole
45
+ * (`personal/claude-opus-5`), because that is what pi has to resolve.
46
+ */
34
47
  export function groupByProvider(models: RegistryModel[]): Map<string, string[]> {
35
48
  const groups = new Map<string, string[]>();
36
49
  for (const model of models) {
37
- const existing = groups.get(model.provider);
50
+ const slash = model.id.indexOf("/");
51
+ // An id that begins or ends with the slash has no usable prefix; it is kept
52
+ // whole under its provider rather than producing an empty group.
53
+ const key = slash > 0 && slash < model.id.length - 1
54
+ ? `${model.provider}/${model.id.slice(0, slash)}`
55
+ : model.provider;
56
+ const existing = groups.get(key);
38
57
  if (existing) existing.push(model.id);
39
- else groups.set(model.provider, [model.id]);
58
+ else groups.set(key, [model.id]);
40
59
  }
41
60
  return groups;
42
61
  }
@@ -56,8 +75,34 @@ export function parseAssignment(text: string): Assignment | null {
56
75
  return { slot, provider, model };
57
76
  }
58
77
 
78
+ /** The real provider names, with the browsing prefixes folded back off. */
79
+ function providerNames(groups: Map<string, string[]>): string[] {
80
+ const names = new Set<string>();
81
+ for (const key of groups.keys()) {
82
+ const slash = key.indexOf("/");
83
+ names.add(slash > 0 ? key.slice(0, slash) : key);
84
+ }
85
+ return [...names].sort();
86
+ }
87
+
88
+ /** Every id reachable under a provider, across all of its prefixed groups. */
89
+ function idsForProvider(groups: Map<string, string[]>, provider: string): string[] | undefined {
90
+ const ids: string[] = [];
91
+ for (const [key, group] of groups) {
92
+ if (key === provider || key.startsWith(`${provider}/`)) ids.push(...group);
93
+ }
94
+ return ids.length > 0 ? ids : undefined;
95
+ }
96
+
97
+ /** The providers owning a model id, with the browsing prefix stripped back off. */
59
98
  function providersOwning(groups: Map<string, string[]>, model: string): string[] {
60
- return [...groups.entries()].filter(([, ids]) => ids.includes(model)).map(([provider]) => provider);
99
+ const owners = new Set<string>();
100
+ for (const [key, ids] of groups) {
101
+ if (!ids.includes(model)) continue;
102
+ const slash = key.indexOf("/");
103
+ owners.add(slash > 0 ? key.slice(0, slash) : key);
104
+ }
105
+ return [...owners];
61
106
  }
62
107
 
63
108
  function suggest(groups: Map<string, string[]>, model: string): string[] {
@@ -89,11 +134,13 @@ export function validateAssignment(assignment: Assignment, groups: Map<string, s
89
134
  if (groups.size === 0) return { ok: true, provider: assignment.provider };
90
135
 
91
136
  if (assignment.provider) {
92
- const ids = groups.get(assignment.provider);
137
+ // The group keys are browsing devices (`cliproxy/personal`), so a provider
138
+ // owns every group it prefixes, not just the one bearing its exact name.
139
+ const ids = idsForProvider(groups, assignment.provider);
93
140
  if (!ids) {
94
141
  return {
95
142
  ok: false,
96
- message: `provider desconocido: ${assignment.provider}. Usá uno de: ${[...groups.keys()].sort().join(", ")}`,
143
+ message: `provider desconocido: ${assignment.provider}. Usá uno de: ${providerNames(groups).join(", ")}`,
97
144
  };
98
145
  }
99
146
  if (!ids.includes(assignment.model)) {
@@ -3,7 +3,9 @@ import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import {
5
5
  fitRows,
6
+ frameBox,
6
7
  padToWidth,
8
+ sideBySide,
7
9
  stripAnsi,
8
10
  truncateToWidth,
9
11
  usableRows,
@@ -120,3 +122,46 @@ test("the layout helpers import nothing at all", () => {
120
122
  .join("\n");
121
123
  assert.ok(!/^import /m.test(source), "the layout must stay dependency-free: no node:*, no pi, no TUI");
122
124
  });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // The boxed two-panel layout
128
+ // ---------------------------------------------------------------------------
129
+
130
+ test("frameBox draws four sides and pads every row to the same width", () => {
131
+ const out = frameBox([{ text: "uno" }, { text: "" }, { text: "dos" }], 20);
132
+
133
+ assert.equal(out.length, 5, "top, three rows, bottom");
134
+ assert.ok(out[0].startsWith("┌") && out[0].endsWith("┐"));
135
+ assert.ok(out[4].startsWith("└") && out[4].endsWith("┘"));
136
+ for (const line of out) {
137
+ assert.equal(visibleWidth(line), 20, `every line is the box width: ${JSON.stringify(line)}`);
138
+ }
139
+ assert.ok(out[1].startsWith("│ uno") && out[1].endsWith("│"));
140
+ });
141
+
142
+ test("frameBox truncates on display cells, so the closing edge never moves", () => {
143
+ const out = frameBox([{ text: "una fila larguísima que no entra de ningún modo" }], 20);
144
+ assert.equal(visibleWidth(out[1]), 20);
145
+ assert.ok(out[1].endsWith("│"), "the right edge survives the cut");
146
+ });
147
+
148
+ test("below the split width only the menu is rendered", () => {
149
+ const menu = [{ text: "menu" }];
150
+ const preview = [{ text: "preview" }];
151
+ const narrow = sideBySide(menu, preview, 40);
152
+
153
+ assert.ok(narrow.every((line) => !line.includes("preview")), "no room: the preview is dropped, not squeezed");
154
+ assert.ok(narrow.some((line) => line.includes("menu")));
155
+ });
156
+
157
+ test("side by side, both panels are framed and equally tall", () => {
158
+ const out = sideBySide([{ text: "a" }, { text: "b" }, { text: "c" }], [{ text: "p" }], 100);
159
+
160
+ assert.ok(out.every((line) => visibleWidth(line) <= 100), "the pair never exceeds the terminal");
161
+ const joined = out.join("\n");
162
+ assert.ok(joined.includes("a") && joined.includes("p"), "both panels are present");
163
+ // Five rows: the taller panel is three rows plus its two frame lines, and the
164
+ // shorter one is padded to match rather than leaving a ragged edge.
165
+ assert.equal(out.length, 5);
166
+ for (const line of out) assert.equal(visibleWidth(line), visibleWidth(out[0]), "rows are flush");
167
+ });
@@ -185,3 +185,72 @@ export function fitRows(lines: readonly string[], maxRows: number): string[] {
185
185
  if (maxRows <= 0) return [];
186
186
  return lines.length <= maxRows ? [...lines] : lines.slice(0, maxRows);
187
187
  }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // The boxed two-panel layout
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** One content row of a panel: plain text, measured before any colour runs. */
194
+ export type BoxRow = { text: string };
195
+
196
+ /** Below this width a frame cannot be drawn; the rows are returned bare. */
197
+ export const MIN_BOX_WIDTH = 10;
198
+ /** Below this terminal width the preview panel is dropped, never squeezed. */
199
+ export const MIN_SPLIT_WIDTH = 76;
200
+ /** The menu never shrinks past this, so profile names stay readable. */
201
+ const MIN_MENU_WIDTH = 30;
202
+ /** Nor does it take more than this share when both panels compete. */
203
+ const MAX_MENU_SHARE = 0.62;
204
+
205
+ /**
206
+ * Wrap rows in a four-sided box of the given outer width.
207
+ *
208
+ * Every row is sized on its plain text to `width - 4` (two `│` columns plus a
209
+ * space of padding each side) and measured in display cells, so a wide glyph
210
+ * can never push the closing edge off the line.
211
+ */
212
+ export function frameBox(rows: readonly BoxRow[], width: number): string[] {
213
+ if (width < MIN_BOX_WIDTH) return rows.map((row) => truncateToWidth(row.text, Math.max(0, width)));
214
+ const horizontal = "─".repeat(width - 2);
215
+ return [
216
+ `┌${horizontal}┐`,
217
+ ...rows.map((row) => `│ ${padToWidth(row.text, width - 4)} │`),
218
+ `└${horizontal}┘`,
219
+ ];
220
+ }
221
+
222
+ /** The widest row of a block, in display cells. */
223
+ function contentWidth(rows: readonly BoxRow[]): number {
224
+ let max = 0;
225
+ for (const row of rows) max = Math.max(max, visibleWidth(row.text));
226
+ return max;
227
+ }
228
+
229
+ /**
230
+ * Two framed panels side by side, padded to equal height.
231
+ *
232
+ * Under {@link MIN_SPLIT_WIDTH}, or with nothing to preview, only the menu is
233
+ * returned: a preview crushed into a handful of columns is worse than none.
234
+ */
235
+ export function sideBySide(menu: readonly BoxRow[], preview: readonly BoxRow[], width: number): string[] {
236
+ if (width < MIN_SPLIT_WIDTH || preview.length === 0) {
237
+ return frameBox(menu, Math.min(width, Math.max(MIN_BOX_WIDTH, contentWidth(menu) + 4)));
238
+ }
239
+
240
+ const gap = 1;
241
+ const menuWidth = Math.max(
242
+ MIN_MENU_WIDTH,
243
+ Math.min(contentWidth(menu) + 4, Math.floor(width * MAX_MENU_SHARE)),
244
+ );
245
+ const previewWidth = Math.max(MIN_BOX_WIDTH, width - menuWidth - gap);
246
+
247
+ // Pad to a common height first: framing a short panel and padding afterwards
248
+ // would leave its bottom border floating mid-block.
249
+ const height = Math.max(menu.length, preview.length);
250
+ const pad = (rows: readonly BoxRow[]): BoxRow[] =>
251
+ rows.length >= height ? [...rows] : [...rows, ...Array.from({ length: height - rows.length }, () => ({ text: "" }))];
252
+
253
+ const left = frameBox(pad(menu), menuWidth);
254
+ const right = frameBox(pad(preview), previewWidth);
255
+ return left.map((line, index) => `${line}${" ".repeat(gap)}${right[index] ?? ""}`);
256
+ }
@@ -10,6 +10,7 @@ import {
10
10
  enter,
11
11
  navigate,
12
12
  pickerTitle,
13
+ previewRows,
13
14
  rebuildEntries,
14
15
  submitText,
15
16
  type PickerState,
@@ -529,3 +530,45 @@ test("no transition writes: every one returns state the caller may discard", ()
529
530
  const source = readFileSync(new URL("./picker.ts", import.meta.url), "utf8");
530
531
  assert.ok(!/writeFileSync|readFileSync/.test(source), "the state machine performs no IO");
531
532
  });
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // The preview panel
536
+ // ---------------------------------------------------------------------------
537
+
538
+ test("the preview shows the profile under the cursor, model and level per slot", () => {
539
+ const state = createPickerState({
540
+ models: {},
541
+ thinking: {},
542
+ groups: new Map(),
543
+ profiles: {
544
+ rapido: {
545
+ models: { implement: "cliproxy/ds/deepseek-v4-pro", explore: "cliproxy/ds/deepseek-flash" },
546
+ thinking: { implement: "high", explore: "low" },
547
+ },
548
+ lento: { models: { implement: "cliproxy/personal/claude-opus-5" }, thinking: { implement: "xhigh" } },
549
+ },
550
+ activeProfile: "rapido",
551
+ });
552
+
553
+ // The menu is alphabetical, so the cursor opens on `lento`; one row down is
554
+ // `rapido`, and the preview follows the cursor rather than the active profile.
555
+ const rows = previewRows(navigate(state, 1)).map((row) => row.text);
556
+ assert.match(rows[0], /vista previa · rapido \(activo\)/, "the profile under the cursor is named");
557
+ assert.ok(rows.some((r) => /implement\s+→ cliproxy\/ds\/deepseek-v4-pro · high/.test(r)), rows.join("\n"));
558
+ assert.ok(rows.some((r) => /explore\s+→ cliproxy\/ds\/deepseek-flash · low/.test(r)), rows.join("\n"));
559
+ assert.ok(rows.some((r) => r.includes("proveedores: cliproxy")), "the providers are summarised");
560
+ });
561
+
562
+ test("the preview marks mechanism steps rather than pretending they take a model", () => {
563
+ const state = createPickerState({
564
+ models: {}, thinking: {}, groups: new Map(),
565
+ profiles: { p: { models: {}, thinking: {} } }, activeProfile: "p",
566
+ });
567
+ const rows = previewRows(state).map((row) => row.text);
568
+ assert.ok(rows.some((r) => r.includes("authorize") && r.includes(MECHANISM_PLACEHOLDER)), rows.join("\n"));
569
+ });
570
+
571
+ test("with no profile at all there is nothing to preview", () => {
572
+ const state = createPickerState({ models: {}, thinking: {}, groups: new Map(), profiles: {}, activeProfile: null });
573
+ assert.deepEqual(previewRows(state), [], "an empty preview is what suppresses the second panel");
574
+ });
@@ -22,7 +22,7 @@
22
22
  // Forge's autotune screen is deliberately absent: autotune is a forge feature and
23
23
  // NODD has no learning loop to configure.
24
24
 
25
- import { SLOT_ROWS, isMechanismSlot } from "./slots.ts";
25
+ import { MECHANISM_PLACEHOLDER, SLOT_ROWS, isMechanismSlot } from "./slots.ts";
26
26
  import { THINKING_LEVELS, type SlotThinking, type ThinkingLevel } from "./thinking.ts";
27
27
  import { isValidProfileName, type Profile } from "./profiles.ts";
28
28
 
@@ -675,3 +675,67 @@ function submitProfileName(
675
675
  : `perfil «${name}» creado — elegí los modelos (activar es aparte)`,
676
676
  });
677
677
  }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // The preview panel
681
+ // ---------------------------------------------------------------------------
682
+
683
+ /** One row of the preview panel. */
684
+ export type PreviewRow = { text: string };
685
+
686
+ /** The profile the preview should show, or null when there is nothing to show. */
687
+ function previewTarget(state: PickerState): { name: string; profile: Profile } | null {
688
+ if (state.screen === "profile-actions" && state.drillProfile) {
689
+ const profile = state.edits.profiles[state.drillProfile];
690
+ return profile ? { name: state.drillProfile, profile } : null;
691
+ }
692
+ if (state.screen !== "main") return null;
693
+
694
+ // On the menu the cursor drives the preview, so arrowing down the list shows
695
+ // each profile in turn. Off a profile row it falls back to the active one.
696
+ const entry = state.entries[state.cursor];
697
+ const name = entry?.kind === "profile"
698
+ ? entry.value
699
+ : state.edits.activeProfile ?? Object.keys(state.edits.profiles).sort()[0];
700
+ if (!name) return null;
701
+ const profile = state.edits.profiles[name];
702
+ return profile ? { name, profile } : null;
703
+ }
704
+
705
+ /**
706
+ * The preview rows: every canonical step with its model and thinking level.
707
+ *
708
+ * Empty when there is no profile to show, which is what suppresses the second
709
+ * panel rather than drawing an empty box.
710
+ */
711
+ export function previewRows(state: PickerState): PreviewRow[] {
712
+ const target = previewTarget(state);
713
+ if (!target) return [];
714
+
715
+ const { name, profile } = target;
716
+ const active = name === state.edits.activeProfile ? " (activo)" : "";
717
+ const rows: PreviewRow[] = [{ text: `vista previa · ${name}${active}` }, { text: "" }];
718
+
719
+ const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
720
+ const providers = new Set<string>();
721
+ for (const row of SLOT_ROWS) {
722
+ const label = row.id.padEnd(width);
723
+ if (isMechanismSlot(row.id)) {
724
+ rows.push({ text: `${label} ${MECHANISM_PLACEHOLDER}` });
725
+ continue;
726
+ }
727
+ const model = profile.models?.[row.id];
728
+ if (!model) {
729
+ rows.push({ text: `${label} → sin asignar` });
730
+ continue;
731
+ }
732
+ const slash = model.indexOf("/");
733
+ if (slash > 0) providers.add(model.slice(0, slash));
734
+ const level = profile.thinking?.[row.id];
735
+ rows.push({ text: `${label} → ${model}${level ? ` · ${level}` : ""}` });
736
+ }
737
+
738
+ rows.push({ text: "" });
739
+ rows.push({ text: `proveedores: ${providers.size > 0 ? [...providers].sort().join(", ") : "por defecto"}` });
740
+ return rows;
741
+ }