dsh-code 1.0.2 → 1.0.4

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.
Files changed (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
package/src/mentions.ts CHANGED
@@ -120,29 +120,54 @@ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: stri
120
120
  const sessionCapable = agent !== undefined && resolver !== undefined
121
121
  // Pre-session fallback: one lazily built search over the launch cwd with
122
122
  // the official defaults — pure in-memory index, no handles to release.
123
+ // The mounted service invalidates its per-agent index after every tool
124
+ // result; nothing drives that for this bare instance, so a time window
125
+ // refreshes it instead — without it, files created or deleted before the
126
+ // first session never appear in (or never leave) the fuzzy @ menu.
127
+ const PRE_SESSION_INDEX_TTL_MS = 30_000
123
128
  let preSessionSearch: WorkspaceFileSearch | undefined
129
+ let preSessionIndexedAt = 0
124
130
  const preSessionFiles = (query: string, signal?: AbortSignal): Promise<readonly ServiceFileCandidate[]> => {
125
- preSessionSearch ??= new WorkspaceFileSearch(cwd, {
126
- maxResults: DEFAULT_FILE_SEARCH_MAX_RESULTS,
127
- maxEntries: DEFAULT_FILE_SEARCH_MAX_ENTRIES,
128
- excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES],
129
- })
131
+ if (preSessionSearch === undefined || Date.now() - preSessionIndexedAt > PRE_SESSION_INDEX_TTL_MS) {
132
+ preSessionSearch?.invalidate()
133
+ preSessionSearch = new WorkspaceFileSearch(cwd, {
134
+ maxResults: DEFAULT_FILE_SEARCH_MAX_RESULTS,
135
+ maxEntries: DEFAULT_FILE_SEARCH_MAX_ENTRIES,
136
+ excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES],
137
+ })
138
+ preSessionIndexedAt = Date.now()
139
+ }
130
140
  return preSessionSearch.list(query, signal ?? new AbortController().signal)
131
141
  }
132
142
 
133
143
  return {
134
144
  async candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]> {
135
145
  const needle = query.trim()
146
+ // A failed discovery half is remembered, never masked: when NOTHING came
147
+ // back the rejection tells the menu "search unavailable" instead of a
148
+ // silent empty list that reads as "no matches". A half that still
149
+ // returned rows shows them — partial results beat an error wall.
150
+ let fileFailure: unknown
151
+ let sessionFailure: unknown
136
152
  const [files, sessions] = await Promise.all([
137
153
  agent !== undefined && fileReferences !== undefined
138
154
  ? fileReferences
139
155
  .list(agent, needle, signal ?? new AbortController().signal)
140
- .catch(() => [] as readonly ServiceFileCandidate[])
156
+ .catch((error: unknown) => {
157
+ fileFailure = error
158
+ return [] as readonly ServiceFileCandidate[]
159
+ })
141
160
  : agent === undefined
142
- ? preSessionFiles(needle, signal).catch(() => [] as readonly ServiceFileCandidate[])
161
+ ? preSessionFiles(needle, signal).catch((error: unknown) => {
162
+ fileFailure = error
163
+ return [] as readonly ServiceFileCandidate[]
164
+ })
143
165
  : Promise.resolve([] as readonly ServiceFileCandidate[]),
144
166
  sessionCapable && needle !== '' && !isPathLikeMentionQuery(needle) && agent !== undefined
145
- ? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
167
+ ? resolver!.listCandidates(agent, needle, 10, signal).catch((error: unknown) => {
168
+ sessionFailure = error
169
+ return [] as readonly SessionReferenceCandidate[]
170
+ })
146
171
  : Promise.resolve([] as readonly SessionReferenceCandidate[]),
147
172
  ])
148
173
  // The service owns ranking (and the bare-@ default rows); the menu caps
@@ -161,7 +186,15 @@ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: stri
161
186
  description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
162
187
  kind: 'session',
163
188
  }))
164
- return [...fileRows, ...sessionRows]
189
+ const rows = [...fileRows, ...sessionRows]
190
+ if (rows.length === 0 && (fileFailure !== undefined || sessionFailure !== undefined)) {
191
+ throw fileFailure instanceof Error
192
+ ? fileFailure
193
+ : sessionFailure instanceof Error
194
+ ? sessionFailure
195
+ : new Error(String(fileFailure ?? sessionFailure))
196
+ }
197
+ return rows
165
198
  },
166
199
  parse(text: string): ParsedSessionReferenceText {
167
200
  return parseSessionReferenceText(text)
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Same-id reasoning-capability inheritance for hand-declared pi-ai routes:
3
+ * the model catalog inherits capabilities by route key, not by model id, so a
4
+ * relay route listing `gpt-5.5` reads nothing from the installed `openai`
5
+ * catalog entry and materializes as `reasoning: false` until its settings
6
+ * entry declares `reasoningEfforts`. This adapter closes that gap without
7
+ * touching upstream: whenever a pi-ai profile's model entry carries no
8
+ * declaration and its live row advertises no efforts, but the same model id
9
+ * is declared (in a sibling settings entry) or advertised (on another route)
10
+ * elsewhere, the declaration is materialized into settings — verbatim from a
11
+ * sibling declaration when one exists, otherwise as an identity level map
12
+ * (`off` maps to null, every other level to its own name), which is the
13
+ * correct wire spelling for OpenAI-compatible relays. Writes ride the same
14
+ * `settings.mutate` path as the provider panel, so the upstream
15
+ * `assertServiceable` gate still rejects anything invalid atomically.
16
+ *
17
+ * @module @deepseek-ai/dsh-tui/model-capabilities
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import { loadModelDirectory, type ModelRow } from './models.ts'
22
+
23
+ /** The settings namespace the llm-pi-ai plugin owns (identity via `settingsNamespace`). */
24
+ const PI_AI_SETTINGS_NS = 'llm-pi-ai'
25
+
26
+ /** The effort level that means "send no reasoning parameter"; its wire value is always null. */
27
+ const OFF_LEVEL = 'off'
28
+
29
+ /* ------------------------------------------------------------------ *
30
+ * Structural service faces (same discipline as provider-settings: the
31
+ * settings package is never imported, only described structurally).
32
+ * ------------------------------------------------------------------ */
33
+
34
+ /** One configurable-provider directory entry (subset of the `llm` service face). */
35
+ interface ConfigurableEntryFace {
36
+ readonly provider: string
37
+ readonly settingsNs: string
38
+ readonly settingsPath: readonly string[]
39
+ }
40
+
41
+ /** One redacted settings descriptor (subset of `SettingsDescriptor`). */
42
+ interface DescriptorFace {
43
+ readonly ns: string
44
+ readonly value: unknown
45
+ readonly revision: number
46
+ }
47
+
48
+ /** One path-addressed edit to a stored section (subset of `SettingsPathOp`). */
49
+ interface PathOpFace {
50
+ readonly op: 'set' | 'unset'
51
+ readonly path: readonly string[]
52
+ readonly value?: unknown
53
+ }
54
+
55
+ /** The subset of the `settings` service this module reads and writes. */
56
+ interface SettingsFace {
57
+ describe(options?: { readonly redactSecrets?: boolean }): readonly DescriptorFace[]
58
+ mutate(ns: string, ops: readonly PathOpFace[], expectedRevision?: number): Promise<void>
59
+ }
60
+
61
+ /** A notice sink structurally compatible with the app bridge's `notify`. */
62
+ export type CapabilityNotice = (text: string, tone?: 'info' | 'warning' | 'error') => void
63
+
64
+ /** Single-line a misbehaving error for the notice channel. */
65
+ function singleLine(message: string): string {
66
+ const spaced = [...message].map(ch => { const code = ch.charCodeAt(0); return code < 32 || code === 127 ? ' ' : ch }).join('')
67
+ return spaced.split(' ').filter(part => part !== '').join(' ')
68
+ }
69
+
70
+ /** Human text for a rejection value. */
71
+ function messageOf(error: unknown): string {
72
+ return error instanceof Error ? error.message : String(error)
73
+ }
74
+
75
+ /** Read the value at a path through plain objects; undefined when any segment misses. */
76
+ function getPath(value: unknown, path: readonly string[]): unknown {
77
+ let current = value
78
+ for (const segment of path) {
79
+ if (typeof current !== 'object' || current === null) return undefined
80
+ current = (current as Record<string, unknown>)[segment]
81
+ }
82
+ return current
83
+ }
84
+
85
+ /** Whether a raw settings value is a usable `reasoningEfforts` dict (non-empty, non-false). */
86
+ function isDeclaredEfforts(value: unknown): value is Record<string, unknown> {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0
88
+ }
89
+
90
+ /**
91
+ * The identity level map for an advertised effort list: `off` sends no
92
+ * parameter, every other level keeps its own name — the wire spelling
93
+ * OpenAI-compatible relays accept as-is.
94
+ */
95
+ function identityEfforts(levels: readonly string[]): Record<string, string | null> {
96
+ const dict: Record<string, string | null> = {}
97
+ for (const level of levels) dict[level] = level === OFF_LEVEL ? null : level
98
+ return dict
99
+ }
100
+
101
+ /* ------------------------------------------------------------------ *
102
+ * Pure planning.
103
+ * ------------------------------------------------------------------ */
104
+
105
+ /** One pi-ai provider profile as stored in settings, addressed for mutation. */
106
+ export interface CapabilityProfileSource {
107
+ /** Settings namespace owning the profile (`llm-pi-ai`). */
108
+ readonly settingsNs: string
109
+ /** Path from the section root to this provider's profile. */
110
+ readonly settingsPath: readonly string[]
111
+ /** Revision of the owning section at read time. */
112
+ readonly revision: number
113
+ /** Raw model entries, exactly as stored (declaration fields included). */
114
+ readonly models: readonly Record<string, unknown>[]
115
+ }
116
+
117
+ /** One planned per-provider models rewrite. */
118
+ export interface CapabilitySyncPlan {
119
+ /** Provider route the plan targets. */
120
+ readonly provider: string
121
+ /** Settings namespace owning the profile. */
122
+ readonly settingsNs: string
123
+ /** Path from the section root to the provider profile. */
124
+ readonly settingsPath: readonly string[]
125
+ /** Raw model entries, exactly as stored (declaration fields included). */
126
+ readonly models: readonly Record<string, unknown>[]
127
+ /** Fingerprint of the source models array this plan was derived from. */
128
+ readonly sourceFingerprint: string
129
+ /** Document revision of the owning section when the plan was derived. */
130
+ readonly sourceRevision: number
131
+ /** Model ids that gained a declaration, notice-facing. */
132
+ readonly inherited: readonly string[]
133
+ /** `provider/model` labels the declarations came from, notice-facing. */
134
+ readonly sources: readonly string[]
135
+ }
136
+
137
+ /**
138
+ * Plan the reasoning declarations to materialize. A model entry inherits
139
+ * when it declares nothing (`reasoningEfforts` absent — a dict or `false` is
140
+ * an explicit choice and is never touched) and its live row advertises no
141
+ * efforts; the donor is the first sibling settings declaration for the same
142
+ * id, copied verbatim so dialect wire spellings survive, otherwise the first
143
+ * other-route row advertising efforts for that id, mapped by identity.
144
+ * Entries never lose fields and keep their key order; a provider appears in
145
+ * the result only when at least one entry changes.
146
+ * @param input - the live model rows and the raw pi-ai profiles from settings.
147
+ * @returns one plan per provider with at least one inheritance.
148
+ */
149
+ export function planCapabilitySync(input: {
150
+ readonly rows: readonly ModelRow[]
151
+ readonly profiles: ReadonlyMap<string, CapabilityProfileSource>
152
+ }): readonly CapabilitySyncPlan[] {
153
+ // Sibling declarations, first profile order wins: { id -> { dict, provider } }.
154
+ const declared = new Map<string, { dict: Record<string, unknown>; provider: string }>()
155
+ for (const [provider, profile] of input.profiles) {
156
+ for (const entry of profile.models) {
157
+ const efforts = entry.reasoningEfforts
158
+ if (!isDeclaredEfforts(efforts)) continue
159
+ if (!declared.has(String(entry.id))) declared.set(String(entry.id), { dict: efforts, provider })
160
+ }
161
+ }
162
+ // Advertised donors, first row order wins: { id -> { levels, provider } }.
163
+ // A row whose only level is `off` advertises nothing usable and is skipped.
164
+ const advertised = new Map<string, { levels: readonly string[]; provider: string }>()
165
+ for (const row of input.rows) {
166
+ const levels = row.reasoning?.efforts.map(effort => effort.id) ?? []
167
+ if (levels.filter(level => level !== OFF_LEVEL).length === 0) continue
168
+ if (!advertised.has(row.model)) advertised.set(row.model, { levels, provider: row.provider })
169
+ }
170
+ const plans: CapabilitySyncPlan[] = []
171
+ for (const [provider, profile] of input.profiles) {
172
+ const inherited: string[] = []
173
+ const sources: string[] = []
174
+ const models = profile.models.map((entry): Record<string, unknown> => {
175
+ if (entry.reasoningEfforts !== undefined) return entry
176
+ const id = String(entry.id)
177
+ const sibling = declared.get(id)
178
+ if (sibling !== undefined && sibling.provider !== provider) {
179
+ inherited.push(id)
180
+ if (!sources.includes(`${sibling.provider}/${id}`)) sources.push(`${sibling.provider}/${id}`)
181
+ return { ...entry, reasoningEfforts: sibling.dict }
182
+ }
183
+ const donor = advertised.get(id)
184
+ if (donor !== undefined && donor.provider !== provider) {
185
+ inherited.push(id)
186
+ if (!sources.includes(`${donor.provider}/${id}`)) sources.push(`${donor.provider}/${id}`)
187
+ return { ...entry, reasoningEfforts: identityEfforts(donor.levels) }
188
+ }
189
+ return entry
190
+ })
191
+ if (inherited.length > 0) {
192
+ plans.push({
193
+ provider,
194
+ settingsNs: profile.settingsNs,
195
+ settingsPath: profile.settingsPath,
196
+ models,
197
+ sourceFingerprint: JSON.stringify(profile.models),
198
+ sourceRevision: profile.revision,
199
+ inherited,
200
+ sources,
201
+ })
202
+ }
203
+ }
204
+ return plans
205
+ }
206
+
207
+ /* ------------------------------------------------------------------ *
208
+ * Application.
209
+ * ------------------------------------------------------------------ */
210
+
211
+ /** Provider -> the source snapshot and document revision its last write landed against. */
212
+ const lastApplied = new Map<string, { fingerprint: string; revision: number }>()
213
+
214
+ /** Fresh revision of one namespace, or undefined when it does not resolve. */
215
+ function revisionOf(settings: SettingsFace, ns: string): number | undefined {
216
+ try {
217
+ for (const descriptor of settings.describe({ redactSecrets: true })) {
218
+ if (descriptor.ns === ns) return descriptor.revision
219
+ }
220
+ } catch {
221
+ return undefined
222
+ }
223
+ return undefined
224
+ }
225
+
226
+ /**
227
+ * Materialize same-id reasoning declarations once per provider. Reads the
228
+ * configurable directory, the redacted settings document, and the live model
229
+ * rows; plans; then writes each provider's merged models array through
230
+ * `settings.mutate` under a fresh revision (writes bump the section
231
+ * revision, so per-plan revisions are re-read). Every failure converges to a
232
+ * single-line notice — the caller's promise never rejects and the session
233
+ * keeps running on the previous configuration.
234
+ * @param ctx - context carrying the `llm` and `settings` services (optional).
235
+ * @param notify - the app bridge's notice sink, when one is live.
236
+ */
237
+ export async function syncModelCapabilities(ctx: Context, notify?: CapabilityNotice): Promise<void> {
238
+ try {
239
+ const llm = ctx.get('llm') as { listConfigurableProviders?: () => readonly ConfigurableEntryFace[] } | undefined
240
+ if (llm?.listConfigurableProviders === undefined) return
241
+ const settings = ctx.get('settings') as SettingsFace | undefined
242
+ if (settings === undefined) return
243
+ let entries: readonly ConfigurableEntryFace[]
244
+ try {
245
+ entries = llm.listConfigurableProviders()
246
+ } catch {
247
+ return
248
+ }
249
+ const routed = entries.filter(entry => entry.settingsNs === PI_AI_SETTINGS_NS)
250
+ if (routed.length === 0) return
251
+ let descriptors: readonly DescriptorFace[]
252
+ try {
253
+ descriptors = settings.describe({ redactSecrets: true })
254
+ } catch {
255
+ return
256
+ }
257
+ const namespaces = new Map(descriptors.map(descriptor => [descriptor.ns, descriptor] as const))
258
+ const profiles = new Map<string, CapabilityProfileSource>()
259
+ for (const entry of routed) {
260
+ const namespace = namespaces.get(entry.settingsNs)
261
+ if (namespace === undefined) continue
262
+ const profile = entry.settingsPath.length === 0
263
+ ? namespace.value
264
+ : getPath(namespace.value, entry.settingsPath)
265
+ const models = Array.isArray((profile as { models?: unknown } | null)?.models)
266
+ ? (profile as { models: unknown }).models as readonly Record<string, unknown>[]
267
+ : undefined
268
+ // An absent models list is a dormant or unconfigured route: nothing to
269
+ // inherit into (and the empty allow-list there is deliberate upstream).
270
+ if (models === undefined) continue
271
+ profiles.set(entry.provider, {
272
+ settingsNs: entry.settingsNs,
273
+ settingsPath: entry.settingsPath,
274
+ revision: namespace.revision,
275
+ models,
276
+ })
277
+ }
278
+ if (profiles.size === 0) return
279
+ const directory = await loadModelDirectory(ctx)
280
+ for (const plan of planCapabilitySync({ rows: directory.rows, profiles })) {
281
+ // Skip only a provable echo of our own write: the same source
282
+ // snapshot at the same document revision we already wrote against
283
+ // (our write re-triggers the document event before the re-read
284
+ // catches up). An external edit that removes a materialized
285
+ // declaration bumps the revision, so identical source content
286
+ // re-plans and re-writes instead of being skipped forever.
287
+ const applied = lastApplied.get(plan.provider)
288
+ if (applied !== undefined && applied.fingerprint === plan.sourceFingerprint && applied.revision === plan.sourceRevision) continue
289
+ // Writes bump the section revision; re-read per plan so the second
290
+ // provider's optimistic revision is not already stale.
291
+ const revision = revisionOf(settings, plan.settingsNs)
292
+ if (revision === undefined) continue
293
+ try {
294
+ await settings.mutate(
295
+ plan.settingsNs,
296
+ [{ op: 'set', path: [...plan.settingsPath, 'models'], value: plan.models }],
297
+ revision,
298
+ )
299
+ lastApplied.set(plan.provider, { fingerprint: plan.sourceFingerprint, revision: plan.sourceRevision })
300
+ const shown = plan.sources.slice(0, 3).join(', ')
301
+ const more = plan.sources.length > 3 ? `, +${plan.sources.length - 3}` : ''
302
+ notify?.(
303
+ `inherited reasoning levels for ${plan.inherited.length} model${plan.inherited.length === 1 ? '' : 's'} on ${plan.provider} (from ${shown}${more})`,
304
+ 'info',
305
+ )
306
+ } catch (error) {
307
+ notify?.(`capability inheritance failed on ${plan.provider}: ${singleLine(messageOf(error))}`, 'warning')
308
+ }
309
+ }
310
+ } catch {
311
+ // A background sync must never surface as an unhandled rejection.
312
+ }
313
+ }
314
+
315
+ /** Test seam: forget the applied-write fingerprints. */
316
+ export function resetCapabilitySyncState(): void {
317
+ lastApplied.clear()
318
+ }
@@ -34,6 +34,21 @@ interface LlmFace {
34
34
  readonly settingsPath: readonly string[]
35
35
  readonly declared?: boolean
36
36
  }[]
37
+ /**
38
+ * Registered endpoint model discovery; absent on an older service. The
39
+ * request is a draft (provider route and/or baseURL, optional one-shot
40
+ * key); the reply is candidate metadata for adoption, never a write.
41
+ */
42
+ discoverModels?(
43
+ settingsNs: string,
44
+ request: { readonly provider?: string; readonly baseURL?: string; readonly api?: string; readonly apiKey?: string },
45
+ signal?: AbortSignal,
46
+ ): Promise<readonly {
47
+ readonly id: string
48
+ readonly name?: string
49
+ readonly contextWindow?: number
50
+ readonly maxTokens?: number
51
+ }[]>
37
52
  }
38
53
 
39
54
  /** One redacted settings descriptor (subset of `SettingsDescriptor`). */
@@ -71,11 +86,19 @@ interface ProviderEventsFace {
71
86
  on(event: string, listener: (...args: unknown[]) => void): () => void
72
87
  }
73
88
 
89
+ /** One resolved credential value; never rendered, never persisted by callers. */
90
+ interface CredentialValueFace {
91
+ readonly value: string
92
+ readonly source: string
93
+ }
94
+
74
95
  /** The subset of the `credentials` service this module reads and writes. */
75
96
  interface CredentialsFace {
76
97
  describe(ref: string): Promise<CredentialFactsFace>
77
98
  set(ref: string, value: string): Promise<void>
78
99
  unset(ref: string): Promise<void>
100
+ /** Same-process value resolution; absent on an older service. */
101
+ resolve?(ref: string): Promise<CredentialValueFace | undefined>
79
102
  }
80
103
 
81
104
  /* ------------------------------------------------------------------ *
@@ -138,15 +161,21 @@ function configurationOf(profile: unknown): ProviderConfiguration {
138
161
  if (typeof value !== 'object' || value === null) return []
139
162
  const entry = value as Record<string, unknown>
140
163
  if (typeof entry.id !== 'string' || entry.id.trim() === '') return []
164
+ // Fields the editor does not understand ride along untouched: dropping
165
+ // them here would let the next save strip hand-written reasoningEfforts
166
+ // or compat declarations out of the stored profile.
167
+ const { id: _id, name: _name, contextWindow: _contextWindow, maxTokens: _maxTokens, ...extras } = entry
141
168
  return [{
142
169
  id: entry.id,
143
170
  ...(typeof entry.name === 'string' && entry.name.trim() !== '' ? { name: entry.name } : {}),
144
171
  ...(typeof entry.contextWindow === 'number' && Number.isFinite(entry.contextWindow) ? { contextWindow: entry.contextWindow } : {}),
145
172
  ...(typeof entry.maxTokens === 'number' && Number.isFinite(entry.maxTokens) ? { maxTokens: entry.maxTokens } : {}),
173
+ ...Object.keys(extras).length > 0 ? { extras } : {},
146
174
  }]
147
175
  })
148
176
  return {
149
177
  ...(typeof record.baseURL === 'string' && record.baseURL.trim() !== '' ? { baseURL: record.baseURL } : {}),
178
+ ...(typeof record.api === 'string' && record.api.trim() !== '' ? { api: record.api } : {}),
150
179
  models,
151
180
  }
152
181
  }
@@ -194,14 +223,112 @@ export interface ProviderModelSettings {
194
223
  readonly name?: string
195
224
  readonly contextWindow?: number
196
225
  readonly maxTokens?: number
226
+ /**
227
+ * Remaining entry fields the editor does not model (`reasoningEfforts`,
228
+ * `compat`, `input`, …), carried verbatim so a save preserves them.
229
+ * Populated by {@link loadProviderSettings}; never contains the four
230
+ * modelled keys.
231
+ */
232
+ readonly extras?: Readonly<Record<string, unknown>>
197
233
  }
198
234
 
199
235
  /** The small, portable subset of a provider profile the terminal edits. */
200
236
  export interface ProviderConfiguration {
201
237
  readonly baseURL?: string
238
+ /**
239
+ * Wire protocol the stored profile names (e.g. `openai-responses`), when it
240
+ * names one. Load-only: the editor never writes it, but endpoint discovery
241
+ * passes it so the listing speaks the same protocol as real requests.
242
+ */
243
+ readonly api?: string
202
244
  readonly models: readonly ProviderModelSettings[]
203
245
  }
204
246
 
247
+ /** One model an endpoint reported about itself (mirrors `LlmDiscoveredModel`). */
248
+ export interface DiscoveredModelView {
249
+ /** Model id the endpoint accepts. */
250
+ readonly id: string
251
+ /** Human-readable name when the endpoint supplies one. */
252
+ readonly name?: string
253
+ /** Context window when disclosed; adoption still owes it if absent. */
254
+ readonly contextWindow?: number
255
+ /** Output cap when disclosed. */
256
+ readonly maxTokens?: number
257
+ }
258
+
259
+ /** One model an endpoint reported about itself (mirrors LlmDiscoveredModel). */
260
+ export interface DiscoveredModelView {
261
+ /** Model id the endpoint accepts. */
262
+ readonly id: string
263
+ /** Human-readable name when the endpoint supplies one. */
264
+ readonly name?: string
265
+ /** Context window when disclosed; adoption still owes it if absent. */
266
+ readonly contextWindow?: number
267
+ /** Output cap when disclosed. */
268
+ readonly maxTokens?: number
269
+ }
270
+
271
+ /**
272
+ * The seven canonical reasoning levels a reasoningEfforts key may name -
273
+ * pi-ai's THINKING_LEVELS. A pi-ai upgrade that adds or removes one fails
274
+ * upstream's own drift gate; this mirror exists so the terminal editor can
275
+ * validate drafts without importing the pi-ai package.
276
+ */
277
+ export const REASONING_EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
278
+
279
+ /**
280
+ * One stored reasoningEfforts declaration: a display-level to wire-value map
281
+ * (null sends no reasoning parameter), an explicit false disabling the
282
+ * picker, or undefined leaving the entry to inherit.
283
+ */
284
+ export type ReasoningEffortsValue = Record<string, string | null> | false | undefined
285
+
286
+ /** Whether a raw extras value is a declared efforts dict (non-empty, non-false). */
287
+ export function isDeclaredReasoningEfforts(value: unknown): value is Record<string, string | null> {
288
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0
289
+ }
290
+
291
+ /**
292
+ * Parse the setup page's compact efforts draft into a storable declaration.
293
+ * Grammar: empty = clear back to inherit; the single token "false" = disable
294
+ * the picker; otherwise space-separated level:wire pairs where level is one
295
+ * of REASONING_EFFORT_LEVELS and wire is any non-empty string or the literal
296
+ * "null" (send no parameter).
297
+ */
298
+ export function parseReasoningEffortsDraft(draft: string):
299
+ | { readonly ok: true; readonly value: ReasoningEffortsValue }
300
+ | { readonly ok: false; readonly error: string } {
301
+ const text = draft.trim()
302
+ if (text === '') return { ok: true, value: undefined }
303
+ if (text === 'false') return { ok: true, value: false }
304
+ const value: Record<string, string | null> = {}
305
+ for (const token of text.split(/\s+/u)) {
306
+ const split = token.indexOf(':')
307
+ if (split <= 0 || split === token.length - 1) {
308
+ return { ok: false, error: 'each entry needs level:wire, got "' + token + '"' }
309
+ }
310
+ const level = token.slice(0, split)
311
+ const wire = token.slice(split + 1)
312
+ if (!(REASONING_EFFORT_LEVELS as readonly string[]).includes(level)) {
313
+ return { ok: false, error: '"' + level + '" is not a level; use one of ' + REASONING_EFFORT_LEVELS.join('/') }
314
+ }
315
+ if (level in value) {
316
+ return { ok: false, error: 'level "' + level + '" appears twice' }
317
+ }
318
+ value[level] = wire === 'null' ? null : wire
319
+ }
320
+ return { ok: true, value }
321
+ }
322
+
323
+ /** Serialize a stored declaration back to the compact draft form (stored key order preserved). */
324
+ export function serializeReasoningEfforts(value: unknown): string {
325
+ if (value === false) return 'false'
326
+ if (!isDeclaredReasoningEfforts(value)) return ''
327
+ return Object.entries(value)
328
+ .map(([level, wire]) => level + ':' + (wire === null ? 'null' : String(wire)))
329
+ .join(' ')
330
+ }
331
+
205
332
  /**
206
333
  * One provider row in the TUI provider-management panel: the configurable
207
334
  * directory entry joined with its settings profile and credential facts.
@@ -514,6 +641,10 @@ export async function saveProviderConfiguration(
514
641
  }
515
642
  return {
516
643
  id,
644
+ // Editor-invisible fields ride along after the id; the modelled keys
645
+ // spread last so a panel edit (or clear) always wins over a carried
646
+ // value. extras never contains those keys — see configurationOf.
647
+ ...model.extras,
517
648
  ...(model.name === undefined || model.name.trim() === '' ? {} : { name: model.name.trim() }),
518
649
  ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
519
650
  ...(model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),
@@ -535,6 +666,95 @@ export async function saveProviderConfiguration(
535
666
  }
536
667
  }
537
668
 
669
+ /**
670
+ * Interrogate a provider endpoint for the models it really serves, through
671
+ * the model-discovery capability the provider's settings namespace
672
+ * registered — the same pipe the official Web Models page uses. The request
673
+ * is a draft: a typed key forces direct endpoint interrogation (gateway
674
+ * truth), while an empty key lets the harness resolve the route's stored
675
+ * credential; with neither baseURL nor route the adapter answers from its
676
+ * own knowledge.
677
+ * @param ctx - context carrying the `llm` service (optional discovery).
678
+ * @param target - provider row whose settings namespace serves the draft.
679
+ * @param request - typed key and/or endpoint override for this one probe.
680
+ * @param signal - caller cancellation (panel navigation aborts the probe).
681
+ * @returns the advertised models in endpoint order, deduplicated.
682
+ */
683
+ export async function discoverProviderModels(
684
+ ctx: Context,
685
+ target: ProviderTargetView,
686
+ request: { readonly apiKey?: string; readonly baseURL?: string },
687
+ signal?: AbortSignal,
688
+ ): Promise<readonly DiscoveredModelView[]> {
689
+ if (target.settingsNs.length === 0) {
690
+ throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; its models cannot be discovered here`)
691
+ }
692
+ const llm = ctx.get('llm') as LlmFace | undefined
693
+ if (llm?.discoverModels === undefined) {
694
+ throw new ProviderSettingsError('model discovery is unavailable in this profile; enter models by hand')
695
+ }
696
+ const typedKey = request.apiKey?.trim()
697
+ const baseURL = request.baseURL?.trim()
698
+ const hasUrl = baseURL !== undefined && baseURL !== ''
699
+ let oneShotKey = typedKey
700
+ // A filled endpoint means the user wants THAT endpoint's real list: sending
701
+ // the route id alongside would make the adapter short-circuit to its
702
+ // installed catalog (official providers) and ignore the URL entirely. The
703
+ // probe therefore goes out as a draft — with the typed key, or with the
704
+ // stored credential resolved once for this request (never displayed,
705
+ // never persisted; exactly what provider-mode resolution does internally).
706
+ if (hasUrl && (oneShotKey === undefined || oneShotKey === '')) {
707
+ const credentialsService = ctx.get('credentials') as CredentialsFace | undefined
708
+ const ref = target.credentialRef ?? target.suggestedRef
709
+ if (credentialsService?.resolve !== undefined && ref !== undefined) {
710
+ try {
711
+ // Call resolve AS A METHOD on the service: destructured off it the
712
+ // call loses `this` and throws on the provider's first field read
713
+ // (the same lesson loadProviderSettings documents for listProviders).
714
+ const resolved = await credentialsService.resolve(ref)
715
+ oneShotKey = resolved?.value
716
+ } catch {
717
+ // A failed resolution degrades to an unauthenticated probe; the
718
+ // endpoint's own 401 names the problem better than we can.
719
+ }
720
+ }
721
+ }
722
+ const draft = hasUrl
723
+ ? {
724
+ ...(oneShotKey !== undefined && oneShotKey !== '' ? { apiKey: oneShotKey } : {}),
725
+ baseURL: baseURL!,
726
+ ...target.configuration.api === undefined ? {} : { api: target.configuration.api },
727
+ }
728
+ : {
729
+ // No endpoint override: the route's own knowledge answers (the official
730
+ // catalog for builtin providers — richer than any listing).
731
+ provider: target.provider,
732
+ }
733
+ try {
734
+ const discovered = signal === undefined
735
+ ? await llm.discoverModels(target.settingsNs, draft)
736
+ : await llm.discoverModels(target.settingsNs, draft, signal)
737
+ // Defensive dedupe in endpoint order (the service dedupes too; an older
738
+ // one must not leak duplicate rows into the checkable list).
739
+ const seen = new Set<string>()
740
+ const rows: DiscoveredModelView[] = []
741
+ for (const model of discovered) {
742
+ if (typeof model.id !== 'string' || model.id.trim() === '' || seen.has(model.id)) continue
743
+ seen.add(model.id)
744
+ rows.push({
745
+ id: model.id,
746
+ ...model.name === undefined ? {} : { name: model.name },
747
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
748
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
749
+ })
750
+ }
751
+ return rows
752
+ } catch (error) {
753
+ if (signal?.aborted === true) throw error
754
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
755
+ }
756
+ }
757
+
538
758
  /**
539
759
  * Remove the currently named credential without touching the provider
540
760
  * profile. Only the resolved profile's own reference is unset; a dormant or