@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/index.js +69 -1
- package/lib/index.js.map +1 -1
- package/lib/live-widgets.js +3 -1
- package/lib/live-widgets.js.map +1 -1
- package/lib/login.d.ts +114 -0
- package/lib/login.js +218 -0
- package/lib/login.js.map +1 -0
- package/lib/panels.d.ts +76 -1
- package/lib/panels.js +178 -2
- package/lib/panels.js.map +1 -1
- package/lib/settings.d.ts +63 -7
- package/lib/settings.js +234 -196
- package/lib/settings.js.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
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,
|
|
39
|
-
import {
|
|
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
|
|
326
|
-
|
|
327
|
-
lines
|
|
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(
|
|
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
|
|
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
|
|
375
|
+
const fns = panelThemeFns(this.theme);
|
|
374
376
|
return [
|
|
375
|
-
|
|
377
|
+
fns.accent(BOLD + this.label + RESET),
|
|
376
378
|
'',
|
|
377
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
510
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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 (
|
|
524
|
-
// skill list.
|
|
525
|
-
const
|
|
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
|
-
|
|
545
|
-
+
|
|
546
|
-
+
|
|
547
|
-
+
|
|
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
|
-
|
|
552
|
-
}
|
|
553
|
-
else {
|
|
554
|
-
out.push('');
|
|
521
|
+
lines.push(fns.subtle(clipToWidth(` ${sel.description}`, wrap)));
|
|
555
522
|
}
|
|
556
|
-
|
|
557
|
-
|
|
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 ??
|
|
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,
|
|
655
|
-
* title
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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.
|
|
705
|
+
this.direct?.invalidate();
|
|
706
|
+
this.list?.invalidate();
|
|
715
707
|
}
|
|
716
708
|
render(width) {
|
|
717
|
-
|
|
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
|
-
|
|
714
|
+
fns.accent(BOLD + '⚙ Add provider' + RESET),
|
|
721
715
|
'',
|
|
722
|
-
|
|
723
|
-
|
|
716
|
+
fns.muted('All built-in providers are already configured.'),
|
|
717
|
+
'',
|
|
718
|
+
fns.subtle(' Esc to close'),
|
|
724
719
|
];
|
|
725
720
|
}
|
|
726
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
+
value: '',
|
|
1156
1197
|
description: 'configure a built-in provider with its API key',
|
|
1157
|
-
submenu: (_current, done) => new AddProviderFlow(this.tui, this.theme,
|
|
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
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
|
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
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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.
|
|
1534
|
-
*
|
|
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
|
|
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 (
|
|
1549
|
-
return
|
|
1550
|
-
|
|
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
|