dsh-coding-sidebar 1.0.7 → 1.0.9

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 (81) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +485 -228
  3. package/lib/client-registry.js +1238 -7583
  4. package/lib/client-terminal.js +345 -189
  5. package/lib/client.js +1213 -7558
  6. package/lib/index.js +518 -69
  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/DiffView.d.ts +33 -1
  10. package/lib/types/client/EditorHost.d.ts +4 -1
  11. package/lib/types/client/FileTree.d.ts +6 -2
  12. package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
  13. package/lib/types/client/TreePanel.d.ts +4 -1
  14. package/lib/types/client/api.d.ts +26 -0
  15. package/lib/types/client/chunk-loader.d.ts +1 -1
  16. package/lib/types/client/chunks/locale.d.ts +3 -0
  17. package/lib/types/client/conversation-draft.d.ts +85 -4
  18. package/lib/types/client/locales.d.ts +13 -20
  19. package/lib/types/client/selection-popup.d.ts +58 -0
  20. package/lib/types/client/service.d.ts +1 -1
  21. package/lib/types/client/state.d.ts +15 -0
  22. package/lib/types/client/terminal-font.d.ts +25 -2
  23. package/lib/types/context-types.d.ts +3 -1
  24. package/lib/types/fs-operations.d.ts +42 -0
  25. package/lib/types/git.d.ts +15 -0
  26. package/lib/types/index.d.ts +9 -0
  27. package/lib/types/prefs-shared.d.ts +7 -5
  28. package/lib/types/pty-manager.d.ts +53 -0
  29. package/lib/types/wire.d.ts +6 -2
  30. package/package.json +1 -1
  31. package/src/agent-pty.ts +221 -33
  32. package/src/bundle-route.ts +1 -1
  33. package/src/client/DiffTab.tsx +10 -1
  34. package/src/client/DiffView.tsx +174 -19
  35. package/src/client/EditorHost.tsx +9 -2
  36. package/src/client/FileTree.tsx +151 -8
  37. package/src/client/Sidebar.tsx +95 -25
  38. package/src/client/TerminalView.tsx +41 -0
  39. package/src/client/TerminalWaitBanner.tsx +32 -0
  40. package/src/client/TextEditor.tsx +27 -45
  41. package/src/client/TreePanel.tsx +22 -2
  42. package/src/client/api.ts +20 -0
  43. package/src/client/chunk-loader.ts +1 -1
  44. package/src/client/chunks/locale.tsx +60 -0
  45. package/src/client/conversation-draft.ts +233 -7
  46. package/src/client/index.tsx +19 -8
  47. package/src/client/locales-ar.ts +17 -4
  48. package/src/client/locales-de.ts +17 -4
  49. package/src/client/locales-fr.ts +17 -4
  50. package/src/client/locales-hi.ts +17 -4
  51. package/src/client/locales-id.ts +17 -4
  52. package/src/client/locales-it.ts +17 -4
  53. package/src/client/locales-ja.ts +17 -4
  54. package/src/client/locales-ko.ts +17 -4
  55. package/src/client/locales-nl.ts +17 -4
  56. package/src/client/locales-pl.ts +17 -4
  57. package/src/client/locales-pt.ts +17 -4
  58. package/src/client/locales-ru.ts +17 -4
  59. package/src/client/locales-sv.ts +17 -4
  60. package/src/client/locales-th.ts +17 -4
  61. package/src/client/locales-tr.ts +17 -4
  62. package/src/client/locales-vi.ts +17 -4
  63. package/src/client/locales-zh-HK.ts +17 -4
  64. package/src/client/locales-zh-MO.ts +17 -4
  65. package/src/client/locales-zh-TW.ts +17 -4
  66. package/src/client/locales.ts +41 -51
  67. package/src/client/selection-popup.ts +155 -0
  68. package/src/client/service.ts +1 -1
  69. package/src/client/sidebar.module.css +68 -0
  70. package/src/client/state.ts +56 -10
  71. package/src/client/terminal-font.ts +34 -3
  72. package/src/context-types.ts +3 -2
  73. package/src/fs-operations.ts +126 -4
  74. package/src/git.ts +56 -3
  75. package/src/index.ts +82 -7
  76. package/src/open-external.ts +3 -4
  77. package/src/prefs-shared.ts +7 -5
  78. package/src/pty-manager.ts +176 -5
  79. package/src/sidechat-routes.ts +13 -1
  80. package/src/tools.ts +24 -6
  81. package/src/wire.ts +3 -0
@@ -112,6 +112,21 @@ export declare function log(cwd: string, count?: number, skip?: number, selected
112
112
  * revision has no such path (a new/untracked file has no HEAD side).
113
113
  */
114
114
  export declare function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null>;
115
+ /**
116
+ * Both sides' full file contents for a diff-fold expansion. `path` is
117
+ * repo-relative. The sides resolve per diff kind: a commit reads
118
+ * `<hash>^` vs `<hash>`; a staged change reads HEAD vs the index (`:`);
119
+ * an unstaged change reads HEAD vs the working tree file on disk (a side
120
+ * that does not exist — untracked, deleted, binary-refused — comes back
121
+ * null and the client degrades the fold to a static marker).
122
+ */
123
+ export declare function foldContents(cwd: string, path: string, opts?: {
124
+ staged?: boolean;
125
+ hash?: string;
126
+ }, selected?: string): Promise<{
127
+ old: string | null;
128
+ new: string | null;
129
+ }>;
115
130
  /** Full patch text of one commit (`git show` with the commit header suppressed).
116
131
  * Merge commits show their diff against the first parent (`-m --first-parent`
117
132
  * is a no-op for regular commits), so a history click always has content. */
@@ -45,3 +45,12 @@ export interface SidebarSettingsFace {
45
45
  * {@link resolveSidebarConfig}.
46
46
  */
47
47
  export declare function apply(ctx: Context, config?: SidebarConfig): void;
48
+ /**
49
+ * The WS close reason for a failed terminal attach. A missing configured
50
+ * shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
51
+ * capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
52
+ * validates with `Buffer.byteLength`) that the client maps to a localized,
53
+ * actionable banner; every other failure keeps the raw message (the
54
+ * model-side tool errors read it verbatim).
55
+ */
56
+ export declare function wsCloseReasonOf(error: unknown): string;
@@ -14,14 +14,16 @@ export interface SidebarPrefs {
14
14
  /** Default panel width as a percent of the window width (20–60). */
15
15
  defaultWidthPercent: number;
16
16
  /**
17
- * Whether the sidebar auto-activates (opens the panel) and expands the
18
- * Subagent page when the current conversation spawns a new subagent.
17
+ * Whether the sidebar auto-activates the Subagent page when the current
18
+ * conversation spawns a new subagent. Wide viewports also open the panel;
19
+ * narrow viewports prepare the tab without opening the full-screen drawer.
19
20
  */
20
21
  autoOpenSubagent: boolean;
21
22
  /**
22
- * Whether the sidebar auto-activates (opens the panel) and expands the
23
- * Jobs page when a NEW background job appears for the current
24
- * conversation (any new job id, not just the first one).
23
+ * Whether the sidebar auto-activates the Jobs page when a NEW background
24
+ * job appears for the current conversation (any new job id, not just the
25
+ * first one). Wide viewports also open the panel; narrow viewports prepare
26
+ * the tab without opening the full-screen drawer.
25
27
  */
26
28
  autoOpenJobs: boolean;
27
29
  /**
@@ -134,6 +134,41 @@ export interface ShellResolutionOptions {
134
134
  /** File-existence probe override (defaults to `existsSync`). */
135
135
  exists?: (path: string) => boolean;
136
136
  }
137
+ /** Inputs for resolving one configured shell into the executable path passed
138
+ * to node-pty. Injectable so the Windows-only search semantics stay covered
139
+ * on POSIX CI runners. */
140
+ export interface ShellExecutableResolutionOptions {
141
+ /** Platform override (defaults to `process.platform`). */
142
+ platform?: NodeJS.Platform;
143
+ /** Environment override; Windows reads PATH/PATHEXT/SystemRoot plus the
144
+ * PowerShell well-known-location variables. */
145
+ env?: NodeJS.ProcessEnv;
146
+ /** File-existence probe override (defaults to `existsSync`). */
147
+ exists?: (path: string) => boolean;
148
+ }
149
+ /**
150
+ * Resolve the configured shell executable before handing it to node-pty.
151
+ *
152
+ * Windows' native backend does not consistently apply the shell's PATHEXT
153
+ * lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
154
+ * `File not found:` error), so perform the lookup ourselves: an explicit
155
+ * path is accepted as-is when it exists (or with a PATHEXT suffix when the
156
+ * user omitted `.exe`), and a bare name is searched through PATH, System32,
157
+ * and PowerShell's known install directories.
158
+ *
159
+ * POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
160
+ * but a wrong name made the pty die with a bare
161
+ * `[process exited with code N]`, so probe like Windows anyway: a path with
162
+ * a separator must exist; a bare name is searched along PATH (the colon
163
+ * form is fixed by the platform). A miss is a clear, actionable
164
+ * `shell-not-found` error instead of a cryptic exit code.
165
+ *
166
+ * @param shell - the configured shell (settings page or yaml `config.shell`).
167
+ * @param options - platform/env/exists injection points for tests.
168
+ * @returns the executable path passed to node-pty.
169
+ * @throws {SidebarError} `shell-not-found` when no candidate exists.
170
+ */
171
+ export declare function resolveShellExecutable(shell: string, options?: ShellExecutableResolutionOptions): string;
137
172
  /**
138
173
  * The interactive shell for this platform, resolved like a terminal
139
174
  * emulator: an explicitly configured shell (the `shell` config field) wins,
@@ -166,3 +201,21 @@ export declare function shellDisplayName(shell: string): string;
166
201
  * defaults entirely, giving deployments full control over shell startup.
167
202
  */
168
203
  export declare function shellSpawnArgs(configured?: string[]): string[];
204
+ /**
205
+ * Strip ONE pair of surrounding quotes from a configured shell path. Users
206
+ * paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
207
+ * quotes are shell-input syntax, not part of the path. Unpaired quotes and
208
+ * shorter values stay verbatim.
209
+ */
210
+ export declare function unquotePath(value: string): string;
211
+ /**
212
+ * Split a settings-page shell-arguments string into argv with quote-aware
213
+ * grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
214
+ * LITERAL — a backslash is never an escape, so Windows paths survive intact
215
+ * (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
216
+ * The price is that an argument containing a literal quote character cannot
217
+ * be expressed; shell startup arguments never need one. An unclosed quote
218
+ * folds the remainder into the current token (settings input stays
219
+ * forgiving); an empty quote pair yields no argument.
220
+ */
221
+ export declare function splitShellArgs(input: string): string[];
@@ -6,12 +6,16 @@
6
6
  */
7
7
  import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts';
8
8
  /** Machine-readable error codes of the sidebar API. */
9
- export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
9
+ export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'shell-not-found' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
10
10
  /** One API failure with its wire code and HTTP status. */
11
11
  export declare class SidebarError extends Error {
12
12
  readonly code: SidebarErrorCode;
13
13
  readonly status: number;
14
- constructor(code: SidebarErrorCode, message: string, status?: number);
14
+ /** Optional structured context (e.g. `{ shell }` for shell-not-found). */
15
+ readonly meta?: Record<string, string> | undefined;
16
+ constructor(code: SidebarErrorCode, message: string, status?: number,
17
+ /** Optional structured context (e.g. `{ shell }` for shell-not-found). */
18
+ meta?: Record<string, string> | undefined);
15
19
  }
16
20
  /** Success envelope of one API method. */
17
21
  export interface SidebarOk<T> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-coding-sidebar",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes the betterSidebar service for other plugins to register sidebar tabs and file viewers. KCoder-maintained fork of DSH-better-sidebar 0.17.2 (bottom panel removed, upstream-decoupled release line).",
5
5
  "type": "module",
6
6
  "repository": {
package/src/agent-pty.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { randomUUID } from 'node:crypto'
15
15
  import type { IPty } from 'node-pty'
16
- import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
16
+ import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts'
17
17
  import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
18
18
  import { SidebarError } from './wire.ts'
19
19
 
@@ -40,6 +40,79 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num
40
40
  return { cols: clamp(cols), rows: clamp(rows) }
41
41
  }
42
42
 
43
+ /**
44
+ * Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
45
+ */
46
+ interface ResizeGateState {
47
+ /** Whether the pty has produced any output yet (ConPTY flush completed). */
48
+ sawData: boolean
49
+ /** Latest dims requested before the first output; replayed once it arrives. */
50
+ pending?: { cols: number; rows: number }
51
+ }
52
+
53
+ /**
54
+ * node-pty's Windows terminal queues resize calls that arrive before the
55
+ * ConPTY control socket's first data flush (`_deferNoArgs` in
56
+ * windowsTerminal.js). If the pty exits before the queue flushes, the
57
+ * deferred resize throws inside the socket's 'data' handler — uncatchable
58
+ * by any caller and fatal to the host process. POSIX terminals have no such
59
+ * queue (resize is synchronous), so the gate is armed on Windows only:
60
+ * {@link tryResizePty} parks dims requested before the first output and
61
+ * replays them after the flush, when node-pty executes resizes
62
+ * synchronously (and the throw for an exited pty is catchable).
63
+ */
64
+ const ptyResizeGates = new WeakMap<object, ResizeGateState>()
65
+
66
+ /**
67
+ * Arm the Windows pre-ready resize gate for one freshly spawned pty.
68
+ * No-op on POSIX and for injected ptys without `onData`.
69
+ */
70
+ export function armPtyResizeGate(pty: IPty): void {
71
+ if (process.platform !== 'win32') return
72
+ if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
73
+ const state: ResizeGateState = { sawData: false }
74
+ ptyResizeGates.set(pty, state)
75
+ pty.onData(() => {
76
+ if (state.sawData) return
77
+ state.sawData = true
78
+ const dims = state.pending
79
+ state.pending = undefined
80
+ if (dims === undefined) return
81
+ // node-pty's deferred flush shares this very 'data' dispatch; hop past
82
+ // it so the replayed resize executes synchronously (catchable) instead
83
+ // of being queued into the same deferred list we are draining.
84
+ setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Best-effort resize for WebSocket-driven terminal views. Layout animation
90
+ * can briefly produce unusable dimensions, and node-pty can reject a resize
91
+ * after the socket setup's outer try/catch has returned. Ignore that one
92
+ * frame so the host stays alive and a later valid measurement can retry.
93
+ * Returns whether node-pty accepted the resize (or parked it for replay on
94
+ * the first output — the Windows pre-ready window).
95
+ */
96
+ export function tryResizePty(
97
+ pty: Pick<IPty, 'resize'>,
98
+ cols: number,
99
+ rows: number,
100
+ ): boolean {
101
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
102
+ const dims = clampDims(cols, rows)
103
+ const gate = ptyResizeGates.get(pty)
104
+ if (gate !== undefined && !gate.sawData) {
105
+ gate.pending = dims
106
+ return true
107
+ }
108
+ try {
109
+ pty.resize(dims.cols, dims.rows)
110
+ return true
111
+ } catch {
112
+ return false
113
+ }
114
+ }
115
+
43
116
  /**
44
117
  * Serializable snapshot of one agent terminal — the shape the model sees
45
118
  * through `terminal_list` and the sidebar sees through the push endpoint.
@@ -61,6 +134,13 @@ export interface AgentTerminalSnapshot {
61
134
  exitCode?: number | null
62
135
  /** Exit signal name if the process was killed by a signal; null otherwise. */
63
136
  exitSignal?: string | null
137
+
138
+ /**
139
+ * The model's active `terminal_wait_for` on this terminal (the sidebar
140
+ * renders the wait banner from it). Present only while a wait is
141
+ * registered; carries the LATEST wait when several overlap.
142
+ */
143
+ waiting?: { needle: string; since: number }
64
144
  }
65
145
 
66
146
  /** Map a POSIX signal number to its conventional name (best-effort). */
@@ -76,22 +156,56 @@ function signalNameOf(signal: number | null | undefined): string | null {
76
156
  return SIGNAL_NAMES[signal] ?? `signal ${signal}`
77
157
  }
78
158
 
79
- /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
80
- function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
159
+ /**
160
+ * Compile a wait needle into a RegExp. The needle is treated as a JavaScript
161
+ * regular expression; a pattern that fails to compile (e.g. an unbalanced
162
+ * group typed as a literal) degrades to verbatim substring matching so
163
+ * legacy literal needles keep working.
164
+ */
165
+ function compileNeedle(needle: string): RegExp | null {
166
+ try {
167
+ return new RegExp(needle)
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Locate the first occurrence of `needle` in `transcript`, returning its
175
+ * line/column plus the actual matched text — for alternation patterns
176
+ * (e.g. `(BUILD_OK|BUILD_FAIL)`) the match tells which alternative hit.
177
+ * `re` is the precompiled form of `needle` (from {@link compileNeedle});
178
+ * `null` means verbatim substring matching.
179
+ */
180
+ function locateNeedle(
181
+ transcript: string,
182
+ needle: string,
183
+ re: RegExp | null,
184
+ ): { line: number; column: number; match: string } | undefined {
81
185
  if (needle === '') return undefined
82
- const idx = transcript.indexOf(needle)
83
- if (idx === -1) return undefined
186
+ // Regex hit → use the match index/text; literal fallback → indexOf with
187
+ // the needle itself as the matched text.
188
+ let hit: { index: number; text: string } | undefined
189
+ if (re !== null) {
190
+ re.lastIndex = 0
191
+ const m = re.exec(transcript)
192
+ hit = m === null ? undefined : { index: m.index, text: m[0] }
193
+ } else {
194
+ const idx = transcript.indexOf(needle)
195
+ hit = idx === -1 ? undefined : { index: idx, text: needle }
196
+ }
197
+ if (hit === undefined) return undefined
84
198
  // Walk the transcript up to the match index, counting newlines to derive
85
199
  // the 0-based line; the column is the offset within that line.
86
200
  let line = 0
87
201
  let lineStart = 0
88
- for (let i = 0; i < idx; i += 1) {
202
+ for (let i = 0; i < hit.index; i += 1) {
89
203
  if (transcript.charCodeAt(i) === 0x0a /* \n */) {
90
204
  line += 1
91
205
  lineStart = i + 1
92
206
  }
93
207
  }
94
- return { line, column: idx - lineStart }
208
+ return { line, column: hit.index - lineStart, match: hit.text }
95
209
  }
96
210
 
97
211
  /** One live agent terminal. */
@@ -116,6 +230,18 @@ export interface AgentTerminalHandle {
116
230
  exitCode?: number | null
117
231
  /** Exit signal number once known (POSIX only; undefined on Windows). */
118
232
  exitSignal?: number | null
233
+ /** Active wait_for registrations (skip bookkeeping; empty while idle). */
234
+ waits: AgentTerminalActiveWait[]
235
+ }
236
+
237
+ /** One active wait_for registration on a handle (banner + skip bookkeeping). */
238
+ export interface AgentTerminalActiveWait {
239
+ /** The needle being awaited (shown on the sidebar wait banner). */
240
+ needle: string
241
+ /** Epoch ms when the wait registered (age display / debugging). */
242
+ since: number
243
+ /** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
244
+ skipped: boolean
119
245
  }
120
246
 
121
247
  /** Read result shape (mirrors the official tool-pty terminal_read contract). */
@@ -133,14 +259,18 @@ export interface AgentTerminalReadResult {
133
259
  /** Outcome of {@link AgentPtyRegistry.waitFor}. */
134
260
  export type AgentTerminalWaitResult =
135
261
  | {
136
- /** The needle was found in the transcript. */
262
+ /** The pattern that was awaited. */
137
263
  kind: 'found'
138
- /** The matched substring. */
139
264
  needle: string
140
265
  /** 0-based line index (in the retained transcript) where the needle first appeared. */
141
266
  line: number
142
267
  /** 0-based column index within that line where the match starts. */
143
268
  column: number
269
+ /**
270
+ * The text that actually matched — for multi-outcome patterns
271
+ * (e.g. `(BUILD_OK|BUILD_FAIL)`) this tells which alternative matched.
272
+ */
273
+ match: string
144
274
  /** Elapsed wall-clock milliseconds from the wait start to the match. */
145
275
  elapsedMs: number
146
276
  }
@@ -164,6 +294,12 @@ export type AgentTerminalWaitResult =
164
294
  /** The exit signal name, if the process was killed by a signal. */
165
295
  exitSignal?: string | null
166
296
  }
297
+ | {
298
+ /** The user skipped the wait from the sidebar banner. */
299
+ kind: 'skipped'
300
+ /** The needle that was awaited. */
301
+ needle: string
302
+ }
167
303
 
168
304
  /** Snapshot projection of a handle (drops the pty reference and transcript). */
169
305
  export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
@@ -177,6 +313,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
177
313
  out.exitCode = handle.exitCode ?? null
178
314
  out.exitSignal = signalNameOf(handle.exitSignal)
179
315
  }
316
+ const active = handle.waits.at(-1)
317
+ if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
180
318
  return out
181
319
  }
182
320
 
@@ -219,13 +357,15 @@ export class AgentPtyRegistry {
219
357
  ): string {
220
358
  const uuid = randomUUID()
221
359
  const dims = clampDims(cols, rows)
222
- const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
360
+ const executable = resolveShellExecutable(shell ?? this.shell)
361
+ const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
223
362
  name: 'xterm-256color',
224
363
  cols: dims.cols,
225
364
  rows: dims.rows,
226
365
  cwd,
227
366
  env: { ...process.env },
228
367
  })
368
+ armPtyResizeGate(pty)
229
369
  const handle: AgentTerminalHandle = {
230
370
  uuid,
231
371
  sessionId,
@@ -235,6 +375,7 @@ export class AgentPtyRegistry {
235
375
  pty,
236
376
  transcript: '',
237
377
  exited: false,
378
+ waits: [],
238
379
  }
239
380
  pty.onData((data) => {
240
381
  handle.transcript += data
@@ -353,7 +494,11 @@ export class AgentPtyRegistry {
353
494
  resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
354
495
  const handle = this.expect(uuid)
355
496
  const dims = clampDims(cols, rows)
356
- if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
497
+ // Through tryResizePty: node-pty throws on a just-exited pty (the exit
498
+ // event may not have flipped `handle.exited` yet), and on Windows a
499
+ // pre-ready resize must go through the gate instead of node-pty's own
500
+ // deferred queue (which flushes uncatchably inside its socket handler).
501
+ if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
357
502
  return dims
358
503
  }
359
504
 
@@ -377,10 +522,17 @@ export class AgentPtyRegistry {
377
522
  * make event-driven wakeups unreliable. A 50ms poll is fast enough for
378
523
  * interactive use and simple enough to be obviously correct.
379
524
  * @param uuid - terminal to watch.
380
- * @param needle - substring to search for (case-sensitive, verbatim).
525
+ * @param needle - JavaScript regular expression to search for
526
+ * (case-sensitive); a pattern that fails to compile falls back to
527
+ * verbatim substring matching. May cover several outcomes at once
528
+ * (e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure) — the
529
+ * returned `match` reports the text that actually matched, so callers
530
+ * can tell which outcome hit.
381
531
  * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
382
532
  * @param signal - caller-owned cancellation; aborts the wait re-throwing.
383
- * @returns one of `found` / `timeout` / `exited`.
533
+ * A wait can also be skipped by the user from the sidebar banner
534
+ * (`skipWait`), which resolves it with `{kind:'skipped'}`.
535
+ * @returns one of `found` / `timeout` / `exited` / `skipped`.
384
536
  */
385
537
  async waitFor(
386
538
  uuid: string,
@@ -395,37 +547,73 @@ export class AgentPtyRegistry {
395
547
  const timeout = Math.max(100, Math.floor(timeoutMs))
396
548
  const start = Date.now()
397
549
  const deadline = start + timeout
550
+ // Compile the needle once (regex semantics; invalid patterns degrade to
551
+ // verbatim matching) and reuse the compiled form across every poll.
552
+ const re = compileNeedle(needle)
398
553
  // Fast path: already exited, or the needle is already in the transcript
399
554
  // (a `terminal_send` may have produced the expected output before this
400
555
  // call even started).
401
556
  if (handle.exited) {
402
557
  return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
403
558
  }
404
- const firstHit = locateNeedle(handle.transcript, needle)
559
+ const firstHit = locateNeedle(handle.transcript, needle, re)
405
560
  if (firstHit !== undefined) {
406
- return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
561
+ return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
407
562
  }
408
- // Poll loop: check the transcript every 50ms, exit on match / exit /
409
- // abort / timeout. The handle is read live each iteration (its transcript
410
- // and exited fields mutate as the pty produces output).
411
- while (true) {
412
- if (signal?.aborted) signal.throwIfAborted()
413
- if (handle.exited) {
414
- return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
415
- }
416
- const hit = locateNeedle(handle.transcript, needle)
417
- if (hit !== undefined) {
418
- return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
563
+ // Register the active wait so the sidebar can show the wait banner (the
564
+ // snapshot's `waiting` field rides the agent-terminals push). Registered
565
+ // only after the fast paths: a wait that resolves instantly never
566
+ // flashes the banner. The notify() makes every push subscriber (the
567
+ // sidebar view) converge on the new waiting state immediately.
568
+ const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
569
+ handle.waits.push(record)
570
+ this.notify()
571
+ try {
572
+ while (true) {
573
+ if (signal?.aborted) signal.throwIfAborted()
574
+ if (handle.exited) {
575
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
576
+ }
577
+ if (record.skipped) {
578
+ return { kind: 'skipped', needle }
579
+ }
580
+ const hit = locateNeedle(handle.transcript, needle, re)
581
+ if (hit !== undefined) {
582
+ return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
583
+ }
584
+ if (Date.now() >= deadline) {
585
+ return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
586
+ }
587
+ await new Promise(resolve => {
588
+ const t = setTimeout(resolve, 50)
589
+ // Allow the Node process to exit even if the timer is pending.
590
+ if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
591
+ })
419
592
  }
420
- if (Date.now() >= deadline) {
421
- return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
593
+ } finally {
594
+ const index = handle.waits.indexOf(record)
595
+ if (index !== -1) handle.waits.splice(index, 1)
596
+ this.notify()
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Mark every active wait on one terminal as skipped (the sidebar banner's
602
+ * skip button). Each waiting poll loop observes its record's flag within
603
+ * one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
604
+ * is waiting (a stale banner racing a wait that already resolved).
605
+ * @returns the number of waits that transitioned to skipped.
606
+ */
607
+ skipWait(uuid: string): number {
608
+ const handle = this.expect(uuid)
609
+ let count = 0
610
+ for (const record of handle.waits) {
611
+ if (!record.skipped) {
612
+ record.skipped = true
613
+ count += 1
422
614
  }
423
- await new Promise(resolve => {
424
- const t = setTimeout(resolve, 50)
425
- // Allow the Node process to exit even if the timer is pending.
426
- if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
427
- })
428
615
  }
616
+ return count
429
617
  }
430
618
 
431
619
  /**
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
19
19
  import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
20
20
 
21
21
  /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
22
- export const CHUNK_NAMES = ['terminal', 'editor'] as const
22
+ export const CHUNK_NAMES = ['terminal', 'editor', 'locale'] as const
23
23
  export type ChunkName = (typeof CHUNK_NAMES)[number]
24
24
 
25
25
  /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
@@ -103,7 +103,16 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
103
103
  <>
104
104
  {data.untracked !== undefined
105
105
  ? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
106
- : <DiffView diff={data.diff} />}
106
+ : (
107
+ <DiffView
108
+ diff={data.diff}
109
+ foldSource={{
110
+ scope: { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) },
111
+ ref: diff,
112
+ cwd,
113
+ }}
114
+ />
115
+ )}
107
116
  {data.diff === '' && data.untracked === undefined && (
108
117
  <div className={css.gitEmpty}>{t('diffEmpty')}</div>
109
118
  )}