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.
@@ -1,454 +1,846 @@
1
- import z from '@deepseek-ai/schemastery'
2
-
3
- const WEB_SETTING_SCHEMAS = [
4
- ['ui-theme', z.object({ preference: z.union(['light', 'dark', 'system']).default('system') })],
5
- ['locale', z.object({ preference: z.union(['zh', 'en']).required(false) })],
6
- ['ui-conversation', z.object({ busyEnter: z.union(['queue', 'steer']).default('queue') })],
7
- ['agent-presets', z.object({ default: z.string().required(false) })],
8
- ['tui-updates', z.object({ startupCheck: z.union(['on', 'off']).default('on') })],
9
- ]
10
-
11
- // The settings dialog's left menu: Main keeps the general settings, Model
12
- // carries the merged provider + model settings, Update carries the in-app
13
- // dsh / dsh-oc-tui version manager. Tab switches the entries.
14
- export const SETTINGS_MENU = [
15
- { id: 'main', label: 'Main' },
16
- { id: 'model', label: 'Model' },
17
- { id: 'update', label: 'Update' },
18
- ]
19
-
20
- function valueAt(source, path) {
21
- let value = source
22
- for (const part of path) {
23
- if (typeof value !== 'object' || value === null) return undefined
24
- value = value[part]
25
- }
26
- return value
27
- }
28
-
29
- function hasPath(source, path) {
30
- let value = source
31
- for (const part of path) {
32
- if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
33
- value = value[part]
34
- }
35
- return true
36
- }
37
-
38
- function keyRef(provider) {
39
- return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
40
- }
41
-
42
- // A provider's stored model catalog tolerates both string entries and
43
- // `{ id, ... }` objects; normalize to objects with an id so the merged view
44
- // and the picker can treat them uniformly.
45
- function normalizeModels(models) {
46
- if (!Array.isArray(models)) return []
47
- const out = []
48
- for (const entry of models) {
49
- if (typeof entry === 'string' && entry) out.push({ id: entry })
50
- else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
51
- }
52
- return out
53
- }
54
-
55
- function modelEntryId(entry) {
56
- return typeof entry === 'string' ? entry : entry?.id
57
- }
58
-
59
- // Picker/choice annotation: a glanceable capability marker per model.
60
- function capabilitySuffix(model) {
61
- const capabilities = []
62
- if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
63
- if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
64
- return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
65
- }
66
-
67
- // Discover the models a configurable provider advertises. Uses the
68
- // model-discovery seam (`ctx.llm.discoverModels`): it answers from the
69
- // installed catalog for a known route and interrogates a custom/dormant
70
- // route's endpoint — resolving the stored credential itself. `listModels`
71
- // only covers registered adapters, so it silently missed user-added
72
- // providers that are not currently registered; it is kept as the fallback for
73
- // namespaces that register no discovery (e.g. the built-in DeepSeek adapter).
74
- // Returns the normalized list plus whether discovery failed.
75
- async function fetchProviderModels(ctx, entry, record) {
76
- const llm = ctx.get('llm')
77
- let fetched = null
78
- let failed = false
79
- if (llm?.discoverModels) {
80
- const request = {
81
- ...(entry.provider === undefined ? {} : { provider: entry.provider }),
82
- ...(typeof record.baseURL === 'string' && record.baseURL.trim().length > 0 ? { baseURL: record.baseURL } : {}),
83
- ...(typeof record.api === 'string' && record.api.trim().length > 0 ? { api: record.api } : {}),
84
- }
85
- try {
86
- fetched = await llm.discoverModels(entry.settingsNs, request)
87
- } catch (error) {
88
- // `NO_DISCOVERY` means the namespace registered no discovery; its
89
- // registered adapter answers from its own catalog below.
90
- if (error?.code !== 'NO_DISCOVERY') failed = true
91
- }
92
- }
93
- if (!Array.isArray(fetched) && !failed && llm?.listModels) {
94
- try { fetched = await llm.listModels(entry.provider) } catch { failed = true }
95
- }
96
- if (!Array.isArray(fetched) && !failed) failed = true
97
- return { models: normalizeModels(fetched), failed }
98
- }
99
-
100
- function effortOptions(models, selectedModel) {
101
- const model = models.find((candidate) => candidate.id === selectedModel)
102
- // The adapter-reported reasoning listing (LlmModelInfo.reasoning) is the
103
- // authoritative "can this model's thinking strength be adjusted?" answer.
104
- if (model?.reasoning && Array.isArray(model.reasoning.efforts)) {
105
- return model.reasoning.efforts.map((effort) => String(effort.id))
106
- }
107
- const efforts = model?.reasoningEfforts
108
- if (Array.isArray(efforts)) return efforts.map(String)
109
- if (efforts && typeof efforts === 'object') return Object.keys(efforts)
110
- return undefined
111
- }
112
-
113
- export function installWebSettingSchemas(ctx) {
114
- const settings = ctx.get('settings')
115
- if (!settings) return
116
- const registered = new Set(settings.describe().map((entry) => String(entry.ns)))
117
- for (const [namespace, schema] of WEB_SETTING_SCHEMAS) {
118
- if (!registered.has(namespace)) settings.register(namespace, schema)
119
- }
120
- }
121
-
122
- // Main menu tab: the general settings (the models section moved to the Model
123
- // tab together with the provider settings).
124
- export async function loadWebSettings(ctx) {
125
- installWebSettingSchemas(ctx)
126
- const settings = ctx.get('settings')
127
- if (!settings) {
128
- return { settings: null, items: [{ label: 'DSH settings', value: 'unavailable', disabled: true }], title: 'Settings', menu: SETTINGS_MENU, menuIndex: 0 }
129
- }
130
- const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
131
- const items = []
132
- const add = (ns, field, label, options, extra = {}) => {
133
- const descriptor = descriptors.get(ns)
134
- if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
135
- const value = descriptor.value[field]
136
- items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
137
- }
138
- // `ui-theme` (General · Appearance) and `locale` (General · Language) are
139
- // WebUI-only settings; they have no effect inside the TUI, so they are not
140
- // projected here.
141
- items.push({ kind: 'header', label: 'General', value: '', disabled: true })
142
- add('ui-conversation', 'busyEnter', 'Busy Enter', ['queue', 'steer'])
143
- const agentPresets = ctx.get('agentPresets')
144
- let presetOptions
145
- if (agentPresets) {
146
- try {
147
- // The roster is best-effort: an unreadable root must not take down the
148
- // whole settings panel. Broken presets are excluded because no session
149
- // can be composed from one.
150
- presetOptions = (await agentPresets.list())
151
- .filter((preset) => preset.broken === undefined)
152
- .map((preset) => preset.id)
153
- } catch { /* roster unavailable; fall back to the free-text row below */ }
154
- }
155
- add('agent-presets', 'default', 'Default preset', presetOptions, presetOptions?.length ? { kind: 'agent-preset', value: agentPresets.defaultId } : undefined)
156
- const permissionPresets = ctx.get('permissionPresets')
157
- add('permission', 'defaultPreset', 'Permission preset', permissionPresets ? [...permissionPresets.names] : undefined, { confirmValue: 'danger-full-access', confirmText: 'Enable unrestricted tool and file access?' })
158
-
159
- items.push({ kind: 'header', label: 'Sessions', value: '', disabled: true })
160
- items.push({ kind: 'new-session', label: 'New session', value: 'Enter', disabled: false })
161
- items.push({ kind: 'manage-sessions', label: 'Manage sessions', value: 'Enter', disabled: false })
162
- items.push({ kind: 'header', label: 'System', value: '', disabled: true })
163
- items.push({ kind: 'provider-config-info', label: 'Provider API config', value: 'Model tab', disabled: true })
164
- items.push({ kind: 'open-update', label: 'Update manager', value: 'Update tab', disabled: false })
165
- if (settings.documentPath) items.push({ label: 'Settings file', value: settings.documentPath, disabled: true })
166
- return { settings, items, title: 'Settings', menu: SETTINGS_MENU, menuIndex: 0 }
167
- }
168
-
169
- // Model menu tab: the merged provider + model settings. Per provider:
170
- // Provider URL, Provider API key, Models (the saved selection, listed below
171
- // the row; Enter auto-fetches the catalog and opens the selection window).
172
- export async function loadModelSettings(ctx) {
173
- installWebSettingSchemas(ctx)
174
- const settings = ctx.get('settings')
175
- if (!settings) {
176
- return { settings: null, items: [{ label: 'Model settings', value: 'unavailable', disabled: true }], title: 'Model', menu: SETTINGS_MENU, menuIndex: 1 }
177
- }
178
- const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
179
- const items = []
180
- const add = (ns, field, label, options, extra = {}) => {
181
- const descriptor = descriptors.get(ns)
182
- if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
183
- const value = descriptor.value[field]
184
- items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
185
- }
186
- const llm = ctx.get('llm')
187
- const credentials = ctx.get('credentials')
188
-
189
- // Only providers the user has actually added are listed. A provider counts
190
- // as added when its profile is present in the user settings layer; a
191
- // built-in namespace provider (empty settings path) counts only when the
192
- // user configured something in that namespace. Preset providers that were
193
- // never added stay hidden.
194
- const configurable = (llm?.listConfigurableProviders?.() ?? []).filter((entry) => {
195
- const descriptor = descriptors.get(entry.settingsNs)
196
- if (!descriptor) return false
197
- if (entry.settingsPath.length === 0) {
198
- return descriptor.user && typeof descriptor.user === 'object'
199
- && Object.keys(descriptor.user).length > 0
200
- }
201
- return hasPath(descriptor.user, entry.settingsPath)
202
- })
203
-
204
- // ---- default route ----------------------------------------------------
205
- items.push({ kind: 'header', label: 'Default', value: '', disabled: true })
206
- const providers = descriptors.get('llm-pi-ai')?.value?.providers
207
- const providerEntries = providers && typeof providers === 'object' ? Object.entries(providers) : []
208
- const defaultModel = descriptors.get('agent-default-model')?.value
209
- const selectedProvider = defaultModel && typeof defaultModel === 'object' ? defaultModel.provider : undefined
210
- const selectedModel = defaultModel && typeof defaultModel === 'object' ? defaultModel.model : undefined
211
- const liveProviders = llm?.listProviders?.() ?? []
212
- const providerOptions = liveProviders.length > 0 ? liveProviders.map((provider) => provider.id) : providerEntries.map(([id]) => id)
213
- // The default-model options are exactly the models the user has saved under
214
- // the selected provider (its `models` list). Do not auto-fetch the provider
215
- // catalog here: that catalog can include models the user never added, and it
216
- // would diverge from the saved selection shown in the provider block below.
217
- // The auto-fetched catalog stays available behind the Models row (Enter →
218
- // loadProviderModels).
219
- const selectedEntry = configurable.find((entry) => entry.provider === selectedProvider)
220
- let models = []
221
- if (selectedEntry) {
222
- const selectedDescriptor = descriptors.get(selectedEntry.settingsNs)
223
- const selectedProfile = selectedDescriptor ? valueAt(selectedDescriptor.value, selectedEntry.settingsPath) : undefined
224
- const selectedRecord = typeof selectedProfile === 'object' && selectedProfile !== null ? selectedProfile : {}
225
- models = normalizeModels(selectedRecord.models)
226
- } else {
227
- models = normalizeModels(providerEntries.find(([id]) => id === selectedProvider)?.[1]?.models)
228
- }
229
- add('agent-default-model', 'provider', 'Default provider', providerOptions, { kind: 'default-provider' })
230
- // The picker annotates each candidate with its capabilities, so a user can
231
- // tell at a glance which models take image input and which can adjust their
232
- // thinking strength instead of discovering the refusal after sending.
233
- const modelOptions = models.map((model) => {
234
- const suffix = capabilitySuffix(model)
235
- return suffix ? { label: model.id + suffix, value: model.id } : model.id
236
- })
237
- add('agent-default-model', 'model', 'Default model', modelOptions, { kind: 'default-model' })
238
- let reasoningOptions = effortOptions(models, selectedModel)
239
- let reasoningDefault
240
- if (llm?.resolveModelInfo && selectedProvider && selectedModel) {
241
- try {
242
- const info = await llm.resolveModelInfo(selectedProvider, selectedModel)
243
- reasoningOptions = info.reasoning?.efforts.map((effort) => String(effort.id))
244
- reasoningDefault = info.reasoning?.defaultEffort === undefined ? undefined : String(info.reasoning.defaultEffort)
245
- } catch { /* keep catalog fallback */ }
246
- }
247
- const configuredEffort = defaultModel?.reasoningEffort === undefined ? undefined : String(defaultModel.reasoningEffort)
248
- const reasoningValue = reasoningOptions?.includes(configuredEffort)
249
- ? configuredEffort
250
- : reasoningOptions?.includes(reasoningDefault) ? reasoningDefault : undefined
251
- const reasoningExtra = reasoningOptions?.length
252
- ? { kind: 'effort', ...reasoningValue === undefined ? {} : { value: reasoningValue } }
253
- : undefined
254
- add('agent-default-model', 'reasoningEffort', 'Reasoning', reasoningOptions, reasoningExtra)
255
-
256
- // ---- merged provider blocks --------------------------------------------
257
- for (const entry of configurable) {
258
- const descriptor = descriptors.get(entry.settingsNs)
259
- if (!descriptor) continue
260
- items.push({ kind: 'header', label: entry.displayName, value: '', disabled: true })
261
- const profile = valueAt(descriptor.value, entry.settingsPath)
262
- const removable = entry.settingsPath.length > 0 && hasPath(descriptor.user, entry.settingsPath) && !hasPath(descriptor.base, entry.settingsPath)
263
- const record = typeof profile === 'object' && profile !== null ? profile : {}
264
- const ref = typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : keyRef(entry.provider)
265
- let credential
266
- try { credential = credentials ? await credentials.describe(ref) : undefined } catch { credential = undefined }
267
- if (entry.settingsNs === 'llm-deepseek' || entry.settingsNs === 'llm-pi-ai') items.push({ kind: 'path', label: 'Provider URL', value: typeof record.baseURL === 'string' ? record.baseURL : 'default', ns: entry.settingsNs, path: [...entry.settingsPath, 'baseURL'], revision: descriptor.revision, disabled: !settings.writable })
268
- items.push({ kind: 'secret', label: 'Provider API key', value: credential?.configured ? 'configured (' + (credential.source ?? 'stored') + ')' : 'not configured', credentialRef: ref, disabled: !credentials || credential?.writable === false })
269
- if (entry.settingsNs === 'llm-pi-ai' && entry.declared === true) {
270
- items.push({ kind: 'path', label: 'Display name', value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...entry.settingsPath, 'displayName'], revision: descriptor.revision, disabled: !settings.writable })
271
- const choices = ['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai']
272
- items.push({ kind: 'path-choice', label: 'Wire protocol', value: typeof record.api === 'string' ? record.api : choices[0], options: choices, ns: entry.settingsNs, path: [...entry.settingsPath, 'api'], revision: descriptor.revision, disabled: !settings.writable })
273
- }
274
- // Route-level request modalities. This is what makes a hand-declared vision
275
- // model usable: pi-ai under-claims text by default (refusing the image before
276
- // it is attached is the safe answer when nothing is declared), so a gateway
277
- // serving vision models declares `[text, image]` once here instead of on
278
- // every entry. Catalog models keep the modalities the catalog records for
279
- // them; this value never narrows one.
280
- if (entry.settingsNs === 'llm-pi-ai') {
281
- const declaredInput = Array.isArray(record.defaultInput) ? record.defaultInput : undefined
282
- items.push({
283
- kind: 'input-modalities',
284
- label: 'Default image input',
285
- value: declaredInput?.includes('image') ? 'text + image' : 'text only',
286
- options: ['text only', 'text + image'],
287
- ns: entry.settingsNs,
288
- path: [...entry.settingsPath, 'defaultInput'],
289
- revision: descriptor.revision,
290
- disabled: !settings.writable,
291
- })
292
- }
293
- // Models: Enter auto-fetches the provider catalog and opens the selection
294
- // window; the saved selection is listed right below, one row per model.
295
- const stored = normalizeModels(record.models)
296
- items.push({ kind: 'provider-models', label: 'Models', value: stored.length > 0 ? stored.length + ' selected' : 'fetch…', providerId: entry.provider, disabled: false })
297
- for (const model of stored) {
298
- const isDefault = entry.provider === selectedProvider && model.id === selectedModel
299
- items.push({
300
- kind: 'provider-model',
301
- label: model.id,
302
- value: isDefault ? 'default' : '',
303
- indent: 1,
304
- modelId: model.id,
305
- providerId: entry.provider,
306
- ns: 'agent-default-model',
307
- revision: descriptors.get('agent-default-model')?.revision,
308
- disabled: false,
309
- })
310
- }
311
- if (removable) items.push({ kind: 'remove-provider', label: 'Remove provider', value: 'Enter', ns: entry.settingsNs, path: entry.settingsPath, revision: descriptor.revision, credentialRef: credential?.configured && credential?.writable ? ref : undefined, confirmText: 'Remove ' + entry.displayName + ' and its managed credential?', disabled: !settings.writable })
312
- }
313
- return { settings, items, title: 'Model', subtitle: 'Enter selects a listed model as default', menu: SETTINGS_MENU, menuIndex: 1 }
314
- }
315
-
316
- // The auto-fetched model selection window for one provider: the provider's
317
- // discovered catalog (`ctx.llm.discoverModels`, with a registered-adapter
318
- // `listModels` fallback) as checkbox rows marking the saved selection. Enter
319
- // toggles a model in or out of the provider's saved models list. A failed
320
- // fetch falls back to the saved selection so stored models stay manageable.
321
- export async function loadProviderModels(ctx, providerId) {
322
- const settings = ctx.get('settings')
323
- const entry = ctx.get('llm')?.listConfigurableProviders?.().find((candidate) => candidate.provider === providerId)
324
- if (!settings || !entry) throw new Error('provider is no longer available')
325
- const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
326
- if (!descriptor) throw new Error('provider settings namespace is unavailable')
327
- const profile = valueAt(descriptor.value, entry.settingsPath)
328
- const record = typeof profile === 'object' && profile !== null ? profile : {}
329
- const stored = normalizeModels(record.models)
330
- const storedIds = new Set(stored.map((model) => model.id))
331
- const discovered = await fetchProviderModels(ctx, entry, record)
332
- const fetched = discovered.models
333
- const fetchFailed = discovered.failed
334
- const items = []
335
- const seen = new Set()
336
- const pushRow = (model) => {
337
- if (!model?.id || seen.has(model.id)) return
338
- seen.add(model.id)
339
- const checked = storedIds.has(model.id)
340
- items.push({
341
- kind: 'model-toggle',
342
- label: model.id + capabilitySuffix(model),
343
- value: checked ? '[x]' : '[ ]',
344
- modelId: model.id,
345
- checked,
346
- selectedIds: [...storedIds],
347
- storedModels: stored,
348
- providerId,
349
- settingsNs: entry.settingsNs,
350
- providerPath: entry.settingsPath,
351
- revision: descriptor.revision,
352
- disabled: !settings.writable,
353
- })
354
- }
355
- for (const model of fetched) pushRow(model)
356
- // Saved models that the catalog no longer returns stay toggleable.
357
- for (const model of stored) pushRow(model)
358
- if (items.length === 0) {
359
- items.push({ label: fetchFailed ? 'no models available' : 'provider returned no models', value: '', disabled: true })
360
- }
361
- return {
362
- settings,
363
- items,
364
- title: entry.displayName + ' · models',
365
- subtitle: fetchFailed
366
- ? 'auto-fetch unavailable — saved models · Enter toggle'
367
- : 'auto-fetched · Enter toggle · Esc back',
368
- }
369
- }
370
-
371
- export async function saveWebSetting(ctx, settings, item, value) {
372
- if (!item || item.disabled) return
373
- if (item.kind === 'secret') {
374
- if (!value.trim()) throw new Error('API key cannot be empty')
375
- const credentials = ctx.get('credentials')
376
- if (!credentials) throw new Error('credentials service is unavailable')
377
- await credentials.set(item.credentialRef, value.trim())
378
- return
379
- }
380
- if (item.kind === 'path' || item.kind === 'path-choice') {
381
- const trimmed = value.trim()
382
- const op = trimmed === '' || trimmed === 'default' ? { op: 'unset', path: item.path } : { op: 'set', path: item.path, value: trimmed }
383
- await settings.mutate(item.ns, [op], item.revision)
384
- return
385
- }
386
- if (item.kind === 'input-modalities') {
387
- const modalities = value.trim() === 'text + image' ? ['text', 'image'] : ['text']
388
- await settings.mutate(item.ns, [{ op: 'set', path: item.path, value: modalities }], item.revision)
389
- return
390
- }
391
- if (item.kind === 'enable-provider') return settings.mutate(item.ns, [{ op: 'set', path: item.path, value: {} }], item.revision)
392
- if (item.kind === 'remove-provider') {
393
- const credentials = ctx.get('credentials')
394
- if (item.credentialRef && credentials) await credentials.unset(item.credentialRef)
395
- return settings.mutate(item.ns, [{ op: 'unset', path: item.path }], item.revision)
396
- }
397
- if (item.kind === 'default-provider') {
398
- // Resolve the new provider's first advertised model through the same
399
- // discovery seam as the model pickers, so a user-added (dormant) provider
400
- // answers its full catalog instead of failing on `listModels`.
401
- const llm = ctx.get('llm')
402
- let model
403
- const entry = llm?.listConfigurableProviders?.().find((candidate) => candidate.provider === value)
404
- if (entry) {
405
- const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
406
- const profile = descriptor ? valueAt(descriptor.value, entry.settingsPath) : undefined
407
- const record = typeof profile === 'object' && profile !== null ? profile : {}
408
- const discovered = await fetchProviderModels(ctx, entry, record)
409
- model = discovered.models[0]?.id
410
- } else {
411
- const models = await llm?.listModels?.(value)
412
- model = models?.[0]?.id
413
- }
414
- if (!model) throw new Error('selected provider has no available models')
415
- return settings.mutate(item.ns, [
416
- { op: 'set', path: ['provider'], value },
417
- { op: 'set', path: ['model'], value: model },
418
- { op: 'unset', path: ['reasoningEffort'] },
419
- ], item.revision)
420
- }
421
- if (item.kind === 'default-model') {
422
- return settings.mutate(item.ns, [
423
- { op: 'set', path: ['model'], value },
424
- { op: 'unset', path: ['reasoningEffort'] },
425
- ], item.revision)
426
- }
427
- // A model listed under its provider becomes the default route.
428
- if (item.kind === 'provider-model') {
429
- return settings.mutate(item.ns, [
430
- { op: 'set', path: ['provider'], value: item.providerId },
431
- { op: 'set', path: ['model'], value: item.modelId },
432
- { op: 'unset', path: ['reasoningEffort'] },
433
- ], item.revision)
434
- }
435
- // The auto-fetched selection window: toggle one model in or out of the
436
- // provider's saved models list. Existing stored entries keep their shape;
437
- // additions are plain `{ id }` records.
438
- if (item.kind === 'model-toggle') {
439
- const ids = new Set(item.selectedIds ?? [])
440
- if (item.checked) ids.delete(item.modelId)
441
- else ids.add(item.modelId)
442
- const next = []
443
- for (const entry of item.storedModels ?? []) {
444
- if (ids.has(modelEntryId(entry))) next.push(entry)
445
- }
446
- for (const id of ids) {
447
- if (!next.some((entry) => modelEntryId(entry) === id)) next.push({ id })
448
- }
449
- return settings.mutate(item.settingsNs, [{ op: 'set', path: [...item.providerPath, 'models'], value: next }], item.revision)
450
- }
451
- if (!item.ns || !item.field) return
452
- if (item.ns === 'locale' && item.field === 'preference' && value === 'system') return settings.replace(item.ns, {}, item.revision)
453
- return settings.update(item.ns, { [item.field]: value }, item.revision)
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
+