@aiwayds/dsh-tui-pi 0.5.2 → 0.7.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.
package/lib/settings.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * settings surface (schema-form driven there, pi-tui overlays here).
4
4
  *
5
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:
6
+ * schemastery schemas → resolved values) and renders it as nested FW list
7
+ * panels (src/panels.ts SettingsListPanel), one level per schema depth:
8
8
  *
9
9
  * level 0 category list (searchable): general / models / plugins / agent,
10
10
  * then `other` for unmapped namespaces. Namespace→category comes
@@ -35,8 +35,9 @@
35
35
  */
36
36
  import { getPath, nodeAtPath, rehydrateSchema, } from '@deepseek-ai/dsh-client-schema-form';
37
37
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
38
- import { getKeybindings, Input, matchesKey, SettingsList, } from '@earendil-works/pi-tui';
39
- import { ansiBg, ansiFg, BOLD, RESET } from "./theme/index.js";
38
+ import { getKeybindings, Input, matchesKey, } from '@earendil-works/pi-tui';
39
+ import { panelThemeFns, SettingsListPanel, ViewerPanel, } from "./panels.js";
40
+ import { ansiFg, BOLD, RESET } from "./theme/index.js";
40
41
  import { clipToWidth, visibleWidth } from "./text.js";
41
42
  import { wrapFramedOverlay } from "./frame.js";
42
43
  import { catalogEntry, deriveKeyRef, directoryProviderEntries, providerProfileFor, providerRowView, unconfiguredCatalogEntries, } from "./provider-catalog.js";
@@ -236,6 +237,7 @@ export function fieldDescription(node, userOverride) {
236
237
  /** Inline value editor: title, current-value line, error/notice line, Input. */
237
238
  export class EditField {
238
239
  tui;
240
+ theme;
239
241
  options;
240
242
  input;
241
243
  error;
@@ -245,19 +247,12 @@ export class EditField {
245
247
  pending = false;
246
248
  /** Guards onDone: exactly one terminal transition (submit success/keep/escape). */
247
249
  done = false;
248
- fg;
249
- fgMuted;
250
250
  fgDanger;
251
- /** Popup-surface background for the secret mask line. */
252
- maskBg;
253
251
  constructor(tui, options, theme) {
254
252
  this.tui = tui;
253
+ this.theme = theme;
255
254
  this.options = options;
256
- this.fg = text => ansiFg(theme.palette.accent) + text + RESET;
257
- this.fgMuted = text => ansiFg(theme.palette.fgMuted) + text + RESET;
258
255
  this.fgDanger = text => ansiFg(theme.palette.danger) + text + RESET;
259
- // Same canvasSubtle backdrop the browser's listTheme paints on every row.
260
- this.maskBg = text => ansiBg(theme.palette.canvasSubtle) + text + RESET;
261
256
  this.input = new Input();
262
257
  this.input.setValue(options.initial);
263
258
  this.input.cursor = options.initial.length;
@@ -322,13 +317,16 @@ export class EditField {
322
317
  }
323
318
  invalidate() { }
324
319
  render(width) {
325
- const lines = [];
326
- lines.push(this.fg(BOLD + `✎ ${this.options.title}` + RESET));
327
- lines.push(this.fgMuted(this.options.subtitle));
320
+ const fns = panelThemeFns(this.theme);
321
+ const wrap = Math.max(2, width - 2);
322
+ const lines = [
323
+ fns.accent(BOLD + clipToWidth(this.options.title, wrap) + RESET),
324
+ fns.muted(clipToWidth(this.options.subtitle, wrap)),
325
+ ];
328
326
  if (this.error !== undefined)
329
327
  lines.push(this.fgDanger(`✘ ${this.error}`));
330
328
  else if (this.notice !== undefined)
331
- lines.push(this.fgMuted(this.notice));
329
+ lines.push(fns.muted(clipToWidth(this.notice, wrap)));
332
330
  lines.push('');
333
331
  if (this.options.secret === true) {
334
332
  lines.push(this.maskLine(width));
@@ -336,19 +334,23 @@ export class EditField {
336
334
  else {
337
335
  lines.push(...this.input.render(width));
338
336
  }
337
+ lines.push('');
338
+ lines.push(fns.subtle(clipToWidth('Enter save · Esc back', wrap)));
339
339
  return lines;
340
340
  }
341
341
  /**
342
342
  * Masked input line for secret fields: a dot row (one dot per visible
343
343
  * column of the value, capped to the popup width) with a `▎` marker at the
344
344
  * cursor position. The real cursor is not rendered — the marker only hints
345
- * at the editing position; input semantics live in the internal Input.
345
+ * at the editing position; input semantics live in the internal Input. The
346
+ * row needs no background of its own — FramedOverlay fills the canvasSubtle
347
+ * backdrop.
346
348
  */
347
349
  maskLine(width) {
348
350
  const maxDots = Math.max(0, width - 2);
349
351
  const dots = '•'.repeat(Math.min(maxDots, visibleWidth(this.input.getValue())));
350
352
  const cursor = Math.min(this.input.cursor, dots.length);
351
- return this.maskBg(dots.slice(0, cursor) + '▎' + dots.slice(cursor));
353
+ return dots.slice(0, cursor) + '▎' + dots.slice(cursor);
352
354
  }
353
355
  handleInput(data) {
354
356
  this.input.handleInput(data);
@@ -370,13 +372,11 @@ class ConfirmReset {
370
372
  }
371
373
  invalidate() { }
372
374
  render(_width) {
373
- const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
375
+ const fns = panelThemeFns(this.theme);
374
376
  return [
375
- fg(this.theme.palette.attention)(BOLD + `↺ ${this.label}` + RESET),
377
+ fns.accent(BOLD + this.label + RESET),
376
378
  '',
377
- fg(this.theme.palette.fgMuted)(this.pending
378
- ? ' resetting…'
379
- : ' Enter: reset to defaults · Esc: cancel'),
379
+ fns.subtle(this.pending ? ' resetting…' : ' Enter: reset to defaults · Esc: cancel'),
380
380
  ];
381
381
  }
382
382
  handleInput(data) {
@@ -394,45 +394,10 @@ class ConfirmReset {
394
394
  }
395
395
  }
396
396
  }
397
- /** Read-only JSON view for array / literal / unknown nodes. */
398
- class ReadOnlyViewer {
399
- theme;
400
- label;
401
- json;
402
- onClose;
403
- constructor(theme, label, json, onClose) {
404
- this.theme = theme;
405
- this.label = label;
406
- this.json = json;
407
- this.onClose = onClose;
408
- }
409
- invalidate() { }
410
- render(width) {
411
- const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
412
- const lines = [
413
- fg(this.theme.palette.accent)(BOLD + `ⓘ ${this.label}` + RESET),
414
- '',
415
- ];
416
- const text = JSON.stringify(this.json, null, 2);
417
- const max = Math.max(2, width - 2);
418
- for (const line of text.split('\n').slice(0, 40)) {
419
- lines.push(fg(this.theme.palette.fgMuted)(clipToWidth(line, max)));
420
- }
421
- lines.push('');
422
- lines.push(fg(this.theme.palette.fgSubtle)(' read-only in the TUI — edit the settings document to change it · Esc to close'));
423
- return lines;
424
- }
425
- handleInput(data) {
426
- if (getKeybindings().matches(data, 'tui.select.cancel')
427
- || getKeybindings().matches(data, 'tui.select.confirm')) {
428
- this.onClose();
429
- }
430
- }
431
- }
432
397
  /**
433
- * Swappable shell around the Models category's SettingsList. Provider rows
398
+ * Swappable shell around the Models category's FW list panel. Provider rows
434
399
  * change structurally — a new provider row must appear after an add, and
435
- * SettingsList.updateValue cannot express that — so the shell swaps in a
400
+ * SettingsListPanel.updateValue cannot express that — so the shell swaps in a
436
401
  * freshly built list while staying the category list's stable submenu
437
402
  * component.
438
403
  */
@@ -452,16 +417,17 @@ class ModelsCategoryView {
452
417
  }
453
418
  }
454
419
  /**
455
- * Self-drawn Skills panel for the `/settings` Skills category. Drawn directly
456
- * (no pi-tui SettingsList) so a row shows its toggle state exactly once, in
457
- * front — SettingsList forces the currentValue into a right-hand value column
458
- * too, which duplicated the state (`true [skill] x true`). Navigation is
459
- * up/down plus PgUp/PgDn paging and Home/End jump; Enter/Space toggles (the
460
- * same keys SettingsList accepts), Esc exits; the selected row's description
461
- * is shown under the list, plus a footer hint. Every rendered row is clipped
462
- * to the panel width so narrow terminals truncate instead of overflowing.
463
- * The browser swaps rows in asynchronously (loading / empty / no-service states
464
- * come through setStatus).
420
+ * Self-drawn Skills panel for the `/settings` Skills category, aligned to the
421
+ * FW panel style (accent BOLD title + footer with scroll info; colors through
422
+ * panelThemeFns). Drawn directly (no pi-tui SettingsList) so a row shows its
423
+ * toggle state exactly once, in front — SettingsList forces the currentValue
424
+ * into a right-hand value column too, which duplicated the state
425
+ * (`true [skill] x true`). Navigation is up/down plus PgUp/PgDn paging
426
+ * and Home/End jump; Enter/Space toggles (the same keys SettingsList accepts),
427
+ * Esc exits; the selected row's description is shown under the list. Every
428
+ * rendered row is clipped to the panel width so narrow terminals truncate
429
+ * instead of overflowing. The browser swaps rows in asynchronously (loading /
430
+ * empty / no-service states come through setStatus).
465
431
  */
466
432
  class SkillsPanel {
467
433
  tui;
@@ -477,9 +443,10 @@ class SkillsPanel {
477
443
  filterQuery = '';
478
444
  /** The overlay renders at `maxHeight` of terminal rows; FramedOverlay adds
479
445
  * 4 chrome rows (top border + spacer + bottom spacer + border); the child
480
- * also appends2 tail rows (description + footer) after the skill list. */
446
+ * adds 5 tail rows after the skill list (title + spacer + description +
447
+ * spacer + footer). */
481
448
  static FRAME_OVERHEAD = 4;
482
- static TAIL_ROWS = 2;
449
+ static TAIL_ROWS = 5;
483
450
  constructor(tui, theme, onToggle, onExit) {
484
451
  this.tui = tui;
485
452
  this.theme = theme;
@@ -506,30 +473,33 @@ class SkillsPanel {
506
473
  this.tui.requestRender();
507
474
  }
508
475
  render(width) {
509
- const p = this.theme.palette;
510
- const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
476
+ const fns = panelThemeFns(this.theme);
477
+ const wrap = Math.max(2, width - 2);
478
+ const lines = [
479
+ fns.accent(BOLD + clipToWidth('⚙ Skills', wrap) + RESET),
480
+ '',
481
+ ];
511
482
  if (this.rows.length === 0) {
512
- return [fg(p.fgMuted)(clipToWidth(this.status ?? '', width))];
483
+ lines.push(fns.muted(clipToWidth(this.status ?? '', wrap)));
484
+ return lines;
513
485
  }
514
486
  const filtered = this.getFilteredRows();
515
487
  if (filtered.length === 0) {
516
- return [
517
- fg(p.fgMuted)(clipToWidth(`No matches for '${this.filterQuery}'`, width)),
518
- fg(p.fgSubtle)(clipToWidth(this.footer, width)),
519
- ];
488
+ lines.push(fns.muted(clipToWidth(`No matches for '${this.filterQuery}'`, wrap)));
489
+ lines.push('');
490
+ lines.push(fns.subtle(clipToWidth(this.footer + this.scrollText(filtered), wrap)));
491
+ return lines;
520
492
  }
521
493
  // Calculate how many skill rows fit. The overlay is capped at 80% of
522
494
  // terminal rows (SettingsBrowser's maxHeight); FramedOverlay adds 4 chrome
523
- // rows; the child appends TAIL_ROWS (description + footer) after the
524
- // skill list.
525
- const overlayMax = Math.floor(this.tui.terminal.rows * 0.8);
526
- const maxVisibleRows = Math.max(1, overlayMax - SkillsPanel.FRAME_OVERHEAD - SkillsPanel.TAIL_ROWS);
495
+ // rows; the child appends TAIL_ROWS (title + spacer + description +
496
+ // spacer + footer) after the skill list.
497
+ const maxVisibleRows = this.maxVisibleRows();
527
498
  // Ensure the cursor is in the visible window.
528
499
  this.scrollToCursor(maxVisibleRows, filtered.length);
529
500
  const visibleRows = filtered.slice(this.scrollOffset, this.scrollOffset + maxVisibleRows);
530
501
  // Fixed-width prefix segments: marker(2) + index(4) + state(6) + badge(8) = 20.
531
502
  const prefixCols = 2 + (SKILL_INDEX_WIDTH + 1) + (SKILL_STATE_WIDTH + 1) + (BADGE_WIDTH + 1);
532
- const out = [];
533
503
  for (let vi = 0; vi < visibleRows.length; vi++) {
534
504
  const i = this.scrollOffset + vi;
535
505
  const row = filtered[i];
@@ -541,20 +511,26 @@ class SkillsPanel {
541
511
  // takes the rest, and clipToWidth guarantees no row ever exceeds
542
512
  // `width`.
543
513
  const plain = clipToWidth(skillPanelRowLine(selected, row.enabled, row.name, i + 1), width);
544
- out.push(fg(selected ? p.accent : p.fgMuted)(plain.slice(0, 2))
545
- + fg(p.fgSubtle)(plain.slice(2, 2 + SKILL_INDEX_WIDTH + 1))
546
- + fg(row.enabled ? p.success : p.fgMuted)(plain.slice(2 + SKILL_INDEX_WIDTH + 1, prefixCols))
547
- + fg(selected ? p.accent : p.fgDefault)(plain.slice(prefixCols)));
514
+ lines.push(fns[selected ? 'accent' : 'muted'](plain.slice(0, 2))
515
+ + fns.subtle(plain.slice(2, 2 + SKILL_INDEX_WIDTH + 1))
516
+ + fns[row.enabled ? 'success' : 'muted'](plain.slice(2 + SKILL_INDEX_WIDTH + 1, prefixCols))
517
+ + fns[selected ? 'accent' : 'muted'](plain.slice(prefixCols)));
548
518
  }
549
519
  const sel = filtered[this.cursor];
550
520
  if (sel !== undefined && sel.description !== '') {
551
- out.push(fg(p.fgSubtle)(clipToWidth(` ${sel.description}`, width)));
552
- }
553
- else {
554
- out.push('');
521
+ lines.push(fns.subtle(clipToWidth(` ${sel.description}`, wrap)));
555
522
  }
556
- out.push(fg(p.fgSubtle)(clipToWidth(this.footer, width)));
557
- return out;
523
+ lines.push('');
524
+ lines.push(fns.subtle(clipToWidth(this.footer + this.scrollText(filtered), wrap)));
525
+ return lines;
526
+ }
527
+ /** Skill rows that fit under the framed overlay on this terminal. */
528
+ maxVisibleRows() {
529
+ return Math.max(1, Math.floor(this.tui.terminal.rows * 0.8) - SkillsPanel.FRAME_OVERHEAD - SkillsPanel.TAIL_ROWS);
530
+ }
531
+ /** Scroll suffix ` (x/y)` — only when the list overflows the viewport. */
532
+ scrollText(filtered) {
533
+ return filtered.length > this.maxVisibleRows() ? ` (${this.cursor + 1}/${filtered.length})` : '';
558
534
  }
559
535
  handleInput(data) {
560
536
  const kb = getKeybindings();
@@ -632,7 +608,7 @@ class SkillsPanel {
632
608
  }
633
609
  /** Adjust scrollOffset so the cursor is within `[offset, offset+visibleRows)`. */
634
610
  scrollToCursor(visibleRows, length) {
635
- const vr = visibleRows ?? Math.max(1, Math.floor(this.tui.terminal.rows * 0.8) - SkillsPanel.FRAME_OVERHEAD - SkillsPanel.TAIL_ROWS);
611
+ const vr = visibleRows ?? this.maxVisibleRows();
636
612
  const len = length ?? this.getFilteredRows().length;
637
613
  this.scrollOffset = clampScrollOffset(this.cursor, vr, len, this.scrollOffset);
638
614
  }
@@ -651,33 +627,48 @@ class SkillsPanel {
651
627
  /**
652
628
  * Add-provider flow for the Models category — the terminal counterpart of
653
629
  * pi-agent's /login, trimmed to the information a user actually needs: pick
654
- * a provider from the built-in directory (searchable, oauth-selector-style
655
- * title line), enter exactly one API key, done. The key editor reuses
630
+ * a provider from the built-in directory (searchable FW list panel, accent
631
+ * BOLD title), enter exactly one API key, done. The key editor reuses
656
632
  * EditField (pending guard, late-error sink); the picker is a searchable
657
- * SettingsList of the unconfigured directory entries.
633
+ * SettingsListPanel of the directory entries. `/login <provider>` reuses this
634
+ * flow through `initialEntry`, which swaps the picker for a direct key
635
+ * editor. Exported because /login instantiates it as a top-level overlay.
658
636
  */
659
- class AddProviderFlow {
637
+ export class AddProviderFlow {
660
638
  tui;
661
639
  theme;
662
640
  list;
641
+ /** Direct-launch key editor (`initialEntry` set); replaces the picker list. */
642
+ direct;
663
643
  empty;
664
644
  onExit;
665
- fg;
666
- fgMuted;
667
- constructor(tui, theme, listTheme, options) {
645
+ constructor(tui, theme, options) {
668
646
  this.tui = tui;
669
647
  this.theme = theme;
670
648
  this.empty = options.entries.length === 0;
671
649
  this.onExit = options.onExit;
672
- this.fg = text => ansiFg(theme.palette.accent) + text + RESET;
673
- this.fgMuted = text => ansiFg(theme.palette.fgMuted) + text + RESET;
674
- this.list = new SettingsList(options.entries.map(entry => ({
675
- id: entry.id,
676
- label: entry.name,
677
- currentValue: '',
678
- description: entry.hint,
679
- submenu: (_current, done) => this.keyEditor(entry, options, done),
680
- })), 12, listTheme, () => { }, () => options.onExit(), { enableSearch: true });
650
+ if (options.initialEntry !== undefined) {
651
+ // Direct launch: the picker never shows. The key editor's `done` is a
652
+ // no-op (there is no list submenu to close); Esc pops the whole flow
653
+ // through onDone → onExit.
654
+ this.direct = this.keyEditor(options.initialEntry, options, () => { });
655
+ this.list = undefined;
656
+ return;
657
+ }
658
+ this.list = new SettingsListPanel(theme, {
659
+ title: '⚙ Add provider',
660
+ rows: options.entries.map(entry => ({
661
+ id: entry.id,
662
+ label: entry.name,
663
+ value: '',
664
+ description: entry.hint,
665
+ submenu: (_current, done) => this.keyEditor(entry, options, done),
666
+ })),
667
+ maxVisible: 12,
668
+ enableSearch: true,
669
+ onCancel: () => options.onExit(),
670
+ });
671
+ this.direct = undefined;
681
672
  }
682
673
  /** Key editor for one directory entry; commits through the write chain. */
683
674
  keyEditor(entry, options, done) {
@@ -711,19 +702,23 @@ class AddProviderFlow {
711
702
  }, this.theme);
712
703
  }
713
704
  invalidate() {
714
- this.list.invalidate();
705
+ this.direct?.invalidate();
706
+ this.list?.invalidate();
715
707
  }
716
708
  render(width) {
717
- const title = this.fg(BOLD + 'Select provider to configure:' + RESET);
709
+ if (this.direct !== undefined)
710
+ return this.direct.render(width);
718
711
  if (this.empty) {
712
+ const fns = panelThemeFns(this.theme);
719
713
  return [
720
- title,
714
+ fns.accent(BOLD + '⚙ Add provider' + RESET),
721
715
  '',
722
- this.fgMuted('All built-in providers are already configured.'),
723
- this.fgMuted(' Esc to close'),
716
+ fns.muted('All built-in providers are already configured.'),
717
+ '',
718
+ fns.subtle(' Esc to close'),
724
719
  ];
725
720
  }
726
- return [title, '', ...this.list.render(width)];
721
+ return this.list.render(width);
727
722
  }
728
723
  handleInput(data) {
729
724
  if (this.empty) {
@@ -731,9 +726,53 @@ class AddProviderFlow {
731
726
  this.onExit();
732
727
  return;
733
728
  }
729
+ if (this.direct !== undefined) {
730
+ this.direct.handleInput?.(data);
731
+ return;
732
+ }
734
733
  this.list.handleInput(data);
735
734
  }
736
735
  }
736
+ // -------------------------------------------------------------------- the browser --
737
+ /**
738
+ * Commit a provider (profile + key) through the settings + credentials seams —
739
+ * the same two writes the web Models page performs. Shared by the Models
740
+ * category's add flow and the /login command; the caller supplies the profile
741
+ * write (each surface keeps its own serialized settings chain). A missing
742
+ * credentials service still commits the profile (it names the derived ref; the
743
+ * key then has to come from the environment).
744
+ *
745
+ * Outcome surface: a write failure is an `error`; a committed profile whose
746
+ * key could not be stored is an `error` with the manual fallback spelled out
747
+ * (B3); a committed profile with no credentials service at all is a `notice`
748
+ * (success + hint, no ✘ — C11). Resolves `undefined` on a full success.
749
+ */
750
+ export async function commitProvider(ctx, writeProfile, entry, key) {
751
+ const ref = deriveKeyRef(entry.id);
752
+ const error = await writeProfile();
753
+ if (error !== undefined)
754
+ return { error };
755
+ const credentials = ctx.get('credentials');
756
+ if (credentials === undefined) {
757
+ // No credential store in this process — the row is configured and the
758
+ // key must come from the environment; this is a success with a hint,
759
+ // never an error. Enter re-runs the commit idempotently.
760
+ return { notice: `provider added — no credentials service in this process: export ${ref} to use it` };
761
+ }
762
+ try {
763
+ await credentials.set(ref, key);
764
+ }
765
+ catch (cause) {
766
+ // The profile is committed but the key did not land: the row already
767
+ // counts as configured, so the user needs the manual path. The error
768
+ // stays retryable in place — Enter re-runs the whole commit (B3).
769
+ return {
770
+ error: `API key not stored: ${cause instanceof Error ? cause.message : String(cause)}`
771
+ + ` — provider added; export ${ref}=<key> to use it`,
772
+ };
773
+ }
774
+ return undefined;
775
+ }
737
776
  /**
738
777
  * Open the modal settings browser. Resolves when it closes with the number of
739
778
  * committed writes, or -1 when no namespace is registered (nothing to show).
@@ -749,7 +788,6 @@ class SettingsBrowser {
749
788
  ctx;
750
789
  tui;
751
790
  theme;
752
- listTheme;
753
791
  settings;
754
792
  restoreFocus;
755
793
  onError;
@@ -791,22 +829,6 @@ class SettingsBrowser {
791
829
  // Assigned here, not as a field initializer: a later field declaration
792
830
  // would `defineProperty(…, undefined)` over the promise's resolve.
793
831
  this.closed = new Promise(resolve => { this.closeResolve = resolve; });
794
- const p = options.theme.palette;
795
- const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
796
- // canvasSubtle backdrop for every browser line. Raw lines (the search
797
- // Input row, the bare "" separators from settings-list.js renderMainList,
798
- // AddProviderFlow's title/blank lines, EditField's title/subtitle/error/
799
- // notice rows) carry no theme styling of their own — the framed overlay
800
- // (frame.ts fillLine) paints the full-width backdrop under them, so the
801
- // popup stays one solid panel surface.
802
- const bg = (hex) => (text) => ansiBg(hex) + text + RESET;
803
- this.listTheme = {
804
- label: (text, selected) => bg(p.canvasSubtle)(fg(p.fgDefault)(selected ? BOLD + text + RESET : text)),
805
- value: (text, selected) => bg(p.canvasSubtle)(fg(selected ? p.accent : p.fgMuted)(text)),
806
- description: text => bg(p.canvasSubtle)(fg(p.fgSubtle)(text)),
807
- cursor: bg(p.canvasSubtle)(fg(p.accent)(BOLD + '▸ ')),
808
- hint: text => bg(p.canvasSubtle)(fg(p.fgSubtle)(text)),
809
- };
810
832
  }
811
833
  async open() {
812
834
  this.refresh();
@@ -866,20 +888,25 @@ class SettingsBrowser {
866
888
  const items = this.categories().map(cat => ({
867
889
  id: cat.id,
868
890
  label: cat.label,
869
- currentValue: this.categorySummary(cat),
891
+ value: this.categorySummary(cat),
870
892
  description: this.categoryDescription(cat),
871
893
  submenu: cat.id === 'models'
872
894
  ? (_current, done) => this.openModelsSubmenu(done)
873
895
  : cat.id === 'skills'
874
896
  ? (_current, done) => this.openSkillsSubmenu(done)
875
897
  : (_current, done) => {
876
- const list = this.namespaceList(this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
898
+ const list = this.namespaceList(cat.label, this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
877
899
  this.nsList = list;
878
900
  return list;
879
901
  },
880
902
  }));
881
- const list = new SettingsList(items, 10, this.listTheme, () => { }, () => this.close(), { enableSearch: true });
882
- return list;
903
+ return new SettingsListPanel(this.theme, {
904
+ title: '⚙ settings',
905
+ rows: items,
906
+ maxVisible: 10,
907
+ enableSearch: true,
908
+ onCancel: () => this.close(),
909
+ });
883
910
  }
884
911
  refreshCategoryList() {
885
912
  if (this.catList === undefined)
@@ -904,11 +931,11 @@ class SettingsBrowser {
904
931
  return parts.join(' · ');
905
932
  }
906
933
  /** Namespace list for one category; Esc pops back to the category level. */
907
- namespaceList(descriptors, onExit) {
934
+ namespaceList(categoryLabel, descriptors, onExit) {
908
935
  const items = descriptors.map(desc => ({
909
936
  id: desc.ns,
910
937
  label: desc.ns,
911
- currentValue: this.nsSummary(desc),
938
+ value: this.nsSummary(desc),
912
939
  description: this.nsDescription(desc),
913
940
  submenu: (_current, done) => {
914
941
  const section = this.sectionList(desc.ns, [], () => {
@@ -918,11 +945,16 @@ class SettingsBrowser {
918
945
  return section.list;
919
946
  },
920
947
  }));
921
- const list = new SettingsList(items, 10, this.listTheme, () => { }, () => {
922
- this.refreshCategoryList();
923
- onExit();
924
- }, { enableSearch: true });
925
- return list;
948
+ return new SettingsListPanel(this.theme, {
949
+ title: categoryLabel,
950
+ rows: items,
951
+ maxVisible: 10,
952
+ enableSearch: true,
953
+ onCancel: () => {
954
+ this.refreshCategoryList();
955
+ onExit();
956
+ },
957
+ });
926
958
  }
927
959
  refreshNsList() {
928
960
  if (this.nsList === undefined)
@@ -1102,7 +1134,7 @@ class SettingsBrowser {
1102
1134
  items.push({
1103
1135
  id: `provider:${id}`,
1104
1136
  label: view.label,
1105
- currentValue: view.summary,
1137
+ value: view.summary,
1106
1138
  description: view.status,
1107
1139
  // Read-only: the raw llm-pi-ai fields are deliberately not editable
1108
1140
  // here — Enter shows the stored profile, nothing more. A "re-store
@@ -1110,7 +1142,16 @@ class SettingsBrowser {
1110
1142
  // only) was considered for rows whose key never landed (B3) but
1111
1143
  // needs a multi-action submenu component (~60 lines); skipped —
1112
1144
  // the commit failure text names the manual fallback instead.
1113
- submenu: (_current, done) => new ReadOnlyViewer(this.theme, `providers.${id}`, profile, done),
1145
+ submenu: (_current, done) => new ViewerPanel(this.theme, {
1146
+ title: `providers.${id}`,
1147
+ lines: [
1148
+ 'read-only in the TUI — edit the settings document to change it',
1149
+ '',
1150
+ ...JSON.stringify(profile, null, 2).split('\n'),
1151
+ ],
1152
+ maxLines: 40,
1153
+ onClose: done,
1154
+ }),
1114
1155
  });
1115
1156
  }
1116
1157
  }
@@ -1119,7 +1160,7 @@ class SettingsBrowser {
1119
1160
  items.push({
1120
1161
  id: 'llm-deepseek',
1121
1162
  label: 'DeepSeek (official)',
1122
- currentValue: this.nsSummary(deepseekDesc),
1163
+ value: this.nsSummary(deepseekDesc),
1123
1164
  description: this.nsDescription(deepseekDesc),
1124
1165
  submenu: (_current, done) => {
1125
1166
  const section = this.sectionList(deepseekDesc.ns, [], () => {
@@ -1135,7 +1176,7 @@ class SettingsBrowser {
1135
1176
  items.push({
1136
1177
  id: 'agent-default-model',
1137
1178
  label: 'Default model',
1138
- currentValue: this.defaultModelSummary(agentDesc),
1179
+ value: this.defaultModelSummary(agentDesc),
1139
1180
  description: this.nsDescription(agentDesc),
1140
1181
  submenu: (_current, done) => {
1141
1182
  const section = this.sectionList(agentDesc.ns, [], () => {
@@ -1152,9 +1193,9 @@ class SettingsBrowser {
1152
1193
  items.push({
1153
1194
  id: '\u0000add-provider',
1154
1195
  label: '+ Add provider…',
1155
- currentValue: '',
1196
+ value: '',
1156
1197
  description: 'configure a built-in provider with its API key',
1157
- submenu: (_current, done) => new AddProviderFlow(this.tui, this.theme, this.listTheme, {
1198
+ submenu: (_current, done) => new AddProviderFlow(this.tui, this.theme, {
1158
1199
  entries: this.addProviderEntries(configured),
1159
1200
  onCommit: (entry, key) => this.commitNewProvider(entry, key),
1160
1201
  onExit: () => {
@@ -1165,10 +1206,15 @@ class SettingsBrowser {
1165
1206
  }),
1166
1207
  });
1167
1208
  }
1168
- return new SettingsList(items, 12, this.listTheme, () => { },
1169
- // exit() (the modelsExit hook) already refreshes the category list —
1170
- // calling it again here would double-refresh (C8).
1171
- () => { exit(); }, { enableSearch: true });
1209
+ return new SettingsListPanel(this.theme, {
1210
+ title: '⚙ Models',
1211
+ rows: items,
1212
+ maxVisible: 12,
1213
+ enableSearch: true,
1214
+ // exit() (the modelsExit hook) already refreshes the category list —
1215
+ // calling it again here would double-refresh (C8).
1216
+ onCancel: () => { exit(); },
1217
+ });
1172
1218
  }
1173
1219
  /** Cap for a skill row's description line (columns; width-safe). */
1174
1220
  static SKILL_DESC_MAX = 60;
@@ -1306,7 +1352,7 @@ class SettingsBrowser {
1306
1352
  }
1307
1353
  // ------------------------------------------------------------- section levels --
1308
1354
  /**
1309
- * Build the SettingsList for one schema node at `path` of `ns`.
1355
+ * Build the FW list panel for one schema node at `path` of `ns`.
1310
1356
  * `onExit` runs when the list is popped (Esc) — it must refresh the parent
1311
1357
  * level and call the parent's submenu `done()`.
1312
1358
  */
@@ -1319,10 +1365,17 @@ class SettingsBrowser {
1319
1365
  const rows = node === undefined ? [] : this.buildRows(ns, node, path, desc?.value);
1320
1366
  const refresh = () => { this.refreshRows(rows, list); };
1321
1367
  const items = rows.map(row => this.rowItem(row, refresh));
1322
- const list = new SettingsList(items, 12, this.listTheme, (id, newValue) => { void this.onCycle(rows, list, id, newValue); }, () => {
1323
- refresh();
1324
- onExit();
1325
- }, { enableSearch: true });
1368
+ const list = new SettingsListPanel(this.theme, {
1369
+ title: path.length === 0 ? ns : path.join('.'),
1370
+ rows: items,
1371
+ maxVisible: 12,
1372
+ enableSearch: true,
1373
+ onChange: (id, newValue) => { void this.onCycle(rows, list, id, newValue); },
1374
+ onCancel: () => {
1375
+ refresh();
1376
+ onExit();
1377
+ },
1378
+ });
1326
1379
  return { list, refresh };
1327
1380
  }
1328
1381
  buildRows(ns, node, path, value) {
@@ -1332,7 +1385,7 @@ class SettingsBrowser {
1332
1385
  id: '\u0000reset',
1333
1386
  ns,
1334
1387
  path,
1335
- label: `↺ Reset ${path.length === 0 ? 'this namespace' : path.join('.')} to defaults`,
1388
+ label: `Reset ${path.length === 0 ? 'this namespace' : path.join('.')} to defaults`,
1336
1389
  kind: 'reset',
1337
1390
  node,
1338
1391
  value: undefined,
@@ -1436,12 +1489,12 @@ class SettingsBrowser {
1436
1489
  default: return formatValue(value);
1437
1490
  }
1438
1491
  }
1439
- /** SettingItem for one row; drill/input/reset/addkey attach their submenus. */
1492
+ /** SettingsRow for one row; drill/input/reset/addkey attach their submenus. */
1440
1493
  rowItem(row, refresh) {
1441
1494
  const base = {
1442
1495
  id: row.id,
1443
1496
  label: row.label,
1444
- currentValue: row.display,
1497
+ value: row.display,
1445
1498
  description: this.rowDescription(row),
1446
1499
  };
1447
1500
  switch (row.kind) {
@@ -1463,7 +1516,16 @@ class SettingsBrowser {
1463
1516
  // full instead of silently doing nothing (the module header's promise).
1464
1517
  default: return {
1465
1518
  ...base,
1466
- submenu: (_current, done) => new ReadOnlyViewer(this.theme, row.path.join('.'), row.value, done),
1519
+ submenu: (_current, done) => new ViewerPanel(this.theme, {
1520
+ title: row.path.join('.'),
1521
+ lines: [
1522
+ 'read-only in the TUI — edit the settings document to change it',
1523
+ '',
1524
+ ...JSON.stringify(row.value, null, 2).split('\n'),
1525
+ ],
1526
+ maxLines: 40,
1527
+ onClose: done,
1528
+ }),
1467
1529
  };
1468
1530
  }
1469
1531
  }
@@ -1530,43 +1592,19 @@ class SettingsBrowser {
1530
1592
  * Add-provider commit: write the llm-pi-ai profile through the serialized
1531
1593
  * settings chain (revision read at execution time), then store the key
1532
1594
  * through the credentials seam — the same two writes the web Models page
1533
- * performs. A missing credentials service still commits the profile (it
1534
- * names the derived ref; the key then has to come from the environment).
1535
- *
1536
- * Outcome surface: a write failure is an `error`; a committed profile whose
1537
- * key could not be stored is an `error` with the manual fallback spelled
1538
- * out (B3); a committed profile with no credentials service at all is a
1539
- * `notice` (success + hint, no ✘ — C11).
1595
+ * performs. See `commitProvider` for the outcome surface; on success the
1596
+ * browser also records the ref so the Models rows read as key set and
1597
+ * rebuilds the list (B5).
1540
1598
  */
1541
1599
  async commitNewProvider(entry, key) {
1542
- const ref = deriveKeyRef(entry.id);
1543
- const error = await this.write(NS_LLM_PI_AI, [{
1600
+ const result = await commitProvider(this.ctx, () => this.write(NS_LLM_PI_AI, [{
1544
1601
  op: 'set',
1545
1602
  path: ['providers', entry.id],
1546
1603
  value: providerProfileFor(entry),
1547
- }]);
1548
- if (error !== undefined)
1549
- return { error };
1550
- const credentials = this.ctx.get('credentials');
1551
- if (credentials === undefined) {
1552
- // No credential store in this process — the row is configured and the
1553
- // key must come from the environment; this is a success with a hint,
1554
- // never an error. Enter re-runs the commit idempotently.
1555
- return { notice: `provider added — no credentials service in this process: export ${ref} to use it` };
1556
- }
1557
- try {
1558
- await credentials.set(ref, key);
1559
- }
1560
- catch (cause) {
1561
- // The profile is committed but the key did not land: the row already
1562
- // counts as configured, so the user needs the manual path. The error
1563
- // stays retryable in place — Enter re-runs the whole commit (B3).
1564
- return {
1565
- error: `API key not stored: ${cause instanceof Error ? cause.message : String(cause)}`
1566
- + ` — provider added; export ${ref}=<key> to use it`,
1567
- };
1568
- }
1569
- this.justStoredRefs.add(ref);
1604
+ }]), entry, key);
1605
+ if (result !== undefined)
1606
+ return result;
1607
+ this.justStoredRefs.add(deriveKeyRef(entry.id));
1570
1608
  // Settle-time rebuild: if Esc closed the editor while the write was in
1571
1609
  // flight, the onExit refresh already ran against stale descriptors and
1572
1610
  // nothing else would repaint the new row. Idempotent (B5); on the normal