dsh-oc-tui 0.2.0-pre.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/util.js CHANGED
@@ -242,6 +242,55 @@ export function localImagePath(text) {
242
242
  return s
243
243
  }
244
244
 
245
+ // What a bracketed paste contributes to a single-line text field.
246
+ //
247
+ // An image has no text form — decoding its bytes would type mojibake — so it
248
+ // yields null and the caller drops it. A newline run is removed entirely for a
249
+ // secret (a key pasted across lines is still one key) and folded into a single
250
+ // space otherwise, which leaves a pasted JSON blob parseable. Remaining control
251
+ // characters are stripped: some terminals pad their clipboard payload with NULs.
252
+ export function fieldPasteText(bytes, { secret = false } = {}) {
253
+ if (!bytes || bytes.length === 0) return null
254
+ if (detectImageMediaType(bytes)) return null
255
+ const raw = bytes.toString('utf8')
256
+ const folded = secret ? raw.replace(/[\r\n]+/g, '') : raw.replace(/[\r\n]+/g, ' ')
257
+ const clean = folded.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
258
+ return clean === '' ? null : clean
259
+ }
260
+
261
+ // Reasoning as it should be displayed, with the transport's thinking signature
262
+ // removed.
263
+ //
264
+ // Some endpoints prefix the reasoning text with `<signature>…</signature>`: the
265
+ // opaque blob a later request replays to prove the chain of thought is the one
266
+ // the model produced. It is routing data, not prose, and it is long enough to
267
+ // fill the whole thinking box on its own. Mirrors the host's own
268
+ // `reasoningText()` so both frontends show the same thing.
269
+ //
270
+ // Streaming is why this is a function of the raw text rather than a one-shot
271
+ // strip: a delta can stop anywhere, including inside the tag itself. Anything
272
+ // that could still turn out to be the opening tag — a lone `<`, `<sig`, the
273
+ // complete opening tag with no close yet — yields empty text, so the box stays
274
+ // hidden instead of flashing a partial tag and then vanishing. Text that merely
275
+ // starts with `<` but cannot be that tag is returned untouched.
276
+ export function reasoningText(text) {
277
+ if (typeof text !== 'string' || text[0] !== '<') return text ?? ''
278
+ const opening = '<signature>'
279
+ if (opening.startsWith(text)) return ''
280
+ if (!text.startsWith(opening)) return text
281
+ const end = text.indexOf('</signature>', opening.length)
282
+ if (end === -1) return ''
283
+ let start = end + '</signature>'.length
284
+ // The signature sits on its own lines; dropping the break that follows it
285
+ // keeps the first real paragraph from inheriting its indentation.
286
+ while (start < text.length) {
287
+ if (text[start] === '\n') start++
288
+ else if (text[start] === '\r' && text[start + 1] === '\n') start += 2
289
+ else break
290
+ }
291
+ return text.slice(start)
292
+ }
293
+
245
294
  // A stored image reference is usable in message content only while it still
246
295
  // carries the media type every adapter reads to describe the image. The
247
296
  // attachment store returns the complete reference; a partial object reduced to
@@ -1,7 +1,23 @@
1
1
  import z from '@deepseek-ai/schemastery'
2
2
  import { dirname } from 'node:path'
3
3
  import { collectServers } from './mcp.js'
4
- import { LOCALES, isLocaleId, localeInfo, resolveLocale, setLocale, t, tv, valueChoices } from './i18n.js'
4
+ import { LOCALES, isLocaleId, localeInfo, resolveLocale, setLocale, t, tn, tv, valueChoices } from './i18n.js'
5
+ import {
6
+ ACCOUNT_ROUTE,
7
+ URL_FAMILIES,
8
+ adoptDiscovered,
9
+ capabilitySuffix,
10
+ capacitySuffix,
11
+ fetchProviderModels,
12
+ hasPath,
13
+ mergeModelSelection,
14
+ modelEntryId,
15
+ normalizeModels,
16
+ protocolChoices,
17
+ providerDirectory,
18
+ routeKeyRef,
19
+ valueAt,
20
+ } from './providers.js'
5
21
 
6
22
  // The settings this plugin owns. Each is declared twice on purpose: as a
7
23
  // namespace for a 0.1.x settings provider (which has a registry and stores a
@@ -63,84 +79,11 @@ export const SETTINGS_MENU = [
63
79
 
64
80
  const MCP_MENU_INDEX = SETTINGS_MENU.findIndex((entry) => entry.id === 'mcp')
65
81
 
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 }
82
+ function modelPickerAnnotations(models) {
83
+ return models.map((model) => {
84
+ const suffix = capabilitySuffix(model)
85
+ return suffix ? { label: model.id + suffix, value: model.id } : model.id
86
+ })
144
87
  }
145
88
 
146
89
  function effortOptions(models, selectedModel) {
@@ -368,14 +311,16 @@ export function applyLocalePreference(ctx, config) {
368
311
  }
369
312
  // Model menu tab: the merged provider + model settings. Per provider:
370
313
  // Provider URL, Provider API key, Models (the saved selection, listed below
371
- // the row; Enter auto-fetches the catalog and opens the selection window).
314
+ // the row; Enter auto-fetches the catalog and opens the selection window), a
315
+ // manual "add model by id" row for an endpoint whose listing lies, and the
316
+ // removal. The last row is the add flow itself.
372
317
  export async function loadModelSettings(ctx) {
373
318
  installWebSettingSchemas(ctx)
374
- const settings = ctx.get('settings')
375
- if (!settings) {
319
+ const directory = providerDirectory(ctx)
320
+ if (!directory) {
376
321
  return { settings: null, items: [{ label: t('model.title'), value: t('common.unavailable'), disabled: true }], title: t('model.title'), menu: SETTINGS_MENU, menuIndex: 1 }
377
322
  }
378
- const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
323
+ const { settings, descriptors, llm, configured, addable } = directory
379
324
  const items = []
380
325
  const add = (ns, field, label, options, extra = {}) => {
381
326
  const descriptor = descriptors.get(ns)
@@ -383,24 +328,8 @@ export async function loadModelSettings(ctx) {
383
328
  const value = descriptor.value[field]
384
329
  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
330
  }
386
- const llm = ctx.get('llm')
387
331
  const credentials = ctx.get('credentials')
388
332
 
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
333
  // ---- default route ----------------------------------------------------
405
334
  items.push({ kind: 'header', label: t('model.defaultSection'), value: '', disabled: true })
406
335
  const providers = descriptors.get('llm-pi-ai')?.value?.providers
@@ -416,12 +345,10 @@ export async function loadModelSettings(ctx) {
416
345
  // would diverge from the saved selection shown in the provider block below.
417
346
  // The auto-fetched catalog stays available behind the Models row (Enter →
418
347
  // loadProviderModels).
419
- const selectedEntry = configurable.find((entry) => entry.provider === selectedProvider)
348
+ const selectedEntry = configured.find((row) => row.entry.provider === selectedProvider)
420
349
  let models = []
421
350
  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 : {}
351
+ const selectedRecord = typeof selectedEntry.profile === 'object' && selectedEntry.profile !== null ? selectedEntry.profile : {}
425
352
  models = normalizeModels(selectedRecord.models)
426
353
  } else {
427
354
  models = normalizeModels(providerEntries.find(([id]) => id === selectedProvider)?.[1]?.models)
@@ -430,10 +357,7 @@ export async function loadModelSettings(ctx) {
430
357
  // The picker annotates each candidate with its capabilities, so a user can
431
358
  // tell at a glance which models take image input and which can adjust their
432
359
  // 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
- })
360
+ const modelOptions = modelPickerAnnotations(models)
437
361
  add('agent-default-model', 'model', t('model.defaultModel'), modelOptions, { kind: 'default-model' })
438
362
  let reasoningOptions = effortOptions(models, selectedModel)
439
363
  let reasoningDefault
@@ -454,22 +378,45 @@ export async function loadModelSettings(ctx) {
454
378
  add('agent-default-model', 'reasoningEffort', t('model.reasoning'), reasoningOptions, reasoningExtra)
455
379
 
456
380
  // ---- 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)
381
+ for (const row of configured) {
382
+ const { entry, descriptor, path: settingsPath } = row
383
+ // A whole-section profile (`settingsPath: []`) is the plugin entry's own
384
+ // Config: there is no per-route record, and its `models` is the adapter's
385
+ // catalog rather than a selection, so it gets no model editing here.
386
+ const wholeSection = settingsPath.length === 0
387
+ const profile = row.profile
463
388
  const record = typeof profile === 'object' && profile !== null ? profile : {}
464
- const ref = typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : keyRef(entry.provider)
389
+ const isAccount = entry.provider === ACCOUNT_ROUTE
390
+ items.push({ kind: 'header', label: entry.displayName, value: '', disabled: true })
391
+ // A catalog diagnostic the adapter reported (a stored profile its installed
392
+ // catalog can no longer serve) is the whole reason a request from this route
393
+ // would fail, so it belongs on the row instead of in a log the TUI cannot
394
+ // show.
395
+ if (typeof entry.error === 'string' && entry.error !== '') {
396
+ items.push({ kind: 'provider-error', label: entry.error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
397
+ }
398
+ // The account route authenticates through a sign-in, not a stored key, so it
399
+ // has no reference to describe and nothing to remove.
400
+ const ref = isAccount
401
+ ? undefined
402
+ : (typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : routeKeyRef(entry.provider))
465
403
  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 })
404
+ if (ref !== undefined) {
405
+ try { credential = credentials ? await credentials.describe(ref) : undefined } catch { credential = undefined }
406
+ }
407
+ if (URL_FAMILIES.has(entry.settingsNs)) items.push({ kind: 'path', label: t('model.providerUrl'), value: typeof record.baseURL === 'string' ? record.baseURL : 'default', ns: entry.settingsNs, path: [...settingsPath, 'baseURL'], revision: descriptor.revision, disabled: !settings.writable })
408
+ if (isAccount) {
409
+ items.push({ kind: 'provider-note', label: t('provider.accountManaged'), wide: true, indent: 1, disabled: true, selectable: false })
410
+ } else {
411
+ 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 })
412
+ }
469
413
  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 })
414
+ items.push({ kind: 'path', label: t('model.displayName'), value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...settingsPath, 'displayName'], revision: descriptor.revision, disabled: !settings.writable })
415
+ // The protocols offered are the ones this adapter can serve, read out of
416
+ // its own schema: a hardcoded list offered `google-generative-ai`, which
417
+ // the family has no implementation for and would refuse at request time.
418
+ const choices = protocolChoices(descriptor)
419
+ if (choices.length > 0) items.push({ kind: 'path-choice', label: t('model.wireProtocol'), value: typeof record.api === 'string' ? record.api : choices[0], options: choices, ns: entry.settingsNs, path: [...settingsPath, 'api'], revision: descriptor.revision, disabled: !settings.writable })
473
420
  }
474
421
  // Route-level request modalities. This is what makes a hand-declared vision
475
422
  // model usable: pi-ai under-claims text by default (refusing the image before
@@ -477,7 +424,7 @@ export async function loadModelSettings(ctx) {
477
424
  // serving vision models declares `[text, image]` once here instead of on
478
425
  // every entry. Catalog models keep the modalities the catalog records for
479
426
  // them; this value never narrows one.
480
- if (entry.settingsNs === 'llm-pi-ai') {
427
+ if (entry.settingsNs === 'llm-pi-ai' && !wholeSection) {
481
428
  const declaredInput = Array.isArray(record.defaultInput) ? record.defaultInput : undefined
482
429
  items.push({
483
430
  kind: 'input-modalities',
@@ -485,7 +432,7 @@ export async function loadModelSettings(ctx) {
485
432
  value: declaredInput?.includes('image') ? 'text + image' : 'text only',
486
433
  options: ['text only', 'text + image'].map((id) => ({ label: tv(id), value: id })),
487
434
  ns: entry.settingsNs,
488
- path: [...entry.settingsPath, 'defaultInput'],
435
+ path: [...settingsPath, 'defaultInput'],
489
436
  revision: descriptor.revision,
490
437
  disabled: !settings.writable,
491
438
  })
@@ -493,13 +440,16 @@ export async function loadModelSettings(ctx) {
493
440
  // Models: Enter auto-fetches the provider catalog and opens the selection
494
441
  // window; the saved selection is listed right below, one row per model.
495
442
  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 })
443
+ if (!wholeSection) {
444
+ 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 })
445
+ }
497
446
  for (const model of stored) {
498
447
  const isDefault = entry.provider === selectedProvider && model.id === selectedModel
448
+ const suffix = capabilitySuffix(model)
499
449
  items.push({
500
450
  kind: 'provider-model',
501
451
  label: model.id,
502
- value: isDefault ? t('model.defaultTag') : '',
452
+ value: isDefault ? t('model.defaultTag') + suffix : suffix,
503
453
  indent: 1,
504
454
  modelId: model.id,
505
455
  providerId: entry.provider,
@@ -508,8 +458,37 @@ export async function loadModelSettings(ctx) {
508
458
  disabled: false,
509
459
  })
510
460
  }
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 })
461
+ // A hand-declared route's endpoint may advertise nothing usable, so a model
462
+ // id has to be typeable without a discovery round trip.
463
+ if (!wholeSection && entry.settingsNs === 'llm-pi-ai') {
464
+ items.push({
465
+ kind: 'provider-model-add',
466
+ label: t('provider.field.addModel'),
467
+ value: '',
468
+ indent: 1,
469
+ ns: entry.settingsNs,
470
+ path: settingsPath,
471
+ storedModels: stored,
472
+ revision: descriptor.revision,
473
+ disabled: !settings.writable,
474
+ })
475
+ }
476
+ const removable = !wholeSection && hasPath(descriptor.user, settingsPath) && !hasPath(descriptor.base, settingsPath)
477
+ if (removable) items.push({ kind: 'remove-provider', label: t('model.removeProvider'), value: 'Enter', ns: entry.settingsNs, path: settingsPath, revision: descriptor.revision, credentialRef: credential?.configured && credential?.writable ? ref : undefined, confirmText: t('model.removeProviderConfirm', { name: entry.displayName }), disabled: !settings.writable })
478
+ }
479
+ // ---- add a provider ---------------------------------------------------
480
+ // The WebUI's add card, as a row: with nothing configured it is also the only
481
+ // way out of an otherwise empty Model tab, which is exactly the state a fresh
482
+ // install starts in.
483
+ if (configured.length === 0) {
484
+ items.push({ kind: 'provider-none', label: t('provider.empty'), wide: true, disabled: true, selectable: false })
512
485
  }
486
+ items.push({
487
+ kind: 'provider-add',
488
+ label: t('provider.add'),
489
+ value: tn('model.addCount', addable.length, { count: addable.length }),
490
+ disabled: !settings.writable || addable.length === 0,
491
+ })
513
492
  return { settings, items, title: t('model.title'), subtitle: t('model.subtitle'), menu: SETTINGS_MENU, menuIndex: 1 }
514
493
  }
515
494
 
@@ -532,18 +511,33 @@ export async function loadProviderModels(ctx, providerId) {
532
511
  const fetched = discovered.models
533
512
  const fetchFailed = discovered.failed
534
513
  const items = []
514
+ // The refusal, verbatim: "answered 401; check the API key" and "no model
515
+ // listing for this protocol" are the difference between a fetch that looks
516
+ // broken and one a user can fix with the next keystroke.
517
+ if (discovered.error) {
518
+ items.push({ kind: 'model-error', label: discovered.error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
519
+ }
535
520
  const seen = new Set()
521
+ const rows = []
536
522
  const pushRow = (model) => {
537
523
  if (!model?.id || seen.has(model.id)) return
538
524
  seen.add(model.id)
539
525
  const checked = storedIds.has(model.id)
540
- items.push({
526
+ const saved = stored.find((candidate) => candidate.id === model.id)
527
+ rows.push({
541
528
  kind: 'model-toggle',
542
529
  label: model.id + capabilitySuffix(model),
543
- value: checked ? '[x]' : '[ ]',
530
+ // The saved entry owns the capacities: a model the user corrected must
531
+ // read back as corrected, not as what the endpoint advertises.
532
+ value: (checked ? '[x]' : '[ ]') + capacitySuffix(saved ?? model),
544
533
  modelId: model.id,
545
534
  checked,
546
- selectedIds: [...storedIds],
535
+ // What the entry would be if adopted: the endpoint's metadata, or the
536
+ // stored entry itself when the row came from the saved list.
537
+ candidate: model,
538
+ // Position in the saved list, or -1 for a model this provider lists
539
+ // nowhere yet. The editor addresses the array by position.
540
+ storedIndex: saved === undefined ? -1 : stored.indexOf(saved),
547
541
  storedModels: stored,
548
542
  providerId,
549
543
  settingsNs: entry.settingsNs,
@@ -555,16 +549,42 @@ export async function loadProviderModels(ctx, providerId) {
555
549
  for (const model of fetched) pushRow(model)
556
550
  // Saved models that the catalog no longer returns stay toggleable.
557
551
  for (const model of stored) pushRow(model)
558
- if (items.length === 0) {
552
+ if (fetched.length > 0) {
553
+ const picked = fetched.filter((model) => storedIds.has(model.id)).length
554
+ items.push({
555
+ kind: 'model-select-all',
556
+ label: t('model.selectAll'),
557
+ value: (picked === fetched.length ? '[x] ' : '[ ] ') + picked + '/' + fetched.length,
558
+ allPicked: picked === fetched.length,
559
+ candidates: fetched,
560
+ storedModels: stored,
561
+ providerId,
562
+ settingsNs: entry.settingsNs,
563
+ providerPath: entry.settingsPath,
564
+ revision: descriptor.revision,
565
+ disabled: !settings.writable,
566
+ })
567
+ }
568
+ items.push(...rows)
569
+ if (rows.length === 0) {
559
570
  items.push({ label: fetchFailed ? t('model.noModels') : t('model.noModelsReturned'), value: '', disabled: true })
560
571
  }
572
+ // The two actions a discovery round trip cannot cover: a model the endpoint
573
+ // does not advertise (or one whose metadata needs correcting), and asking
574
+ // again now that the endpoint came up.
575
+ items.push({ kind: 'model-add', label: t('provider.field.addModel'), value: 'Enter', storedModels: stored, providerId, settingsNs: entry.settingsNs, providerPath: entry.settingsPath, revision: descriptor.revision, disabled: !settings.writable })
576
+ items.push({ kind: 'model-refresh', label: t('model.refresh'), value: 'Enter', providerId, disabled: !settings.writable })
561
577
  return {
562
578
  settings,
563
579
  items,
580
+ storedModels: stored,
581
+ fetched,
582
+ fetchFailed,
583
+ fetchError: discovered.error,
564
584
  title: entry.displayName + ' · ' + t('model.models').toLowerCase(),
565
585
  subtitle: fetchFailed
566
- ? t('model.fetchUnavailable')
567
- : t('model.fetchOk'),
586
+ ? t('model.fetchUnavailable') + ' · ' + t('model.editHint')
587
+ : t('model.fetchOk') + ' · ' + t('model.editHint'),
568
588
  }
569
589
  }
570
590
 
@@ -633,21 +653,41 @@ export async function saveWebSetting(ctx, settings, item, value) {
633
653
  ], item.revision)
634
654
  }
635
655
  // 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.
656
+ // provider's saved models list. Existing stored entries keep their shape
657
+ // (and their position, so nothing the user corrected is rewritten);
658
+ // an adopted one keeps what the endpoint advertised.
638
659
  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 })
660
+ const next = [...(item.storedModels ?? [])]
661
+ if (item.checked) {
662
+ const index = next.findIndex((entry) => modelEntryId(entry) === item.modelId)
663
+ if (index >= 0) next.splice(index, 1)
664
+ } else if (!next.some((entry) => modelEntryId(entry) === item.modelId)) {
665
+ next.push(adoptDiscovered(item.candidate) ?? { id: item.modelId })
648
666
  }
649
667
  return settings.mutate(item.settingsNs, [{ op: 'set', path: [...item.providerPath, 'models'], value: next }], item.revision)
650
668
  }
669
+ // A model id typed by hand joins the saved list. Existing entries keep their
670
+ // shape and their order; the new row is a plain `{ id }` record, which is all
671
+ // a catalog model needs (the adapter fills the capacities it knows).
672
+ if (item.kind === 'provider-model-add') {
673
+ const id = value.trim()
674
+ if (id === '') return
675
+ const next = [...(item.storedModels ?? [])]
676
+ if (next.some((entry) => modelEntryId(entry) === id)) throw new Error(t('provider.error.modelDuplicate', { id }))
677
+ next.push({ id })
678
+ return settings.mutate(item.ns, [{ op: 'set', path: [...item.path, 'models'], value: next }], item.revision)
679
+ }
680
+ // The whole-selection toggle: every model the endpoint advertised, or none.
681
+ //
682
+ // Deselecting means the empty list — the stored rows go too, which is the only
683
+ // reading of "deselect all" that leaves nothing ticked. For a catalog route the
684
+ // unset is also "serve your own catalog again"; a hand-declared route has no
685
+ // catalog to fall back to, so the adapter refuses that write and says why.
686
+ if (item.kind === 'model-select-all') {
687
+ const next = item.allPicked ? [] : mergeModelSelection(item.storedModels ?? [], item.candidates ?? [])
688
+ const path = [...item.providerPath, 'models']
689
+ return settings.mutate(item.settingsNs, [next.length === 0 ? { op: 'unset', path } : { op: 'set', path, value: next }], item.revision)
690
+ }
651
691
  if (!item.ns || !item.field) return
652
692
  if (item.ns === 'locale' && item.field === 'preference' && value === 'system') return settings.replace(item.ns, {}, item.revision)
653
693
  // A TUI-owned field set back to the value it defaults to is cleared rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-oc-tui",
3
- "version": "0.2.0-pre.13",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A terminal UI (TUI) for DeepSeek Harness — an opencode-inspired chat client that boots as a dsh profile app plugin. | DeepSeek Harness 的终端界面(TUI),受 opencode 启发,以 dsh profile 应用插件形式启动。",
6
6
  "keywords": [
@@ -25,8 +25,8 @@
25
25
  "author": "rayafriandion",
26
26
  "license": "LGPL-3.0-or-later",
27
27
  "scripts": {
28
- "test": "node tests/smoke.test.mjs && node tests/i18n.test.mjs && node tests/locale.test.mjs && node tests/locale-forms.test.mjs && node tests/graphics.test.mjs && node tests/render.test.mjs && node tests/rail.test.mjs && node tests/rewind.test.mjs && node tests/std.test.mjs && node tests/resume.test.mjs && node tests/mcp.test.mjs && node tests/mcp-form.test.mjs && node tests/mcp-sse-bridge.test.mjs && node tests/mcp-skill.test.mjs && node tests/mcp-live.test.mjs && node tests/esc-questions.test.mjs && node tests/deadline.test.mjs && node tests/secret-prompt.test.mjs && node tests/test-mermaid-ascii.mjs",
29
- "check": "node --check lib/index.js && node --check lib/ui.js && node --check lib/term.js && node --check lib/caps.js && node --check lib/image.js && node --check lib/mcp.js && node --check lib/mcp-skill.js && node --check lib/mermaid.js && node --check lib/mermaid-ascii.js && node --check lib/util.js && node --check lib/metrics.js && node --check lib/interrupt.js && node --check lib/web-settings.js && node --check lib/updates.js && node --check lib/rewind.js && node --check lib/bridge.js && node --check lib/facet.js && node --check lib/i18n.js && node --check lib/locales/en.js && node --check lib/locales/zh-CN.js && node --check lib/locales/zh-TW.js && node --check lib/locales/ja.js && node --check lib/locales/ko.js && node --check lib/locales/de.js && node --check lib/locales/fr.js && node --check lib/locales/es.js && node --check lib/locales/pt-BR.js && node --check lib/locales/ru.js && node --check lib/locales/it.js && node --check lib/locales/tr.js && node --check lib/locales/vi.js && node --check lib/locales/id.js && node --check lib/locales/pl.js && node --check lib/locales/nl.js && node --check lib/locales/ar.js && node --check lib/std/adapt.js && node --check lib/std/presentation.js && node --check lib/std/command-list.js && node --check lib/std/commands.js && node --check bin/dsh-oc-tui.js && node --check bin/apply-update.cjs && node --check bin/mcp-sse-bridge.mjs"
28
+ "test": "node tests/smoke.test.mjs && node tests/i18n.test.mjs && node tests/locale.test.mjs && node tests/locale-forms.test.mjs && node tests/provider-form.test.mjs && node tests/graphics.test.mjs && node tests/render.test.mjs && node tests/reasoning.test.mjs && node tests/diagnostics.test.mjs && node tests/media.test.mjs && node tests/rail.test.mjs && node tests/rewind.test.mjs && node tests/git-branch.test.mjs && node tests/std.test.mjs && node tests/resume.test.mjs && node tests/mcp.test.mjs && node tests/mcp-form.test.mjs && node tests/mcp-sse-bridge.test.mjs && node tests/mcp-skill.test.mjs && node tests/mcp-live.test.mjs && node tests/esc-questions.test.mjs && node tests/deadline.test.mjs && node tests/secret-prompt.test.mjs && node tests/settings-paste.test.mjs && node tests/test-mermaid-ascii.mjs",
29
+ "check": "node --check lib/index.js && node --check lib/ui.js && node --check lib/term.js && node --check lib/diagnostics.js && node --check lib/caps.js && node --check lib/image.js && node --check lib/mcp.js && node --check lib/mcp-skill.js && node --check lib/mermaid.js && node --check lib/mermaid-ascii.js && node --check lib/providers.js && node --check lib/util.js && node --check lib/metrics.js && node --check lib/interrupt.js && node --check lib/web-settings.js && node --check lib/updates.js && node --check lib/rewind.js && node --check lib/git-branch.js && node --check lib/bridge.js && node --check lib/facet.js && node --check lib/i18n.js && node --check lib/locales/en.js && node --check lib/locales/zh-CN.js && node --check lib/locales/zh-TW.js && node --check lib/locales/ja.js && node --check lib/locales/ko.js && node --check lib/locales/de.js && node --check lib/locales/fr.js && node --check lib/locales/es.js && node --check lib/locales/pt-BR.js && node --check lib/locales/ru.js && node --check lib/locales/it.js && node --check lib/locales/tr.js && node --check lib/locales/vi.js && node --check lib/locales/id.js && node --check lib/locales/pl.js && node --check lib/locales/nl.js && node --check lib/locales/ar.js && node --check lib/std/adapt.js && node --check lib/std/presentation.js && node --check lib/std/command-list.js && node --check lib/std/commands.js && node --check bin/dsh-oc-tui.js && node --check bin/apply-update.cjs && node --check bin/mcp-sse-bridge.mjs"
30
30
  },
31
31
  "main": "lib/index.js",
32
32
  "exports": {
@@ -45,7 +45,8 @@
45
45
  "docs/用户手册.md"
46
46
  ],
47
47
  "engines": {
48
- "node": "^22.19 || >=24"
48
+ "node": "^22.19 || >=24",
49
+ "dsh": ">=0.1.1-rc.2 <0.3.0-0"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "@deepseek-ai/cordis": "*",