dsh-coding-sidebar 1.0.7 → 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.
- package/README.md +2 -2
- package/lib/client-editor.js +436 -227
- package/lib/client-registry.js +627 -7391
- package/lib/client-terminal.js +234 -182
- package/lib/client.js +634 -7398
- package/lib/index.js +367 -67
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/EditorHost.d.ts +1 -1
- package/lib/types/client/FileTree.d.ts +2 -2
- package/lib/types/client/TreePanel.d.ts +1 -1
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +1 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/EditorHost.tsx +1 -1
- package/src/client/FileTree.tsx +4 -4
- package/src/client/Sidebar.tsx +41 -22
- package/src/client/TerminalView.tsx +13 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +16 -1
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +5 -4
- package/src/client/locales-de.ts +5 -4
- package/src/client/locales-fr.ts +5 -4
- package/src/client/locales-hi.ts +5 -4
- package/src/client/locales-id.ts +5 -4
- package/src/client/locales-it.ts +5 -4
- package/src/client/locales-ja.ts +5 -4
- package/src/client/locales-ko.ts +5 -4
- package/src/client/locales-nl.ts +5 -4
- package/src/client/locales-pl.ts +5 -4
- package/src/client/locales-pt.ts +5 -4
- package/src/client/locales-ru.ts +5 -4
- package/src/client/locales-sv.ts +5 -4
- package/src/client/locales-th.ts +5 -4
- package/src/client/locales-tr.ts +5 -4
- package/src/client/locales-vi.ts +5 -4
- package/src/client/locales-zh-HK.ts +5 -4
- package/src/client/locales-zh-MO.ts +5 -4
- package/src/client/locales-zh-TW.ts +5 -4
- package/src/client/locales.ts +17 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/state.ts +14 -7
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/git.ts +17 -1
- package/src/index.ts +39 -6
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
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
|
-
/**
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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 <
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
-
*
|
|
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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
/**
|
package/src/bundle-route.ts
CHANGED
|
@@ -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). */
|
|
@@ -107,7 +107,7 @@ export function EditorHost(props: {
|
|
|
107
107
|
expanded: string[]
|
|
108
108
|
revealed: string[]
|
|
109
109
|
onToggleDir: (path: string) => void
|
|
110
|
-
onReferenceFile: (path: string) => void
|
|
110
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
111
111
|
}) {
|
|
112
112
|
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
|
|
113
113
|
const path = tab.path ?? ''
|
package/src/client/FileTree.tsx
CHANGED
|
@@ -127,8 +127,8 @@ export function FileTree(props: {
|
|
|
127
127
|
onOpenWith?: (targetId: string, path: string) => void
|
|
128
128
|
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
|
129
129
|
onToggleOpenWithPin?: (targetId: string) => void
|
|
130
|
-
/** Insert `@<relative path>` into the composer draft. */
|
|
131
|
-
onReferenceFile: (path: string) => void
|
|
130
|
+
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
|
131
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
132
132
|
/** Bump to wipe the level cache and reload the visible set. */
|
|
133
133
|
refreshTick: number
|
|
134
134
|
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
|
@@ -310,7 +310,7 @@ export function FileTree(props: {
|
|
|
310
310
|
title={t('referenceFile')}
|
|
311
311
|
onClick={(event) => {
|
|
312
312
|
event.stopPropagation()
|
|
313
|
-
onReferenceFile(entry.path)
|
|
313
|
+
onReferenceFile(entry.path, entry.isDir)
|
|
314
314
|
}}
|
|
315
315
|
>
|
|
316
316
|
{t('referenceFile')}
|
|
@@ -532,7 +532,7 @@ export function FileTree(props: {
|
|
|
532
532
|
title={t('referenceFile')}
|
|
533
533
|
onClick={(event) => {
|
|
534
534
|
event.stopPropagation()
|
|
535
|
-
onReferenceFile(root)
|
|
535
|
+
onReferenceFile(root, true)
|
|
536
536
|
}}
|
|
537
537
|
>
|
|
538
538
|
{t('referenceFile')}
|
package/src/client/Sidebar.tsx
CHANGED
|
@@ -25,7 +25,7 @@ import { useSyncExternalStore } from 'react'
|
|
|
25
25
|
import clsx from 'clsx'
|
|
26
26
|
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
27
27
|
import type { Context, SidebarSessionList } from '../context-types.ts'
|
|
28
|
-
import { appendToDraft } from './conversation-draft.ts'
|
|
28
|
+
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
|
29
29
|
import {
|
|
30
30
|
PANEL_MIN, activateTab, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
|
31
31
|
isAgentTabId, leafWithTab,
|
|
@@ -119,7 +119,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
|
|
119
119
|
* pane; sessionId/cwd cover onReferenceFile). */
|
|
120
120
|
interface TabContentProps extends TabContentMemoKey {
|
|
121
121
|
onToggleDir: (path: string) => void
|
|
122
|
-
onReferenceFile: (path: string) => void
|
|
122
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
123
123
|
ctx: Context
|
|
124
124
|
store: SidebarStore
|
|
125
125
|
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
|
@@ -498,8 +498,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
498
498
|
* Subagent auto-activation: the moment the current conversation spawns its
|
|
499
499
|
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
|
500
500
|
* open" pref is on, and the Subagent tab type is enabled in settings,
|
|
501
|
-
*
|
|
502
|
-
*
|
|
501
|
+
* focus the Subagent page (single-instance: an existing tab is focused,
|
|
502
|
+
* never duplicated). On wide viewports the right panel also expands; on
|
|
503
|
+
* narrow viewports background activity never forces the full-screen drawer
|
|
504
|
+
* open over the chat.
|
|
503
505
|
* Switching to a session that already has subagents never triggers — its
|
|
504
506
|
* baseline starts at the current count — so a deliberate layout is never
|
|
505
507
|
* fought.
|
|
@@ -525,9 +527,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
525
527
|
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
|
526
528
|
if (!store.getPrefs().autoOpenSubagent) return
|
|
527
529
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
//
|
|
530
|
+
// Read the viewport when the delayed activation fires: a resize while
|
|
531
|
+
// the debounce is armed must not let background activity force the
|
|
532
|
+
// narrow full-screen drawer open over the chat.
|
|
533
|
+
if (!isNarrowWidth(window.innerWidth)) {
|
|
534
|
+
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
|
535
|
+
}
|
|
536
|
+
// Pin the landing to the first pane: the auto-activated Subagent page
|
|
537
|
+
// must be ready in the panel that just expanded, not wherever the user
|
|
531
538
|
// last touched.
|
|
532
539
|
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
|
533
540
|
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
|
@@ -545,11 +552,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
545
552
|
/**
|
|
546
553
|
* Job auto-activation: the moment a NEW background job appears for the
|
|
547
554
|
* current conversation (a job id the previous snapshot lacked), the
|
|
548
|
-
* auto-open pref is on, and the Jobs tab type is enabled,
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
555
|
+
* auto-open pref is on, and the Jobs tab type is enabled, focus the Jobs
|
|
556
|
+
* page. The right panel expands only on wide viewports — background
|
|
557
|
+
* activity never forces the narrow full-screen drawer open. Unlike the
|
|
558
|
+
* subagent trigger (0 → N only), ANY new job id triggers: the agent may
|
|
559
|
+
* start several jobs in one session, and each should surface. A fresh page
|
|
560
|
+
* load never triggers — its baseline starts at the current snapshot.
|
|
553
561
|
*/
|
|
554
562
|
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
|
555
563
|
useEffect(() => {
|
|
@@ -559,7 +567,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
559
567
|
if (!detectNewJob(prev, sessionList, sessionId)) return
|
|
560
568
|
if (!store.getPrefs().autoOpenJobs) return
|
|
561
569
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
562
|
-
|
|
570
|
+
if (!isNarrowWidth(window.innerWidth)) {
|
|
571
|
+
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
|
572
|
+
}
|
|
563
573
|
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
|
564
574
|
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
|
565
575
|
}, [sessionList, sessionId, store, ctx])
|
|
@@ -1128,17 +1138,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1128
1138
|
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
|
1129
1139
|
|
|
1130
1140
|
/**
|
|
1131
|
-
* The explorer's @-reference button
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1141
|
+
* The explorer's @-reference button. Directories append the folder mention
|
|
1142
|
+
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
|
|
1143
|
+
* working; files insert a structured chip like the native `@` picker, so
|
|
1144
|
+
* the whole reference stays one link instead of decorating only the
|
|
1145
|
+
* leading folder. Resolves the session-scope ctx and the conversation
|
|
1146
|
+
* input service at click time; a missing service or scope degrades to a
|
|
1147
|
+
* logged no-op, never a crash. Defined above the no-session early return
|
|
1148
|
+
* — a hook must never sit behind a conditional return (React counts hooks
|
|
1149
|
+
* per render).
|
|
1138
1150
|
*/
|
|
1139
|
-
const referenceInChat = useCallback((path: string): void => {
|
|
1151
|
+
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
|
|
1140
1152
|
if (sessionId === undefined) return
|
|
1141
|
-
|
|
1153
|
+
const rel = relativeTo(cwd ?? '', path)
|
|
1154
|
+
if (isDir) {
|
|
1155
|
+
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
|
|
1156
|
+
return
|
|
1157
|
+
}
|
|
1158
|
+
if (!insertFileReference(ctx, sessionId, rel)) {
|
|
1159
|
+
appendToDraft(ctx, sessionId, `@${rel}`)
|
|
1160
|
+
}
|
|
1142
1161
|
}, [ctx, sessionId, cwd])
|
|
1143
1162
|
|
|
1144
1163
|
if (state === undefined || sessionId === undefined) {
|
|
@@ -60,6 +60,13 @@ const FAILURE_LIMIT = 3
|
|
|
60
60
|
*/
|
|
61
61
|
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The WS close-reason prefix the host sends when the CONFIGURED shell was
|
|
65
|
+
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
|
|
66
|
+
* literal in lockstep). The view renders a localized, actionable banner.
|
|
67
|
+
*/
|
|
68
|
+
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
|
|
69
|
+
|
|
63
70
|
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
|
64
71
|
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
|
65
72
|
|
|
@@ -241,6 +248,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
241
248
|
})
|
|
242
249
|
return
|
|
243
250
|
}
|
|
251
|
+
// The configured shell could not be found (settings page or yaml):
|
|
252
|
+
// a localized banner beats the raw English close reason.
|
|
253
|
+
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
|
|
254
|
+
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
|
|
255
|
+
return
|
|
256
|
+
}
|
|
244
257
|
// A server-side refusal carries a close code + reason; retrying it
|
|
245
258
|
// forever would only spin the banner, so surface it with a retry.
|
|
246
259
|
if (event.code === 1011 && event.reason !== '') {
|