dsh-coding-sidebar 1.0.6 → 1.0.8

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 (70) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +436 -227
  3. package/lib/client-registry.js +639 -7397
  4. package/lib/client-terminal.js +239 -187
  5. package/lib/client.js +644 -7402
  6. package/lib/index.js +367 -67
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/EditorHost.d.ts +1 -1
  10. package/lib/types/client/FileTree.d.ts +2 -2
  11. package/lib/types/client/TreePanel.d.ts +1 -1
  12. package/lib/types/client/chunk-loader.d.ts +1 -1
  13. package/lib/types/client/chunks/locale.d.ts +3 -0
  14. package/lib/types/client/conversation-draft.d.ts +85 -4
  15. package/lib/types/client/locales.d.ts +1 -20
  16. package/lib/types/client/selection-popup.d.ts +58 -0
  17. package/lib/types/client/service.d.ts +1 -1
  18. package/lib/types/client/terminal-font.d.ts +25 -2
  19. package/lib/types/context-types.d.ts +3 -1
  20. package/lib/types/index.d.ts +9 -0
  21. package/lib/types/prefs-shared.d.ts +7 -5
  22. package/lib/types/pty-manager.d.ts +53 -0
  23. package/lib/types/wire.d.ts +6 -2
  24. package/package.json +1 -1
  25. package/src/agent-pty.ts +221 -33
  26. package/src/bundle-route.ts +1 -1
  27. package/src/client/BrowserView.tsx +17 -9
  28. package/src/client/EditorHost.tsx +1 -1
  29. package/src/client/FileTree.tsx +4 -4
  30. package/src/client/Sidebar.tsx +41 -22
  31. package/src/client/TerminalView.tsx +13 -0
  32. package/src/client/TextEditor.tsx +27 -45
  33. package/src/client/TreePanel.tsx +16 -1
  34. package/src/client/chunk-loader.ts +1 -1
  35. package/src/client/chunks/locale.tsx +60 -0
  36. package/src/client/conversation-draft.ts +233 -7
  37. package/src/client/index.tsx +19 -8
  38. package/src/client/locales-ar.ts +5 -4
  39. package/src/client/locales-de.ts +5 -4
  40. package/src/client/locales-fr.ts +5 -4
  41. package/src/client/locales-hi.ts +5 -4
  42. package/src/client/locales-id.ts +5 -4
  43. package/src/client/locales-it.ts +5 -4
  44. package/src/client/locales-ja.ts +5 -4
  45. package/src/client/locales-ko.ts +5 -4
  46. package/src/client/locales-nl.ts +5 -4
  47. package/src/client/locales-pl.ts +5 -4
  48. package/src/client/locales-pt.ts +5 -4
  49. package/src/client/locales-ru.ts +5 -4
  50. package/src/client/locales-sv.ts +5 -4
  51. package/src/client/locales-th.ts +5 -4
  52. package/src/client/locales-tr.ts +5 -4
  53. package/src/client/locales-vi.ts +5 -4
  54. package/src/client/locales-zh-HK.ts +5 -4
  55. package/src/client/locales-zh-MO.ts +5 -4
  56. package/src/client/locales-zh-TW.ts +5 -4
  57. package/src/client/locales.ts +17 -51
  58. package/src/client/selection-popup.ts +155 -0
  59. package/src/client/service.ts +1 -1
  60. package/src/client/state.ts +14 -7
  61. package/src/client/terminal-font.ts +34 -3
  62. package/src/context-types.ts +3 -2
  63. package/src/git.ts +17 -1
  64. package/src/index.ts +39 -6
  65. package/src/open-external.ts +3 -4
  66. package/src/prefs-shared.ts +7 -5
  67. package/src/pty-manager.ts +176 -5
  68. package/src/sidechat-routes.ts +13 -1
  69. package/src/tools.ts +24 -6
  70. package/src/wire.ts +3 -0
@@ -7,7 +7,7 @@
7
7
  * tab is closed or the plugin tears down.
8
8
  */
9
9
  import { chmodSync, existsSync } from 'node:fs'
10
- import { dirname, join } from 'node:path'
10
+ import { dirname, join, win32 as win32Path } from 'node:path'
11
11
  import { createRequire } from 'node:module'
12
12
  import { userInfo } from 'node:os'
13
13
  import type { IPty } from 'node-pty'
@@ -153,7 +153,7 @@ export class PtyManager {
153
153
  sessionId,
154
154
  tabId,
155
155
  cwd,
156
- pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
156
+ pty: this.nodePty.spawn(resolveShellExecutable(shell ?? this.shell), shellSpawnArgs(shellArgs ?? this.shellArgs), {
157
157
  name: 'xterm-256color',
158
158
  cols: Math.max(2, Math.floor(cols)),
159
159
  rows: Math.max(2, Math.floor(rows)),
@@ -270,6 +270,32 @@ export interface ShellResolutionOptions {
270
270
  exists?: (path: string) => boolean
271
271
  }
272
272
 
273
+ /** Inputs for resolving one configured shell into the executable path passed
274
+ * to node-pty. Injectable so the Windows-only search semantics stay covered
275
+ * on POSIX CI runners. */
276
+ export interface ShellExecutableResolutionOptions {
277
+ /** Platform override (defaults to `process.platform`). */
278
+ platform?: NodeJS.Platform
279
+ /** Environment override; Windows reads PATH/PATHEXT/SystemRoot plus the
280
+ * PowerShell well-known-location variables. */
281
+ env?: NodeJS.ProcessEnv
282
+ /** File-existence probe override (defaults to `existsSync`). */
283
+ exists?: (path: string) => boolean
284
+ }
285
+
286
+ /** Read one Windows environment value case-insensitively. Real
287
+ * `process.env` has case-insensitive lookup on Windows, but injected objects
288
+ * and some embedders do not preserve that behavior. */
289
+ function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
290
+ const direct = env[name]
291
+ if (direct !== undefined) return direct
292
+ const lowered = name.toLowerCase()
293
+ for (const [key, value] of Object.entries(env)) {
294
+ if (key.toLowerCase() === lowered) return value
295
+ }
296
+ return undefined
297
+ }
298
+
273
299
  /**
274
300
  * Candidate directories that may contain a `pwsh.exe` on Windows: PATH
275
301
  * entries first, then the well-known machine/user install locations
@@ -281,7 +307,7 @@ export interface ShellResolutionOptions {
281
307
  */
282
308
  function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
283
309
  const dirs: string[] = []
284
- const pathEntries = env.PATH
310
+ const pathEntries = windowsEnv(env, 'PATH')
285
311
  if (pathEntries !== undefined) {
286
312
  // The win32 branch always uses the Windows PATH separator; hardcoding it
287
313
  // keeps the function testable from POSIX runners without a delimiter
@@ -291,12 +317,12 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
291
317
  if (trimmed !== '') dirs.push(trimmed)
292
318
  }
293
319
  }
294
- for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
320
+ for (const programFiles of [windowsEnv(env, 'ProgramW6432'), windowsEnv(env, 'ProgramFiles')]) {
295
321
  if (programFiles === undefined || programFiles.trim() === '') continue
296
322
  dirs.push(join(programFiles, 'PowerShell', '7'))
297
323
  dirs.push(join(programFiles, 'PowerShell', '7-preview'))
298
324
  }
299
- const localAppData = env.LOCALAPPDATA
325
+ const localAppData = windowsEnv(env, 'LOCALAPPDATA')
300
326
  if (localAppData !== undefined && localAppData.trim() !== '') {
301
327
  dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
302
328
  dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
@@ -306,6 +332,95 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
306
332
  return [...new Set(dirs)]
307
333
  }
308
334
 
335
+ /**
336
+ * Resolve the configured shell executable before handing it to node-pty.
337
+ *
338
+ * Windows' native backend does not consistently apply the shell's PATHEXT
339
+ * lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
340
+ * `File not found:` error), so perform the lookup ourselves: an explicit
341
+ * path is accepted as-is when it exists (or with a PATHEXT suffix when the
342
+ * user omitted `.exe`), and a bare name is searched through PATH, System32,
343
+ * and PowerShell's known install directories.
344
+ *
345
+ * POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
346
+ * but a wrong name made the pty die with a bare
347
+ * `[process exited with code N]`, so probe like Windows anyway: a path with
348
+ * a separator must exist; a bare name is searched along PATH (the colon
349
+ * form is fixed by the platform). A miss is a clear, actionable
350
+ * `shell-not-found` error instead of a cryptic exit code.
351
+ *
352
+ * @param shell - the configured shell (settings page or yaml `config.shell`).
353
+ * @param options - platform/env/exists injection points for tests.
354
+ * @returns the executable path passed to node-pty.
355
+ * @throws {SidebarError} `shell-not-found` when no candidate exists.
356
+ */
357
+ export function resolveShellExecutable(
358
+ shell: string,
359
+ options: ShellExecutableResolutionOptions = {},
360
+ ): string {
361
+ const configured = unquotePath(shell.trim())
362
+ if (configured === '') return configured
363
+
364
+ const platform = options.platform ?? process.platform
365
+ const env = options.env ?? process.env
366
+ const exists = options.exists ?? existsSync
367
+ const notFound = (): SidebarError =>
368
+ new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
369
+
370
+ if (platform === 'win32') {
371
+ const rawPathext = windowsEnv(env, 'PATHEXT')
372
+ const executableExts = (rawPathext ?? '.COM;.EXE')
373
+ .split(';')
374
+ .map(extension => extension.trim())
375
+ // node-pty ultimately calls CreateProcess; batch files need an
376
+ // intermediate cmd.exe and therefore are not valid shell executables.
377
+ .filter(extension => /^\.(?:com|exe)$/i.test(extension))
378
+ if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
379
+
380
+ const hasExtension = win32Path.extname(configured) !== ''
381
+ const names = hasExtension
382
+ ? [configured]
383
+ : executableExts.map(extension => configured + extension.toLowerCase())
384
+ const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
385
+ const candidates: string[] = []
386
+ if (hasPath) {
387
+ candidates.push(...names)
388
+ } else {
389
+ const path = windowsEnv(env, 'PATH')
390
+ if (path !== undefined) {
391
+ for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
392
+ for (const name of names) candidates.push(win32Path.join(dir, name))
393
+ }
394
+ }
395
+ const systemRoot = windowsEnv(env, 'SystemRoot')
396
+ if (systemRoot !== undefined && systemRoot.trim() !== '') {
397
+ for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
398
+ }
399
+ if (/^pwsh(?:\.exe)?$/i.test(configured)) {
400
+ for (const dir of windowsPwshCandidateDirs(env)) {
401
+ candidates.push(win32Path.join(dir, 'pwsh.exe'))
402
+ }
403
+ }
404
+ }
405
+
406
+ for (const candidate of [...new Set(candidates)]) {
407
+ if (exists(candidate)) return candidate
408
+ }
409
+ throw notFound()
410
+ }
411
+
412
+ if (configured.includes('/')) {
413
+ if (!exists(configured)) throw notFound()
414
+ return configured
415
+ }
416
+ const path = env.PATH ?? '/usr/bin:/bin'
417
+ for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
418
+ const candidate = join(dir, configured)
419
+ if (exists(candidate)) return candidate
420
+ }
421
+ throw notFound()
422
+ }
423
+
309
424
  /**
310
425
  * The interactive shell for this platform, resolved like a terminal
311
426
  * emulator: an explicitly configured shell (the `shell` config field) wins,
@@ -374,3 +489,59 @@ export function shellSpawnArgs(configured: string[] = []): string[] {
374
489
  if (configured.length > 0) return [...configured]
375
490
  return process.platform === 'win32' ? [] : ['-l']
376
491
  }
492
+
493
+ /**
494
+ * Strip ONE pair of surrounding quotes from a configured shell path. Users
495
+ * paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
496
+ * quotes are shell-input syntax, not part of the path. Unpaired quotes and
497
+ * shorter values stay verbatim.
498
+ */
499
+ export function unquotePath(value: string): string {
500
+ if (value.length >= 2) {
501
+ const first = value[0]
502
+ const last = value[value.length - 1]
503
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
504
+ }
505
+ return value
506
+ }
507
+
508
+ /**
509
+ * Split a settings-page shell-arguments string into argv with quote-aware
510
+ * grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
511
+ * LITERAL — a backslash is never an escape, so Windows paths survive intact
512
+ * (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
513
+ * The price is that an argument containing a literal quote character cannot
514
+ * be expressed; shell startup arguments never need one. An unclosed quote
515
+ * folds the remainder into the current token (settings input stays
516
+ * forgiving); an empty quote pair yields no argument.
517
+ */
518
+ export function splitShellArgs(input: string): string[] {
519
+ const args: string[] = []
520
+ let current = ''
521
+ let quote: '"' | "'" | null = null
522
+ let started = false
523
+ for (const ch of input) {
524
+ if (quote !== null) {
525
+ if (ch === quote) quote = null
526
+ else current += ch
527
+ continue
528
+ }
529
+ if (ch === '"' || ch === "'") {
530
+ quote = ch
531
+ started = true
532
+ continue
533
+ }
534
+ if (/\s/.test(ch)) {
535
+ if (started) {
536
+ args.push(current)
537
+ current = ''
538
+ started = false
539
+ }
540
+ continue
541
+ }
542
+ current += ch
543
+ started = true
544
+ }
545
+ if (started) args.push(current)
546
+ return args.filter(arg => arg !== '')
547
+ }
@@ -26,6 +26,7 @@ import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '
26
26
  import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
27
27
  import type { Context as CordisContext } from '@deepseek-ai/cordis'
28
28
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
29
+ import { SessionLogOffset } from '@deepseek-ai/dsh-session'
29
30
  import type { SidebarSessionEvent } from './context-types.ts'
30
31
  import type {
31
32
  Context,
@@ -196,17 +197,28 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
196
197
  data: descriptor as unknown as Record<string, unknown>,
197
198
  }
198
199
  const seed = [...inheritance.seed, descriptorEvent]
200
+ // Fork-marker fields (the exact shape the host's own session.fork uses):
201
+ // without `isSeeded` + `inheritedEventCount` the session treats the whole
202
+ // seed as the child's OWN events, so the child's Inbox constructor
203
+ // replays the parent's `agent/inbox/spliced` events and inherits
204
+ // whatever input sat UNCLAIMED in the parent at the click moment (a
205
+ // queued follow-up, or a tool-result context spliced into next-step
206
+ // between step boundaries of a long-running turn). The first side
207
+ // prompt would then claim and send that stale message BEFORE the
208
+ // boundary + question. The marker keeps `ownEvents()` at the end-seed
209
+ // boundary, so the inherited inbox replays to empty.
199
210
  const options: CreateAgentOptions = {
200
211
  sessionId: childId,
201
212
  meta: {
202
213
  ...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
203
214
  parentSession: parentSession.id,
204
- isSeeded: seed.length > 0,
215
+ isSeeded: true,
205
216
  origin: 'subagent',
206
217
  delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
207
218
  ...(agentPreset === undefined ? {} : { agentPreset }),
208
219
  },
209
220
  seed: seed as unknown as readonly SessionEvent[],
221
+ inheritedEventCount: SessionLogOffset(seed.length),
210
222
  agentOptions: { ...parent.options },
211
223
  setup,
212
224
  signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
package/src/tools.ts CHANGED
@@ -287,13 +287,17 @@ export function registerTools(
287
287
  register(defineTool({
288
288
  name: 'terminal_wait_for',
289
289
  description:
290
- 'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
290
+ 'Block until a pattern appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
291
291
  + 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
292
292
  + 'without busy-polling terminal_read. '
293
+ + 'The needle is a JavaScript regular expression ( a pattern that fails to compile falls back to verbatim substring matching ). '
294
+ + 'One needle may cover MULTIPLE outcomes — e.g. wait on `(BUILD_OK|BUILD_FAIL)` or `Build (succeeded|failed)` returns as soon as EITHER marker appears, '
295
+ + 'and the found result\'s `match` field tells which alternative hit ( build success vs failure ). '
293
296
  + 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. '
294
- + 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
297
+ + 'Returns `found` with the line/column and the matched text, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
295
298
  + 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
296
- + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
299
+ + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately. '
300
+ + 'The user can skip the wait from the sidebar ( a banner on the terminal\'s tab shows the needle and a skip button ) — the tool then returns `skipped`.',
297
301
  parameters: {
298
302
  uuid: {
299
303
  type: 'string',
@@ -303,7 +307,8 @@ export function registerTools(
303
307
  needle: {
304
308
  type: 'string',
305
309
  required: true,
306
- description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
310
+ description: 'JavaScript regular expression to wait for (case-sensitive); a pattern that fails to compile falls back to verbatim substring matching. '
311
+ + 'May cover several outcomes in one wait ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success/failure ) — check `match` in the found result to see which one hit. Must be non-empty.',
307
312
  },
308
313
  timeout_ms: {
309
314
  type: 'number',
@@ -321,6 +326,7 @@ export function registerTools(
321
326
  needle: { type: 'string', required: true },
322
327
  line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
323
328
  column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
329
+ match: { type: 'string', required: true, description: 'The text that actually matched — for multi-outcome patterns ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched.' },
324
330
  elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
325
331
  },
326
332
  },
@@ -344,16 +350,28 @@ export function registerTools(
344
350
  exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
345
351
  },
346
352
  },
353
+ {
354
+ type: 'object',
355
+ additionalProperties: false,
356
+ properties: {
357
+ kind: { type: 'string', required: true, const: 'skipped' },
358
+ needle: { type: 'string', required: true },
359
+ },
360
+ },
347
361
  ],
348
362
  },
349
363
  render: (_args, value) => {
350
- const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
364
+ const v = value as { kind: 'found' | 'timeout' | 'exited' | 'skipped'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; match?: string; exitCode?: number | null; exitSignal?: string | null }
351
365
  if (v.kind === 'found') {
352
- return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
366
+ const matched = v.match !== undefined && v.match !== '' ? `, matched "${v.match}"` : ''
367
+ return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column}${matched} (after ${v.elapsedMs}ms).` }]
353
368
  }
354
369
  if (v.kind === 'timeout') {
355
370
  return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
356
371
  }
372
+ if (v.kind === 'skipped') {
373
+ return [{ type: 'text', text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.` }]
374
+ }
357
375
  const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
358
376
  return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
359
377
  },
package/src/wire.ts CHANGED
@@ -17,6 +17,7 @@ export type SidebarErrorCode =
17
17
  | 'git-error'
18
18
  | 'pty-error'
19
19
  | 'pty-deps-missing'
20
+ | 'shell-not-found'
20
21
  | 'job-error'
21
22
  | 'cdp-down'
22
23
  | 'sidechat-error'
@@ -31,6 +32,8 @@ export class SidebarError extends Error {
31
32
  readonly code: SidebarErrorCode,
32
33
  message: string,
33
34
  readonly status = 400,
35
+ /** Optional structured context (e.g. `{ shell }` for shell-not-found). */
36
+ readonly meta?: Record<string, string>,
34
37
  ) {
35
38
  super(message)
36
39
  }