dsh-code 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
package/src/approval.ts CHANGED
@@ -96,6 +96,13 @@ export function mountApprovalAnswerer(
96
96
 
97
97
  let resolved = false
98
98
  let settle!: (outcome: ApprovalOutcome) => void
99
+ // Established BEFORE the abort listener mounts: a synchronous throw
100
+ // between the listener registration and a later construction site would
101
+ // otherwise leave a subsequent abort invoking an unassigned settle from
102
+ // inside the AbortSignal listener (an uncaughtException).
103
+ const settled = new Promise<ApprovalOutcome>((resolve) => {
104
+ settle = resolve
105
+ })
99
106
  const signal = request.signal
100
107
  const onAbort = (): void => withdraw()
101
108
  // Detach on every settle so an answered ask never retains a listener on
@@ -136,9 +143,7 @@ export function mountApprovalAnswerer(
136
143
  queue.push(slot)
137
144
  publish()
138
145
 
139
- return new Promise<ApprovalOutcome>((resolve) => {
140
- settle = resolve
141
- }).then((outcome) => {
146
+ return settled.then((outcome) => {
142
147
  if (outcome !== 'cancelled') {
143
148
  removeSlot(slot)
144
149
  publish()
@@ -196,8 +196,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
196
196
  if (input !== '' && !key.ctrl && !key.meta) setDraft(current => current + input)
197
197
  })
198
198
 
199
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
200
- if (viewport.compact) {
199
+ if (viewport.maxHeight === 0 || viewport.compact) {
201
200
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider login · esc cancel', viewport.contentColumns))
202
201
  }
203
202
 
@@ -271,8 +270,7 @@ export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
271
270
  setError(reason instanceof Error ? reason.message : String(reason))
272
271
  })
273
272
  })
274
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
275
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
273
+ if (viewport.maxHeight === 0 || viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
276
274
  return createElement(
277
275
  Box,
278
276
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
package/src/commands.ts CHANGED
@@ -38,16 +38,22 @@ export function watchCommands(ctx: Context): CommandsView {
38
38
  let agent: Agent | undefined
39
39
  let descriptors: readonly CommandDescriptor[] = []
40
40
  let error: string | undefined
41
+ // The agent whose scoped view the current descriptors were read for: a
42
+ // failure before this agent ever loaded clears the list instead of keeping
43
+ // another session's commands completable here.
44
+ let loadedFor: Agent | undefined
41
45
  const listeners = new Set<() => void>()
42
46
  const refresh = (): void => {
43
47
  if (commands === undefined || agent === undefined) return
44
48
  try {
45
49
  descriptors = commands.list(agent)
50
+ loadedFor = agent
46
51
  error = undefined
47
52
  } catch (cause: unknown) {
48
- // Keep the last good catalog, but change its identity so subscribers
49
- // can render the recoverable failure in /help.
50
- descriptors = [...descriptors]
53
+ // Keep the last good catalog for the SAME agent, but change its identity
54
+ // so subscribers can render the recoverable failure in /help; an agent
55
+ // that never loaded starts from empty.
56
+ descriptors = loadedFor === agent ? [...descriptors] : []
51
57
  error = cause instanceof Error ? cause.message : String(cause)
52
58
  }
53
59
  for (const listener of listeners) listener()
@@ -83,3 +89,21 @@ export function watchCommands(ctx: Context): CommandsView {
83
89
  export function isSlashLine(line: string): boolean {
84
90
  return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line)
85
91
  }
92
+
93
+ /**
94
+ * The submission payload for one composer line. Trim is a blank check, not a
95
+ * rewrite: an ordinary prompt keeps its exact leading indentation, inner
96
+ * layout, and trailing spaces (pasted code must reach the model verbatim).
97
+ * Only trailing line terminators are stripped — a draft's final newline is a
98
+ * paste/Enter artifact (an open bracketed paste turns Enter into an inserted
99
+ * newline), never deliberate content. A syntactic slash line still normalizes
100
+ * fully so command routing stays stable (completion inserts a trailing space
101
+ * after `/name`).
102
+ * @param line - the complete draft text.
103
+ * @returns the text to submit verbatim.
104
+ */
105
+ export function submissionPayload(line: string): string {
106
+ const withoutTrailingNewlines = line.replace(/[\r\n]+$/u, '')
107
+ const trimmed = withoutTrailingNewlines.trim()
108
+ return isSlashLine(trimmed) ? trimmed : withoutTrailingNewlines
109
+ }
@@ -0,0 +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
+ }
@@ -44,9 +44,9 @@ export function parseGitDiffSpec(argument: string): GitDiffSpec {
44
44
  return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--unified=3', value, '--'] }
45
45
  }
46
46
 
47
- function executeGit(cwd: string, args: readonly string[]): Promise<string> {
47
+ function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
48
48
  return new Promise((resolve, reject) => {
49
- execFile('git', [...args], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
49
+ execFile('git', [...args], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true, signal }, (error, stdout, stderr) => {
50
50
  if (error !== null) {
51
51
  reject(new Error(stderr.trim() || error.message))
52
52
  return
@@ -56,17 +56,21 @@ function executeGit(cwd: string, args: readonly string[]): Promise<string> {
56
56
  })
57
57
  }
58
58
 
59
- /** Load one complete textual diff without invoking external diff drivers. */
60
- export async function loadGitDiff(cwd: string, argument: string): Promise<GitDiffView> {
59
+ /**
60
+ * Load one complete textual diff without invoking external diff drivers.
61
+ * @param signal - aborted by the caller on session switches/quit, killing the
62
+ * git subprocess instead of letting a stale repository's diff land later.
63
+ */
64
+ export async function loadGitDiff(cwd: string, argument: string, signal?: AbortSignal): Promise<GitDiffView> {
61
65
  const spec = parseGitDiffSpec(argument)
62
66
  try {
63
- const text = await executeGit(cwd, spec.args)
67
+ const text = await executeGit(cwd, spec.args, signal)
64
68
  return { title: `git diff - ${spec.label}`, files: parseGitDiffFiles(text) }
65
69
  } catch (error: unknown) {
66
70
  // An unborn repository has no HEAD. Preserve useful unstaged output for
67
71
  // the default form while still surfacing all other Git failures.
68
72
  if (argument.trim() !== '') throw error
69
- const text = await executeGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--'])
73
+ const text = await executeGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--'], signal)
70
74
  return { title: 'git diff - working tree', files: parseGitDiffFiles(text) }
71
75
  }
72
76
  }