dsh-oc-tui 0.1.0

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