dsh-code 0.7.0 → 0.8.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/README.en.md +20 -6
- package/README.md +20 -6
- package/lib/index.mjs +2685 -622
- package/lib/types/app.d.ts +77 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +7 -0
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +4 -1
- package/lib/types/session-directory.d.ts +15 -0
- package/lib/types/store.d.ts +13 -2
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +847 -150
- package/src/approval.ts +11 -2
- package/src/history.ts +20 -5
- package/src/index.ts +402 -159
- package/src/kernel-panels.ts +45 -8
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +21 -6
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +665 -10
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +18 -2
- package/src/session-directory.ts +44 -5
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/version.ts +16 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider settings adapter for the TUI `/model` provider-management panel:
|
|
3
|
+
* the same-process equivalent of the web host's Models page join
|
|
4
|
+
* (`packages/client/ui-settings-models`), reading the advisory `ctx.llm`
|
|
5
|
+
* registry, the redacted `ctx.settings` descriptors, and the value-free
|
|
6
|
+
* `ctx.credentials` facts directly. Secrets never cross this module: settings
|
|
7
|
+
* are read with `redactSecrets: true`, credentials are only ever described
|
|
8
|
+
* (never resolved), and every message is single-line without embedding key
|
|
9
|
+
* data.
|
|
10
|
+
*
|
|
11
|
+
* @module @deepseek-ai/dsh-tui/provider-settings
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
|
16
|
+
|
|
17
|
+
/* ------------------------------------------------------------------ *
|
|
18
|
+
* Structural service faces. Branding is erased at runtime, so credential
|
|
19
|
+
* refs and settings namespaces are plain strings here; they originate only
|
|
20
|
+
* from trusted settings descriptors or the validated derivation below.
|
|
21
|
+
* Declaring the faces locally keeps this a dependency-free adapter over
|
|
22
|
+
* `ctx.get(...)` — the settings and credentials packages are never imported.
|
|
23
|
+
* ------------------------------------------------------------------ */
|
|
24
|
+
|
|
25
|
+
/** The subset of the `llm` service this module reads. */
|
|
26
|
+
interface LlmFace {
|
|
27
|
+
/** Registered provider routes with a live adapter (`listProviders()`). */
|
|
28
|
+
listProviders(): readonly { readonly id: string; readonly name: string }[]
|
|
29
|
+
/** Declared configurable-provider directory; absent on an older service. */
|
|
30
|
+
listConfigurableProviders?(): readonly {
|
|
31
|
+
readonly provider: string
|
|
32
|
+
readonly displayName: string
|
|
33
|
+
readonly settingsNs: string
|
|
34
|
+
readonly settingsPath: readonly string[]
|
|
35
|
+
readonly declared?: boolean
|
|
36
|
+
}[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One redacted settings descriptor (subset of `SettingsDescriptor`). */
|
|
40
|
+
interface SettingsDescriptorFace {
|
|
41
|
+
readonly ns: string
|
|
42
|
+
readonly value: unknown
|
|
43
|
+
readonly revision: number
|
|
44
|
+
readonly base?: unknown
|
|
45
|
+
readonly user?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One path-addressed edit to a stored section (subset of `SettingsPathOp`). */
|
|
49
|
+
interface SettingsPathOpFace {
|
|
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
|
+
readonly writable: boolean
|
|
58
|
+
describe(options?: { readonly redactSecrets?: boolean }): readonly SettingsDescriptorFace[]
|
|
59
|
+
mutate(ns: string, ops: readonly SettingsPathOpFace[], expectedRevision?: number): Promise<void>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Value-free facts about one credential reference (subset of `CredentialInfo`). */
|
|
63
|
+
interface CredentialFactsFace {
|
|
64
|
+
readonly configured: boolean
|
|
65
|
+
readonly source?: string
|
|
66
|
+
readonly writable: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Event subscription face used for the official Models invalidation trio. */
|
|
70
|
+
interface ProviderEventsFace {
|
|
71
|
+
on(event: string, listener: (...args: unknown[]) => void): () => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The subset of the `credentials` service this module reads and writes. */
|
|
75
|
+
interface CredentialsFace {
|
|
76
|
+
describe(ref: string): Promise<CredentialFactsFace>
|
|
77
|
+
set(ref: string, value: string): Promise<void>
|
|
78
|
+
unset(ref: string): Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ------------------------------------------------------------------ *
|
|
82
|
+
* Pure helpers.
|
|
83
|
+
* ------------------------------------------------------------------ */
|
|
84
|
+
|
|
85
|
+
/** Human text for a rejection value (mirrors the web page's `messageOf`). */
|
|
86
|
+
function messageOf(error: unknown): string {
|
|
87
|
+
return error instanceof Error ? error.message : String(error)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Collapse every whitespace/control run to one space so a notice stays one line. */
|
|
91
|
+
function singleLine(message: string): string {
|
|
92
|
+
return message.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Keep a misbehaving credential provider from reflecting the submitted secret. */
|
|
96
|
+
function credentialWriteMessage(error: unknown, secret: string): string {
|
|
97
|
+
const message = singleLine(messageOf(error))
|
|
98
|
+
return message.includes(secret) ? 'credentials service rejected the API key' : message
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Obvious shell-assignment paste; mirrors the official Web Models editor. */
|
|
102
|
+
const ENV_ASSIGNMENT = /^[A-Z][A-Z0-9_]*=[^=]/
|
|
103
|
+
|
|
104
|
+
/** Whether the whole draft is wrapped in one matching quote pair. */
|
|
105
|
+
function hasWrappingQuotes(value: string): boolean {
|
|
106
|
+
const first = value[0]
|
|
107
|
+
return (first === '"' || first === '\'' || first === '`') && value.length > 1 && value.endsWith(first)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read the value at a path through plain objects; undefined when any segment misses. */
|
|
111
|
+
function getPath(value: unknown, path: readonly string[]): unknown {
|
|
112
|
+
let current = value
|
|
113
|
+
for (const segment of path) {
|
|
114
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
115
|
+
current = (current as Record<string, unknown>)[segment]
|
|
116
|
+
}
|
|
117
|
+
return current
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether a path resolves to a defined value (the empty path reads the root). */
|
|
121
|
+
function hasPath(value: unknown, path: readonly string[]): boolean {
|
|
122
|
+
return path.length === 0 ? value !== undefined : getPath(value, path) !== undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
|
|
126
|
+
function profileRefOf(profile: unknown): string | undefined {
|
|
127
|
+
if (typeof profile !== 'object' || profile === null) return undefined
|
|
128
|
+
const ref = (profile as { readonly apiKeyEnv?: unknown }).apiKeyEnv
|
|
129
|
+
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ------------------------------------------------------------------ *
|
|
133
|
+
* Exported contracts.
|
|
134
|
+
* ------------------------------------------------------------------ */
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The conventional credential reference for a provider route: `<ROUTE>_API_KEY`
|
|
138
|
+
* with the route uppercased and every non-alphanumeric run collapsed to one
|
|
139
|
+
* underscore — the exact derivation the official Models page uses
|
|
140
|
+
* (`deriveKeyRef` in `ui-settings-models`), so a key saved here is found there.
|
|
141
|
+
* @param provider - provider route id (e.g. `pi-ai`, `minimax-cn`).
|
|
142
|
+
* @returns the derived reference name (e.g. `PI_AI_API_KEY`).
|
|
143
|
+
*/
|
|
144
|
+
export function deriveCredentialRef(provider: string): string {
|
|
145
|
+
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Value-free facts about one credential reference — never the value. */
|
|
149
|
+
export interface ProviderCredentialFacts {
|
|
150
|
+
/** Whether the reference currently resolves to a stored value. */
|
|
151
|
+
readonly configured: boolean
|
|
152
|
+
/** Source layer supplying the value; absent while unconfigured. */
|
|
153
|
+
readonly source?: string
|
|
154
|
+
/** Whether a write through this panel would currently succeed. */
|
|
155
|
+
readonly writable: boolean
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One row's credential state: value-free facts once the reference was
|
|
160
|
+
* described, a bounded error when that describe failed (the row itself is
|
|
161
|
+
* never dropped), or `undefined` when the row names no reference to describe —
|
|
162
|
+
* an unmanaged active provider, a dormant route, or a profile authenticating
|
|
163
|
+
* through the provider's own path.
|
|
164
|
+
*/
|
|
165
|
+
export type ProviderCredentialView =
|
|
166
|
+
| ({ readonly kind: 'facts' } & ProviderCredentialFacts)
|
|
167
|
+
| { readonly kind: 'error'; readonly message: string }
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One provider row in the TUI provider-management panel: the configurable
|
|
171
|
+
* directory entry joined with its settings profile and credential facts.
|
|
172
|
+
* Every mutation below addresses this row, and the caller passes the row back
|
|
173
|
+
* after re-loading so the revision/ref facts are current.
|
|
174
|
+
*/
|
|
175
|
+
export interface ProviderTargetView {
|
|
176
|
+
/** Provider route id (`GenerateOptions.provider`). */
|
|
177
|
+
readonly provider: string
|
|
178
|
+
/** Human-readable provider name. */
|
|
179
|
+
readonly displayName: string
|
|
180
|
+
/** Whether an adapter currently serves this route. */
|
|
181
|
+
readonly active: boolean
|
|
182
|
+
/** User-settings namespace whose section configures this provider; '' when unmanaged. */
|
|
183
|
+
readonly settingsNs: string
|
|
184
|
+
/** Path from that section's root to this provider's profile; [] when the whole section is the profile. */
|
|
185
|
+
readonly settingsPath: readonly string[]
|
|
186
|
+
/** Revision of the owning settings section at load (0 when no namespace resolved). */
|
|
187
|
+
readonly settingsRevision: number
|
|
188
|
+
/** Whether the resolved profile exists (the whole section, or at `settingsPath`). */
|
|
189
|
+
readonly configured: boolean
|
|
190
|
+
/** Whether only the user settings layer carries the profile, so removal restores the base. */
|
|
191
|
+
readonly removable: boolean
|
|
192
|
+
/** The credential reference the resolved profile names, when one does. */
|
|
193
|
+
readonly credentialRef?: string
|
|
194
|
+
/** The conventional reference a save uses for a dormant or ref-less profile. */
|
|
195
|
+
readonly suggestedRef: string
|
|
196
|
+
/** Credential facts, a bounded describe error, or undefined when there is no ref to describe. */
|
|
197
|
+
readonly credential: ProviderCredentialView | undefined
|
|
198
|
+
/** The owning adapter reports this route as hand-declared (absent when it draws no distinction). */
|
|
199
|
+
readonly declared?: boolean
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The resolved provider/settings/credential join. */
|
|
203
|
+
export interface ProviderSettingsDirectory {
|
|
204
|
+
/** Provider rows: configurable-directory order first, active-unmanaged rows after. */
|
|
205
|
+
readonly rows: readonly ProviderTargetView[]
|
|
206
|
+
/** Whether the settings provider accepts writes (mirrors the web page's flag). */
|
|
207
|
+
readonly writable: boolean
|
|
208
|
+
/** Non-fatal join failures (settings/directory reads), for a degradation notice. */
|
|
209
|
+
readonly failures: readonly string[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Events that invalidate the official Models provider/settings/credential join. */
|
|
213
|
+
const PROVIDER_SETTINGS_EVENTS = [
|
|
214
|
+
'credentials/updated',
|
|
215
|
+
'settings/document-updated',
|
|
216
|
+
'llm/adapters-updated',
|
|
217
|
+
] as const
|
|
218
|
+
|
|
219
|
+
/** Subscribe to the same provider-directory invalidations as the official Web Models page. */
|
|
220
|
+
export function subscribeProviderSettings(ctx: Context, listener: () => void): () => void {
|
|
221
|
+
const events = ctx as unknown as ProviderEventsFace
|
|
222
|
+
const disposers = PROVIDER_SETTINGS_EVENTS.map(event => events.on(event, () => listener()))
|
|
223
|
+
return () => {
|
|
224
|
+
for (const dispose of disposers) dispose()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A single-line, bounded error from the provider-management adapter. */
|
|
229
|
+
export class ProviderSettingsError extends Error {
|
|
230
|
+
constructor(message: string) {
|
|
231
|
+
super(message)
|
|
232
|
+
this.name = 'ProviderSettingsError'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------ *
|
|
237
|
+
* Load.
|
|
238
|
+
* ------------------------------------------------------------------ */
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Join the configurable-provider directory, the redacted settings
|
|
242
|
+
* namespaces, and the referenced credentials into panel rows, web-parity:
|
|
243
|
+
* - directory entries merge with `listProviders()` to mark each live or
|
|
244
|
+
* dormant, and routes registered without a directory declaration appear as
|
|
245
|
+
* read-only/unmanaged rows (no settings address);
|
|
246
|
+
* - a whole-section entry is configured whenever its namespace resolves;
|
|
247
|
+
* a path-addressed one only when the profile resolves there;
|
|
248
|
+
* - a row is removable when the user layer alone carries its profile;
|
|
249
|
+
* - only refs named by resolved profiles are described, and a per-ref failure
|
|
250
|
+
* degrades to that row's bounded error instead of losing it.
|
|
251
|
+
* Absent `settings`/`credentials` services are tolerated the same way.
|
|
252
|
+
* @param ctx - context carrying the `llm` service (settings/credentials optional).
|
|
253
|
+
* @returns the resolved directory; empty rows when `llm` is unavailable.
|
|
254
|
+
*/
|
|
255
|
+
export async function loadProviderSettings(ctx: Context): Promise<ProviderSettingsDirectory> {
|
|
256
|
+
const llm = ctx.get('llm') as LlmFace | undefined
|
|
257
|
+
if (llm === undefined) return { rows: [], writable: false, failures: [] }
|
|
258
|
+
// Call listProviders/listConfigurableProviders AS METHODS: destructured off
|
|
259
|
+
// the service they lose `this` and throw on the first read (same lesson as
|
|
260
|
+
// loadModelDirectory).
|
|
261
|
+
const registered = llm.listProviders()
|
|
262
|
+
const failures: string[] = []
|
|
263
|
+
const directoryEntries: Array<{
|
|
264
|
+
provider: string
|
|
265
|
+
displayName: string
|
|
266
|
+
settingsNs: string
|
|
267
|
+
settingsPath: readonly string[]
|
|
268
|
+
declared?: boolean
|
|
269
|
+
}> = []
|
|
270
|
+
if (llm.listConfigurableProviders !== undefined) {
|
|
271
|
+
try {
|
|
272
|
+
directoryEntries.push(...llm.listConfigurableProviders())
|
|
273
|
+
} catch (error) {
|
|
274
|
+
failures.push(`configurable-provider directory failed: ${singleLine(messageOf(error))}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const settings = ctx.get('settings') as SettingsFace | undefined
|
|
278
|
+
let descriptors: readonly SettingsDescriptorFace[] = []
|
|
279
|
+
let writable = false
|
|
280
|
+
if (settings !== undefined) {
|
|
281
|
+
try {
|
|
282
|
+
descriptors = settings.describe({ redactSecrets: true })
|
|
283
|
+
writable = settings.writable === true
|
|
284
|
+
} catch (error) {
|
|
285
|
+
failures.push(`settings describe failed: ${singleLine(messageOf(error))}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const namespaces = new Map(descriptors.map(descriptor => [descriptor.ns, descriptor] as const))
|
|
289
|
+
const active = new Set(registered.map(provider => provider.id))
|
|
290
|
+
const declared = new Set(directoryEntries.map(entry => entry.provider))
|
|
291
|
+
// Directory order first, then registered-but-undeclared routes (web parity:
|
|
292
|
+
// they exist and serve models, just with no settings address).
|
|
293
|
+
const bases: Array<{
|
|
294
|
+
provider: string
|
|
295
|
+
displayName: string
|
|
296
|
+
active: boolean
|
|
297
|
+
settingsNs: string
|
|
298
|
+
settingsPath: readonly string[]
|
|
299
|
+
declared?: boolean
|
|
300
|
+
}> = [
|
|
301
|
+
...directoryEntries.map(entry => ({
|
|
302
|
+
provider: entry.provider,
|
|
303
|
+
displayName: entry.displayName,
|
|
304
|
+
active: active.has(entry.provider),
|
|
305
|
+
settingsNs: entry.settingsNs,
|
|
306
|
+
settingsPath: entry.settingsPath,
|
|
307
|
+
...entry.declared === undefined ? {} : { declared: entry.declared },
|
|
308
|
+
})),
|
|
309
|
+
...registered
|
|
310
|
+
.filter(provider => !declared.has(provider.id))
|
|
311
|
+
.map(provider => ({
|
|
312
|
+
provider: provider.id,
|
|
313
|
+
displayName: provider.name,
|
|
314
|
+
active: true,
|
|
315
|
+
settingsNs: '',
|
|
316
|
+
settingsPath: [] as readonly string[],
|
|
317
|
+
})),
|
|
318
|
+
]
|
|
319
|
+
const rows: Array<Omit<ProviderTargetView, 'credential'>> = bases.map((base) => {
|
|
320
|
+
const namespace = base.settingsNs.length === 0 ? undefined : namespaces.get(base.settingsNs)
|
|
321
|
+
const profile = namespace === undefined
|
|
322
|
+
? undefined
|
|
323
|
+
: base.settingsPath.length === 0
|
|
324
|
+
? namespace.value
|
|
325
|
+
: getPath(namespace.value, base.settingsPath)
|
|
326
|
+
const configured = namespace !== undefined
|
|
327
|
+
&& (base.settingsPath.length === 0 || profile !== undefined)
|
|
328
|
+
const removable = namespace !== undefined
|
|
329
|
+
&& base.settingsPath.length > 0
|
|
330
|
+
&& hasPath(namespace.user, base.settingsPath)
|
|
331
|
+
&& !hasPath(namespace.base, base.settingsPath)
|
|
332
|
+
const credentialRef = profileRefOf(profile)
|
|
333
|
+
return {
|
|
334
|
+
provider: base.provider,
|
|
335
|
+
displayName: base.displayName,
|
|
336
|
+
active: base.active,
|
|
337
|
+
settingsNs: base.settingsNs,
|
|
338
|
+
settingsPath: base.settingsPath,
|
|
339
|
+
settingsRevision: namespace?.revision ?? 0,
|
|
340
|
+
configured,
|
|
341
|
+
removable,
|
|
342
|
+
...credentialRef === undefined ? {} : { credentialRef },
|
|
343
|
+
suggestedRef: deriveCredentialRef(base.provider),
|
|
344
|
+
...base.declared === undefined ? {} : { declared: base.declared },
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
const refs = [...new Set(rows.flatMap(row => row.credentialRef === undefined ? [] : [row.credentialRef]))]
|
|
348
|
+
const credentialViews = new Map<string, ProviderCredentialView>()
|
|
349
|
+
const credentials = ctx.get('credentials') as CredentialsFace | undefined
|
|
350
|
+
if (refs.length > 0) {
|
|
351
|
+
if (credentials === undefined) {
|
|
352
|
+
for (const ref of refs) {
|
|
353
|
+
credentialViews.set(ref, { kind: 'error', message: 'credentials service is unavailable' })
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
await Promise.all(refs.map(async (ref) => {
|
|
357
|
+
try {
|
|
358
|
+
const facts = await credentials.describe(ref)
|
|
359
|
+
credentialViews.set(ref, {
|
|
360
|
+
kind: 'facts',
|
|
361
|
+
configured: facts.configured,
|
|
362
|
+
writable: facts.writable,
|
|
363
|
+
...facts.source === undefined ? {} : { source: facts.source },
|
|
364
|
+
})
|
|
365
|
+
} catch (error) {
|
|
366
|
+
credentialViews.set(ref, { kind: 'error', message: singleLine(messageOf(error)) })
|
|
367
|
+
}
|
|
368
|
+
}))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
rows: rows.map(row => ({
|
|
373
|
+
...row,
|
|
374
|
+
credential: row.credentialRef === undefined
|
|
375
|
+
? undefined
|
|
376
|
+
: credentialViews.get(row.credentialRef) ?? { kind: 'error', message: 'credential describe returned no view' },
|
|
377
|
+
})),
|
|
378
|
+
writable,
|
|
379
|
+
failures,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ------------------------------------------------------------------ *
|
|
384
|
+
* Writes. Official web ordering is kept: settings.mutate first whenever a
|
|
385
|
+
* profile/path or apiKeyEnv must be materialized, then credentials.set; a
|
|
386
|
+
* removal unsets the managed credential before the profile. All operations
|
|
387
|
+
* are idempotent, so re-running one after a partial failure is safe.
|
|
388
|
+
* ------------------------------------------------------------------ */
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Store a provider API key, web-parity: validate with `normalizeApiKey`
|
|
392
|
+
* (single-line, actionable errors that never echo the key), materialize the
|
|
393
|
+
* profile/`apiKeyEnv` through `settings.mutate` first when the resolved
|
|
394
|
+
* profile names no reference (dormant route or ref-less profile), then store
|
|
395
|
+
* under the trusted named ref or the derived conventional ref. An existing
|
|
396
|
+
* whole-section DeepSeek whose resolved profile already names
|
|
397
|
+
* `DEEPSEEK_API_KEY` needs no settings mutation. Env-supplied read-only keys
|
|
398
|
+
* are refused before any service call.
|
|
399
|
+
* @param ctx - context carrying `settings` (when materializing) and `credentials`.
|
|
400
|
+
* @param target - the joined row to write through.
|
|
401
|
+
* @param rawKey - the key exactly as typed; surrounding whitespace is trimmed.
|
|
402
|
+
* @throws {@link ProviderSettingsError} with a single-line, key-free message.
|
|
403
|
+
*/
|
|
404
|
+
export async function saveProviderCredential(ctx: Context, target: ProviderTargetView, rawKey: string): Promise<void> {
|
|
405
|
+
const trimmed = rawKey.trim()
|
|
406
|
+
if (ENV_ASSIGNMENT.test(trimmed) || hasWrappingQuotes(trimmed)) {
|
|
407
|
+
throw new ProviderSettingsError('paste only the API key, without an environment-variable name or wrapping quotes')
|
|
408
|
+
}
|
|
409
|
+
const checked = normalizeApiKey(rawKey)
|
|
410
|
+
if (!checked.ok) {
|
|
411
|
+
throw new ProviderSettingsError(checked.reason === 'empty'
|
|
412
|
+
? 'the API key is empty after trimming surrounding whitespace'
|
|
413
|
+
: 'the API key contains characters an HTTP header cannot carry; type a plain printable-ASCII key')
|
|
414
|
+
}
|
|
415
|
+
if (target.settingsNs.length === 0) {
|
|
416
|
+
throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; configure it in settings.yaml`)
|
|
417
|
+
}
|
|
418
|
+
if (target.credential?.kind === 'facts' && target.credential.writable === false) {
|
|
419
|
+
throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead of overwriting it here`)
|
|
420
|
+
}
|
|
421
|
+
const credentials = ctx.get('credentials') as CredentialsFace | undefined
|
|
422
|
+
if (credentials === undefined) {
|
|
423
|
+
throw new ProviderSettingsError('credentials service is unavailable; cannot store the API key')
|
|
424
|
+
}
|
|
425
|
+
const ref = target.credentialRef ?? deriveCredentialRef(target.provider)
|
|
426
|
+
if (target.credentialRef === undefined) {
|
|
427
|
+
const settings = ctx.get('settings') as SettingsFace | undefined
|
|
428
|
+
if (settings === undefined) {
|
|
429
|
+
throw new ProviderSettingsError('settings service is unavailable; cannot materialize the credential reference')
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
await settings.mutate(target.settingsNs, [{ op: 'set', path: [...target.settingsPath, 'apiKeyEnv'], value: ref }])
|
|
433
|
+
} catch (error) {
|
|
434
|
+
throw new ProviderSettingsError(singleLine(messageOf(error)))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
await credentials.set(ref, checked.value)
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new ProviderSettingsError(credentialWriteMessage(error, checked.value))
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Remove the currently named credential without touching the provider
|
|
446
|
+
* profile. Only the resolved profile's own reference is unset; a dormant or
|
|
447
|
+
* ref-less row (nothing to remove), an already-absent key, and an
|
|
448
|
+
* env-supplied read-only key are rejected safely before any service call.
|
|
449
|
+
* @param ctx - context carrying the `credentials` service.
|
|
450
|
+
* @param target - the joined row whose named credential to unset.
|
|
451
|
+
* @throws {@link ProviderSettingsError} with a single-line, key-free message.
|
|
452
|
+
*/
|
|
453
|
+
export async function unsetProviderCredential(ctx: Context, target: ProviderTargetView): Promise<void> {
|
|
454
|
+
const ref = target.credentialRef
|
|
455
|
+
if (ref === undefined) {
|
|
456
|
+
throw new ProviderSettingsError(`provider "${target.provider}" names no credential reference to remove`)
|
|
457
|
+
}
|
|
458
|
+
const facts = target.credential
|
|
459
|
+
if (facts?.kind === 'facts' && facts.configured === false) {
|
|
460
|
+
throw new ProviderSettingsError(`provider "${target.provider}" has no configured credential to remove`)
|
|
461
|
+
}
|
|
462
|
+
if (facts?.kind === 'facts' && facts.writable === false) {
|
|
463
|
+
throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead`)
|
|
464
|
+
}
|
|
465
|
+
const credentials = ctx.get('credentials') as CredentialsFace | undefined
|
|
466
|
+
if (credentials === undefined) {
|
|
467
|
+
throw new ProviderSettingsError('credentials service is unavailable; cannot remove the API key')
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
await credentials.unset(ref)
|
|
471
|
+
} catch (error) {
|
|
472
|
+
throw new ProviderSettingsError(singleLine(messageOf(error)))
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Remove a user-added provider profile, web-parity: only `removable` rows may
|
|
478
|
+
* be removed; a page-managed credential — the derived ref, configured and
|
|
479
|
+
* writable — is unset first (so a second-step failure leaves the row visible
|
|
480
|
+
* and the operation retryable), then `settings.mutate` unsets
|
|
481
|
+
* `target.settingsPath`. Both steps are idempotent. A hand-named credential
|
|
482
|
+
* ref may be shared elsewhere and is left alone.
|
|
483
|
+
* @param ctx - context carrying `credentials` and `settings`.
|
|
484
|
+
* @param target - the joined row to remove.
|
|
485
|
+
* @throws {@link ProviderSettingsError} with a single-line, key-free message.
|
|
486
|
+
*/
|
|
487
|
+
export async function removeProviderSettings(ctx: Context, target: ProviderTargetView): Promise<void> {
|
|
488
|
+
if (!target.removable) {
|
|
489
|
+
throw new ProviderSettingsError(`provider "${target.provider}" is not removable from the user settings layer`)
|
|
490
|
+
}
|
|
491
|
+
if (target.settingsNs.length === 0) {
|
|
492
|
+
throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings profile to remove`)
|
|
493
|
+
}
|
|
494
|
+
const managedRef = target.credentialRef === target.suggestedRef
|
|
495
|
+
&& target.credential?.kind === 'facts'
|
|
496
|
+
&& target.credential.configured === true
|
|
497
|
+
&& target.credential.writable === true
|
|
498
|
+
? target.credentialRef
|
|
499
|
+
: undefined
|
|
500
|
+
if (managedRef !== undefined) {
|
|
501
|
+
const credentials = ctx.get('credentials') as CredentialsFace | undefined
|
|
502
|
+
if (credentials === undefined) {
|
|
503
|
+
throw new ProviderSettingsError('credentials service is unavailable; cannot remove the managed API key')
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await credentials.unset(managedRef)
|
|
507
|
+
} catch (error) {
|
|
508
|
+
throw new ProviderSettingsError(singleLine(messageOf(error)))
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const settings = ctx.get('settings') as SettingsFace | undefined
|
|
512
|
+
if (settings === undefined) {
|
|
513
|
+
throw new ProviderSettingsError('settings service is unavailable; cannot remove the provider profile')
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
await settings.mutate(target.settingsNs, [{ op: 'unset', path: [...target.settingsPath] }])
|
|
517
|
+
} catch (error) {
|
|
518
|
+
throw new ProviderSettingsError(singleLine(messageOf(error)))
|
|
519
|
+
}
|
|
520
|
+
}
|
package/src/questions.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface PendingQuestion {
|
|
|
28
28
|
resolve(answers: AskUserQuestionAnswer): void
|
|
29
29
|
/** Reject the provider promise as aborted (also used for Esc cancel). */
|
|
30
30
|
reject(error: Error): void
|
|
31
|
+
/** Detach the request's abort listener once the question settles (internal). */
|
|
32
|
+
detachAbort?(): void
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** The pending-question snapshot the renderer subscribes to. */
|
|
@@ -81,11 +83,6 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
81
83
|
service.registerProvider({
|
|
82
84
|
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
83
85
|
return new Promise((resolve, reject) => {
|
|
84
|
-
const pending: PendingQuestion = {
|
|
85
|
-
request,
|
|
86
|
-
resolve,
|
|
87
|
-
reject,
|
|
88
|
-
}
|
|
89
86
|
// Abort settles through the same channel as an Esc cancel: the
|
|
90
87
|
// owning tool/step died, so the answer must not linger.
|
|
91
88
|
const onAbort = (): void => {
|
|
@@ -99,6 +96,17 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
99
96
|
}
|
|
100
97
|
reject(ABORT_ERROR)
|
|
101
98
|
}
|
|
99
|
+
// Detach on every settle so an answered/cancelled question never
|
|
100
|
+
// retains a listener on the owning tool call's signal.
|
|
101
|
+
const detachAbort = (): void => {
|
|
102
|
+
if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
|
|
103
|
+
}
|
|
104
|
+
const pending: PendingQuestion = {
|
|
105
|
+
request,
|
|
106
|
+
resolve,
|
|
107
|
+
reject,
|
|
108
|
+
detachAbort,
|
|
109
|
+
}
|
|
102
110
|
if (request.signal?.aborted === true) {
|
|
103
111
|
reject(ABORT_ERROR)
|
|
104
112
|
return
|
|
@@ -129,6 +137,7 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
129
137
|
if (active !== pending) return
|
|
130
138
|
active = undefined
|
|
131
139
|
set({ pending: undefined })
|
|
140
|
+
pending.detachAbort?.()
|
|
132
141
|
pending.resolve(answers)
|
|
133
142
|
advance()
|
|
134
143
|
},
|
|
@@ -136,6 +145,7 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
|
|
|
136
145
|
if (active !== pending) return
|
|
137
146
|
active = undefined
|
|
138
147
|
set({ pending: undefined })
|
|
148
|
+
pending.detachAbort?.()
|
|
139
149
|
pending.reject(ABORT_ERROR)
|
|
140
150
|
advance()
|
|
141
151
|
},
|
package/src/render/animations.ts
CHANGED
|
@@ -115,19 +115,33 @@ export const DEEPSEEK_WAVE_BANDS: Readonly<Record<DeepseekWaveStyle, Readonly<Re
|
|
|
115
115
|
},
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Extra display time applied to every Codex ignition style. */
|
|
119
|
+
export const DEEPSEEK_WAVE_DURATION_EXTENSION_MS = 200
|
|
120
|
+
|
|
121
|
+
/** Original Codex duration used as the animation's sampling timeline. */
|
|
122
|
+
function deepseekWaveBaseDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
|
|
123
|
+
switch (style) {
|
|
124
|
+
case 'aurora': return tier === 'flash' ? 1300 : 1600
|
|
125
|
+
case 'pulse': return tier === 'flash' ? 900 : 1250
|
|
126
|
+
case 'wave': return tier === 'flash' ? 1000 : 1300
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/**
|
|
119
|
-
* Total
|
|
120
|
-
*
|
|
131
|
+
* Total visible duration: the Codex ignition duration plus 200ms so its motion
|
|
132
|
+
* remains readable in a busy terminal.
|
|
121
133
|
* @param tier - the active wave tier.
|
|
122
134
|
* @param style - the active ignition style.
|
|
123
135
|
* @returns the duration in milliseconds.
|
|
124
136
|
*/
|
|
125
137
|
export function deepseekWaveDuration(tier: DeepseekWaveTier, style: DeepseekWaveStyle = 'wave'): number {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
return deepseekWaveBaseDuration(tier, style) + DEEPSEEK_WAVE_DURATION_EXTENSION_MS
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Map the extended display timeline back onto the original Codex samples. */
|
|
142
|
+
function deepseekWaveSampleElapsedMs(tick: number, tier: DeepseekWaveTier, style: DeepseekWaveStyle): number {
|
|
143
|
+
const base = deepseekWaveBaseDuration(tier, style)
|
|
144
|
+
return tick * DEEPSEEK_WAVE_TICK_MS * base / deepseekWaveDuration(tier, style)
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
/**
|
|
@@ -282,8 +296,8 @@ export function deepseekWaveColumnBg(
|
|
|
282
296
|
hues: readonly [RgbTriple, RgbTriple, RgbTriple],
|
|
283
297
|
base: RgbTriple,
|
|
284
298
|
): RgbTriple | null {
|
|
285
|
-
const total =
|
|
286
|
-
const elapsed = (tick
|
|
299
|
+
const total = deepseekWaveBaseDuration(tier, style) / 1000
|
|
300
|
+
const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
|
|
287
301
|
const fade = style === 'aurora' ? envelope(elapsed, total, 0.25, 0.40) : 1
|
|
288
302
|
const weights = [0, 0, 0]
|
|
289
303
|
for (const band of DEEPSEEK_WAVE_BANDS[style][tier]) {
|
|
@@ -311,14 +325,14 @@ export function deepseekWaveColumnBg(
|
|
|
311
325
|
}
|
|
312
326
|
|
|
313
327
|
/**
|
|
314
|
-
* The sparkle glyph for a tick — Codex `spark_frame
|
|
315
|
-
*
|
|
316
|
-
*
|
|
328
|
+
* The sparkle glyph for a tick — Codex `spark_frame`, sampled on the same
|
|
329
|
+
* proportionally slowed DeepSeek Wave timeline as the composer background.
|
|
330
|
+
* The Ink layer still must skip occupied cells.
|
|
317
331
|
* @param tick - wave frame at DEEPSEEK_WAVE_TICK_MS.
|
|
318
|
-
* @returns the sparkle glyph, or null outside the
|
|
332
|
+
* @returns the sparkle glyph, or null outside the stretched tail window.
|
|
319
333
|
*/
|
|
320
334
|
export function deepseekWaveSpark(tick: number): string | null {
|
|
321
|
-
const elapsed = tick
|
|
335
|
+
const elapsed = deepseekWaveSampleElapsedMs(tick, 'deepseek', 'wave')
|
|
322
336
|
if (elapsed < SPARK_START_MS) return null
|
|
323
337
|
const frame = Math.floor((elapsed - SPARK_START_MS) / SPARK_FRAME_MS)
|
|
324
338
|
return SPARK_GLYPHS[frame] ?? null
|
|
@@ -342,8 +356,8 @@ export function deepseekWaveBorderColor(
|
|
|
342
356
|
hues: readonly [RgbTriple, RgbTriple, RgbTriple],
|
|
343
357
|
dim: RgbTriple,
|
|
344
358
|
): RgbTriple {
|
|
345
|
-
const total =
|
|
346
|
-
const elapsed = (tick
|
|
359
|
+
const total = deepseekWaveBaseDuration(tier, style) / 1000
|
|
360
|
+
const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
|
|
347
361
|
// The border stays visibly on the tier accent for the whole sweep (a
|
|
348
362
|
// floor keeps it glowing, not just cresting mid-wave): it ramps in as the
|
|
349
363
|
// first band launches and relaxes after the last crest passes.
|
|
@@ -361,8 +375,8 @@ export function deepseekWaveBorderColor(
|
|
|
361
375
|
* @returns true while the wordmark should be visible.
|
|
362
376
|
*/
|
|
363
377
|
export function deepseekWaveWordVisible(tick: number, tier: DeepseekWaveTier, style: DeepseekWaveStyle = 'wave'): boolean {
|
|
364
|
-
const total =
|
|
365
|
-
const elapsed = (tick
|
|
378
|
+
const total = deepseekWaveBaseDuration(tier, style) / 1000
|
|
379
|
+
const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1000
|
|
366
380
|
return envelope(elapsed, total, total * 0.2, total * 0.35) > 0.25
|
|
367
381
|
}
|
|
368
382
|
|