dsh-code 1.0.1 → 1.0.3

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 (44) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +21 -13
  3. package/lib/index.mjs +1902 -1092
  4. package/lib/types/app.d.ts +4 -13
  5. package/lib/types/attachments.d.ts +1 -1
  6. package/lib/types/editor-keys.d.ts +105 -0
  7. package/lib/types/git-workflow.d.ts +6 -2
  8. package/lib/types/keyboard.d.ts +31 -0
  9. package/lib/types/mentions.d.ts +2 -0
  10. package/lib/types/model-capabilities.d.ts +82 -0
  11. package/lib/types/provider-settings.d.ts +7 -0
  12. package/lib/types/render/animations.d.ts +27 -11
  13. package/lib/types/render/editor.d.ts +32 -7
  14. package/lib/types/render/lines.d.ts +26 -1
  15. package/lib/types/render/markdown.d.ts +1 -1
  16. package/lib/types/render/projection.d.ts +15 -1
  17. package/lib/types/render/text.d.ts +15 -9
  18. package/lib/types/render/width.d.ts +29 -0
  19. package/lib/types/session-directory.d.ts +27 -0
  20. package/lib/types/settings-file.d.ts +33 -0
  21. package/lib/types/store.d.ts +10 -0
  22. package/lib/types/subagents.d.ts +13 -3
  23. package/package.json +159 -159
  24. package/src/app.ts +920 -764
  25. package/src/attachments.ts +7 -0
  26. package/src/editor-keys.ts +371 -0
  27. package/src/git-workflow.ts +10 -6
  28. package/src/index.ts +1637 -1523
  29. package/src/internals.ts +26 -9
  30. package/src/keyboard.ts +131 -7
  31. package/src/mentions.ts +6 -1
  32. package/src/model-capabilities.ts +318 -0
  33. package/src/provider-settings.ts +16 -0
  34. package/src/render/animations.ts +64 -17
  35. package/src/render/editor.ts +125 -25
  36. package/src/render/lines.ts +403 -342
  37. package/src/render/markdown.ts +4 -7
  38. package/src/render/projection.ts +63 -40
  39. package/src/render/text.ts +152 -150
  40. package/src/render/width.ts +189 -0
  41. package/src/session-directory.ts +56 -0
  42. package/src/settings-file.ts +56 -0
  43. package/src/store.ts +26 -7
  44. package/src/subagents.ts +39 -6
package/src/internals.ts CHANGED
@@ -8,7 +8,16 @@
8
8
 
9
9
  import { render } from 'ink'
10
10
  import type { ReactElement } from 'react'
11
- import { BRACKETED_PASTE_DISABLE, BRACKETED_PASTE_ENABLE, KEYBOARD_ENHANCE_DISABLE, KEYBOARD_ENHANCE_ENABLE } from './keyboard.ts'
11
+ import {
12
+ BRACKETED_PASTE_DISABLE,
13
+ BRACKETED_PASTE_ENABLE,
14
+ KEYBOARD_ENHANCE_DISABLE,
15
+ KEYBOARD_ENHANCE_ENABLE,
16
+ TERMINAL_FOCUS_REPORT_DISABLE,
17
+ TERMINAL_FOCUS_REPORT_ENABLE,
18
+ isVsCodeTerminalEnv,
19
+ shouldEnableKeyboardEnhancement,
20
+ } from './keyboard.ts'
12
21
 
13
22
  /** A mounted terminal app instance; the runner owns unmount ordering. */
14
23
  export interface TuiMount {
@@ -29,11 +38,16 @@ export const internals: {
29
38
  stderr: { write(chunk: string): unknown }
30
39
  } = {
31
40
  mount: (element: ReactElement): TuiMount => {
32
- // Codex keyboard_modes parity: push the kitty keyboard protocol so
33
- // modified controls remain distinguishable, and enable bracketed paste so
34
- // pasted newlines insert instead of submitting. Both are inert in
35
- // terminals without support.
36
- process.stdout.write(KEYBOARD_ENHANCE_ENABLE + BRACKETED_PASTE_ENABLE)
41
+ // VS Code's integrated terminal can route Tab to the workbench when Kitty
42
+ // enhancement is enabled. Keep bracketed paste everywhere, but only push
43
+ // the keyboard protocol on terminals that can safely own those key events.
44
+ const keyboardEnhanced = shouldEnableKeyboardEnhancement()
45
+ const focusReporting = isVsCodeTerminalEnv()
46
+ process.stdout.write(
47
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_ENABLE : '')
48
+ + BRACKETED_PASTE_ENABLE
49
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
50
+ )
37
51
  // App owns Ctrl+C's deliberate three-state contract (interrupt, clear
38
52
  // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
39
53
  // normalized control byte first, unmount only its renderer, and leave the
@@ -45,9 +59,12 @@ export const internals: {
45
59
  },
46
60
  unmount(): void {
47
61
  instance.unmount()
48
- // Pop the stack so the parent shell does not inherit enhanced key
49
- // reporting (Codex resets even harder on forced exit).
50
- process.stdout.write(KEYBOARD_ENHANCE_DISABLE + BRACKETED_PASTE_DISABLE)
62
+ // Pop only a stack this mount pushed, then disable bracketed paste.
63
+ process.stdout.write(
64
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
65
+ + BRACKETED_PASTE_DISABLE
66
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
67
+ )
51
68
  },
52
69
  }
53
70
  },
package/src/keyboard.ts CHANGED
@@ -20,13 +20,56 @@
20
20
  export const KEYBOARD_ENHANCE_ENABLE = '\x1b[>4;0m\x1b[>5u'
21
21
 
22
22
  /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
23
- export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
-
25
- /** Enable bracketed paste reporting. */
26
- export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
23
+ export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
+
25
+ /** Explicit environment overrides for terminal keyboard enhancement. */
26
+ export const DSH_DISABLE_KEYBOARD_ENHANCEMENT = 'DSH_DISABLE_KEYBOARD_ENHANCEMENT'
27
+ export const DSH_ENABLE_KEYBOARD_ENHANCEMENT = 'DSH_ENABLE_KEYBOARD_ENHANCEMENT'
28
+
29
+ function parseBooleanEnv(value: string | undefined): boolean | undefined {
30
+ if (value === undefined) return undefined
31
+ const normalized = value.trim().toLowerCase()
32
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes') return true
33
+ if (normalized === '0' || normalized === 'false' || normalized === 'no') return false
34
+ return undefined
35
+ }
36
+
37
+ /** True when the process is running inside the VS Code integrated terminal. */
38
+ export function isVsCodeTerminalEnv(env: NodeJS.ProcessEnv = process.env): boolean {
39
+ return env.TERM_PROGRAM?.trim().toLowerCase() === 'vscode' || env.VSCODE_INJECTION === '1'
40
+ }
41
+
42
+ /** Whether to push Kitty keyboard enhancement for the current terminal. */
43
+ export function shouldEnableKeyboardEnhancement(env: NodeJS.ProcessEnv = process.env): boolean {
44
+ const explicitDisable = parseBooleanEnv(env[DSH_DISABLE_KEYBOARD_ENHANCEMENT])
45
+ if (explicitDisable === true) return false
46
+ const explicitEnable = parseBooleanEnv(env[DSH_ENABLE_KEYBOARD_ENHANCEMENT])
47
+ if (explicitEnable !== undefined) return explicitEnable
48
+ return !isVsCodeTerminalEnv(env)
49
+ }
27
50
 
28
- /** Disable bracketed paste reporting. */
29
- export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
51
+ /** Enable bracketed paste reporting. */
52
+ export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
53
+
54
+ /** Disable bracketed paste reporting. */
55
+ export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l'
56
+
57
+ /** Enable terminal focus-in/focus-out reporting (xterm focus protocol). */
58
+ export const TERMINAL_FOCUS_REPORT_ENABLE = '\x1b[?1004h'
59
+
60
+ /** Disable terminal focus-in/focus-out reporting. */
61
+ export const TERMINAL_FOCUS_REPORT_DISABLE = '\x1b[?1004l'
62
+
63
+ /**
64
+ * Remove xterm focus reports from one input chunk and update the caller's
65
+ * focus state. Focus reports are terminal protocol, not composer text.
66
+ */
67
+ export function stripTerminalFocusEvents(chunk: string, onFocus: (focused: boolean) => void): string {
68
+ return chunk.replace(/\x1b\[(I|O)/gu, (_whole, event: string) => {
69
+ onFocus(event === 'I')
70
+ return ''
71
+ })
72
+ }
30
73
 
31
74
  /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
32
75
  export const PASTE_START_MARKER = '[200~'
@@ -123,4 +166,85 @@ export function normalizeKeyboardChunk(chunk: string): string {
123
166
  })
124
167
  return legacy ?? whole
125
168
  })
126
- }
169
+ }
170
+
171
+ /** Editor actions Ink cannot distinguish reliably when terminal bytes batch. */
172
+ export type RawEditorToken =
173
+ | { readonly kind: 'text'; readonly text: string }
174
+ | { readonly kind: 'home' | 'end' | 'delete-backward' | 'delete-word-backward' | 'delete-forward' | 'delete-word-forward' }
175
+
176
+ const HOME_SEQUENCES = ['\x1b[H', '\x1b[1~', '\x1b[7~', '\x1bOH'] as const
177
+ const END_SEQUENCES = ['\x1b[F', '\x1b[4~', '\x1b[8~', '\x1bOF'] as const
178
+
179
+ /** Parse one CSI functional-key sequence at `offset`. */
180
+ function functionalToken(chunk: string, offset: number): { token: RawEditorToken; length: number } | undefined {
181
+ const tail = chunk.slice(offset)
182
+ for (const sequence of HOME_SEQUENCES) {
183
+ if (tail.startsWith(sequence)) return { token: { kind: 'home' }, length: sequence.length }
184
+ }
185
+ for (const sequence of END_SEQUENCES) {
186
+ if (tail.startsWith(sequence)) return { token: { kind: 'end' }, length: sequence.length }
187
+ }
188
+ const modifiedHome = /^\x1b\[1;(\d+)H/u.exec(tail)
189
+ if (modifiedHome !== null) return { token: { kind: 'home' }, length: modifiedHome[0].length }
190
+ const modifiedEnd = /^\x1b\[1;(\d+)F/u.exec(tail)
191
+ if (modifiedEnd !== null) return { token: { kind: 'end' }, length: modifiedEnd[0].length }
192
+ const modifiedDelete = /^\x1b\[3(?:;(\d+))?~/u.exec(tail)
193
+ if (modifiedDelete !== null) {
194
+ const modifiers = Number.parseInt(modifiedDelete[1] ?? '1', 10) - 1
195
+ const byWord = (modifiers & 2) !== 0 || (modifiers & 4) !== 0
196
+ return {
197
+ token: { kind: byWord ? 'delete-word-forward' : 'delete-forward' },
198
+ length: modifiedDelete[0].length,
199
+ }
200
+ }
201
+ return undefined
202
+ }
203
+
204
+ /**
205
+ * Tokenize a stdin chunk containing at least one editor-only key. Ink calls
206
+ * `useInput` once for a pasted/batched chunk, so preserving each action here
207
+ * prevents repeated Backspace/Home/End/Delete presses from collapsing into
208
+ * one blurred key event. Unknown escape sequences return undefined and stay
209
+ * under Ink's ownership.
210
+ */
211
+ export function tokenizeRawEditorChunk(chunk: string): readonly RawEditorToken[] | undefined {
212
+ const tokens: RawEditorToken[] = []
213
+ let text = ''
214
+ let special = false
215
+ const flushText = (): void => {
216
+ if (text === '') return
217
+ tokens.push({ kind: 'text', text })
218
+ text = ''
219
+ }
220
+ for (let offset = 0; offset < chunk.length;) {
221
+ if (chunk.startsWith('\x1b\x7f', offset) || chunk.startsWith('\x1b\b', offset)) {
222
+ flushText()
223
+ tokens.push({ kind: 'delete-word-backward' })
224
+ special = true
225
+ offset += 2
226
+ continue
227
+ }
228
+ const functional = functionalToken(chunk, offset)
229
+ if (functional !== undefined) {
230
+ flushText()
231
+ tokens.push(functional.token)
232
+ special = true
233
+ offset += functional.length
234
+ continue
235
+ }
236
+ const char = chunk[offset]!
237
+ if (char === '\x7f' || char === '\b') {
238
+ flushText()
239
+ tokens.push({ kind: 'delete-backward' })
240
+ special = true
241
+ offset += 1
242
+ continue
243
+ }
244
+ if (char === '\x1b') return undefined
245
+ text += char
246
+ offset += 1
247
+ }
248
+ flushText()
249
+ return special ? tokens : undefined
250
+ }
package/src/mentions.ts CHANGED
@@ -75,6 +75,11 @@ interface FileReferenceServiceLike {
75
75
  /** Menu cap on file rows; the service owns ranking and default rows. */
76
76
  const MAX_FILE_ROWS = 20
77
77
 
78
+ /** Whether a mention token is already navigating a filesystem path. */
79
+ export function isPathLikeMentionQuery(query: string): boolean {
80
+ return /[\\/]/u.test(query)
81
+ }
82
+
78
83
  /** The mention API the input editor and the runner share. */
79
84
  export interface MentionsApi {
80
85
  /** Ranked menu candidates for the typed `@` query. */
@@ -136,7 +141,7 @@ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: stri
136
141
  : agent === undefined
137
142
  ? preSessionFiles(needle, signal).catch(() => [] as readonly ServiceFileCandidate[])
138
143
  : Promise.resolve([] as readonly ServiceFileCandidate[]),
139
- sessionCapable && needle !== '' && agent !== undefined
144
+ sessionCapable && needle !== '' && !isPathLikeMentionQuery(needle) && agent !== undefined
140
145
  ? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
141
146
  : Promise.resolve([] as readonly SessionReferenceCandidate[]),
142
147
  ])
@@ -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
+ }
@@ -138,11 +138,16 @@ function configurationOf(profile: unknown): ProviderConfiguration {
138
138
  if (typeof value !== 'object' || value === null) return []
139
139
  const entry = value as Record<string, unknown>
140
140
  if (typeof entry.id !== 'string' || entry.id.trim() === '') return []
141
+ // Fields the editor does not understand ride along untouched: dropping
142
+ // them here would let the next save strip hand-written reasoningEfforts
143
+ // or compat declarations out of the stored profile.
144
+ const { id: _id, name: _name, contextWindow: _contextWindow, maxTokens: _maxTokens, ...extras } = entry
141
145
  return [{
142
146
  id: entry.id,
143
147
  ...(typeof entry.name === 'string' && entry.name.trim() !== '' ? { name: entry.name } : {}),
144
148
  ...(typeof entry.contextWindow === 'number' && Number.isFinite(entry.contextWindow) ? { contextWindow: entry.contextWindow } : {}),
145
149
  ...(typeof entry.maxTokens === 'number' && Number.isFinite(entry.maxTokens) ? { maxTokens: entry.maxTokens } : {}),
150
+ ...Object.keys(extras).length > 0 ? { extras } : {},
146
151
  }]
147
152
  })
148
153
  return {
@@ -194,6 +199,13 @@ export interface ProviderModelSettings {
194
199
  readonly name?: string
195
200
  readonly contextWindow?: number
196
201
  readonly maxTokens?: number
202
+ /**
203
+ * Remaining entry fields the editor does not model (`reasoningEfforts`,
204
+ * `compat`, `input`, …), carried verbatim so a save preserves them.
205
+ * Populated by {@link loadProviderSettings}; never contains the four
206
+ * modelled keys.
207
+ */
208
+ readonly extras?: Readonly<Record<string, unknown>>
197
209
  }
198
210
 
199
211
  /** The small, portable subset of a provider profile the terminal edits. */
@@ -514,6 +526,10 @@ export async function saveProviderConfiguration(
514
526
  }
515
527
  return {
516
528
  id,
529
+ // Editor-invisible fields ride along after the id; the modelled keys
530
+ // spread last so a panel edit (or clear) always wins over a carried
531
+ // value. extras never contains those keys — see configurationOf.
532
+ ...model.extras,
517
533
  ...(model.name === undefined || model.name.trim() === '' ? {} : { name: model.name.trim() }),
518
534
  ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
519
535
  ...(model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),