dsh-code 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
@@ -1,371 +1,371 @@
1
- /**
2
- * VS Code-family terminal keybinding repair. VS Code hands Ctrl+R to the
3
- * workbench (Open Recent) even while an integrated terminal owns focus, so
4
- * the reasoning-fold key never reaches the TUI. Workspace-scoped keybindings
5
- * do not exist, so the fix is one user-level keybindings.json rule that
6
- * forwards the raw Ctrl byte via sendSequence under terminalFocus. This
7
- * module detects the hosting editor variant, resolves its user
8
- * keybindings.json, and merges the rule idempotently; pure merge/detect
9
- * helpers are separated from the fs orchestration so both stay testable.
10
- * @module @deepseek-ai/dsh-code/editor-keys
11
- */
12
-
13
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
14
- import { basename, dirname, join } from 'node:path'
15
-
16
- /** Integrated-terminal editor variants this module can repair. */
17
- export type EditorTerminalFamily = 'vscode' | 'cursor' | 'vscodium' | 'windsurf'
18
-
19
- /** Install directory names per family ('vscode' covers stable and Insiders). */
20
- const FAMILY_DIRS: Record<EditorTerminalFamily, readonly string[]> = {
21
- vscode: ['Code', 'Code - Insiders'],
22
- cursor: ['Cursor'],
23
- vscodium: ['VSCodium'],
24
- windsurf: ['Windsurf'],
25
- }
26
-
27
- /**
28
- * Detect the editor hosting this integrated terminal.
29
- * @param env - process environment (TERM_PROGRAM decides; case/whitespace tolerant).
30
- * @returns the family, or undefined outside VS Code-family terminals.
31
- */
32
- export function detectEditorTerminalFamily(env: NodeJS.ProcessEnv = process.env): EditorTerminalFamily | undefined {
33
- const program = env.TERM_PROGRAM?.trim().toLowerCase()
34
- if (program === 'vscode' || program === 'cursor' || program === 'vscodium' || program === 'windsurf') return program
35
- return undefined
36
- }
37
-
38
- /**
39
- * Whether the pty is hosted away from the editor UI (ssh/container/tunnel).
40
- * Keybindings live on the client machine, so a remote session must never
41
- * write them server-side.
42
- */
43
- export function isRemoteTerminalEnv(env: NodeJS.ProcessEnv = process.env): boolean {
44
- return (env.VSCODE_IPC_HOOK_CLI ?? '').trim() !== ''
45
- }
46
-
47
- /** Filesystem anchors used to resolve editor config paths (injectable for tests). */
48
- export interface EditorPathContext {
49
- /** User home directory. */
50
- homedir: string
51
- /** %APPDATA% on Windows; only read for win32 resolution. */
52
- appdata?: string
53
- /** Node platform qualifier. */
54
- platform: NodeJS.Platform
55
- }
56
-
57
- /**
58
- * Resolve the user keybindings.json candidates for one family, most likely
59
- * install first. Only paths that exist on disk are repaired.
60
- */
61
- export function editorKeybindingCandidates(family: EditorTerminalFamily, context: EditorPathContext): readonly string[] {
62
- return FAMILY_DIRS[family].map(dir => {
63
- if (context.platform === 'win32') {
64
- return join(context.appdata ?? join(context.homedir, 'AppData', 'Roaming'), dir, 'User', 'keybindings.json')
65
- }
66
- if (context.platform === 'darwin') {
67
- return join(context.homedir, 'Library', 'Application Support', dir, 'User', 'keybindings.json')
68
- }
69
- return join(context.homedir, '.config', dir, 'User', 'keybindings.json')
70
- })
71
- }
72
-
73
- /** The one workbench rule that hands Ctrl+R to the focused terminal. */
74
- export const CTRL_R_PASSTHROUGH_RULE = {
75
- key: 'ctrl+r',
76
- command: 'workbench.action.terminal.sendSequence',
77
- args: { text: '\u0012' },
78
- when: 'terminalFocus',
79
- } as const
80
-
81
- /** Serialized rule block (4-space indent, the editors' default style). */
82
- const RULE_BLOCK = [
83
- ' {',
84
- ' "key": "ctrl+r",',
85
- ' "command": "workbench.action.terminal.sendSequence",',
86
- ' "args": { "text": "\\u0012" },',
87
- ' "when": "terminalFocus"',
88
- ' }',
89
- ].join('\n')
90
-
91
- /** Fresh-file template carrying the editors' standard header comment. */
92
- const KEYBINDINGS_TEMPLATE = '// Place your key bindings in this file to override the defaults\n[\n' + RULE_BLOCK + '\n]\n'
93
-
94
- /** Whether one parsed keybindings entry already forwards Ctrl+R to the terminal. */
95
- function isCtrlRPassthroughEntry(entry: unknown): boolean {
96
- if (typeof entry !== 'object' || entry === null) return false
97
- const record = entry as Record<string, unknown>
98
- if (record.command !== CTRL_R_PASSTHROUGH_RULE.command) return false
99
- if (typeof record.key !== 'string' || record.key.trim().toLowerCase() !== CTRL_R_PASSTHROUGH_RULE.key) return false
100
- const args = record.args
101
- if (typeof args !== 'object' || args === null) return false
102
- const text = (args as Record<string, unknown>).text
103
- if (typeof text !== 'string' || !text.includes('\u0012')) return false
104
- return typeof record.when === 'string' && /\bterminalFocus\b/u.test(record.when)
105
- }
106
-
107
- /**
108
- * Remove // and block comments from one JSONC document. Double-quoted strings
109
- * survive untouched, so comment markers inside string values are preserved.
110
- */
111
- export function stripJsoncComments(text: string): string {
112
- let out = ''
113
- let index = 0
114
- let inString = false
115
- while (index < text.length) {
116
- const char = text[index]!
117
- if (inString) {
118
- out += char
119
- if (char === '\\' && index + 1 < text.length) {
120
- out += text[index + 1]!
121
- index += 2
122
- continue
123
- }
124
- if (char === '"') inString = false
125
- index += 1
126
- continue
127
- }
128
- if (char === '"') {
129
- inString = true
130
- out += char
131
- index += 1
132
- continue
133
- }
134
- if (char === '/' && text[index + 1] === '/') {
135
- while (index < text.length && text[index] !== '\n') index += 1
136
- continue
137
- }
138
- if (char === '/' && text[index + 1] === '*') {
139
- index += 2
140
- while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
141
- if (text[index] === '\n') out += '\n'
142
- index += 1
143
- }
144
- index += 2
145
- continue
146
- }
147
- out += char
148
- index += 1
149
- }
150
- return out
151
- }
152
-
153
- /** Drop commas directly before a closing bracket (string-aware). */
154
- function removeTrailingCommas(text: string): string {
155
- let out = ''
156
- let index = 0
157
- let inString = false
158
- while (index < text.length) {
159
- const char = text[index]!
160
- if (inString) {
161
- out += char
162
- if (char === '\\' && index + 1 < text.length) {
163
- out += text[index + 1]!
164
- index += 2
165
- continue
166
- }
167
- if (char === '"') inString = false
168
- index += 1
169
- continue
170
- }
171
- if (char === '"') {
172
- inString = true
173
- out += char
174
- index += 1
175
- continue
176
- }
177
- if (char === ',') {
178
- let peek = index + 1
179
- while (peek < text.length && (text[peek] === ' ' || text[peek] === '\t' || text[peek] === '\n' || text[peek] === '\r')) peek += 1
180
- const next = text[peek]
181
- if (next === '}' || next === ']') {
182
- index += 1
183
- continue
184
- }
185
- }
186
- out += char
187
- index += 1
188
- }
189
- return out
190
- }
191
-
192
- /** Parse one JSONC document; trailing commas are tolerated. */
193
- export function parseJsonc(text: string): unknown {
194
- return JSON.parse(removeTrailingCommas(stripJsoncComments(text)))
195
- }
196
-
197
- /** Raw index of the top-level rule array's opening bracket, or -1 when absent. */
198
- function rawOpenBracketIndex(text: string): number {
199
- let index = 0
200
- let inString = false
201
- while (index < text.length) {
202
- const char = text[index]!
203
- if (inString) {
204
- if (char === '\\') {
205
- index += 2
206
- continue
207
- }
208
- if (char === '"') inString = false
209
- index += 1
210
- continue
211
- }
212
- if (char === '"') {
213
- inString = true
214
- index += 1
215
- continue
216
- }
217
- if (char === '/' && text[index + 1] === '/') {
218
- while (index < text.length && text[index] !== '\n') index += 1
219
- continue
220
- }
221
- if (char === '/' && text[index + 1] === '*') {
222
- index += 2
223
- while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) index += 1
224
- index += 2
225
- continue
226
- }
227
- if (char === '[') return index
228
- index += 1
229
- }
230
- return -1
231
- }
232
-
233
- /** Outcome of merging the passthrough rule into one keybindings document. */
234
- export type KeybindingsMerge =
235
- | { readonly status: 'present' }
236
- | { readonly status: 'updated'; readonly text: string }
237
- | { readonly status: 'created'; readonly text: string }
238
-
239
- /**
240
- * Merge the Ctrl+R passthrough into one keybindings.json document. The raw
241
- * text is preserved verbatim (comments included); the rule is inserted right
242
- * after the array opener so it cannot be shadowed by later conflicting user
243
- * rules. Missing files resolve to a fresh template.
244
- * @throws when the document does not carry a rule array.
245
- */
246
- export function mergeCtrlRPassthrough(raw: string | undefined): KeybindingsMerge {
247
- if (raw === undefined) return { status: 'created', text: KEYBINDINGS_TEMPLATE }
248
- const parsed = parseJsonc(raw)
249
- if (!Array.isArray(parsed)) throw new Error('keybindings.json does not contain a rule array')
250
- if (parsed.some(isCtrlRPassthroughEntry)) return { status: 'present' }
251
- const open = rawOpenBracketIndex(raw)
252
- if (open === -1) throw new Error('keybindings.json does not contain a rule array')
253
- const insert = parsed.length > 0 ? '\n' + RULE_BLOCK + ',' : '\n' + RULE_BLOCK
254
- return { status: 'updated', text: raw.slice(0, open + 1) + insert + raw.slice(open + 1) }
255
- }
256
-
257
- /** User-level marker file content: the startup hint fires at most once per install. */
258
- export interface EditorKeysFlag {
259
- hintShownAt?: string
260
- }
261
-
262
- /** Parse one flag file snapshot; missing or corrupt content degrades to unshown. */
263
- export function parseEditorKeysFlag(raw: string | undefined): EditorKeysFlag {
264
- if (raw === undefined) return {}
265
- try {
266
- const parsed: unknown = JSON.parse(raw)
267
- if (typeof parsed !== 'object' || parsed === null) return {}
268
- const shown = (parsed as Record<string, unknown>).hintShownAt
269
- return typeof shown === 'string' ? { hintShownAt: shown } : {}
270
- } catch {
271
- return {}
272
- }
273
- }
274
-
275
- /** Persist the shown marker; best-effort, the hint is cosmetic and never a gate. */
276
- export async function markEditorKeysHintShown(path: string): Promise<void> {
277
- await mkdir(dirname(path), { recursive: true })
278
- await writeFile(path, JSON.stringify({ hintShownAt: new Date().toISOString() }, null, 2) + '\n', 'utf8')
279
- }
280
-
281
- /** Inputs shared by the apply and startup-hint flows. */
282
- export interface EditorKeysEnv {
283
- /** Process environment (TERM_PROGRAM / VSCODE_IPC_HOOK_CLI). */
284
- env: NodeJS.ProcessEnv
285
- /** Filesystem anchors for editor config resolution. */
286
- paths: EditorPathContext
287
- /** Absolute path of the one-shot hint marker under the DSH home. */
288
- flagPath: string
289
- }
290
-
291
- /** Read one file if it exists; undefined otherwise (ENOENT and unreadable both). */
292
- async function readIfPresent(path: string): Promise<string | undefined> {
293
- try {
294
- return await readFile(path, 'utf8')
295
- } catch {
296
- return undefined
297
- }
298
- }
299
-
300
- /**
301
- * Apply the Ctrl+R passthrough to every local keybindings.json of the hosting
302
- * editor and mark the startup hint shown. Existing files get a .dsh-bak
303
- * backup before the first write.
304
- * @returns a one-line user-facing summary.
305
- * @throws with an actionable message when the environment cannot be repaired.
306
- */
307
- export async function applyCtrlRPassthrough({ env, paths, flagPath }: EditorKeysEnv): Promise<string> {
308
- const family = detectEditorTerminalFamily(env)
309
- if (family === undefined) {
310
- throw new Error(
311
- 'not a VS Code-family terminal (TERM_PROGRAM=' + (env.TERM_PROGRAM?.trim() || 'unset') + '); add the ctrl+r rule to keybindings.json manually',
312
- )
313
- }
314
- if (isRemoteTerminalEnv(env)) {
315
- throw new Error('remote terminal detected; apply the keybindings rule on the local machine instead')
316
- }
317
- const candidates = editorKeybindingCandidates(family, paths)
318
- const targets: string[] = []
319
- for (const candidate of candidates) {
320
- if (await readIfPresent(candidate) !== undefined) targets.push(candidate)
321
- }
322
- if (targets.length === 0) targets.push(candidates[0]!)
323
- const updated: string[] = []
324
- const present: string[] = []
325
- for (const target of targets) {
326
- const raw = await readIfPresent(target)
327
- const merge = mergeCtrlRPassthrough(raw)
328
- if (merge.status === 'present') {
329
- present.push(target)
330
- continue
331
- }
332
- await mkdir(dirname(target), { recursive: true })
333
- if (raw !== undefined) await writeFile(target + '.dsh-bak', raw, 'utf8')
334
- await writeFile(target, merge.text, 'utf8')
335
- updated.push(target)
336
- }
337
- await markEditorKeysHintShown(flagPath).catch(() => {})
338
- const label = (target: string): string => basename(dirname(dirname(target)))
339
- if (updated.length === 0) {
340
- return 'ctrl+r passthrough already configured in ' + present.map(label).join(', ')
341
- }
342
- return 'ctrl+r passthrough written to ' + updated.map(label).join(', ') + ' — effective immediately'
343
- }
344
-
345
- /**
346
- * Resolve the one-shot startup hint for VS Code-family terminals. Fires at
347
- * most once per install (flag file), never when the passthrough rule is
348
- * already present, and never in remote ptys where the repair cannot run.
349
- * @returns the hint line, or undefined to stay silent.
350
- */
351
- export async function resolveEditorKeysStartupHint({ env, paths, flagPath }: EditorKeysEnv): Promise<string | undefined> {
352
- const flag = parseEditorKeysFlag(await readIfPresent(flagPath))
353
- if (flag.hintShownAt !== undefined) return undefined
354
- const family = detectEditorTerminalFamily(env)
355
- if (family === undefined || isRemoteTerminalEnv(env)) return undefined
356
- for (const candidate of editorKeybindingCandidates(family, paths)) {
357
- const raw = await readIfPresent(candidate)
358
- if (raw === undefined) continue
359
- try {
360
- const parsed: unknown = parseJsonc(raw)
361
- if (Array.isArray(parsed) && parsed.some(isCtrlRPassthroughEntry)) {
362
- await markEditorKeysHintShown(flagPath).catch(() => {})
363
- return undefined
364
- }
365
- } catch {
366
- // An unparseable config still deserves the hint; apply reports the error.
367
- }
368
- }
369
- await markEditorKeysHintShown(flagPath).catch(() => {})
370
- return 'run /vscode-keys to pass ctrl+r through this editor (alt+r works meanwhile)'
371
- }
1
+ /**
2
+ * VS Code-family terminal keybinding repair. VS Code hands Ctrl+R to the
3
+ * workbench (Open Recent) even while an integrated terminal owns focus, so
4
+ * the reasoning-fold key never reaches the TUI. Workspace-scoped keybindings
5
+ * do not exist, so the fix is one user-level keybindings.json rule that
6
+ * forwards the raw Ctrl byte via sendSequence under terminalFocus. This
7
+ * module detects the hosting editor variant, resolves its user
8
+ * keybindings.json, and merges the rule idempotently; pure merge/detect
9
+ * helpers are separated from the fs orchestration so both stay testable.
10
+ * @module @deepseek-ai/dsh-code/editor-keys
11
+ */
12
+
13
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
14
+ import { basename, dirname, join } from 'node:path'
15
+
16
+ /** Integrated-terminal editor variants this module can repair. */
17
+ export type EditorTerminalFamily = 'vscode' | 'cursor' | 'vscodium' | 'windsurf'
18
+
19
+ /** Install directory names per family ('vscode' covers stable and Insiders). */
20
+ const FAMILY_DIRS: Record<EditorTerminalFamily, readonly string[]> = {
21
+ vscode: ['Code', 'Code - Insiders'],
22
+ cursor: ['Cursor'],
23
+ vscodium: ['VSCodium'],
24
+ windsurf: ['Windsurf'],
25
+ }
26
+
27
+ /**
28
+ * Detect the editor hosting this integrated terminal.
29
+ * @param env - process environment (TERM_PROGRAM decides; case/whitespace tolerant).
30
+ * @returns the family, or undefined outside VS Code-family terminals.
31
+ */
32
+ export function detectEditorTerminalFamily(env: NodeJS.ProcessEnv = process.env): EditorTerminalFamily | undefined {
33
+ const program = env.TERM_PROGRAM?.trim().toLowerCase()
34
+ if (program === 'vscode' || program === 'cursor' || program === 'vscodium' || program === 'windsurf') return program
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * Whether the pty is hosted away from the editor UI (ssh/container/tunnel).
40
+ * Keybindings live on the client machine, so a remote session must never
41
+ * write them server-side.
42
+ */
43
+ export function isRemoteTerminalEnv(env: NodeJS.ProcessEnv = process.env): boolean {
44
+ return (env.VSCODE_IPC_HOOK_CLI ?? '').trim() !== ''
45
+ }
46
+
47
+ /** Filesystem anchors used to resolve editor config paths (injectable for tests). */
48
+ export interface EditorPathContext {
49
+ /** User home directory. */
50
+ homedir: string
51
+ /** %APPDATA% on Windows; only read for win32 resolution. */
52
+ appdata?: string
53
+ /** Node platform qualifier. */
54
+ platform: NodeJS.Platform
55
+ }
56
+
57
+ /**
58
+ * Resolve the user keybindings.json candidates for one family, most likely
59
+ * install first. Only paths that exist on disk are repaired.
60
+ */
61
+ export function editorKeybindingCandidates(family: EditorTerminalFamily, context: EditorPathContext): readonly string[] {
62
+ return FAMILY_DIRS[family].map(dir => {
63
+ if (context.platform === 'win32') {
64
+ return join(context.appdata ?? join(context.homedir, 'AppData', 'Roaming'), dir, 'User', 'keybindings.json')
65
+ }
66
+ if (context.platform === 'darwin') {
67
+ return join(context.homedir, 'Library', 'Application Support', dir, 'User', 'keybindings.json')
68
+ }
69
+ return join(context.homedir, '.config', dir, 'User', 'keybindings.json')
70
+ })
71
+ }
72
+
73
+ /** The one workbench rule that hands Ctrl+R to the focused terminal. */
74
+ export const CTRL_R_PASSTHROUGH_RULE = {
75
+ key: 'ctrl+r',
76
+ command: 'workbench.action.terminal.sendSequence',
77
+ args: { text: '\u0012' },
78
+ when: 'terminalFocus',
79
+ } as const
80
+
81
+ /** Serialized rule block (4-space indent, the editors' default style). */
82
+ const RULE_BLOCK = [
83
+ ' {',
84
+ ' "key": "ctrl+r",',
85
+ ' "command": "workbench.action.terminal.sendSequence",',
86
+ ' "args": { "text": "\\u0012" },',
87
+ ' "when": "terminalFocus"',
88
+ ' }',
89
+ ].join('\n')
90
+
91
+ /** Fresh-file template carrying the editors' standard header comment. */
92
+ const KEYBINDINGS_TEMPLATE = '// Place your key bindings in this file to override the defaults\n[\n' + RULE_BLOCK + '\n]\n'
93
+
94
+ /** Whether one parsed keybindings entry already forwards Ctrl+R to the terminal. */
95
+ function isCtrlRPassthroughEntry(entry: unknown): boolean {
96
+ if (typeof entry !== 'object' || entry === null) return false
97
+ const record = entry as Record<string, unknown>
98
+ if (record.command !== CTRL_R_PASSTHROUGH_RULE.command) return false
99
+ if (typeof record.key !== 'string' || record.key.trim().toLowerCase() !== CTRL_R_PASSTHROUGH_RULE.key) return false
100
+ const args = record.args
101
+ if (typeof args !== 'object' || args === null) return false
102
+ const text = (args as Record<string, unknown>).text
103
+ if (typeof text !== 'string' || !text.includes('\u0012')) return false
104
+ return typeof record.when === 'string' && /\bterminalFocus\b/u.test(record.when)
105
+ }
106
+
107
+ /**
108
+ * Remove // and block comments from one JSONC document. Double-quoted strings
109
+ * survive untouched, so comment markers inside string values are preserved.
110
+ */
111
+ export function stripJsoncComments(text: string): string {
112
+ let out = ''
113
+ let index = 0
114
+ let inString = false
115
+ while (index < text.length) {
116
+ const char = text[index]
117
+ if (inString) {
118
+ out += char
119
+ if (char === '\\' && index + 1 < text.length) {
120
+ out += text[index + 1]
121
+ index += 2
122
+ continue
123
+ }
124
+ if (char === '"') inString = false
125
+ index += 1
126
+ continue
127
+ }
128
+ if (char === '"') {
129
+ inString = true
130
+ out += char
131
+ index += 1
132
+ continue
133
+ }
134
+ if (char === '/' && text[index + 1] === '/') {
135
+ while (index < text.length && text[index] !== '\n') index += 1
136
+ continue
137
+ }
138
+ if (char === '/' && text[index + 1] === '*') {
139
+ index += 2
140
+ while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
141
+ if (text[index] === '\n') out += '\n'
142
+ index += 1
143
+ }
144
+ index += 2
145
+ continue
146
+ }
147
+ out += char
148
+ index += 1
149
+ }
150
+ return out
151
+ }
152
+
153
+ /** Drop commas directly before a closing bracket (string-aware). */
154
+ function removeTrailingCommas(text: string): string {
155
+ let out = ''
156
+ let index = 0
157
+ let inString = false
158
+ while (index < text.length) {
159
+ const char = text[index]
160
+ if (inString) {
161
+ out += char
162
+ if (char === '\\' && index + 1 < text.length) {
163
+ out += text[index + 1]
164
+ index += 2
165
+ continue
166
+ }
167
+ if (char === '"') inString = false
168
+ index += 1
169
+ continue
170
+ }
171
+ if (char === '"') {
172
+ inString = true
173
+ out += char
174
+ index += 1
175
+ continue
176
+ }
177
+ if (char === ',') {
178
+ let peek = index + 1
179
+ while (peek < text.length && (text[peek] === ' ' || text[peek] === '\t' || text[peek] === '\n' || text[peek] === '\r')) peek += 1
180
+ const next = text[peek]
181
+ if (next === '}' || next === ']') {
182
+ index += 1
183
+ continue
184
+ }
185
+ }
186
+ out += char
187
+ index += 1
188
+ }
189
+ return out
190
+ }
191
+
192
+ /** Parse one JSONC document; trailing commas are tolerated. */
193
+ export function parseJsonc(text: string): unknown {
194
+ return JSON.parse(removeTrailingCommas(stripJsoncComments(text)))
195
+ }
196
+
197
+ /** Raw index of the top-level rule array's opening bracket, or -1 when absent. */
198
+ function rawOpenBracketIndex(text: string): number {
199
+ let index = 0
200
+ let inString = false
201
+ while (index < text.length) {
202
+ const char = text[index]
203
+ if (inString) {
204
+ if (char === '\\') {
205
+ index += 2
206
+ continue
207
+ }
208
+ if (char === '"') inString = false
209
+ index += 1
210
+ continue
211
+ }
212
+ if (char === '"') {
213
+ inString = true
214
+ index += 1
215
+ continue
216
+ }
217
+ if (char === '/' && text[index + 1] === '/') {
218
+ while (index < text.length && text[index] !== '\n') index += 1
219
+ continue
220
+ }
221
+ if (char === '/' && text[index + 1] === '*') {
222
+ index += 2
223
+ while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) index += 1
224
+ index += 2
225
+ continue
226
+ }
227
+ if (char === '[') return index
228
+ index += 1
229
+ }
230
+ return -1
231
+ }
232
+
233
+ /** Outcome of merging the passthrough rule into one keybindings document. */
234
+ export type KeybindingsMerge =
235
+ | { readonly status: 'present' }
236
+ | { readonly status: 'updated'; readonly text: string }
237
+ | { readonly status: 'created'; readonly text: string }
238
+
239
+ /**
240
+ * Merge the Ctrl+R passthrough into one keybindings.json document. The raw
241
+ * text is preserved verbatim (comments included); the rule is inserted right
242
+ * after the array opener so it cannot be shadowed by later conflicting user
243
+ * rules. Missing files resolve to a fresh template.
244
+ * @throws when the document does not carry a rule array.
245
+ */
246
+ export function mergeCtrlRPassthrough(raw: string | undefined): KeybindingsMerge {
247
+ if (raw === undefined) return { status: 'created', text: KEYBINDINGS_TEMPLATE }
248
+ const parsed = parseJsonc(raw)
249
+ if (!Array.isArray(parsed)) throw new Error('keybindings.json does not contain a rule array')
250
+ if (parsed.some(isCtrlRPassthroughEntry)) return { status: 'present' }
251
+ const open = rawOpenBracketIndex(raw)
252
+ if (open === -1) throw new Error('keybindings.json does not contain a rule array')
253
+ const insert = parsed.length > 0 ? '\n' + RULE_BLOCK + ',' : '\n' + RULE_BLOCK
254
+ return { status: 'updated', text: raw.slice(0, open + 1) + insert + raw.slice(open + 1) }
255
+ }
256
+
257
+ /** User-level marker file content: the startup hint fires at most once per install. */
258
+ export interface EditorKeysFlag {
259
+ hintShownAt?: string
260
+ }
261
+
262
+ /** Parse one flag file snapshot; missing or corrupt content degrades to unshown. */
263
+ export function parseEditorKeysFlag(raw: string | undefined): EditorKeysFlag {
264
+ if (raw === undefined) return {}
265
+ try {
266
+ const parsed: unknown = JSON.parse(raw)
267
+ if (typeof parsed !== 'object' || parsed === null) return {}
268
+ const shown = (parsed as Record<string, unknown>).hintShownAt
269
+ return typeof shown === 'string' ? { hintShownAt: shown } : {}
270
+ } catch {
271
+ return {}
272
+ }
273
+ }
274
+
275
+ /** Persist the shown marker; best-effort, the hint is cosmetic and never a gate. */
276
+ export async function markEditorKeysHintShown(path: string): Promise<void> {
277
+ await mkdir(dirname(path), { recursive: true })
278
+ await writeFile(path, JSON.stringify({ hintShownAt: new Date().toISOString() }, null, 2) + '\n', 'utf8')
279
+ }
280
+
281
+ /** Inputs shared by the apply and startup-hint flows. */
282
+ export interface EditorKeysEnv {
283
+ /** Process environment (TERM_PROGRAM / VSCODE_IPC_HOOK_CLI). */
284
+ env: NodeJS.ProcessEnv
285
+ /** Filesystem anchors for editor config resolution. */
286
+ paths: EditorPathContext
287
+ /** Absolute path of the one-shot hint marker under the DSH home. */
288
+ flagPath: string
289
+ }
290
+
291
+ /** Read one file if it exists; undefined otherwise (ENOENT and unreadable both). */
292
+ async function readIfPresent(path: string): Promise<string | undefined> {
293
+ try {
294
+ return await readFile(path, 'utf8')
295
+ } catch {
296
+ return undefined
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Apply the Ctrl+R passthrough to every local keybindings.json of the hosting
302
+ * editor and mark the startup hint shown. Existing files get a .dsh-bak
303
+ * backup before the first write.
304
+ * @returns a one-line user-facing summary.
305
+ * @throws with an actionable message when the environment cannot be repaired.
306
+ */
307
+ export async function applyCtrlRPassthrough({ env, paths, flagPath }: EditorKeysEnv): Promise<string> {
308
+ const family = detectEditorTerminalFamily(env)
309
+ if (family === undefined) {
310
+ throw new Error(
311
+ 'not a VS Code-family terminal (TERM_PROGRAM=' + (env.TERM_PROGRAM?.trim() || 'unset') + '); add the ctrl+r rule to keybindings.json manually',
312
+ )
313
+ }
314
+ if (isRemoteTerminalEnv(env)) {
315
+ throw new Error('remote terminal detected; apply the keybindings rule on the local machine instead')
316
+ }
317
+ const candidates = editorKeybindingCandidates(family, paths)
318
+ const targets: string[] = []
319
+ for (const candidate of candidates) {
320
+ if (await readIfPresent(candidate) !== undefined) targets.push(candidate)
321
+ }
322
+ if (targets.length === 0) targets.push(candidates[0])
323
+ const updated: string[] = []
324
+ const present: string[] = []
325
+ for (const target of targets) {
326
+ const raw = await readIfPresent(target)
327
+ const merge = mergeCtrlRPassthrough(raw)
328
+ if (merge.status === 'present') {
329
+ present.push(target)
330
+ continue
331
+ }
332
+ await mkdir(dirname(target), { recursive: true })
333
+ if (raw !== undefined) await writeFile(target + '.dsh-bak', raw, 'utf8')
334
+ await writeFile(target, merge.text, 'utf8')
335
+ updated.push(target)
336
+ }
337
+ await markEditorKeysHintShown(flagPath).catch(() => {})
338
+ const label = (target: string): string => basename(dirname(dirname(target)))
339
+ if (updated.length === 0) {
340
+ return 'ctrl+r passthrough already configured in ' + present.map(label).join(', ')
341
+ }
342
+ return 'ctrl+r passthrough written to ' + updated.map(label).join(', ') + ' — effective immediately'
343
+ }
344
+
345
+ /**
346
+ * Resolve the one-shot startup hint for VS Code-family terminals. Fires at
347
+ * most once per install (flag file), never when the passthrough rule is
348
+ * already present, and never in remote ptys where the repair cannot run.
349
+ * @returns the hint line, or undefined to stay silent.
350
+ */
351
+ export async function resolveEditorKeysStartupHint({ env, paths, flagPath }: EditorKeysEnv): Promise<string | undefined> {
352
+ const flag = parseEditorKeysFlag(await readIfPresent(flagPath))
353
+ if (flag.hintShownAt !== undefined) return undefined
354
+ const family = detectEditorTerminalFamily(env)
355
+ if (family === undefined || isRemoteTerminalEnv(env)) return undefined
356
+ for (const candidate of editorKeybindingCandidates(family, paths)) {
357
+ const raw = await readIfPresent(candidate)
358
+ if (raw === undefined) continue
359
+ try {
360
+ const parsed: unknown = parseJsonc(raw)
361
+ if (Array.isArray(parsed) && parsed.some(isCtrlRPassthroughEntry)) {
362
+ await markEditorKeysHintShown(flagPath).catch(() => {})
363
+ return undefined
364
+ }
365
+ } catch {
366
+ // An unparseable config still deserves the hint; apply reports the error.
367
+ }
368
+ }
369
+ await markEditorKeysHintShown(flagPath).catch(() => {})
370
+ return 'run /vscode-keys to pass ctrl+r through this editor (alt+r works meanwhile)'
371
+ }