@gonrocca/nodd 0.3.1 → 0.4.1

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.
@@ -279,3 +279,39 @@ test("nothing in this extension names forge's config file", () => {
279
279
  assert.ok(!source.includes(`zero${"."}json`), "NODD never opens forge's config");
280
280
  assert.ok(source.includes("nodd.json") || source.includes("noddConfigPath"), "it uses its own");
281
281
  });
282
+
283
+ test("the picker offers the providers you can actually use, not the whole catalogue", () => {
284
+ // pi's registry carries every provider it knows how to speak to — bedrock,
285
+ // baseten, huggingface, cloudflare — whether or not you have credentials for
286
+ // any of them. Browsing all of it meant scrolling past dozens of providers
287
+ // that cannot answer. `getAvailable()` is pi's own answer to "which of these
288
+ // has configured auth", so that is what the picker lists.
289
+ const registry = {
290
+ getAll: () => [
291
+ { provider: "cliproxy", id: "ds/deepseek-flash" },
292
+ { provider: "amazon-bedrock", id: "claude-3" },
293
+ { provider: "huggingface", id: "meta-llama/x" },
294
+ ],
295
+ getAvailable: () => [{ provider: "cliproxy", id: "ds/deepseek-flash" }],
296
+ };
297
+
298
+ const groups = register.groupsFrom(registry);
299
+ assert.deepEqual([...groups.keys()], ["cliproxy/ds"], "only the configured provider is browsable");
300
+ });
301
+
302
+ test("with no availability information the full registry is still offered", () => {
303
+ // An older host, or one that cannot answer, must not leave the picker empty:
304
+ // showing everything beats showing nothing.
305
+ const groups = register.groupsFrom({ getAll: () => [{ provider: "anthropic", id: "claude-opus-4-1" }] });
306
+ assert.deepEqual([...groups.keys()], ["anthropic"]);
307
+ });
308
+
309
+ test("an empty availability list falls back rather than offering nothing", () => {
310
+ // Availability is refreshed asynchronously, so early in a session it can be
311
+ // legitimately empty. Trusting it blindly would show an empty provider list.
312
+ const groups = register.groupsFrom({
313
+ getAll: () => [{ provider: "anthropic", id: "claude-opus-4-1" }],
314
+ getAvailable: () => [],
315
+ });
316
+ assert.deepEqual([...groups.keys()], ["anthropic"], "an empty answer is not an answer");
317
+ });
@@ -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 = {
@@ -48,13 +48,31 @@ export function fileConfigIo(path: string = noddConfigPath()): ConfigIo {
48
48
  };
49
49
  }
50
50
 
51
- type ModelRegistry = { getAll(): RegistryModel[] };
51
+ type ModelRegistry = {
52
+ getAll(): RegistryModel[];
53
+ /** pi's own answer to "which providers have configured auth". */
54
+ getAvailable?(): RegistryModel[];
55
+ };
52
56
 
53
57
  const USAGE =
54
58
  "uso: /nodd-models · /nodd-models <slot>=<provider>/<modelo> · /nodd-models profile [list|new <n>|save [n]|use <n>|delete <n>]";
55
59
 
60
+ /**
61
+ * The models worth browsing.
62
+ *
63
+ * pi's registry lists every provider it can speak to — bedrock, baseten,
64
+ * huggingface — whether or not credentials exist for any of them, which buried
65
+ * the few usable ones under dozens that cannot answer. `getAvailable()` narrows
66
+ * that to the configured providers.
67
+ *
68
+ * An empty or missing answer falls back to the full registry: availability is
69
+ * refreshed asynchronously, so early in a session it can be legitimately empty,
70
+ * and an empty picker is worse than a long one.
71
+ */
56
72
  function groupsFrom(registry: ModelRegistry | undefined): Map<string, string[]> {
57
73
  try {
74
+ const available = registry?.getAvailable?.();
75
+ if (available && available.length > 0) return groupByProvider(available);
58
76
  const all = registry?.getAll?.();
59
77
  return all && all.length > 0 ? groupByProvider(all) : new Map();
60
78
  } catch {
@@ -211,7 +229,7 @@ function createComponent(
211
229
  let buffer: string | null = null;
212
230
 
213
231
  function render(width: number): string[] {
214
- const inner = Math.max(20, width - 2);
232
+ const inner = Math.max(20, width - 4);
215
233
  // pi hands the component its width but not its height, so the height comes
216
234
  // from the terminal itself, minus what pi's own chrome takes.
217
235
  const maxRows = usableRows(process.stdout?.rows);
@@ -226,19 +244,24 @@ function createComponent(
226
244
  );
227
245
  }
228
246
 
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);
247
+ // Reserve the header, footer and the two frame lines, then window the list
248
+ // around the cursor so a long list scrolls instead of overflowing.
249
+ const capacity = Math.max(1, maxRows - head.length - 4);
232
250
  const win = windowRows(state.entries.length, state.cursor, capacity);
233
251
  const rows = state.entries.slice(win.start, win.end).map((entry, index) => {
234
252
  const selected = win.start + index === state.cursor;
235
- return truncateToWidth(`${selected ? "❯ " : " "}${entry.label}`, inner);
253
+ return { text: `${selected ? "❯ " : " "}${entry.label}` };
236
254
  });
237
255
 
238
- return fitRows(
239
- [...head, ...rows, "", truncateToWidth("↑↓ mover · enter elegir · esc volver · q salir", inner)],
240
- maxRows,
241
- );
256
+ const menu = [
257
+ ...head.map((text) => ({ text })),
258
+ ...rows,
259
+ { text: "" },
260
+ { text: "↑↓ mover · enter elegir · esc volver · q salir" },
261
+ ];
262
+ // The preview is empty until a profile exists, and `sideBySide` drops the
263
+ // second panel for an empty preview or a narrow terminal.
264
+ return fitRows(sideBySide(menu, previewRows(state), width), maxRows);
242
265
  }
243
266
 
244
267
  /** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
@@ -357,5 +380,6 @@ function register(pi?: PiApi): void {
357
380
  register.runPicker = runPicker;
358
381
  register.createComponent = createComponent;
359
382
  register.pickerInput = pickerInput;
383
+ register.groupsFrom = groupsFrom;
360
384
 
361
385
  export default register;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/nodd",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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,58 @@ 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
+ });
575
+
576
+ test("choosing through a prefixed group does not double the prefix", () => {
577
+ // The group key is `cliproxy/ds` and the id inside it is `ds/deepseek-v4-pro`:
578
+ // joining them naively produced `cliproxy/ds/ds/deepseek-v4-pro`, a model id
579
+ // that resolves to nothing. The key's prefix belongs to the id, not to the
580
+ // provider, so only the provider part may be prepended.
581
+ let state = open({ groups: new Map([["cliproxy/ds", ["ds/deepseek-v4-pro"]]]) });
582
+ state = press(state, "edit-loose");
583
+ state = assign(state, "implement", "cliproxy/ds", "ds/deepseek-v4-pro", "high");
584
+
585
+ assert.equal(state.edits.models.implement, "cliproxy/ds/deepseek-v4-pro");
586
+ assert.equal(state.edits.thinking.implement, "high");
587
+ });
@@ -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
 
@@ -528,9 +528,15 @@ function commitDrill(state: PickerState, level: ThinkingLevel): PickerState {
528
528
  const { drillSlot, drillProvider, drillModel } = state;
529
529
  if (drillSlot === null || drillModel === null) return state;
530
530
 
531
- // No provider is a bare model id which is what the empty-registry path
531
+ // `drillProvider` is a group key, and a pooled provider's key carries the
532
+ // pool prefix (`cliproxy/ds`) which the id already repeats
533
+ // (`ds/deepseek-v4-pro`). Only the provider part is prepended, or the commit
534
+ // doubles the prefix into an id that resolves to nothing.
535
+ //
536
+ // No provider at all is a bare model id — what the empty-registry path
532
537
  // produces, and what the command's validation accepts.
533
- const qualified = drillProvider ? `${drillProvider}/${drillModel}` : drillModel;
538
+ const provider = drillProvider?.split("/")[0] ?? null;
539
+ const qualified = provider ? `${provider}/${drillModel}` : drillModel;
534
540
  return {
535
541
  ...state,
536
542
  edits: {
@@ -675,3 +681,67 @@ function submitProfileName(
675
681
  : `perfil «${name}» creado — elegí los modelos (activar es aparte)`,
676
682
  });
677
683
  }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // The preview panel
687
+ // ---------------------------------------------------------------------------
688
+
689
+ /** One row of the preview panel. */
690
+ export type PreviewRow = { text: string };
691
+
692
+ /** The profile the preview should show, or null when there is nothing to show. */
693
+ function previewTarget(state: PickerState): { name: string; profile: Profile } | null {
694
+ if (state.screen === "profile-actions" && state.drillProfile) {
695
+ const profile = state.edits.profiles[state.drillProfile];
696
+ return profile ? { name: state.drillProfile, profile } : null;
697
+ }
698
+ if (state.screen !== "main") return null;
699
+
700
+ // On the menu the cursor drives the preview, so arrowing down the list shows
701
+ // each profile in turn. Off a profile row it falls back to the active one.
702
+ const entry = state.entries[state.cursor];
703
+ const name = entry?.kind === "profile"
704
+ ? entry.value
705
+ : state.edits.activeProfile ?? Object.keys(state.edits.profiles).sort()[0];
706
+ if (!name) return null;
707
+ const profile = state.edits.profiles[name];
708
+ return profile ? { name, profile } : null;
709
+ }
710
+
711
+ /**
712
+ * The preview rows: every canonical step with its model and thinking level.
713
+ *
714
+ * Empty when there is no profile to show, which is what suppresses the second
715
+ * panel rather than drawing an empty box.
716
+ */
717
+ export function previewRows(state: PickerState): PreviewRow[] {
718
+ const target = previewTarget(state);
719
+ if (!target) return [];
720
+
721
+ const { name, profile } = target;
722
+ const active = name === state.edits.activeProfile ? " (activo)" : "";
723
+ const rows: PreviewRow[] = [{ text: `vista previa · ${name}${active}` }, { text: "" }];
724
+
725
+ const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
726
+ const providers = new Set<string>();
727
+ for (const row of SLOT_ROWS) {
728
+ const label = row.id.padEnd(width);
729
+ if (isMechanismSlot(row.id)) {
730
+ rows.push({ text: `${label} ${MECHANISM_PLACEHOLDER}` });
731
+ continue;
732
+ }
733
+ const model = profile.models?.[row.id];
734
+ if (!model) {
735
+ rows.push({ text: `${label} → sin asignar` });
736
+ continue;
737
+ }
738
+ const slash = model.indexOf("/");
739
+ if (slash > 0) providers.add(model.slice(0, slash));
740
+ const level = profile.thinking?.[row.id];
741
+ rows.push({ text: `${label} → ${model}${level ? ` · ${level}` : ""}` });
742
+ }
743
+
744
+ rows.push({ text: "" });
745
+ rows.push({ text: `proveedores: ${providers.size > 0 ? [...providers].sort().join(", ") : "por defecto"}` });
746
+ return rows;
747
+ }