dsh-oc-tui 0.1.4 → 0.2.0-pre.13
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/README.md +179 -28
- package/bin/apply-update.cjs +187 -0
- package/bin/mcp-sse-bridge.mjs +499 -0
- package/cordis.patch.yml +46 -32
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +560 -424
- package/dsh-plugin.json +65 -65
- package/lib/caps.js +83 -0
- package/lib/i18n.js +283 -0
- package/lib/image.js +326 -0
- package/lib/index.js +980 -185
- package/lib/locales/ar.js +561 -0
- package/lib/locales/de.js +530 -0
- package/lib/locales/en.js +548 -0
- package/lib/locales/es.js +530 -0
- package/lib/locales/fr.js +530 -0
- package/lib/locales/id.js +532 -0
- package/lib/locales/it.js +530 -0
- package/lib/locales/ja.js +530 -0
- package/lib/locales/ko.js +530 -0
- package/lib/locales/nl.js +530 -0
- package/lib/locales/pl.js +544 -0
- package/lib/locales/pt-BR.js +530 -0
- package/lib/locales/ru.js +544 -0
- package/lib/locales/tr.js +530 -0
- package/lib/locales/vi.js +532 -0
- package/lib/locales/zh-CN.js +531 -0
- package/lib/locales/zh-TW.js +531 -0
- package/lib/markdown.js +560 -297
- package/lib/mcp-skill.js +93 -0
- package/lib/mcp.js +838 -0
- package/lib/mermaid-ascii.js +138 -0
- package/lib/mermaid.js +137 -0
- package/lib/rewind.js +15 -14
- package/lib/splash-opencode.js +108 -0
- package/lib/startup.js +16 -10
- package/lib/term.js +242 -10
- package/lib/ui.js +1265 -406
- package/lib/updates.js +1028 -697
- package/lib/util.js +85 -27
- package/lib/web-settings.js +846 -454
- package/package.json +145 -100
- package/skills/mcp-import/SKILL.md +216 -0
package/lib/web-settings.js
CHANGED
|
@@ -1,454 +1,846 @@
|
|
|
1
|
-
import z from '@deepseek-ai/schemastery'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
if (Array.isArray(
|
|
109
|
-
if (efforts &&
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { collectServers } from './mcp.js'
|
|
4
|
+
import { LOCALES, isLocaleId, localeInfo, resolveLocale, setLocale, t, tv, valueChoices } from './i18n.js'
|
|
5
|
+
|
|
6
|
+
// The settings this plugin owns. Each is declared twice on purpose: as a
|
|
7
|
+
// namespace for a 0.1.x settings provider (which has a registry and stores a
|
|
8
|
+
// document of namespaces), and as a volatile field on this plugin's Config for
|
|
9
|
+
// dsh 0.2.0 (whose settings page is derived from Config forms and has no
|
|
10
|
+
// registry at all). `namespace`/`field` are the legacy coordinates;
|
|
11
|
+
// `configField` is the 0.2.0 one. The values are identical, so a build serves
|
|
12
|
+
// both hosts and the reader/writer below picks whichever the host exposes.
|
|
13
|
+
export const TUI_SETTINGS = [
|
|
14
|
+
// The TUI's own interface language. Deliberately NOT the WebUI's `locale`
|
|
15
|
+
// namespace: the two surfaces have different translation catalogs, and one
|
|
16
|
+
// setting must not drag the other's language with it. `system` follows the
|
|
17
|
+
// POSIX/Windows language variables (see lib/i18n.js detectSystemLocale).
|
|
18
|
+
{ namespace: 'tui-locale', field: 'preference', configField: 'locale' },
|
|
19
|
+
{ namespace: 'ui-conversation', field: 'busyEnter', configField: 'busyEnter' },
|
|
20
|
+
{ namespace: 'tui-updates', field: 'startupCheck', configField: 'startupCheck' },
|
|
21
|
+
// TUI-owned graphics pipeline. `graphics: off` disables protocol rendering
|
|
22
|
+
// entirely; `imageFallback` decides what pixels become on terminals with no
|
|
23
|
+
// graphics protocol; `mermaid` gates the provider chain (local mmdc and/or
|
|
24
|
+
// the mermaid.ink network service).
|
|
25
|
+
{ namespace: 'tui-graphics', field: 'graphics', configField: 'graphics' },
|
|
26
|
+
{ namespace: 'tui-graphics', field: 'imageFallback', configField: 'imageFallback' },
|
|
27
|
+
{ namespace: 'tui-graphics', field: 'imageMaxRows', configField: 'imageMaxRows' },
|
|
28
|
+
{ namespace: 'tui-graphics', field: 'mermaid', configField: 'mermaid' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const WEB_SETTING_SCHEMAS = [
|
|
32
|
+
['ui-theme', z.object({ preference: z.union(['light', 'dark', 'system']).default('system') })],
|
|
33
|
+
['locale', z.object({ preference: z.union(['zh', 'en']).required(false) })],
|
|
34
|
+
['agent-presets', z.object({ default: z.string().required(false) })],
|
|
35
|
+
// The legacy (0.1.x) namespaces for the rows above. A 0.2.0 host ignores
|
|
36
|
+
// these — its page comes from Config — but a 0.1.x host needs them registered
|
|
37
|
+
// before `add()` can read a descriptor.
|
|
38
|
+
['ui-conversation', z.object({ busyEnter: z.union(['queue', 'steer']).default('queue') })],
|
|
39
|
+
['tui-updates', z.object({ startupCheck: z.union(['on', 'off']).default('on') })],
|
|
40
|
+
['tui-locale', z.object({ preference: z.string().default('system') })],
|
|
41
|
+
['tui-graphics', z.object({
|
|
42
|
+
graphics: z.union(['auto', 'off']).default('auto'),
|
|
43
|
+
imageFallback: z.union(['halfblock', 'chip']).default('halfblock'),
|
|
44
|
+
imageMaxRows: z.union(['10', '15', '20', '28', '40']).default('20'),
|
|
45
|
+
mermaid: z.union(['auto', 'local', 'off']).default('auto'),
|
|
46
|
+
})],
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
// The settings dialog's left menu: Main keeps the general settings, Model
|
|
50
|
+
// carries the merged provider + model settings, Update carries the in-app
|
|
51
|
+
// dsh / dsh-oc-tui version manager, MCP carries the external MCP servers the
|
|
52
|
+
// TUI connects for the session. Tab switches the entries.
|
|
53
|
+
//
|
|
54
|
+
// `label` is a getter rather than a stored string: the menu outlives a
|
|
55
|
+
// language switch (the dialog repaints without reloading), so it has to
|
|
56
|
+
// resolve through the active catalog every time it is read.
|
|
57
|
+
export const SETTINGS_MENU = [
|
|
58
|
+
{ id: 'main', get label() { return t('menu.main') } },
|
|
59
|
+
{ id: 'model', get label() { return t('menu.model') } },
|
|
60
|
+
{ id: 'update', get label() { return t('menu.update') } },
|
|
61
|
+
{ id: 'mcp', get label() { return t('menu.mcp') } },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
const MCP_MENU_INDEX = SETTINGS_MENU.findIndex((entry) => entry.id === 'mcp')
|
|
65
|
+
|
|
66
|
+
function valueAt(source, path) {
|
|
67
|
+
let value = source
|
|
68
|
+
for (const part of path) {
|
|
69
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
70
|
+
value = value[part]
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasPath(source, path) {
|
|
76
|
+
let value = source
|
|
77
|
+
for (const part of path) {
|
|
78
|
+
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
|
|
79
|
+
value = value[part]
|
|
80
|
+
}
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function keyRef(provider) {
|
|
85
|
+
return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A provider's stored model catalog tolerates both string entries and
|
|
89
|
+
// `{ id, ... }` objects; normalize to objects with an id so the merged view
|
|
90
|
+
// and the picker can treat them uniformly.
|
|
91
|
+
function normalizeModels(models) {
|
|
92
|
+
if (!Array.isArray(models)) return []
|
|
93
|
+
const out = []
|
|
94
|
+
for (const entry of models) {
|
|
95
|
+
if (typeof entry === 'string' && entry) out.push({ id: entry })
|
|
96
|
+
else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function modelEntryId(entry) {
|
|
102
|
+
return typeof entry === 'string' ? entry : entry?.id
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Picker/choice annotation: a glanceable capability marker per model.
|
|
106
|
+
function capabilitySuffix(model) {
|
|
107
|
+
const capabilities = []
|
|
108
|
+
if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
|
|
109
|
+
if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
|
|
110
|
+
return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Discover the models a configurable provider advertises. Uses the
|
|
114
|
+
// model-discovery seam (`ctx.llm.discoverModels`): it answers from the
|
|
115
|
+
// installed catalog for a known route and interrogates a custom/dormant
|
|
116
|
+
// route's endpoint — resolving the stored credential itself. `listModels`
|
|
117
|
+
// only covers registered adapters, so it silently missed user-added
|
|
118
|
+
// providers that are not currently registered; it is kept as the fallback for
|
|
119
|
+
// namespaces that register no discovery (e.g. the built-in DeepSeek adapter).
|
|
120
|
+
// Returns the normalized list plus whether discovery failed.
|
|
121
|
+
async function fetchProviderModels(ctx, entry, record) {
|
|
122
|
+
const llm = ctx.get('llm')
|
|
123
|
+
let fetched = null
|
|
124
|
+
let failed = false
|
|
125
|
+
if (llm?.discoverModels) {
|
|
126
|
+
const request = {
|
|
127
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
128
|
+
...(typeof record.baseURL === 'string' && record.baseURL.trim().length > 0 ? { baseURL: record.baseURL } : {}),
|
|
129
|
+
...(typeof record.api === 'string' && record.api.trim().length > 0 ? { api: record.api } : {}),
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
fetched = await llm.discoverModels(entry.settingsNs, request)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// `NO_DISCOVERY` means the namespace registered no discovery; its
|
|
135
|
+
// registered adapter answers from its own catalog below.
|
|
136
|
+
if (error?.code !== 'NO_DISCOVERY') failed = true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(fetched) && !failed && llm?.listModels) {
|
|
140
|
+
try { fetched = await llm.listModels(entry.provider) } catch { failed = true }
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(fetched) && !failed) failed = true
|
|
143
|
+
return { models: normalizeModels(fetched), failed }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function effortOptions(models, selectedModel) {
|
|
147
|
+
const model = models.find((candidate) => candidate.id === selectedModel)
|
|
148
|
+
// The adapter-reported reasoning listing (LlmModelInfo.reasoning) is the
|
|
149
|
+
// authoritative "can this model's thinking strength be adjusted?" answer.
|
|
150
|
+
if (model?.reasoning && Array.isArray(model.reasoning.efforts)) {
|
|
151
|
+
return model.reasoning.efforts.map((effort) => String(effort.id))
|
|
152
|
+
}
|
|
153
|
+
const efforts = model?.reasoningEfforts
|
|
154
|
+
if (Array.isArray(efforts)) return efforts.map(String)
|
|
155
|
+
if (efforts && typeof efforts === 'object') return Object.keys(efforts)
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function installWebSettingSchemas(ctx) {
|
|
160
|
+
const settings = ctx.get('settings')
|
|
161
|
+
if (!settings) return
|
|
162
|
+
// dsh 0.2.0 replaced the namespace registry with per-entry Config forms
|
|
163
|
+
// (`SettingsForms`): the service no longer exposes `register`, and the
|
|
164
|
+
// namespaces that survive there are the ones whose host rows declare
|
|
165
|
+
// volatile Config fields. One build serves both hosts — and a settings
|
|
166
|
+
// failure must never take the TUI down, so registration is guarded as a
|
|
167
|
+
// whole rather than trusted to succeed.
|
|
168
|
+
if (typeof settings.register !== 'function') return
|
|
169
|
+
try {
|
|
170
|
+
const registered = new Set(settings.describe().map((entry) => String(entry.ns)))
|
|
171
|
+
for (const [namespace, schema] of WEB_SETTING_SCHEMAS) {
|
|
172
|
+
if (!registered.has(namespace)) settings.register(namespace, schema)
|
|
173
|
+
}
|
|
174
|
+
} catch { /* a settings failure must not take the TUI down */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Main menu tab: the general settings (the models section moved to the Model
|
|
178
|
+
// tab together with the provider settings).
|
|
179
|
+
export async function loadWebSettings(ctx) {
|
|
180
|
+
installWebSettingSchemas(ctx)
|
|
181
|
+
const settings = ctx.get('settings')
|
|
182
|
+
if (!settings) {
|
|
183
|
+
return { settings: null, items: [{ label: t('settings.dshSettings'), value: t('common.unavailable'), disabled: true }], title: t('settings.title'), menu: SETTINGS_MENU, menuIndex: 0 }
|
|
184
|
+
}
|
|
185
|
+
const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
|
|
186
|
+
const items = []
|
|
187
|
+
// A row is emitted only for a setting the host actually exposes. TUI-owned
|
|
188
|
+
// settings go through the two-generation resolver; everything else is a
|
|
189
|
+
// namespace some other plugin registered, which only a 0.1.x-style registry
|
|
190
|
+
// can serve.
|
|
191
|
+
const add = (ns, field, label, options, extra = {}) => {
|
|
192
|
+
const owned = TUI_SETTINGS.some((entry) => entry.namespace === ns && entry.field === field)
|
|
193
|
+
const target = owned ? resolveTuiSetting(ctx, descriptors, ns, field) : undefined
|
|
194
|
+
const descriptor = target?.descriptor ?? descriptors.get(ns)
|
|
195
|
+
if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
|
|
196
|
+
const raw = target ? target.value : descriptor.value[field]
|
|
197
|
+
const stored = raw === undefined ? 'system' : String(raw)
|
|
198
|
+
const { translateValues, ...rest } = extra
|
|
199
|
+
items.push({
|
|
200
|
+
kind: options?.length ? 'choice' : 'text',
|
|
201
|
+
// A 0.2.0 row writes through this plugin's own Config entry, so the row
|
|
202
|
+
// carries that entry id rather than the legacy namespace name.
|
|
203
|
+
ns: target?.ns ?? ns,
|
|
204
|
+
field: target?.field ?? field,
|
|
205
|
+
// A locale row clears the field differently depending on which host it
|
|
206
|
+
// is writing to (see saveWebSetting); `settingGroup` is the legacy
|
|
207
|
+
// namespace name, which the commit path uses to recognize the row it
|
|
208
|
+
// knows how to apply live (graphics, language).
|
|
209
|
+
...(owned ? { settingGroup: ns, legacySetting: target?.legacy === true } : {}),
|
|
210
|
+
label,
|
|
211
|
+
value: stored,
|
|
212
|
+
// The row shows the translated label of the stored id; the id itself is
|
|
213
|
+
// what gets committed (see lib/index.js openSettingChoices).
|
|
214
|
+
...(translateValues ? { valueLabel: tv(stored) } : {}),
|
|
215
|
+
options: options ? (translateValues ? valueChoices(options) : options) : undefined,
|
|
216
|
+
revision: descriptor.revision,
|
|
217
|
+
disabled: !settings.writable,
|
|
218
|
+
...rest,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
items.push({ kind: 'header', label: t('settings.general'), value: '', disabled: true })
|
|
222
|
+
// The language row reads its own label: the stored value is a locale id, and
|
|
223
|
+
// the label is the language's native name.
|
|
224
|
+
const locale = resolveTuiSetting(ctx, descriptors, 'tui-locale', 'preference')
|
|
225
|
+
if (locale) {
|
|
226
|
+
add('tui-locale', 'preference', t('settings.language'), localeChoices(), {
|
|
227
|
+
kind: 'locale',
|
|
228
|
+
valueLabel: describeLocale(normalizePreference(locale.value)),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
add('ui-conversation', 'busyEnter', t('settings.busyEnter'), ['queue', 'steer'], { translateValues: true })
|
|
232
|
+
const agentPresets = ctx.get('agentPresets')
|
|
233
|
+
let presetOptions
|
|
234
|
+
if (agentPresets) {
|
|
235
|
+
try {
|
|
236
|
+
// The roster is best-effort: an unreadable root must not take down the
|
|
237
|
+
// whole settings panel. Broken presets are excluded because no session
|
|
238
|
+
// can be composed from one.
|
|
239
|
+
presetOptions = (await agentPresets.list())
|
|
240
|
+
.filter((preset) => preset.broken === undefined)
|
|
241
|
+
.map((preset) => preset.id)
|
|
242
|
+
} catch { /* roster unavailable; fall back to the free-text row below */ }
|
|
243
|
+
}
|
|
244
|
+
add('agent-presets', 'default', t('settings.defaultPreset'), presetOptions, presetOptions?.length ? { kind: 'agent-preset', value: agentPresets.defaultId } : undefined)
|
|
245
|
+
const permissionPresets = ctx.get('permissionPresets')
|
|
246
|
+
add('permission', 'defaultPreset', t('settings.permissionPreset'), permissionPresets ? [...permissionPresets.names] : undefined, { confirmValue: 'danger-full-access', confirmText: t('settings.permissionDangerConfirm') })
|
|
247
|
+
|
|
248
|
+
items.push({ kind: 'header', label: t('settings.graphics'), value: '', disabled: true })
|
|
249
|
+
add('tui-graphics', 'graphics', t('settings.graphicsRendering'), ['auto', 'off'], { translateValues: true })
|
|
250
|
+
add('tui-graphics', 'imageFallback', t('settings.noProtocolFallback'), ['halfblock', 'chip'], { translateValues: true })
|
|
251
|
+
add('tui-graphics', 'imageMaxRows', t('settings.imageHeightCap'), ['10', '15', '20', '28', '40'])
|
|
252
|
+
add('tui-graphics', 'mermaid', t('settings.mermaidRendering'), ['auto', 'local', 'off'], { translateValues: true })
|
|
253
|
+
|
|
254
|
+
items.push({ kind: 'header', label: t('settings.sessions'), value: '', disabled: true })
|
|
255
|
+
items.push({ kind: 'new-session', label: t('settings.newSession'), value: 'Enter', disabled: false })
|
|
256
|
+
items.push({ kind: 'manage-sessions', label: t('settings.manageSessions'), value: 'Enter', disabled: false })
|
|
257
|
+
items.push({ kind: 'header', label: t('settings.system'), value: '', disabled: true })
|
|
258
|
+
items.push({ kind: 'provider-config-info', label: t('settings.providerConfig'), value: t('menu.model'), disabled: true })
|
|
259
|
+
items.push({ kind: 'open-update', label: t('settings.updateManager'), value: t('menu.update'), disabled: false })
|
|
260
|
+
// The 0.2.0 form service resolves the document through its
|
|
261
|
+
// configuration editor, which a composition without that row never mounts,
|
|
262
|
+
// so the getter can throw where the 0.1.x provider returned undefined.
|
|
263
|
+
let settingsDocumentPath
|
|
264
|
+
try { settingsDocumentPath = settings.documentPath } catch { /* no configuration editor */ }
|
|
265
|
+
if (settingsDocumentPath) items.push({ label: t('settings.settingsFile'), value: settingsDocumentPath, disabled: true })
|
|
266
|
+
return { settings, items, title: t('settings.title'), menu: SETTINGS_MENU, menuIndex: 0 }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The language picker's rows: the stored value is a locale id (or `system`),
|
|
270
|
+
// the label is the language's own name so a user looking for their language
|
|
271
|
+
// finds it without already reading the current one.
|
|
272
|
+
export function localeChoices() {
|
|
273
|
+
return [{ label: describeLocale('system'), value: 'system' }, ...LOCALES.map((entry) => ({ label: entry.native, value: entry.id }))]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The language row's display label for the stored preference.
|
|
277
|
+
export function describeLocale(preference) {
|
|
278
|
+
const systemId = resolveLocale('system')
|
|
279
|
+
if (!preference || preference === 'system' || !isLocaleId(preference)) {
|
|
280
|
+
return t('settings.languageSystem', { language: localeInfo(systemId).native })
|
|
281
|
+
}
|
|
282
|
+
return localeInfo(preference).native
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A stored value that is not a usable locale id means "follow the system", not
|
|
286
|
+
// "render an empty row".
|
|
287
|
+
function normalizePreference(value) {
|
|
288
|
+
return typeof value === 'string' && value !== '' ? value : 'system'
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function describeAll(settings) {
|
|
292
|
+
try {
|
|
293
|
+
return new Map((settings?.describe?.({ redactSecrets: true }) ?? []).map((entry) => [String(entry.ns), entry]))
|
|
294
|
+
} catch {
|
|
295
|
+
return new Map()
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// This plugin's own Config entry on a host that derives settings from Config
|
|
300
|
+
// forms. `describe()` keys rows by the entry's raw `options.id` (not the
|
|
301
|
+
// namespaced `Entry.id`), so that is what identifies the row here. A host that
|
|
302
|
+
// composes the plugin without a loader row (a test harness, or the zero-install
|
|
303
|
+
// bootstrap naming its own row id) leaves `entry` undefined, and there the entry
|
|
304
|
+
// carrying the whole set of TUI-owned fields is this plugin's by construction.
|
|
305
|
+
const TUI_CONFIG_FIELDS = TUI_SETTINGS.map((entry) => entry.configField)
|
|
306
|
+
|
|
307
|
+
function ownEntryDescriptor(ctx, descriptors) {
|
|
308
|
+
const entryId = ctx?.fiber?.entry?.options?.id
|
|
309
|
+
if (typeof entryId === 'string' && entryId !== '') {
|
|
310
|
+
const own = descriptors.get(entryId)
|
|
311
|
+
if (own) return { ns: entryId, descriptor: own }
|
|
312
|
+
}
|
|
313
|
+
const candidates = [...descriptors].filter(([, descriptor]) => descriptor?.value !== null
|
|
314
|
+
&& typeof descriptor?.value === 'object'
|
|
315
|
+
&& TUI_CONFIG_FIELDS.every((field) => Object.hasOwn(descriptor.value, field)))
|
|
316
|
+
return candidates.length === 1
|
|
317
|
+
? { ns: candidates[0][0], descriptor: candidates[0][1] }
|
|
318
|
+
: undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Where one TUI-owned setting actually lives on this host: the registered
|
|
322
|
+
// namespace a 0.1.x provider stores, or the volatile Config field a 0.2.0 host
|
|
323
|
+
// projects into its page. Undefined means the host exposes neither, and the
|
|
324
|
+
// caller omits the row rather than rendering one that cannot be read or written.
|
|
325
|
+
export function resolveTuiSetting(ctx, descriptors, namespace, field) {
|
|
326
|
+
const declared = TUI_SETTINGS.find((entry) => entry.namespace === namespace && entry.field === field)
|
|
327
|
+
const legacy = descriptors.get(namespace)
|
|
328
|
+
if (legacy && typeof legacy.value === 'object' && legacy.value !== null) {
|
|
329
|
+
return { ns: namespace, field, configField: declared?.configField, legacy: true, descriptor: legacy, value: legacy.value[field] }
|
|
330
|
+
}
|
|
331
|
+
if (!declared) return undefined
|
|
332
|
+
const own = ownEntryDescriptor(ctx, descriptors)
|
|
333
|
+
if (!own || typeof own.descriptor.value !== 'object' || own.descriptor.value === null) return undefined
|
|
334
|
+
if (!Object.hasOwn(own.descriptor.value, declared.configField)) return undefined
|
|
335
|
+
return {
|
|
336
|
+
ns: own.ns,
|
|
337
|
+
field: declared.configField,
|
|
338
|
+
configField: declared.configField,
|
|
339
|
+
legacy: false,
|
|
340
|
+
descriptor: own.descriptor,
|
|
341
|
+
value: own.descriptor.value[declared.configField],
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Read one TUI-owned setting without requiring a settings service. The stored
|
|
346
|
+
// descriptor wins; failing that (a host whose forms have not settled yet, or a
|
|
347
|
+
// host with no form service at all) the running Config reference is the honest
|
|
348
|
+
// source, because a volatile field is kept current by the loader.
|
|
349
|
+
export function readTuiSetting(ctx, config, namespace, field) {
|
|
350
|
+
const settings = ctx?.get?.('settings')
|
|
351
|
+
const target = resolveTuiSetting(ctx, describeAll(settings), namespace, field)
|
|
352
|
+
if (target) return target.value
|
|
353
|
+
const declared = TUI_SETTINGS.find((entry) => entry.namespace === namespace && entry.field === field)
|
|
354
|
+
const raw = declared ? config?.[declared.configField] : undefined
|
|
355
|
+
return typeof raw?.get === 'function' ? raw.get() : raw
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The stored language preference (a locale id or `system`), read live from the
|
|
359
|
+
// settings service so the Language row and the boot path agree.
|
|
360
|
+
export function readLocalePreference(ctx, config) {
|
|
361
|
+
return normalizePreference(readTuiSetting(ctx, config, 'tui-locale', 'preference'))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Apply the stored language preference to the active catalog. Called at boot
|
|
365
|
+
// and again whenever the Language row is committed.
|
|
366
|
+
export function applyLocalePreference(ctx, config) {
|
|
367
|
+
return setLocale(resolveLocale(readLocalePreference(ctx, config)))
|
|
368
|
+
}
|
|
369
|
+
// Model menu tab: the merged provider + model settings. Per provider:
|
|
370
|
+
// Provider URL, Provider API key, Models (the saved selection, listed below
|
|
371
|
+
// the row; Enter auto-fetches the catalog and opens the selection window).
|
|
372
|
+
export async function loadModelSettings(ctx) {
|
|
373
|
+
installWebSettingSchemas(ctx)
|
|
374
|
+
const settings = ctx.get('settings')
|
|
375
|
+
if (!settings) {
|
|
376
|
+
return { settings: null, items: [{ label: t('model.title'), value: t('common.unavailable'), disabled: true }], title: t('model.title'), menu: SETTINGS_MENU, menuIndex: 1 }
|
|
377
|
+
}
|
|
378
|
+
const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
|
|
379
|
+
const items = []
|
|
380
|
+
const add = (ns, field, label, options, extra = {}) => {
|
|
381
|
+
const descriptor = descriptors.get(ns)
|
|
382
|
+
if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
|
|
383
|
+
const value = descriptor.value[field]
|
|
384
|
+
items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
|
|
385
|
+
}
|
|
386
|
+
const llm = ctx.get('llm')
|
|
387
|
+
const credentials = ctx.get('credentials')
|
|
388
|
+
|
|
389
|
+
// Only providers the user has actually added are listed. A provider counts
|
|
390
|
+
// as added when its profile is present in the user settings layer; a
|
|
391
|
+
// built-in namespace provider (empty settings path) counts only when the
|
|
392
|
+
// user configured something in that namespace. Preset providers that were
|
|
393
|
+
// never added stay hidden.
|
|
394
|
+
const configurable = (llm?.listConfigurableProviders?.() ?? []).filter((entry) => {
|
|
395
|
+
const descriptor = descriptors.get(entry.settingsNs)
|
|
396
|
+
if (!descriptor) return false
|
|
397
|
+
if (entry.settingsPath.length === 0) {
|
|
398
|
+
return descriptor.user && typeof descriptor.user === 'object'
|
|
399
|
+
&& Object.keys(descriptor.user).length > 0
|
|
400
|
+
}
|
|
401
|
+
return hasPath(descriptor.user, entry.settingsPath)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// ---- default route ----------------------------------------------------
|
|
405
|
+
items.push({ kind: 'header', label: t('model.defaultSection'), value: '', disabled: true })
|
|
406
|
+
const providers = descriptors.get('llm-pi-ai')?.value?.providers
|
|
407
|
+
const providerEntries = providers && typeof providers === 'object' ? Object.entries(providers) : []
|
|
408
|
+
const defaultModel = descriptors.get('agent-default-model')?.value
|
|
409
|
+
const selectedProvider = defaultModel && typeof defaultModel === 'object' ? defaultModel.provider : undefined
|
|
410
|
+
const selectedModel = defaultModel && typeof defaultModel === 'object' ? defaultModel.model : undefined
|
|
411
|
+
const liveProviders = llm?.listProviders?.() ?? []
|
|
412
|
+
const providerOptions = liveProviders.length > 0 ? liveProviders.map((provider) => provider.id) : providerEntries.map(([id]) => id)
|
|
413
|
+
// The default-model options are exactly the models the user has saved under
|
|
414
|
+
// the selected provider (its `models` list). Do not auto-fetch the provider
|
|
415
|
+
// catalog here: that catalog can include models the user never added, and it
|
|
416
|
+
// would diverge from the saved selection shown in the provider block below.
|
|
417
|
+
// The auto-fetched catalog stays available behind the Models row (Enter →
|
|
418
|
+
// loadProviderModels).
|
|
419
|
+
const selectedEntry = configurable.find((entry) => entry.provider === selectedProvider)
|
|
420
|
+
let models = []
|
|
421
|
+
if (selectedEntry) {
|
|
422
|
+
const selectedDescriptor = descriptors.get(selectedEntry.settingsNs)
|
|
423
|
+
const selectedProfile = selectedDescriptor ? valueAt(selectedDescriptor.value, selectedEntry.settingsPath) : undefined
|
|
424
|
+
const selectedRecord = typeof selectedProfile === 'object' && selectedProfile !== null ? selectedProfile : {}
|
|
425
|
+
models = normalizeModels(selectedRecord.models)
|
|
426
|
+
} else {
|
|
427
|
+
models = normalizeModels(providerEntries.find(([id]) => id === selectedProvider)?.[1]?.models)
|
|
428
|
+
}
|
|
429
|
+
add('agent-default-model', 'provider', t('model.defaultProvider'), providerOptions, { kind: 'default-provider' })
|
|
430
|
+
// The picker annotates each candidate with its capabilities, so a user can
|
|
431
|
+
// tell at a glance which models take image input and which can adjust their
|
|
432
|
+
// thinking strength instead of discovering the refusal after sending.
|
|
433
|
+
const modelOptions = models.map((model) => {
|
|
434
|
+
const suffix = capabilitySuffix(model)
|
|
435
|
+
return suffix ? { label: model.id + suffix, value: model.id } : model.id
|
|
436
|
+
})
|
|
437
|
+
add('agent-default-model', 'model', t('model.defaultModel'), modelOptions, { kind: 'default-model' })
|
|
438
|
+
let reasoningOptions = effortOptions(models, selectedModel)
|
|
439
|
+
let reasoningDefault
|
|
440
|
+
if (llm?.resolveModelInfo && selectedProvider && selectedModel) {
|
|
441
|
+
try {
|
|
442
|
+
const info = await llm.resolveModelInfo(selectedProvider, selectedModel)
|
|
443
|
+
reasoningOptions = info.reasoning?.efforts.map((effort) => String(effort.id))
|
|
444
|
+
reasoningDefault = info.reasoning?.defaultEffort === undefined ? undefined : String(info.reasoning.defaultEffort)
|
|
445
|
+
} catch { /* keep catalog fallback */ }
|
|
446
|
+
}
|
|
447
|
+
const configuredEffort = defaultModel?.reasoningEffort === undefined ? undefined : String(defaultModel.reasoningEffort)
|
|
448
|
+
const reasoningValue = reasoningOptions?.includes(configuredEffort)
|
|
449
|
+
? configuredEffort
|
|
450
|
+
: reasoningOptions?.includes(reasoningDefault) ? reasoningDefault : undefined
|
|
451
|
+
const reasoningExtra = reasoningOptions?.length
|
|
452
|
+
? { kind: 'effort', ...reasoningValue === undefined ? {} : { value: reasoningValue } }
|
|
453
|
+
: undefined
|
|
454
|
+
add('agent-default-model', 'reasoningEffort', t('model.reasoning'), reasoningOptions, reasoningExtra)
|
|
455
|
+
|
|
456
|
+
// ---- merged provider blocks --------------------------------------------
|
|
457
|
+
for (const entry of configurable) {
|
|
458
|
+
const descriptor = descriptors.get(entry.settingsNs)
|
|
459
|
+
if (!descriptor) continue
|
|
460
|
+
items.push({ kind: 'header', label: entry.displayName, value: '', disabled: true })
|
|
461
|
+
const profile = valueAt(descriptor.value, entry.settingsPath)
|
|
462
|
+
const removable = entry.settingsPath.length > 0 && hasPath(descriptor.user, entry.settingsPath) && !hasPath(descriptor.base, entry.settingsPath)
|
|
463
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
464
|
+
const ref = typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : keyRef(entry.provider)
|
|
465
|
+
let credential
|
|
466
|
+
try { credential = credentials ? await credentials.describe(ref) : undefined } catch { credential = undefined }
|
|
467
|
+
if (entry.settingsNs === 'llm-deepseek' || entry.settingsNs === 'llm-pi-ai') items.push({ kind: 'path', label: t('model.providerUrl'), value: typeof record.baseURL === 'string' ? record.baseURL : 'default', ns: entry.settingsNs, path: [...entry.settingsPath, 'baseURL'], revision: descriptor.revision, disabled: !settings.writable })
|
|
468
|
+
items.push({ kind: 'secret', label: t('model.providerApiKey'), value: credential?.configured ? t('model.credentialConfigured', { source: credential.source ?? 'stored' }) : t('model.credentialMissing'), credentialRef: ref, disabled: !credentials || credential?.writable === false })
|
|
469
|
+
if (entry.settingsNs === 'llm-pi-ai' && entry.declared === true) {
|
|
470
|
+
items.push({ kind: 'path', label: t('model.displayName'), value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...entry.settingsPath, 'displayName'], revision: descriptor.revision, disabled: !settings.writable })
|
|
471
|
+
const choices = ['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai']
|
|
472
|
+
items.push({ kind: 'path-choice', label: t('model.wireProtocol'), value: typeof record.api === 'string' ? record.api : choices[0], options: choices, ns: entry.settingsNs, path: [...entry.settingsPath, 'api'], revision: descriptor.revision, disabled: !settings.writable })
|
|
473
|
+
}
|
|
474
|
+
// Route-level request modalities. This is what makes a hand-declared vision
|
|
475
|
+
// model usable: pi-ai under-claims text by default (refusing the image before
|
|
476
|
+
// it is attached is the safe answer when nothing is declared), so a gateway
|
|
477
|
+
// serving vision models declares `[text, image]` once here instead of on
|
|
478
|
+
// every entry. Catalog models keep the modalities the catalog records for
|
|
479
|
+
// them; this value never narrows one.
|
|
480
|
+
if (entry.settingsNs === 'llm-pi-ai') {
|
|
481
|
+
const declaredInput = Array.isArray(record.defaultInput) ? record.defaultInput : undefined
|
|
482
|
+
items.push({
|
|
483
|
+
kind: 'input-modalities',
|
|
484
|
+
label: t('model.defaultImageInput'),
|
|
485
|
+
value: declaredInput?.includes('image') ? 'text + image' : 'text only',
|
|
486
|
+
options: ['text only', 'text + image'].map((id) => ({ label: tv(id), value: id })),
|
|
487
|
+
ns: entry.settingsNs,
|
|
488
|
+
path: [...entry.settingsPath, 'defaultInput'],
|
|
489
|
+
revision: descriptor.revision,
|
|
490
|
+
disabled: !settings.writable,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
// Models: Enter auto-fetches the provider catalog and opens the selection
|
|
494
|
+
// window; the saved selection is listed right below, one row per model.
|
|
495
|
+
const stored = normalizeModels(record.models)
|
|
496
|
+
items.push({ kind: 'provider-models', label: t('model.models'), value: stored.length > 0 ? t('model.selectedCount', { count: stored.length }) : t('model.fetch'), providerId: entry.provider, disabled: false })
|
|
497
|
+
for (const model of stored) {
|
|
498
|
+
const isDefault = entry.provider === selectedProvider && model.id === selectedModel
|
|
499
|
+
items.push({
|
|
500
|
+
kind: 'provider-model',
|
|
501
|
+
label: model.id,
|
|
502
|
+
value: isDefault ? t('model.defaultTag') : '',
|
|
503
|
+
indent: 1,
|
|
504
|
+
modelId: model.id,
|
|
505
|
+
providerId: entry.provider,
|
|
506
|
+
ns: 'agent-default-model',
|
|
507
|
+
revision: descriptors.get('agent-default-model')?.revision,
|
|
508
|
+
disabled: false,
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
if (removable) items.push({ kind: 'remove-provider', label: t('model.removeProvider'), value: 'Enter', ns: entry.settingsNs, path: entry.settingsPath, revision: descriptor.revision, credentialRef: credential?.configured && credential?.writable ? ref : undefined, confirmText: t('model.removeProviderConfirm', { name: entry.displayName }), disabled: !settings.writable })
|
|
512
|
+
}
|
|
513
|
+
return { settings, items, title: t('model.title'), subtitle: t('model.subtitle'), menu: SETTINGS_MENU, menuIndex: 1 }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The auto-fetched model selection window for one provider: the provider's
|
|
517
|
+
// discovered catalog (`ctx.llm.discoverModels`, with a registered-adapter
|
|
518
|
+
// `listModels` fallback) as checkbox rows marking the saved selection. Enter
|
|
519
|
+
// toggles a model in or out of the provider's saved models list. A failed
|
|
520
|
+
// fetch falls back to the saved selection so stored models stay manageable.
|
|
521
|
+
export async function loadProviderModels(ctx, providerId) {
|
|
522
|
+
const settings = ctx.get('settings')
|
|
523
|
+
const entry = ctx.get('llm')?.listConfigurableProviders?.().find((candidate) => candidate.provider === providerId)
|
|
524
|
+
if (!settings || !entry) throw new Error(t('model.providerUnavailable'))
|
|
525
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
|
|
526
|
+
if (!descriptor) throw new Error(t('model.namespaceUnavailable'))
|
|
527
|
+
const profile = valueAt(descriptor.value, entry.settingsPath)
|
|
528
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
529
|
+
const stored = normalizeModels(record.models)
|
|
530
|
+
const storedIds = new Set(stored.map((model) => model.id))
|
|
531
|
+
const discovered = await fetchProviderModels(ctx, entry, record)
|
|
532
|
+
const fetched = discovered.models
|
|
533
|
+
const fetchFailed = discovered.failed
|
|
534
|
+
const items = []
|
|
535
|
+
const seen = new Set()
|
|
536
|
+
const pushRow = (model) => {
|
|
537
|
+
if (!model?.id || seen.has(model.id)) return
|
|
538
|
+
seen.add(model.id)
|
|
539
|
+
const checked = storedIds.has(model.id)
|
|
540
|
+
items.push({
|
|
541
|
+
kind: 'model-toggle',
|
|
542
|
+
label: model.id + capabilitySuffix(model),
|
|
543
|
+
value: checked ? '[x]' : '[ ]',
|
|
544
|
+
modelId: model.id,
|
|
545
|
+
checked,
|
|
546
|
+
selectedIds: [...storedIds],
|
|
547
|
+
storedModels: stored,
|
|
548
|
+
providerId,
|
|
549
|
+
settingsNs: entry.settingsNs,
|
|
550
|
+
providerPath: entry.settingsPath,
|
|
551
|
+
revision: descriptor.revision,
|
|
552
|
+
disabled: !settings.writable,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
for (const model of fetched) pushRow(model)
|
|
556
|
+
// Saved models that the catalog no longer returns stay toggleable.
|
|
557
|
+
for (const model of stored) pushRow(model)
|
|
558
|
+
if (items.length === 0) {
|
|
559
|
+
items.push({ label: fetchFailed ? t('model.noModels') : t('model.noModelsReturned'), value: '', disabled: true })
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
settings,
|
|
563
|
+
items,
|
|
564
|
+
title: entry.displayName + ' · ' + t('model.models').toLowerCase(),
|
|
565
|
+
subtitle: fetchFailed
|
|
566
|
+
? t('model.fetchUnavailable')
|
|
567
|
+
: t('model.fetchOk'),
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function saveWebSetting(ctx, settings, item, value) {
|
|
572
|
+
if (!item || item.disabled) return
|
|
573
|
+
if (item.kind === 'secret') {
|
|
574
|
+
if (!value.trim()) throw new Error(t('model.apiKeyEmpty'))
|
|
575
|
+
const credentials = ctx.get('credentials')
|
|
576
|
+
if (!credentials) throw new Error(t('model.credentialsUnavailable'))
|
|
577
|
+
await credentials.set(item.credentialRef, value.trim())
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if (item.kind === 'path' || item.kind === 'path-choice') {
|
|
581
|
+
const trimmed = value.trim()
|
|
582
|
+
const op = trimmed === '' || trimmed === 'default' ? { op: 'unset', path: item.path } : { op: 'set', path: item.path, value: trimmed }
|
|
583
|
+
await settings.mutate(item.ns, [op], item.revision)
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
if (item.kind === 'input-modalities') {
|
|
587
|
+
const modalities = value.trim() === tv('text + image') || value.trim() === 'text + image' ? ['text', 'image'] : ['text']
|
|
588
|
+
await settings.mutate(item.ns, [{ op: 'set', path: item.path, value: modalities }], item.revision)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
if (item.kind === 'enable-provider') return settings.mutate(item.ns, [{ op: 'set', path: item.path, value: {} }], item.revision)
|
|
592
|
+
if (item.kind === 'remove-provider') {
|
|
593
|
+
const credentials = ctx.get('credentials')
|
|
594
|
+
if (item.credentialRef && credentials) await credentials.unset(item.credentialRef)
|
|
595
|
+
return settings.mutate(item.ns, [{ op: 'unset', path: item.path }], item.revision)
|
|
596
|
+
}
|
|
597
|
+
if (item.kind === 'default-provider') {
|
|
598
|
+
// Resolve the new provider's first advertised model through the same
|
|
599
|
+
// discovery seam as the model pickers, so a user-added (dormant) provider
|
|
600
|
+
// answers its full catalog instead of failing on `listModels`.
|
|
601
|
+
const llm = ctx.get('llm')
|
|
602
|
+
let model
|
|
603
|
+
const entry = llm?.listConfigurableProviders?.().find((candidate) => candidate.provider === value)
|
|
604
|
+
if (entry) {
|
|
605
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
|
|
606
|
+
const profile = descriptor ? valueAt(descriptor.value, entry.settingsPath) : undefined
|
|
607
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
608
|
+
const discovered = await fetchProviderModels(ctx, entry, record)
|
|
609
|
+
model = discovered.models[0]?.id
|
|
610
|
+
} else {
|
|
611
|
+
const models = await llm?.listModels?.(value)
|
|
612
|
+
model = models?.[0]?.id
|
|
613
|
+
}
|
|
614
|
+
if (!model) throw new Error(t('model.noProviderModels'))
|
|
615
|
+
return settings.mutate(item.ns, [
|
|
616
|
+
{ op: 'set', path: ['provider'], value },
|
|
617
|
+
{ op: 'set', path: ['model'], value: model },
|
|
618
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
619
|
+
], item.revision)
|
|
620
|
+
}
|
|
621
|
+
if (item.kind === 'default-model') {
|
|
622
|
+
return settings.mutate(item.ns, [
|
|
623
|
+
{ op: 'set', path: ['model'], value },
|
|
624
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
625
|
+
], item.revision)
|
|
626
|
+
}
|
|
627
|
+
// A model listed under its provider becomes the default route.
|
|
628
|
+
if (item.kind === 'provider-model') {
|
|
629
|
+
return settings.mutate(item.ns, [
|
|
630
|
+
{ op: 'set', path: ['provider'], value: item.providerId },
|
|
631
|
+
{ op: 'set', path: ['model'], value: item.modelId },
|
|
632
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
633
|
+
], item.revision)
|
|
634
|
+
}
|
|
635
|
+
// The auto-fetched selection window: toggle one model in or out of the
|
|
636
|
+
// provider's saved models list. Existing stored entries keep their shape;
|
|
637
|
+
// additions are plain `{ id }` records.
|
|
638
|
+
if (item.kind === 'model-toggle') {
|
|
639
|
+
const ids = new Set(item.selectedIds ?? [])
|
|
640
|
+
if (item.checked) ids.delete(item.modelId)
|
|
641
|
+
else ids.add(item.modelId)
|
|
642
|
+
const next = []
|
|
643
|
+
for (const entry of item.storedModels ?? []) {
|
|
644
|
+
if (ids.has(modelEntryId(entry))) next.push(entry)
|
|
645
|
+
}
|
|
646
|
+
for (const id of ids) {
|
|
647
|
+
if (!next.some((entry) => modelEntryId(entry) === id)) next.push({ id })
|
|
648
|
+
}
|
|
649
|
+
return settings.mutate(item.settingsNs, [{ op: 'set', path: [...item.providerPath, 'models'], value: next }], item.revision)
|
|
650
|
+
}
|
|
651
|
+
if (!item.ns || !item.field) return
|
|
652
|
+
if (item.ns === 'locale' && item.field === 'preference' && value === 'system') return settings.replace(item.ns, {}, item.revision)
|
|
653
|
+
// A TUI-owned field set back to the value it defaults to is cleared rather
|
|
654
|
+
// than written: the profile patch then carries no literal that a later
|
|
655
|
+
// default change would have to fight, and `system` keeps following the OS.
|
|
656
|
+
// The legacy namespace clears as a whole; a Config field unsets alone.
|
|
657
|
+
if (item.legacySetting === true && value === 'system') return settings.replace(item.ns, {}, item.revision)
|
|
658
|
+
if (item.kind === 'locale' && value === 'system') {
|
|
659
|
+
return settings.mutate(item.ns, [{ op: 'unset', path: [item.field] }], item.revision)
|
|
660
|
+
}
|
|
661
|
+
return settings.update(item.ns, { [item.field]: value }, item.revision)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// MCP menu tab: the servers the TUI would mount for the current session, read
|
|
665
|
+
// from the `mcp.json` config files themselves (lib/mcp.js owns that format).
|
|
666
|
+
// Each row carries the live connection state and the tools the server put on
|
|
667
|
+
// the session's agent; the file list underneath answers "where does this come
|
|
668
|
+
// from", which is the whole diagnostic when a server is missing. Enter opens
|
|
669
|
+
// the add/edit form, Del removes. Reading is filesystem-only, so a broken or
|
|
670
|
+
// unreadable file degrades to an error row here and never to a failed tab.
|
|
671
|
+
export async function loadMcpSettings(ctx, manager, { cwd } = {}) {
|
|
672
|
+
const collected = await collectServers(cwd ?? process.cwd())
|
|
673
|
+
manager?.setConfigured?.(collected.servers)
|
|
674
|
+
const statuses = manager?.status?.() ?? []
|
|
675
|
+
const items = [{ kind: 'header', label: t('mcp.servers'), value: '', disabled: true }]
|
|
676
|
+
if (statuses.length === 0) {
|
|
677
|
+
items.push({ kind: 'mcp-empty', label: t('mcp.empty'), value: '', disabled: true, selectable: false })
|
|
678
|
+
}
|
|
679
|
+
for (const status of statuses) {
|
|
680
|
+
items.push({
|
|
681
|
+
kind: 'mcp-server',
|
|
682
|
+
label: status.name,
|
|
683
|
+
value: describeState(status),
|
|
684
|
+
tone: status.state === 'error' ? 'warning' : status.state === 'connected' ? 'success' : undefined,
|
|
685
|
+
serverName: status.name,
|
|
686
|
+
record: status.record,
|
|
687
|
+
disabled: false,
|
|
688
|
+
})
|
|
689
|
+
// The type, target, scope and tool names are information rows: navigation
|
|
690
|
+
// steps over them so the selection never rests on a row with no action.
|
|
691
|
+
items.push({
|
|
692
|
+
kind: 'mcp-target',
|
|
693
|
+
label: typeLabel(status.type),
|
|
694
|
+
value: status.target,
|
|
695
|
+
indent: 1,
|
|
696
|
+
disabled: true,
|
|
697
|
+
selectable: false,
|
|
698
|
+
})
|
|
699
|
+
items.push({
|
|
700
|
+
kind: 'mcp-scope',
|
|
701
|
+
label: t('mcp.scope'),
|
|
702
|
+
value: describeScope(status),
|
|
703
|
+
indent: 1,
|
|
704
|
+
disabled: true,
|
|
705
|
+
selectable: false,
|
|
706
|
+
})
|
|
707
|
+
for (const tool of status.tools ?? []) {
|
|
708
|
+
items.push({ kind: 'mcp-tool', label: tool, value: '', indent: 1, disabled: true, selectable: false })
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
items.push({ kind: 'mcp-add', label: t('mcp.add'), value: 'Enter', disabled: false })
|
|
712
|
+
items.push({ kind: 'header', label: t('mcp.configFiles'), value: '', disabled: true })
|
|
713
|
+
for (const file of collected.files) {
|
|
714
|
+
items.push({
|
|
715
|
+
kind: 'mcp-file',
|
|
716
|
+
label: file.scope === 'user' ? t('mcp.userScope') : shorten(file.directory ?? dirname(file.path), 40),
|
|
717
|
+
value: describeFile(file),
|
|
718
|
+
indent: 1,
|
|
719
|
+
disabled: true,
|
|
720
|
+
selectable: false,
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
// The deliberate simplification behind no credential vault: env values and
|
|
724
|
+
// headers are written into these files as plain text.
|
|
725
|
+
items.push({ kind: 'mcp-secrets', label: t('mcp.secrets'), value: t('mcp.secretsPlain'), disabled: true, selectable: false })
|
|
726
|
+
return {
|
|
727
|
+
settings: null,
|
|
728
|
+
items,
|
|
729
|
+
title: t('mcp.title'),
|
|
730
|
+
subtitle: t('mcp.subtitle'),
|
|
731
|
+
menu: SETTINGS_MENU,
|
|
732
|
+
menuIndex: MCP_MENU_INDEX,
|
|
733
|
+
servers: collected.servers,
|
|
734
|
+
files: collected.files,
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const MCP_TYPE_KEYS = { stdio: 'mcp.type.stdio', http: 'mcp.type.http', sse: 'mcp.type.sse' }
|
|
739
|
+
|
|
740
|
+
function typeLabel(type) {
|
|
741
|
+
const key = MCP_TYPE_KEYS[type]
|
|
742
|
+
return key ? t(key) : String(type ?? '')
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function describeFile(file) {
|
|
746
|
+
if (file.error) return '⚠ ' + shorten(file.error, 32)
|
|
747
|
+
return file.exists ? t('mcp.loaded') : t('mcp.notCreated')
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Where a row's config lives, for the "why is this server here" question: the
|
|
751
|
+
// user file applies everywhere, a path file applies to sessions under its
|
|
752
|
+
// directory.
|
|
753
|
+
function describeScope(status) {
|
|
754
|
+
const source = String(status.sourcePath ?? '')
|
|
755
|
+
if (status.scope !== 'path') return t('mcp.scopeUserPrefix', { path: shorten(source, 48) })
|
|
756
|
+
return t('mcp.scopePathPrefix', { path: shorten(source.replace(/[\\/]\.dsh[\\/]mcp\.json$/, ''), 48) })
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function describeState(status) {
|
|
760
|
+
switch (status.state) {
|
|
761
|
+
case 'connected':
|
|
762
|
+
return status.tools === null
|
|
763
|
+
? '● ' + t('mcp.state.connected')
|
|
764
|
+
: '● ' + (status.tools.length === 1
|
|
765
|
+
? t('mcp.state.tools.one', { count: 1 })
|
|
766
|
+
: t('mcp.state.tools.other', { count: status.tools.length }))
|
|
767
|
+
case 'pending':
|
|
768
|
+
return '… ' + t('mcp.state.pending')
|
|
769
|
+
case 'error':
|
|
770
|
+
return '⚠ ' + shorten(status.error, 32)
|
|
771
|
+
case 'disabled':
|
|
772
|
+
return '○ ' + t('mcp.state.disabled')
|
|
773
|
+
default:
|
|
774
|
+
return '○ ' + t('mcp.state.idle')
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function shorten(text, max) {
|
|
779
|
+
const value = String(text ?? '')
|
|
780
|
+
return value.length <= max ? value : value.slice(0, max - 1) + '…'
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// The add/edit form. `type` decides which connection fields exist and `scope`
|
|
784
|
+
// whether the path row exists, so switching either rebuilds the list instead of
|
|
785
|
+
// showing fields that would be ignored.
|
|
786
|
+
export function buildMcpFormItems(draft, { isEdit = false } = {}) {
|
|
787
|
+
const items = [{ kind: 'header', label: isEdit ? t('mcp.form.edit', { name: draft.originalName || draft.name }) : t('mcp.form.add'), value: '', disabled: true }]
|
|
788
|
+
// A stored server's name is its identity: it is the config key and the tool
|
|
789
|
+
// namespace, so it stays fixed while editing and is only typed when adding.
|
|
790
|
+
items.push({ kind: 'mcp-field', field: 'name', label: t('mcp.field.name'), value: draft.name, mcpText: true, disabled: isEdit })
|
|
791
|
+
items.push({ kind: 'mcp-field', field: 'type', label: t('mcp.field.type'), value: draft.type, options: mcpTypeChoices() })
|
|
792
|
+
items.push({ kind: 'mcp-field', field: 'timeoutMs', label: t('mcp.field.timeout'), value: draft.timeoutMs, mcpText: true })
|
|
793
|
+
if (draft.type === 'stdio') {
|
|
794
|
+
mcpField(items, draft, 'command', t('mcp.field.command'))
|
|
795
|
+
mcpField(items, draft, 'args', t('mcp.field.args'))
|
|
796
|
+
mcpField(items, draft, 'env', t('mcp.field.env'))
|
|
797
|
+
} else {
|
|
798
|
+
mcpField(items, draft, 'url', t('mcp.field.url'))
|
|
799
|
+
mcpField(items, draft, 'headers', t('mcp.field.headers'))
|
|
800
|
+
}
|
|
801
|
+
items.push({ kind: 'mcp-field', field: 'scope', label: t('mcp.field.scope'), value: draft.scope, options: mcpScopeChoices() })
|
|
802
|
+
if (draft.scope === 'path') mcpField(items, draft, 'path', t('mcp.field.path'))
|
|
803
|
+
items.push({ kind: 'mcp-field', field: 'enabled', label: t('mcp.field.enabled'), value: draft.enabled === false ? 'off' : 'on', options: valueChoices(['on', 'off']) })
|
|
804
|
+
items.push({ kind: 'mcp-save', label: t('mcp.field.save'), value: 'Enter' })
|
|
805
|
+
if (isEdit) {
|
|
806
|
+
items.push({
|
|
807
|
+
kind: 'mcp-remove',
|
|
808
|
+
label: t('mcp.field.remove'),
|
|
809
|
+
value: 'Enter',
|
|
810
|
+
serverName: draft.originalName || draft.name,
|
|
811
|
+
confirmText: t('mcp.removeConfirm'),
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
return items
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// The choice rows carry their label separately from the committed value so the
|
|
818
|
+
// value in the config file stays the short id (`stdio`, `user`, `path`).
|
|
819
|
+
// Functions, not constants: the labels are resolved per call, so a language
|
|
820
|
+
// switch while the form is open renames them.
|
|
821
|
+
export function mcpTypeChoices() {
|
|
822
|
+
return [
|
|
823
|
+
{ label: t('mcp.choice.stdio'), value: 'stdio' },
|
|
824
|
+
{ label: t('mcp.choice.http'), value: 'http' },
|
|
825
|
+
{ label: t('mcp.choice.sse'), value: 'sse' },
|
|
826
|
+
]
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function mcpScopeChoices() {
|
|
830
|
+
return [
|
|
831
|
+
{ label: t('mcp.choice.scopeUser'), value: 'user' },
|
|
832
|
+
{ label: t('mcp.choice.scopePath'), value: 'path' },
|
|
833
|
+
]
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function mcpField(items, draft, field, label) {
|
|
837
|
+
items.push({
|
|
838
|
+
kind: 'mcp-field',
|
|
839
|
+
field,
|
|
840
|
+
label,
|
|
841
|
+
value: String(draft[field] ?? ''),
|
|
842
|
+
mcpText: true,
|
|
843
|
+
disabled: false,
|
|
844
|
+
})
|
|
845
|
+
}
|
|
846
|
+
|