@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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/dsh-tui-pi +5 -0
- package/cordis.patch.yml +9 -0
- package/lib/append-system.d.ts +66 -0
- package/lib/append-system.js +161 -0
- package/lib/append-system.js.map +1 -0
- package/lib/commands.d.ts +53 -0
- package/lib/commands.js +167 -0
- package/lib/commands.js.map +1 -0
- package/lib/dsh-events.d.ts +106 -0
- package/lib/dsh-events.js +30 -0
- package/lib/dsh-events.js.map +1 -0
- package/lib/editor.d.ts +28 -0
- package/lib/editor.js +70 -0
- package/lib/editor.js.map +1 -0
- package/lib/footer.d.ts +36 -0
- package/lib/footer.js +112 -0
- package/lib/footer.js.map +1 -0
- package/lib/frame.d.ts +35 -0
- package/lib/frame.js +75 -0
- package/lib/frame.js.map +1 -0
- package/lib/git.d.ts +17 -0
- package/lib/git.js +51 -0
- package/lib/git.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +781 -0
- package/lib/index.js.map +1 -0
- package/lib/instructions.d.ts +29 -0
- package/lib/instructions.js +67 -0
- package/lib/instructions.js.map +1 -0
- package/lib/live-widgets.d.ts +85 -0
- package/lib/live-widgets.js +218 -0
- package/lib/live-widgets.js.map +1 -0
- package/lib/messages.d.ts +277 -0
- package/lib/messages.js +734 -0
- package/lib/messages.js.map +1 -0
- package/lib/permission.d.ts +27 -0
- package/lib/permission.js +48 -0
- package/lib/permission.js.map +1 -0
- package/lib/provider-catalog.d.ts +114 -0
- package/lib/provider-catalog.js +124 -0
- package/lib/provider-catalog.js.map +1 -0
- package/lib/quotes.d.ts +28 -0
- package/lib/quotes.js +144 -0
- package/lib/quotes.js.map +1 -0
- package/lib/reload.d.ts +23 -0
- package/lib/reload.js +171 -0
- package/lib/reload.js.map +1 -0
- package/lib/selectors.d.ts +48 -0
- package/lib/selectors.js +261 -0
- package/lib/selectors.js.map +1 -0
- package/lib/session.d.ts +157 -0
- package/lib/session.js +555 -0
- package/lib/session.js.map +1 -0
- package/lib/sessions.d.ts +73 -0
- package/lib/sessions.js +253 -0
- package/lib/sessions.js.map +1 -0
- package/lib/settings.d.ts +180 -0
- package/lib/settings.js +1328 -0
- package/lib/settings.js.map +1 -0
- package/lib/text.d.ts +22 -0
- package/lib/text.js +45 -0
- package/lib/text.js.map +1 -0
- package/lib/theme/index.d.ts +79 -0
- package/lib/theme/index.js +121 -0
- package/lib/theme/index.js.map +1 -0
- package/lib/theme/palette.d.ts +56 -0
- package/lib/theme/palette.js +154 -0
- package/lib/theme/palette.js.map +1 -0
- package/lib/theme-settings.d.ts +68 -0
- package/lib/theme-settings.js +223 -0
- package/lib/theme-settings.js.map +1 -0
- package/lib/tui.d.ts +70 -0
- package/lib/tui.js +206 -0
- package/lib/tui.js.map +1 -0
- package/lib/welcome.d.ts +91 -0
- package/lib/welcome.js +281 -0
- package/lib/welcome.js.map +1 -0
- package/package.json +50 -0
- package/patches/@earendil-works__pi-tui.patch +72 -0
- package/pnpm-workspace.yaml +2 -0
- package/templates/APPEND_SYSTEM.md +34 -0
package/lib/settings.js
ADDED
|
@@ -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
|