@aiwayds/dsh-tui-pi 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/dsh-tui-pi +5 -0
  4. package/cordis.patch.yml +9 -0
  5. package/lib/append-system.d.ts +66 -0
  6. package/lib/append-system.js +161 -0
  7. package/lib/append-system.js.map +1 -0
  8. package/lib/commands.d.ts +53 -0
  9. package/lib/commands.js +167 -0
  10. package/lib/commands.js.map +1 -0
  11. package/lib/dsh-events.d.ts +106 -0
  12. package/lib/dsh-events.js +30 -0
  13. package/lib/dsh-events.js.map +1 -0
  14. package/lib/editor.d.ts +28 -0
  15. package/lib/editor.js +70 -0
  16. package/lib/editor.js.map +1 -0
  17. package/lib/footer.d.ts +36 -0
  18. package/lib/footer.js +112 -0
  19. package/lib/footer.js.map +1 -0
  20. package/lib/frame.d.ts +35 -0
  21. package/lib/frame.js +75 -0
  22. package/lib/frame.js.map +1 -0
  23. package/lib/git.d.ts +17 -0
  24. package/lib/git.js +51 -0
  25. package/lib/git.js.map +1 -0
  26. package/lib/index.d.ts +15 -0
  27. package/lib/index.js +781 -0
  28. package/lib/index.js.map +1 -0
  29. package/lib/instructions.d.ts +29 -0
  30. package/lib/instructions.js +67 -0
  31. package/lib/instructions.js.map +1 -0
  32. package/lib/live-widgets.d.ts +85 -0
  33. package/lib/live-widgets.js +218 -0
  34. package/lib/live-widgets.js.map +1 -0
  35. package/lib/messages.d.ts +277 -0
  36. package/lib/messages.js +734 -0
  37. package/lib/messages.js.map +1 -0
  38. package/lib/permission.d.ts +27 -0
  39. package/lib/permission.js +48 -0
  40. package/lib/permission.js.map +1 -0
  41. package/lib/provider-catalog.d.ts +114 -0
  42. package/lib/provider-catalog.js +124 -0
  43. package/lib/provider-catalog.js.map +1 -0
  44. package/lib/quotes.d.ts +28 -0
  45. package/lib/quotes.js +144 -0
  46. package/lib/quotes.js.map +1 -0
  47. package/lib/reload.d.ts +23 -0
  48. package/lib/reload.js +171 -0
  49. package/lib/reload.js.map +1 -0
  50. package/lib/selectors.d.ts +48 -0
  51. package/lib/selectors.js +261 -0
  52. package/lib/selectors.js.map +1 -0
  53. package/lib/session.d.ts +157 -0
  54. package/lib/session.js +555 -0
  55. package/lib/session.js.map +1 -0
  56. package/lib/sessions.d.ts +73 -0
  57. package/lib/sessions.js +253 -0
  58. package/lib/sessions.js.map +1 -0
  59. package/lib/settings.d.ts +180 -0
  60. package/lib/settings.js +1328 -0
  61. package/lib/settings.js.map +1 -0
  62. package/lib/text.d.ts +22 -0
  63. package/lib/text.js +45 -0
  64. package/lib/text.js.map +1 -0
  65. package/lib/theme/index.d.ts +79 -0
  66. package/lib/theme/index.js +121 -0
  67. package/lib/theme/index.js.map +1 -0
  68. package/lib/theme/palette.d.ts +56 -0
  69. package/lib/theme/palette.js +154 -0
  70. package/lib/theme/palette.js.map +1 -0
  71. package/lib/theme-settings.d.ts +68 -0
  72. package/lib/theme-settings.js +223 -0
  73. package/lib/theme-settings.js.map +1 -0
  74. package/lib/tui.d.ts +70 -0
  75. package/lib/tui.js +206 -0
  76. package/lib/tui.js.map +1 -0
  77. package/lib/welcome.d.ts +91 -0
  78. package/lib/welcome.js +281 -0
  79. package/lib/welcome.js.map +1 -0
  80. package/package.json +50 -0
  81. package/patches/@earendil-works__pi-tui.patch +72 -0
  82. package/pnpm-workspace.yaml +2 -0
  83. package/templates/APPEND_SYSTEM.md +34 -0
@@ -0,0 +1,1328 @@
1
+ /**
2
+ * Text-based settings browser — the terminal counterpart of the web GUI's
3
+ * settings surface (schema-form driven there, pi-tui overlays here).
4
+ *
5
+ * Walks `ctx.settings.describe()` (registered namespaces → serialized
6
+ * schemastery schemas → resolved values) and renders it as nested
7
+ * SettingsList overlays, one level per schema depth:
8
+ *
9
+ * level 0 category list (searchable): general / models / plugins / agent,
10
+ * then `other` for unmapped namespaces. Namespace→category comes
11
+ * from a static mapping (categorizeNamespaces) mirroring the web
12
+ * settings page: the web client slots namespaces into categories
13
+ * client-side and the data plane carries no category field, so
14
+ * the mapping is maintained here by hand; `other` is hidden when
15
+ * empty.
16
+ * level 1 namespace list for the chosen category (searchable);
17
+ * description shows applies timing. The Models category is
18
+ * special-cased: it lists configured llm-pi-ai providers (label,
19
+ * model summary, API-key state) instead of the raw namespace —
20
+ * the original schema surface is hidden — plus dedicated
21
+ * llm-deepseek / agent-default-model rows and an add-provider
22
+ * flow (built-in directory → one API key → write)
23
+ * level 2+ schema walk, dispatched on node type:
24
+ * - object/dict → drill in (nested list; dicts get an add-key row)
25
+ * - boolean / all-literal union → Enter cycles the value
26
+ * - string/number/mixed union → inline Input editor
27
+ * - array / literal / const / unknown → read-only viewer
28
+ * - role('secret') fields are masked and edited without prefill
29
+ * - every group row resets its subtree to defaults (Esc confirms)
30
+ *
31
+ * Writes go through `settings.mutate(ns, pathOps, expectedRevision)` so the
32
+ * user layer is edited by path without restating sections the UI never saw;
33
+ * the descriptor (and with it the revision) is re-read after every committed
34
+ * write, and failed writes revert the on-screen row and surface the error.
35
+ */
36
+ import { getPath, nodeAtPath, rehydrateSchema, } from '@deepseek-ai/dsh-client-schema-form';
37
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
38
+ import { getKeybindings, Input, SettingsList, } from '@earendil-works/pi-tui';
39
+ import { ansiBg, ansiFg, BOLD, RESET } from "./theme/index.js";
40
+ import { clipToWidth, visibleWidth } from "./text.js";
41
+ import { wrapFramedOverlay } from "./frame.js";
42
+ import { catalogEntry, deriveKeyRef, providerProfileFor, providerRowView, unconfiguredCatalogEntries, } from "./provider-catalog.js";
43
+ /**
44
+ * Static namespace→category mapping, mirroring the web settings page's
45
+ * client-side slot structure (see the module header): the data plane carries
46
+ * no category field, so the slots are maintained here by hand. Namespaces not
47
+ * listed anywhere fall into the trailing `other` category.
48
+ *
49
+ * Labels are English-only: pi-tui's SettingsList search matches the label
50
+ * text, so English queries (model, shell, permission, …) hit directly.
51
+ */
52
+ export const CATEGORY_MAP = [
53
+ { id: 'general', label: 'General', namespaces: ['permission', 'dsh-tui'] },
54
+ { id: 'models', label: 'Models', namespaces: ['llm-deepseek', 'llm-pi-ai', 'agent-default-model'] },
55
+ { id: 'plugins', label: 'Plugins', namespaces: ['shell', 'agent-loop', 'web-search-deepseek'] },
56
+ { id: 'agent', label: 'Agent Presets', namespaces: ['agent-presets'] },
57
+ ];
58
+ /** Cap for a category row's member-name description line. */
59
+ export const CATEGORY_DESC_MAX = 60;
60
+ /** Branded namespaces the Models category reads and writes. */
61
+ const NS_LLM_PI_AI = settingsNamespace('llm-pi-ai');
62
+ const NS_LLM_DEEPSEEK = settingsNamespace('llm-deepseek');
63
+ const NS_AGENT_DEFAULT_MODEL = settingsNamespace('agent-default-model');
64
+ /**
65
+ * Group a describe() namespace list into ordered categories — general, models,
66
+ * plugins, agent, then `other` for everything unmapped. Categories with no
67
+ * members are dropped, including `other` when nothing falls into it.
68
+ *
69
+ * Defensive: duplicate namespaces in the input count once (a namespace
70
+ * registered twice must not be listed twice), and the `mapped` guard resolves
71
+ * CATEGORY_MAP overlap — a namespace listed in several categories goes to the
72
+ * first one, by category order.
73
+ */
74
+ export function categorizeNamespaces(nses) {
75
+ const categories = [];
76
+ const mapped = new Set();
77
+ const input = new Set(nses);
78
+ for (const def of CATEGORY_MAP) {
79
+ const members = [...new Set(def.namespaces)].filter(ns => input.has(ns) && !mapped.has(ns));
80
+ if (members.length === 0)
81
+ continue;
82
+ for (const ns of members)
83
+ mapped.add(ns);
84
+ categories.push({ id: def.id, label: def.label, namespaces: members });
85
+ }
86
+ const others = [...input].filter(ns => !mapped.has(ns));
87
+ if (others.length > 0)
88
+ categories.push({ id: 'other', label: 'Other', namespaces: others });
89
+ return categories;
90
+ }
91
+ /**
92
+ * Description line for a category row: member names joined with ", ", capped
93
+ * at `max` columns. Namespace names are ASCII, so visible width equals char
94
+ * count here; the width-aware clip is used anyway so the cap semantics stay
95
+ * uniform with every other truncation in the TUI (an exactly-fitting string
96
+ * is kept whole, an ellipsis appears only when a column is free).
97
+ */
98
+ export function categoryDescription(namespaces, max = 60) {
99
+ const joined = [...new Set(namespaces)].join(', ');
100
+ return clipToWidth(joined, max);
101
+ }
102
+ // ----------------------------------------------------------------- pure helpers --
103
+ /** Human display for a resolved value (row value column; never re-scans anything). */
104
+ export function formatValue(value) {
105
+ if (value === undefined)
106
+ return '(unset)';
107
+ if (value === null)
108
+ return 'null';
109
+ if (typeof value === 'string')
110
+ return value;
111
+ if (typeof value === 'number' || typeof value === 'boolean')
112
+ return String(value);
113
+ if (Array.isArray(value))
114
+ return value.length === 0 ? '[]' : `[${value.length} items]`;
115
+ if (typeof value === 'object') {
116
+ const keys = Object.keys(value);
117
+ return keys.length === 0 ? '{}' : `{${keys.length} keys}`;
118
+ }
119
+ return String(value);
120
+ }
121
+ /** Display string for one union literal (the SettingsList cycle vocabulary). */
122
+ export function displayValue(value) {
123
+ return value === null ? 'null' : String(value);
124
+ }
125
+ /** Extract literal branches of a union node (`literal`/`const` members). */
126
+ export function unionLiterals(node) {
127
+ const list = node.list ?? [];
128
+ const values = [];
129
+ let all = list.length > 0;
130
+ for (const member of list) {
131
+ if (member.type === 'literal' || member.type === 'const')
132
+ values.push(member.value);
133
+ else
134
+ all = false;
135
+ }
136
+ return { values, all };
137
+ }
138
+ export function parseNumberInput(text) {
139
+ if (text.trim() === '')
140
+ return { kind: 'unset' };
141
+ // Literal decimal subset: hex/octal/binary prefixes and trailing garbage
142
+ // are rejected; decimal scientific notation stays (1e3 = 1000).
143
+ if (!/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(text.trim())) {
144
+ return { kind: 'error', error: `expected a number, got "${text}"` };
145
+ }
146
+ const n = Number(text);
147
+ if (!Number.isFinite(n))
148
+ return { kind: 'error', error: `expected a number, got "${text}"` };
149
+ return { kind: 'value', value: n };
150
+ }
151
+ export function parseStringInput(text) {
152
+ if (text.trim() === '')
153
+ return { kind: 'unset' };
154
+ // Keep the user's text verbatim — only the emptiness check trims.
155
+ return { kind: 'value', value: text };
156
+ }
157
+ /** Mixed (non-literal) union editing: JSON values, else a raw string branch. */
158
+ export function parseUnionInput(text, node) {
159
+ if (text.trim() === '')
160
+ return { kind: 'unset' };
161
+ try {
162
+ return { kind: 'value', value: JSON.parse(text) };
163
+ }
164
+ catch { /* not JSON — fall through to the string-branch path */ }
165
+ const hasStringBranch = (node.list ?? []).some(member => member.type === 'string' || (member.type === 'literal' && typeof member.value === 'string'));
166
+ if (hasStringBranch)
167
+ return { kind: 'value', value: text };
168
+ return { kind: 'error', error: 'expected a JSON value (number, boolean, string, …)' };
169
+ }
170
+ /** Seed value for a newly added dict key, from the inner schema. */
171
+ export function defaultValueFor(node) {
172
+ const meta = (node.meta ?? {});
173
+ if (meta.default !== undefined)
174
+ return meta.default;
175
+ switch (node.type) {
176
+ case 'boolean': return false;
177
+ case 'number': return 0;
178
+ case 'string': return '';
179
+ case 'array': return [];
180
+ case 'object':
181
+ case 'dict': return {};
182
+ case 'union': {
183
+ const { values } = unionLiterals(node);
184
+ return values.length > 0 ? values[0] : null;
185
+ }
186
+ default: return null;
187
+ }
188
+ }
189
+ function fieldMeta(node) {
190
+ return (node.meta ?? {});
191
+ }
192
+ /** Description line for one field row (static — built once per row). */
193
+ export function fieldDescription(node, userOverride) {
194
+ const meta = fieldMeta(node);
195
+ const parts = [];
196
+ const raw = meta.description ?? meta.comment;
197
+ if (typeof raw === 'string')
198
+ parts.push(raw);
199
+ else if (raw !== undefined && typeof raw === 'object') {
200
+ parts.push(raw['en'] ?? raw[''] ?? Object.values(raw)[0] ?? '');
201
+ }
202
+ if (meta.required === true)
203
+ parts.push('required');
204
+ if (meta.default !== undefined)
205
+ parts.push(`default: ${formatValue(meta.default)}`);
206
+ if (meta.min !== undefined)
207
+ parts.push(`min: ${meta.min}`);
208
+ if (meta.max !== undefined)
209
+ parts.push(`max: ${meta.max}`);
210
+ if (meta.step !== undefined)
211
+ parts.push(`step: ${meta.step}`);
212
+ if (meta.pattern !== undefined)
213
+ parts.push(`pattern: ${meta.pattern.source}`);
214
+ if (meta.role === 'secret')
215
+ parts.push('secret');
216
+ if (meta.disabled === true)
217
+ parts.push('disabled');
218
+ for (const badge of meta.badges ?? []) {
219
+ if (typeof badge.text === 'string')
220
+ parts.push(badge.text);
221
+ }
222
+ if (userOverride)
223
+ parts.push('user-set');
224
+ return parts.filter(part => part !== '').join(' · ');
225
+ }
226
+ /** Inline value editor: title, current-value line, error/notice line, Input. */
227
+ export class EditField {
228
+ tui;
229
+ options;
230
+ input;
231
+ error;
232
+ /** Non-error commit message (success with a follow-up hint, see CommitResult). */
233
+ notice;
234
+ /** Set while a commit write is in flight; extra Enter presses are ignored. */
235
+ pending = false;
236
+ /** Guards onDone: exactly one terminal transition (submit success/keep/escape). */
237
+ done = false;
238
+ fg;
239
+ fgMuted;
240
+ fgDanger;
241
+ /** Popup-surface background for the secret mask line. */
242
+ maskBg;
243
+ constructor(tui, options, theme) {
244
+ this.tui = tui;
245
+ this.options = options;
246
+ this.fg = text => ansiFg(theme.palette.accent) + text + RESET;
247
+ this.fgMuted = text => ansiFg(theme.palette.fgMuted) + text + RESET;
248
+ this.fgDanger = text => ansiFg(theme.palette.danger) + text + RESET;
249
+ // Same canvasSubtle backdrop the browser's listTheme paints on every row.
250
+ this.maskBg = text => ansiBg(theme.palette.canvasSubtle) + text + RESET;
251
+ this.input = new Input();
252
+ this.input.setValue(options.initial);
253
+ this.input.cursor = options.initial.length;
254
+ // Not the TUI's focused component (the overlay list is) — flip the flag
255
+ // so Input renders its cursor marker inside the submenu.
256
+ this.input.focused = true;
257
+ this.input.onSubmit = (text) => {
258
+ if (this.pending)
259
+ return;
260
+ const parsed = options.parse(text);
261
+ if (parsed.kind === 'error') {
262
+ this.error = parsed.error;
263
+ this.notice = undefined;
264
+ this.tui.requestRender();
265
+ return;
266
+ }
267
+ if (parsed.kind === 'keep') {
268
+ this.finish();
269
+ return;
270
+ }
271
+ this.pending = true;
272
+ void options.onCommit(parsed).then(result => {
273
+ this.pending = false;
274
+ if (this.done) {
275
+ // Esc already closed the editor while the write was in flight —
276
+ // the component renders for nobody, so a late failure is surfaced
277
+ // through onError instead of this.error.
278
+ if (result?.error !== undefined)
279
+ options.onError?.(result.error);
280
+ return;
281
+ }
282
+ if (result?.error !== undefined) {
283
+ this.error = result.error;
284
+ this.notice = undefined;
285
+ this.tui.requestRender();
286
+ }
287
+ else if (result?.notice !== undefined) {
288
+ // Success with a hint: stay open so the hint is readable (Enter
289
+ // re-runs the commit idempotently, Esc exits) — no ✘ marker.
290
+ this.error = undefined;
291
+ this.notice = result.notice;
292
+ this.tui.requestRender();
293
+ }
294
+ else {
295
+ this.finish();
296
+ }
297
+ });
298
+ };
299
+ this.input.onEscape = () => {
300
+ // Closing during a pending write is allowed (the write is already in
301
+ // the serialized chain — its outcome is undeliverable through this
302
+ // component either way); the done flag keeps onDone single-shot.
303
+ this.finish();
304
+ };
305
+ }
306
+ /** Terminal transition — runs the caller's onDone exactly once. */
307
+ finish() {
308
+ if (this.done)
309
+ return;
310
+ this.done = true;
311
+ this.options.onDone();
312
+ }
313
+ invalidate() { }
314
+ render(width) {
315
+ const lines = [];
316
+ lines.push(this.fg(BOLD + `✎ ${this.options.title}` + RESET));
317
+ lines.push(this.fgMuted(this.options.subtitle));
318
+ if (this.error !== undefined)
319
+ lines.push(this.fgDanger(`✘ ${this.error}`));
320
+ else if (this.notice !== undefined)
321
+ lines.push(this.fgMuted(this.notice));
322
+ lines.push('');
323
+ if (this.options.secret === true) {
324
+ lines.push(this.maskLine(width));
325
+ }
326
+ else {
327
+ lines.push(...this.input.render(width));
328
+ }
329
+ return lines;
330
+ }
331
+ /**
332
+ * Masked input line for secret fields: a dot row (one dot per visible
333
+ * column of the value, capped to the popup width) with a `▎` marker at the
334
+ * cursor position. The real cursor is not rendered — the marker only hints
335
+ * at the editing position; input semantics live in the internal Input.
336
+ */
337
+ maskLine(width) {
338
+ const maxDots = Math.max(0, width - 2);
339
+ const dots = '•'.repeat(Math.min(maxDots, visibleWidth(this.input.getValue())));
340
+ const cursor = Math.min(this.input.cursor, dots.length);
341
+ return this.maskBg(dots.slice(0, cursor) + '▎' + dots.slice(cursor));
342
+ }
343
+ handleInput(data) {
344
+ this.input.handleInput(data);
345
+ }
346
+ }
347
+ /** Two-line confirmation for destructive resets (Enter confirms, Esc cancels). */
348
+ class ConfirmReset {
349
+ theme;
350
+ label;
351
+ onConfirm;
352
+ onCancel;
353
+ /** Set while the confirmed write is in flight; extra Enter presses are ignored. */
354
+ pending = false;
355
+ constructor(theme, label, onConfirm, onCancel) {
356
+ this.theme = theme;
357
+ this.label = label;
358
+ this.onConfirm = onConfirm;
359
+ this.onCancel = onCancel;
360
+ }
361
+ invalidate() { }
362
+ render(_width) {
363
+ const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
364
+ return [
365
+ fg(this.theme.palette.attention)(BOLD + `↺ ${this.label}` + RESET),
366
+ '',
367
+ fg(this.theme.palette.fgMuted)(this.pending
368
+ ? ' resetting…'
369
+ : ' Enter: reset to defaults · Esc: cancel'),
370
+ ];
371
+ }
372
+ handleInput(data) {
373
+ // While the confirmed write is in flight the commit is already in the
374
+ // serialized chain and cannot be undone — pretending Esc cancels would
375
+ // be a lie, so every key is ignored until the write settles.
376
+ if (this.pending)
377
+ return;
378
+ if (getKeybindings().matches(data, 'tui.select.confirm')) {
379
+ this.pending = true;
380
+ this.onConfirm();
381
+ }
382
+ else if (getKeybindings().matches(data, 'tui.select.cancel')) {
383
+ this.onCancel();
384
+ }
385
+ }
386
+ }
387
+ /** Read-only JSON view for array / literal / unknown nodes. */
388
+ class ReadOnlyViewer {
389
+ theme;
390
+ label;
391
+ json;
392
+ onClose;
393
+ constructor(theme, label, json, onClose) {
394
+ this.theme = theme;
395
+ this.label = label;
396
+ this.json = json;
397
+ this.onClose = onClose;
398
+ }
399
+ invalidate() { }
400
+ render(width) {
401
+ const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
402
+ const lines = [
403
+ fg(this.theme.palette.accent)(BOLD + `ⓘ ${this.label}` + RESET),
404
+ '',
405
+ ];
406
+ const text = JSON.stringify(this.json, null, 2);
407
+ const max = Math.max(2, width - 2);
408
+ for (const line of text.split('\n').slice(0, 40)) {
409
+ lines.push(fg(this.theme.palette.fgMuted)(clipToWidth(line, max)));
410
+ }
411
+ lines.push('');
412
+ lines.push(fg(this.theme.palette.fgSubtle)(' read-only in the TUI — edit the settings document to change it · Esc to close'));
413
+ return lines;
414
+ }
415
+ handleInput(data) {
416
+ if (getKeybindings().matches(data, 'tui.select.cancel')
417
+ || getKeybindings().matches(data, 'tui.select.confirm')) {
418
+ this.onClose();
419
+ }
420
+ }
421
+ }
422
+ /**
423
+ * Swappable shell around the Models category's SettingsList. Provider rows
424
+ * change structurally — a new provider row must appear after an add, and
425
+ * SettingsList.updateValue cannot express that — so the shell swaps in a
426
+ * freshly built list while staying the category list's stable submenu
427
+ * component.
428
+ */
429
+ class ModelsCategoryView {
430
+ list;
431
+ swap(list) {
432
+ this.list = list;
433
+ }
434
+ invalidate() {
435
+ this.list?.invalidate();
436
+ }
437
+ render(width) {
438
+ return this.list === undefined ? [] : this.list.render(width);
439
+ }
440
+ handleInput(data) {
441
+ this.list?.handleInput(data);
442
+ }
443
+ }
444
+ /**
445
+ * Add-provider flow for the Models category — the terminal counterpart of
446
+ * pi-agent's /login, trimmed to the information a user actually needs: pick
447
+ * a provider from the built-in directory (searchable, oauth-selector-style
448
+ * title line), enter exactly one API key, done. The key editor reuses
449
+ * EditField (pending guard, late-error sink); the picker is a searchable
450
+ * SettingsList of the unconfigured directory entries.
451
+ */
452
+ class AddProviderFlow {
453
+ tui;
454
+ theme;
455
+ list;
456
+ empty;
457
+ onExit;
458
+ fg;
459
+ fgMuted;
460
+ constructor(tui, theme, listTheme, options) {
461
+ this.tui = tui;
462
+ this.theme = theme;
463
+ this.empty = options.entries.length === 0;
464
+ this.onExit = options.onExit;
465
+ this.fg = text => ansiFg(theme.palette.accent) + text + RESET;
466
+ this.fgMuted = text => ansiFg(theme.palette.fgMuted) + text + RESET;
467
+ this.list = new SettingsList(options.entries.map(entry => ({
468
+ id: entry.id,
469
+ label: entry.name,
470
+ currentValue: '',
471
+ description: entry.hint,
472
+ submenu: (_current, done) => this.keyEditor(entry, options, done),
473
+ })), 12, listTheme, () => { }, () => options.onExit(), { enableSearch: true });
474
+ }
475
+ /** Key editor for one directory entry; commits through the write chain. */
476
+ keyEditor(entry, options, done) {
477
+ const ref = deriveKeyRef(entry.id);
478
+ return new EditField(this.tui, {
479
+ title: `API key for ${entry.name}`,
480
+ subtitle: `stored as ${ref} — never written to settings.yaml`,
481
+ initial: '',
482
+ // Never echo the key — masked dot row (B2).
483
+ secret: true,
484
+ parse: text => {
485
+ // Unlike an edit of a stored secret, the key is required here: a
486
+ // route with no key address cannot serve a request.
487
+ if (text.trim() === '')
488
+ return { kind: 'error', error: 'API key must not be empty' };
489
+ return { kind: 'value', value: text.trim() };
490
+ },
491
+ onCommit: async (outcome) => {
492
+ if (outcome.kind !== 'value')
493
+ return undefined;
494
+ return options.onCommit(entry, String(outcome.value));
495
+ },
496
+ // Enter and Esc both leave the flow (the commit is already in the
497
+ // serialized chain either way); the Models list refreshes on the way
498
+ // out so the new row is visible immediately.
499
+ onDone: () => {
500
+ done();
501
+ options.onExit();
502
+ },
503
+ onError: message => options.onError(message),
504
+ }, this.theme);
505
+ }
506
+ invalidate() {
507
+ this.list.invalidate();
508
+ }
509
+ render(width) {
510
+ const title = this.fg(BOLD + 'Select provider to configure:' + RESET);
511
+ if (this.empty) {
512
+ return [
513
+ title,
514
+ '',
515
+ this.fgMuted('All built-in providers are already configured.'),
516
+ this.fgMuted(' Esc to close'),
517
+ ];
518
+ }
519
+ return [title, '', ...this.list.render(width)];
520
+ }
521
+ handleInput(data) {
522
+ if (this.empty) {
523
+ if (getKeybindings().matches(data, 'tui.select.cancel'))
524
+ this.onExit();
525
+ return;
526
+ }
527
+ this.list.handleInput(data);
528
+ }
529
+ }
530
+ /**
531
+ * Open the modal settings browser. Resolves when it closes with the number of
532
+ * committed writes, or -1 when no namespace is registered (nothing to show).
533
+ */
534
+ export async function openSettingsBrowser(options) {
535
+ const settings = options.ctx.get('settings');
536
+ if (settings === undefined)
537
+ throw new Error('settings service is not available');
538
+ const browser = new SettingsBrowser({ ...options, settings });
539
+ return browser.open();
540
+ }
541
+ class SettingsBrowser {
542
+ ctx;
543
+ tui;
544
+ theme;
545
+ listTheme;
546
+ settings;
547
+ restoreFocus;
548
+ onError;
549
+ descriptors = [];
550
+ /** Rehydrated schema roots, cached per namespace (schemas never change). */
551
+ roots = new Map();
552
+ changes = { value: 0 };
553
+ overlay;
554
+ catList;
555
+ nsList;
556
+ modelsView;
557
+ modelsExit;
558
+ /**
559
+ * Credential refs just stored by the add flow (possibly several in one
560
+ * browser session) — their rows read as key set via the merged env.
561
+ */
562
+ justStoredRefs = new Set();
563
+ /**
564
+ * Credential-document configuration snapshot, prefetched once per Models
565
+ * category open: ref → a value would resolve (`describe().configured`).
566
+ * Row building stays synchronous — the prefetch only fills this map and
567
+ * re-swaps the list when it settles.
568
+ */
569
+ credentialConfigured = new Map();
570
+ writeChain = Promise.resolve();
571
+ closed;
572
+ closeResolve;
573
+ constructor(options) {
574
+ this.ctx = options.ctx;
575
+ this.tui = options.tui;
576
+ this.theme = options.theme;
577
+ this.settings = options.settings;
578
+ this.restoreFocus = options.restoreFocus;
579
+ this.onError = options.onError;
580
+ // Assigned here, not as a field initializer: a later field declaration
581
+ // would `defineProperty(…, undefined)` over the promise's resolve.
582
+ this.closed = new Promise(resolve => { this.closeResolve = resolve; });
583
+ const p = options.theme.palette;
584
+ const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
585
+ // canvasSubtle backdrop for every browser line. Raw lines (the search
586
+ // Input row, the bare "" separators from settings-list.js renderMainList,
587
+ // AddProviderFlow's title/blank lines, EditField's title/subtitle/error/
588
+ // notice rows) carry no theme styling of their own — the framed overlay
589
+ // (frame.ts fillLine) paints the full-width backdrop under them, so the
590
+ // popup stays one solid panel surface.
591
+ const bg = (hex) => (text) => ansiBg(hex) + text + RESET;
592
+ this.listTheme = {
593
+ label: (text, selected) => bg(p.canvasSubtle)(fg(p.fgDefault)(selected ? BOLD + text + RESET : text)),
594
+ value: (text, selected) => bg(p.canvasSubtle)(fg(selected ? p.accent : p.fgMuted)(text)),
595
+ description: text => bg(p.canvasSubtle)(fg(p.fgSubtle)(text)),
596
+ cursor: bg(p.canvasSubtle)(fg(p.accent)(BOLD + '▸ ')),
597
+ hint: text => bg(p.canvasSubtle)(fg(p.fgSubtle)(text)),
598
+ };
599
+ }
600
+ async open() {
601
+ this.refresh();
602
+ if (this.categories().length === 0)
603
+ return -1;
604
+ const list = this.categoryList();
605
+ this.catList = list;
606
+ // The framed overlay adds 4 rows (borders + spacers) on top of the list;
607
+ // the cap must leave them room or the bottom border is sliced off on
608
+ // small terminals (24 rows: ~15 list rows + 4 frame rows ≤ 19).
609
+ this.overlay = this.tui.showOverlay(wrapFramedOverlay(this.theme, list), { width: '80%', maxHeight: '80%' });
610
+ await this.closed;
611
+ return this.changes.value;
612
+ }
613
+ close() {
614
+ this.overlay?.hide();
615
+ this.overlay = undefined;
616
+ this.catList = undefined;
617
+ this.nsList = undefined;
618
+ this.modelsView = undefined;
619
+ this.modelsExit = undefined;
620
+ this.justStoredRefs.clear();
621
+ this.restoreFocus();
622
+ this.closeResolve();
623
+ }
624
+ /** Re-read descriptors (and with them revisions) from the service. */
625
+ refresh() {
626
+ this.descriptors = this.settings.describe();
627
+ }
628
+ descriptor(ns) {
629
+ return this.descriptors.find(d => d.ns === ns);
630
+ }
631
+ root(ns) {
632
+ const cached = this.roots.get(ns);
633
+ if (cached !== undefined)
634
+ return cached;
635
+ const desc = this.descriptor(ns);
636
+ if (desc === undefined)
637
+ return undefined;
638
+ const root = rehydrateSchema(desc.schema);
639
+ this.roots.set(ns, root);
640
+ return root;
641
+ }
642
+ // ------------------------------------------------------------ category level --
643
+ categories() {
644
+ return categorizeNamespaces(this.descriptors.map(d => d.ns));
645
+ }
646
+ categorySummary(cat) {
647
+ return `${cat.namespaces.length} namespaces`;
648
+ }
649
+ categoryDescription(cat) {
650
+ return categoryDescription(cat.namespaces, CATEGORY_DESC_MAX);
651
+ }
652
+ categoryList() {
653
+ const items = this.categories().map(cat => ({
654
+ id: cat.id,
655
+ label: cat.label,
656
+ currentValue: this.categorySummary(cat),
657
+ description: this.categoryDescription(cat),
658
+ submenu: cat.id === 'models'
659
+ ? (_current, done) => this.openModelsSubmenu(done)
660
+ : (_current, done) => {
661
+ const list = this.namespaceList(this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
662
+ this.nsList = list;
663
+ return list;
664
+ },
665
+ }));
666
+ const list = new SettingsList(items, 10, this.listTheme, () => { }, () => this.close(), { enableSearch: true });
667
+ return list;
668
+ }
669
+ refreshCategoryList() {
670
+ if (this.catList === undefined)
671
+ return;
672
+ this.refresh();
673
+ for (const cat of this.categories()) {
674
+ this.catList.updateValue(cat.id, this.categorySummary(cat));
675
+ }
676
+ }
677
+ // ------------------------------------------------------------ namespace level --
678
+ nsSummary(desc) {
679
+ const root = this.root(desc.ns);
680
+ if (root !== undefined && root.type === 'object') {
681
+ return `${Object.keys(root.dict ?? {}).length} fields`;
682
+ }
683
+ return formatValue(desc.value);
684
+ }
685
+ nsDescription(desc) {
686
+ const parts = [`applies: ${desc.applies}`];
687
+ if (desc.user !== undefined)
688
+ parts.push('user-set');
689
+ return parts.join(' · ');
690
+ }
691
+ /** Namespace list for one category; Esc pops back to the category level. */
692
+ namespaceList(descriptors, onExit) {
693
+ const items = descriptors.map(desc => ({
694
+ id: desc.ns,
695
+ label: desc.ns,
696
+ currentValue: this.nsSummary(desc),
697
+ description: this.nsDescription(desc),
698
+ submenu: (_current, done) => {
699
+ const section = this.sectionList(desc.ns, [], () => {
700
+ this.refreshNsList();
701
+ done();
702
+ });
703
+ return section.list;
704
+ },
705
+ }));
706
+ const list = new SettingsList(items, 10, this.listTheme, () => { }, () => {
707
+ this.refreshCategoryList();
708
+ onExit();
709
+ }, { enableSearch: true });
710
+ return list;
711
+ }
712
+ refreshNsList() {
713
+ if (this.nsList === undefined)
714
+ return;
715
+ this.refresh();
716
+ for (const desc of this.descriptors) {
717
+ this.nsList.updateValue(desc.ns, this.nsSummary(desc));
718
+ }
719
+ }
720
+ // ------------------------------------------------------------ models category --
721
+ /**
722
+ * The Models category does not expose the raw llm-pi-ai namespace: it
723
+ * lists one row per configured provider (label, model summary, API-key
724
+ * state), keeps dedicated rows for llm-deepseek and agent-default-model
725
+ * (each drilling into its original field editor), and ends with the
726
+ * Add-provider action. The list is rebuilt on every return because adding
727
+ * a provider changes it structurally.
728
+ */
729
+ openModelsSubmenu(done) {
730
+ this.modelsExit = () => {
731
+ this.refreshCategoryList();
732
+ done();
733
+ };
734
+ const view = new ModelsCategoryView();
735
+ this.modelsView = view;
736
+ view.swap(this.buildModelsList());
737
+ // The status column reads env + just-stored refs synchronously; the
738
+ // credentials document (`.credentials.yaml`) needs one async probe per
739
+ // ref, prefetched here so rows built later show the real state.
740
+ this.prefetchCredentialStatus();
741
+ return view;
742
+ }
743
+ /**
744
+ * Probe the credentials service for every provider ref the llm-pi-ai
745
+ * namespace references and remember the outcome. Called when the Models
746
+ * category opens (and again on each re-open, so keys added through other
747
+ * surfaces show up); row building stays synchronous. A settling probe
748
+ * re-swaps the Models list only while the category is still open.
749
+ */
750
+ prefetchCredentialStatus() {
751
+ const credentials = this.ctx.get('credentials');
752
+ if (credentials?.describe === undefined)
753
+ return;
754
+ const view = this.modelsView;
755
+ if (view === undefined)
756
+ return;
757
+ const piDesc = this.descriptor(NS_LLM_PI_AI);
758
+ const providers = (piDesc?.value ?? {});
759
+ const refs = [...new Set(Object.values(providers.providers ?? {})
760
+ .map(p => (typeof p === 'object' && p !== null ? p.apiKeyEnv : undefined))
761
+ .filter((ref) => typeof ref === 'string' && ref !== ''))];
762
+ if (refs.length === 0)
763
+ return;
764
+ void Promise.all(refs.map(async (ref) => {
765
+ try {
766
+ const info = await credentials.describe(ref);
767
+ this.credentialConfigured.set(ref, info.configured === true);
768
+ }
769
+ catch {
770
+ // A failing probe must not break the Models view — the row keeps
771
+ // its env-based read.
772
+ }
773
+ })).then(() => {
774
+ if (this.modelsView === view)
775
+ this.refreshModelsView();
776
+ });
777
+ }
778
+ /**
779
+ * Rebuild the Models list in place after a structural change.
780
+ * `justStoredRefs` survives the rebuild (cleared on close): the merged env
781
+ * marks the just-added rows as configured — the keys are stored in the
782
+ * credentials document, not in process.env — and stays accurate as long as
783
+ * the credentials do. Idempotent: each call re-reads descriptors and swaps
784
+ * one freshly built list.
785
+ */
786
+ refreshModelsView() {
787
+ const view = this.modelsView;
788
+ if (view === undefined)
789
+ return;
790
+ this.refresh();
791
+ view.swap(this.buildModelsList());
792
+ }
793
+ /**
794
+ * Environment view handed to providerRowView: process.env, plus every
795
+ * ref the add flow stored this session and every ref the credential probe
796
+ * found configured — both live in the credentials document, not in
797
+ * process.env, so their rows would otherwise read `API key missing`.
798
+ */
799
+ mergedEnv() {
800
+ const extra = {};
801
+ for (const ref of this.justStoredRefs)
802
+ extra[ref] = 'stored';
803
+ for (const [ref, configured] of this.credentialConfigured) {
804
+ if (configured)
805
+ extra[ref] = 'stored';
806
+ }
807
+ return Object.keys(extra).length === 0 ? process.env : { ...process.env, ...extra };
808
+ }
809
+ /**
810
+ * Live configurable-provider directory: route key → catalog-served? The
811
+ * llm service owns the catalog distinction for routes the static catalog
812
+ * does not name, so a web-added gateway row still gets an honest summary.
813
+ * Only llm-pi-ai's own directory entries are consulted — another namespace's
814
+ * configurable providers (e.g. llm-deepseek) configure a different surface.
815
+ * Structural face kept local like the credentials seam; absent or failing
816
+ * service degrades to an empty map (rows then fall back to the static
817
+ * catalog).
818
+ */
819
+ providerDirectory() {
820
+ const llm = this.ctx.get('llm');
821
+ if (llm?.listConfigurableProviders === undefined)
822
+ return new Map();
823
+ try {
824
+ return new Map(llm.listConfigurableProviders()
825
+ .filter(entry => entry.settingsNs === NS_LLM_PI_AI)
826
+ .map(entry => [entry.provider, entry.declared !== true]));
827
+ }
828
+ catch {
829
+ return new Map();
830
+ }
831
+ }
832
+ buildModelsList() {
833
+ const exit = this.modelsExit ?? (() => { });
834
+ const items = [];
835
+ const directory = this.providerDirectory();
836
+ const piDesc = this.descriptor(NS_LLM_PI_AI);
837
+ if (piDesc !== undefined) {
838
+ const providers = (piDesc.value ?? {});
839
+ const entries = Object.entries(providers.providers ?? {});
840
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
841
+ // Keys stored by the add flow live in the credentials document, not
842
+ // in process.env — merge them all so their rows read as configured
843
+ // right away (a second provider added in the same session too), and
844
+ // merge the prefetched credential state so keys stored earlier (e.g.
845
+ // through the web Models page) read as set instead of missing.
846
+ const env = this.mergedEnv();
847
+ for (const [id, profile] of entries) {
848
+ // The static catalog names the routes the TUI can add; the live llm
849
+ // directory tells catalog-served from hand-declared for every other
850
+ // configured route (e.g. one added through the web Models page), so
851
+ // the summary does not claim `0 models` for a route the catalog
852
+ // actually serves.
853
+ const entry = catalogEntry(id) ?? {
854
+ id,
855
+ name: id,
856
+ hint: '',
857
+ catalogRoute: directory.get(id) ?? false,
858
+ };
859
+ const view = providerRowView(id, entry, profile, env);
860
+ items.push({
861
+ id: `provider:${id}`,
862
+ label: view.label,
863
+ currentValue: view.summary,
864
+ description: view.status,
865
+ // Read-only: the raw llm-pi-ai fields are deliberately not editable
866
+ // here — Enter shows the stored profile, nothing more. A "re-store
867
+ // the key" action (reusing keyEditor's EditField, credentials.set
868
+ // only) was considered for rows whose key never landed (B3) but
869
+ // needs a multi-action submenu component (~60 lines); skipped —
870
+ // the commit failure text names the manual fallback instead.
871
+ submenu: (_current, done) => new ReadOnlyViewer(this.theme, `providers.${id}`, profile, done),
872
+ });
873
+ }
874
+ }
875
+ const deepseekDesc = this.descriptor(NS_LLM_DEEPSEEK);
876
+ if (deepseekDesc !== undefined) {
877
+ items.push({
878
+ id: 'llm-deepseek',
879
+ label: 'DeepSeek (official)',
880
+ currentValue: this.nsSummary(deepseekDesc),
881
+ description: this.nsDescription(deepseekDesc),
882
+ submenu: (_current, done) => {
883
+ const section = this.sectionList(deepseekDesc.ns, [], () => {
884
+ this.refreshModelsView();
885
+ done();
886
+ });
887
+ return section.list;
888
+ },
889
+ });
890
+ }
891
+ const agentDesc = this.descriptor(NS_AGENT_DEFAULT_MODEL);
892
+ if (agentDesc !== undefined) {
893
+ items.push({
894
+ id: 'agent-default-model',
895
+ label: 'Default model',
896
+ currentValue: this.defaultModelSummary(agentDesc),
897
+ description: this.nsDescription(agentDesc),
898
+ submenu: (_current, done) => {
899
+ const section = this.sectionList(agentDesc.ns, [], () => {
900
+ this.refreshModelsView();
901
+ done();
902
+ });
903
+ return section.list;
904
+ },
905
+ });
906
+ }
907
+ if (piDesc !== undefined) {
908
+ const providers = (piDesc.value ?? {});
909
+ const configured = new Set(Object.keys(providers.providers ?? {}));
910
+ items.push({
911
+ id: '\u0000add-provider',
912
+ label: '+ Add provider…',
913
+ currentValue: '',
914
+ description: 'configure a built-in provider with its API key',
915
+ submenu: (_current, done) => new AddProviderFlow(this.tui, this.theme, this.listTheme, {
916
+ entries: unconfiguredCatalogEntries(configured),
917
+ onCommit: (entry, key) => this.commitNewProvider(entry, key),
918
+ onExit: () => {
919
+ done();
920
+ this.refreshModelsView();
921
+ },
922
+ onError: message => this.onError(message),
923
+ }),
924
+ });
925
+ }
926
+ return new SettingsList(items, 12, this.listTheme, () => { },
927
+ // exit() (the modelsExit hook) already refreshes the category list —
928
+ // calling it again here would double-refresh (C8).
929
+ () => { exit(); }, { enableSearch: true });
930
+ }
931
+ /** Value column of the Default model row: provider/model · think level. */
932
+ defaultModelSummary(desc) {
933
+ const value = desc.value;
934
+ const provider = value?.provider;
935
+ const model = value?.model;
936
+ if (typeof provider === 'string' && provider !== '' && typeof model === 'string' && model !== '') {
937
+ const effort = value?.reasoningEffort;
938
+ return typeof effort === 'string' && effort !== ''
939
+ ? `${provider}/${model} · think ${effort}`
940
+ : `${provider}/${model}`;
941
+ }
942
+ return formatValue(desc.value);
943
+ }
944
+ // ------------------------------------------------------------- section levels --
945
+ /**
946
+ * Build the SettingsList for one schema node at `path` of `ns`.
947
+ * `onExit` runs when the list is popped (Esc) — it must refresh the parent
948
+ * level and call the parent's submenu `done()`.
949
+ */
950
+ sectionList(ns, path, onExit) {
951
+ const root = this.root(ns);
952
+ const desc = this.descriptor(ns);
953
+ const node = root !== undefined && path.length > 0
954
+ ? (nodeAtPath(root, path) ?? root)
955
+ : root;
956
+ const rows = node === undefined ? [] : this.buildRows(ns, node, path, desc?.value);
957
+ const refresh = () => { this.refreshRows(rows, list); };
958
+ const items = rows.map(row => this.rowItem(row, refresh));
959
+ const list = new SettingsList(items, 12, this.listTheme, (id, newValue) => { void this.onCycle(rows, list, id, newValue); }, () => {
960
+ refresh();
961
+ onExit();
962
+ }, { enableSearch: true });
963
+ return { list, refresh };
964
+ }
965
+ buildRows(ns, node, path, value) {
966
+ const rows = [];
967
+ if (node.type === 'object' || node.type === 'dict') {
968
+ rows.push({
969
+ id: '\u0000reset',
970
+ ns,
971
+ path,
972
+ label: `↺ Reset ${path.length === 0 ? 'this namespace' : path.join('.')} to defaults`,
973
+ kind: 'reset',
974
+ node,
975
+ value: undefined,
976
+ display: '',
977
+ });
978
+ if (node.type === 'dict') {
979
+ rows.push({
980
+ id: '\u0000add',
981
+ ns,
982
+ path,
983
+ label: '+ Add key…',
984
+ kind: 'addkey',
985
+ node,
986
+ value: undefined,
987
+ display: '',
988
+ });
989
+ }
990
+ }
991
+ if (node.type === 'object') {
992
+ for (const [key, child] of Object.entries(node.dict ?? {})) {
993
+ if (fieldMeta(child).hidden === true)
994
+ continue;
995
+ rows.push(this.fieldRow(ns, child, [...path, key], key));
996
+ }
997
+ }
998
+ else if (node.type === 'dict') {
999
+ const entries = Object.entries((getPath(value, path) ?? {}));
1000
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1001
+ const inner = node.inner ?? node;
1002
+ for (const [key] of entries) {
1003
+ rows.push(this.fieldRow(ns, inner, [...path, key], key));
1004
+ }
1005
+ }
1006
+ return rows;
1007
+ }
1008
+ fieldRow(ns, node, path, label) {
1009
+ const desc = this.descriptor(ns);
1010
+ const meta = fieldMeta(node);
1011
+ const value = getPath(desc?.value, path);
1012
+ const secret = meta.role === 'secret';
1013
+ const kind = this.rowKindFor(node);
1014
+ const row = {
1015
+ // JSON-encoded path: a dict key containing '.' must not collide with
1016
+ // the nested-path row id of the same spelling.
1017
+ id: JSON.stringify(path),
1018
+ ns,
1019
+ path,
1020
+ label,
1021
+ kind,
1022
+ node,
1023
+ value,
1024
+ display: this.computeDisplay(kind, node, value, secret),
1025
+ secret,
1026
+ };
1027
+ if (kind === 'cycle') {
1028
+ row.values = this.cycleValues(node);
1029
+ row.toRaw = display => this.cycleToRaw(node, display);
1030
+ }
1031
+ return row;
1032
+ }
1033
+ rowKindFor(node) {
1034
+ switch (node.type) {
1035
+ case 'object':
1036
+ case 'dict': return 'drill';
1037
+ case 'array': return 'readonly';
1038
+ case 'boolean': return 'cycle';
1039
+ case 'string': return 'input';
1040
+ case 'number': return 'input';
1041
+ case 'union': return unionLiterals(node).all ? 'cycle' : 'input';
1042
+ case 'transform': return node.inner === undefined ? 'readonly' : this.rowKindFor(node.inner);
1043
+ default: return 'readonly'; // literal, const, is, intersect, tuple, …
1044
+ }
1045
+ }
1046
+ cycleValues(node) {
1047
+ if (node.type === 'boolean')
1048
+ return ['true', 'false'];
1049
+ return unionLiterals(node).values.map(displayValue);
1050
+ }
1051
+ cycleToRaw(node, display) {
1052
+ if (node.type === 'boolean')
1053
+ return display === 'true';
1054
+ const { values } = unionLiterals(node);
1055
+ const literal = values.find(value => displayValue(value) === display);
1056
+ if (literal === undefined)
1057
+ throw new Error(`unexpected cycle value: ${display}`);
1058
+ return literal;
1059
+ }
1060
+ computeDisplay(kind, node, value, secret) {
1061
+ if (secret)
1062
+ return value === undefined || value === null || value === '' ? '(unset)' : '••••••';
1063
+ switch (kind) {
1064
+ case 'drill': {
1065
+ if (node.type === 'object')
1066
+ return `{${Object.keys(node.dict ?? {}).length} fields}`;
1067
+ if (node.type === 'dict') {
1068
+ const entries = Object.keys((value ?? {}));
1069
+ return `dict · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
1070
+ }
1071
+ return formatValue(value);
1072
+ }
1073
+ default: return formatValue(value);
1074
+ }
1075
+ }
1076
+ /** SettingItem for one row; drill/input/reset/addkey attach their submenus. */
1077
+ rowItem(row, refresh) {
1078
+ const base = {
1079
+ id: row.id,
1080
+ label: row.label,
1081
+ currentValue: row.display,
1082
+ description: this.rowDescription(row),
1083
+ };
1084
+ switch (row.kind) {
1085
+ case 'cycle': return { ...base, values: row.values };
1086
+ case 'drill': return {
1087
+ ...base,
1088
+ submenu: (_current, done) => {
1089
+ const child = this.sectionList(row.ns, row.path, () => { refresh(); done(); });
1090
+ return child.list;
1091
+ },
1092
+ };
1093
+ case 'input': return { ...base, submenu: (_current, done) => this.inputSubmenu(row, refresh, done) };
1094
+ case 'addkey': return { ...base, submenu: (_current, done) => this.addKeySubmenu(row, refresh, done) };
1095
+ case 'reset': return {
1096
+ ...base,
1097
+ submenu: (_current, done) => this.resetSubmenu(row, refresh, done),
1098
+ };
1099
+ // Read-only rows still open the JSON viewer — Enter shows the value in
1100
+ // full instead of silently doing nothing (the module header's promise).
1101
+ default: return {
1102
+ ...base,
1103
+ submenu: (_current, done) => new ReadOnlyViewer(this.theme, row.path.join('.'), row.value, done),
1104
+ };
1105
+ }
1106
+ }
1107
+ rowDescription(row) {
1108
+ if (row.kind === 'reset' || row.kind === 'addkey')
1109
+ return '';
1110
+ const desc = this.descriptor(row.ns);
1111
+ const userOverride = desc?.user !== undefined && this.hasPath(desc.user, row.path);
1112
+ return fieldDescription(row.node, userOverride);
1113
+ }
1114
+ hasPath(user, path) {
1115
+ if (path.length === 0)
1116
+ return user !== undefined;
1117
+ let current = user;
1118
+ for (const key of path) {
1119
+ if (Array.isArray(current)) {
1120
+ current = current[Number(key)];
1121
+ continue;
1122
+ }
1123
+ if (typeof current !== 'object' || current === null)
1124
+ return false;
1125
+ current = current[key];
1126
+ }
1127
+ return true;
1128
+ }
1129
+ /** Recompute every row's value/display from a fresh descriptor. */
1130
+ refreshRows(rows, list) {
1131
+ const ns = rows[0]?.ns;
1132
+ if (ns === undefined)
1133
+ return;
1134
+ this.refresh();
1135
+ const desc = this.descriptor(ns);
1136
+ if (desc === undefined)
1137
+ return;
1138
+ for (const row of rows) {
1139
+ if (row.kind === 'reset' || row.kind === 'addkey')
1140
+ continue;
1141
+ row.value = getPath(desc.value, row.path);
1142
+ row.display = this.computeDisplay(row.kind, row.node, row.value, row.secret === true);
1143
+ list.updateValue(row.id, row.display);
1144
+ }
1145
+ }
1146
+ // ------------------------------------------------------------------- write path --
1147
+ /** Serialized settings write; resolves with an error message, or undefined. */
1148
+ write(ns, ops) {
1149
+ const task = this.writeChain.then(async () => {
1150
+ try {
1151
+ // Revision read at execution time: the previous write in the chain has
1152
+ // already refreshed descriptors, so rapid consecutive writes never
1153
+ // conflict with themselves.
1154
+ await this.settings.mutate(ns, [...ops], this.descriptor(ns)?.revision);
1155
+ }
1156
+ catch (error) {
1157
+ return error instanceof Error ? error.message : String(error);
1158
+ }
1159
+ this.changes.value += 1;
1160
+ this.refresh();
1161
+ return undefined;
1162
+ });
1163
+ this.writeChain = task.then(() => undefined, () => undefined);
1164
+ return task;
1165
+ }
1166
+ /**
1167
+ * Add-provider commit: write the llm-pi-ai profile through the serialized
1168
+ * settings chain (revision read at execution time), then store the key
1169
+ * through the credentials seam — the same two writes the web Models page
1170
+ * performs. A missing credentials service still commits the profile (it
1171
+ * names the derived ref; the key then has to come from the environment).
1172
+ *
1173
+ * Outcome surface: a write failure is an `error`; a committed profile whose
1174
+ * key could not be stored is an `error` with the manual fallback spelled
1175
+ * out (B3); a committed profile with no credentials service at all is a
1176
+ * `notice` (success + hint, no ✘ — C11).
1177
+ */
1178
+ async commitNewProvider(entry, key) {
1179
+ const ref = deriveKeyRef(entry.id);
1180
+ const error = await this.write(NS_LLM_PI_AI, [{
1181
+ op: 'set',
1182
+ path: ['providers', entry.id],
1183
+ value: providerProfileFor(entry),
1184
+ }]);
1185
+ if (error !== undefined)
1186
+ return { error };
1187
+ const credentials = this.ctx.get('credentials');
1188
+ if (credentials === undefined) {
1189
+ // No credential store in this process — the row is configured and the
1190
+ // key must come from the environment; this is a success with a hint,
1191
+ // never an error. Enter re-runs the commit idempotently.
1192
+ return { notice: `provider added — no credentials service in this process: export ${ref} to use it` };
1193
+ }
1194
+ try {
1195
+ await credentials.set(ref, key);
1196
+ }
1197
+ catch (cause) {
1198
+ // The profile is committed but the key did not land: the row already
1199
+ // counts as configured, so the user needs the manual path. The error
1200
+ // stays retryable in place — Enter re-runs the whole commit (B3).
1201
+ return {
1202
+ error: `API key not stored: ${cause instanceof Error ? cause.message : String(cause)}`
1203
+ + ` — provider added; export ${ref}=<key> to use it`,
1204
+ };
1205
+ }
1206
+ this.justStoredRefs.add(ref);
1207
+ // Settle-time rebuild: if Esc closed the editor while the write was in
1208
+ // flight, the onExit refresh already ran against stale descriptors and
1209
+ // nothing else would repaint the new row. Idempotent (B5); on the normal
1210
+ // path the onExit refresh repeats it harmlessly.
1211
+ if (this.modelsView !== undefined)
1212
+ this.refreshModelsView();
1213
+ return undefined;
1214
+ }
1215
+ onCycle(rows, list, id, newValue) {
1216
+ const row = rows.find(r => r.id === id);
1217
+ if (row === undefined || row.kind !== 'cycle' || row.toRaw === undefined)
1218
+ return;
1219
+ let raw;
1220
+ try {
1221
+ raw = row.toRaw(newValue);
1222
+ }
1223
+ catch {
1224
+ this.refreshRows(rows, list);
1225
+ return;
1226
+ }
1227
+ void this.write(row.ns, [{ op: 'set', path: row.path, value: raw }]).then(error => {
1228
+ // Success and failure alike re-read the descriptor: the service's
1229
+ // resolved value is the single source of truth for the row display,
1230
+ // never a local snapshot (which concurrent writes may have aged).
1231
+ if (error !== undefined)
1232
+ this.onError(error);
1233
+ this.refreshRows(rows, list);
1234
+ });
1235
+ }
1236
+ /** Build the edit submenu for a leaf row; commits write to settings. */
1237
+ inputSubmenu(row, refresh, done) {
1238
+ const meta = fieldMeta(row.node);
1239
+ const initial = row.secret === true || row.value === undefined || row.value === null
1240
+ ? ''
1241
+ : String(row.value);
1242
+ return new EditField(this.tui, {
1243
+ title: row.path.join('.'),
1244
+ subtitle: row.secret === true
1245
+ ? 'secret — leave empty to keep the current value'
1246
+ : `current: ${row.display}${meta.required === true ? ' · required' : ''}`,
1247
+ initial,
1248
+ // role('secret') rows get the masked dot-row renderer too (B2).
1249
+ secret: row.secret === true,
1250
+ parse: text => this.parseFor(row, text),
1251
+ onCommit: outcome => this.commitInput(row, refresh, outcome),
1252
+ onDone: done,
1253
+ onError: message => this.onError(message),
1254
+ }, this.theme);
1255
+ }
1256
+ parseFor(row, text) {
1257
+ // Whitespace-only input on a secret also means "keep" — an accidental
1258
+ // space must not unset (delete) the stored secret; clearing a secret is
1259
+ // the reset row's job, not the editor's empty submit.
1260
+ if (row.secret === true && text.trim() === '')
1261
+ return { kind: 'keep' };
1262
+ switch (row.node.type) {
1263
+ case 'number': return parseNumberInput(text);
1264
+ case 'string': return parseStringInput(text);
1265
+ case 'union': return parseUnionInput(text, row.node);
1266
+ case 'transform':
1267
+ return row.node.inner === undefined
1268
+ ? { kind: 'error', error: `cannot edit ${row.node.type} value` }
1269
+ : this.parseFor({ ...row, node: row.node.inner }, text);
1270
+ default: return { kind: 'error', error: `cannot edit ${row.node.type} value` };
1271
+ }
1272
+ }
1273
+ async commitInput(row, refresh, outcome) {
1274
+ if (outcome.kind !== 'value' && outcome.kind !== 'unset')
1275
+ return undefined;
1276
+ const ops = outcome.kind === 'unset'
1277
+ ? [{ op: 'unset', path: row.path }]
1278
+ : [{ op: 'set', path: row.path, value: outcome.value }];
1279
+ const error = await this.write(row.ns, ops);
1280
+ if (error === undefined)
1281
+ refresh();
1282
+ return error === undefined ? undefined : { error };
1283
+ }
1284
+ /** Add-key editor for dict sections; commits a default-valued entry. */
1285
+ addKeySubmenu(row, refresh, done) {
1286
+ const inner = row.node.inner ?? row.node;
1287
+ const existing = (getPath(this.descriptor(row.ns)?.value, row.path) ?? {});
1288
+ return new EditField(this.tui, {
1289
+ title: `+ key in ${row.path.length === 0 ? '…' : row.path.join('.')}`,
1290
+ subtitle: `default: ${formatValue(defaultValueFor(inner))} · Enter to add · Esc to cancel`,
1291
+ initial: '',
1292
+ parse: text => {
1293
+ const key = text.trim();
1294
+ if (key === '')
1295
+ return { kind: 'error', error: 'key must not be empty' };
1296
+ if (key in existing)
1297
+ return { kind: 'error', error: `key "${key}" already exists` };
1298
+ return { kind: 'value', value: key };
1299
+ },
1300
+ onCommit: async (outcome) => {
1301
+ if (outcome.kind !== 'value')
1302
+ return undefined;
1303
+ const key = String(outcome.value);
1304
+ const error = await this.write(row.ns, [{ op: 'set', path: [...row.path, key], value: defaultValueFor(inner) }]);
1305
+ if (error === undefined)
1306
+ refresh();
1307
+ return error === undefined ? undefined : { error };
1308
+ },
1309
+ onDone: done,
1310
+ onError: message => this.onError(message),
1311
+ }, this.theme);
1312
+ }
1313
+ /** Reset-to-defaults confirmation for group/namespace rows. */
1314
+ resetSubmenu(row, refresh, done) {
1315
+ return new ConfirmReset(this.theme, row.path.length === 0
1316
+ ? `Reset "${row.ns}" to defaults`
1317
+ : `Reset ${row.path.join('.')} to defaults`, () => {
1318
+ void this.write(row.ns, [{ op: 'unset', path: row.path }]).then(error => {
1319
+ if (error !== undefined)
1320
+ this.onError(error);
1321
+ else
1322
+ refresh();
1323
+ done();
1324
+ });
1325
+ }, () => done());
1326
+ }
1327
+ }
1328
+ //# sourceMappingURL=settings.js.map