@lijian-ui/dsh-term 0.3.1 → 0.3.2
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/lib/client.js +24 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/client-i18n.d.ts +20 -0
- package/lib/types/client/client-i18n.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +2 -2
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/TerminalPanel.d.ts +4 -3
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
- package/lib/types/client/term/api.d.ts +12 -2
- package/lib/types/client/term/api.d.ts.map +1 -1
- package/lib/types/client/term/chat-helper.d.ts +14 -0
- package/lib/types/client/term/chat-helper.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +23 -3
- package/lib/types/core/types.d.ts.map +1 -1
- package/lib/types/gateway/i18n.d.ts.map +1 -1
- package/lib/types/host/pty-service.d.ts +31 -7
- package/lib/types/host/pty-service.d.ts.map +1 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/client/term/DockItem.tsx +1 -1
- package/src/client/term/TerminalPanel.tsx +574 -566
- package/src/client/term/chat-helper.ts +1 -1
- package/src/client/term/term.module.css +8 -2
- package/src/client/term/xterm-styles.ts +182 -182
|
@@ -1,566 +1,574 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-term panel: a multi-tab local terminal (real PTY via node-pty on the
|
|
3
|
-
* host, bridged over /dsh-term/*). One xterm instance per tab; output lands
|
|
4
|
-
* through a single EventSource routed by session id.
|
|
5
|
-
*
|
|
6
|
-
* Mounting: this component renders a DOCKED column (not a floating overlay).
|
|
7
|
-
* The client entry (`index.ts`) appends that column as the last grid track of
|
|
8
|
-
* the web shell's frame, beside the file-manager panels (preview/explorer)
|
|
9
|
-
* when present. The panel itself only owns its inner content (header + stage).
|
|
10
|
-
*
|
|
11
|
-
* Features: shell selector with availability detection, terminal reuse
|
|
12
|
-
* (close detaches — PTY keeps running; reopen re-attaches), 16-color ANSI
|
|
13
|
-
* palette, safe cwd fallback, selection → add to conversation.
|
|
14
|
-
* @module dsh-term/client/term/TerminalPanel
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
18
|
-
import type { JSX } from 'react'
|
|
19
|
-
import { Terminal } from 'xterm'
|
|
20
|
-
import { FitAddon } from '@xterm/addon-fit'
|
|
21
|
-
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
22
|
-
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
23
|
-
import type { ShellInfo, ShellType, TermEvent, TermSessionInfo } from '../../core/types.ts'
|
|
24
|
-
import type { TermApi } from './api.ts'
|
|
25
|
-
import { appendToConversationDraft } from './chat-helper.ts'
|
|
26
|
-
import { XTERM_CSS } from './xterm-styles.ts'
|
|
27
|
-
import css from './term.module.css'
|
|
28
|
-
|
|
29
|
-
/** Injected props for the panel. */
|
|
30
|
-
interface PanelProps {
|
|
31
|
-
ctx: ClientContext
|
|
32
|
-
api: TermApi
|
|
33
|
-
onClose: () => void
|
|
34
|
-
t: TranslateNS<'dsh-term'>
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** One open tab: the wire info plus its live xterm handles. */
|
|
38
|
-
interface Tab {
|
|
39
|
-
readonly sessionId: string
|
|
40
|
-
readonly title: string
|
|
41
|
-
readonly term: Terminal
|
|
42
|
-
readonly fit: FitAddon
|
|
43
|
-
readonly wrap: HTMLDivElement
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Floating "Add to chat" button state. */
|
|
47
|
-
interface FloatBtn {
|
|
48
|
-
readonly top: number
|
|
49
|
-
readonly left: number
|
|
50
|
-
readonly content: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** All shell kinds in display order (used when detection fails). */
|
|
54
|
-
const FALLBACK_SHELLS: readonly ShellType[] = ['zsh', 'bash', 'gitbash', 'powershell', 'cmd']
|
|
55
|
-
|
|
56
|
-
/** 16-color ANSI palette for dark backgrounds (One Dark inspired). */
|
|
57
|
-
const DARK_PALETTE: Record<string, string> = {
|
|
58
|
-
black: '#282c34',
|
|
59
|
-
red: '#e06c75',
|
|
60
|
-
green: '#98c379',
|
|
61
|
-
yellow: '#e5c07b',
|
|
62
|
-
blue: '#61afef',
|
|
63
|
-
magenta: '#c678dd',
|
|
64
|
-
cyan: '#56b6c2',
|
|
65
|
-
white: '#abb2bf',
|
|
66
|
-
brightBlack: '#5c6370',
|
|
67
|
-
brightRed: '#ef6e7e',
|
|
68
|
-
brightGreen: '#a3d978',
|
|
69
|
-
brightYellow: '#f0c674',
|
|
70
|
-
brightBlue: '#7ab8f5',
|
|
71
|
-
brightMagenta: '#d68adf',
|
|
72
|
-
brightCyan: '#6fd0dc',
|
|
73
|
-
brightWhite: '#d7dae0',
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** 16-color ANSI palette for light backgrounds (Nord inspired). */
|
|
77
|
-
const LIGHT_PALETTE: Record<string, string> = {
|
|
78
|
-
black: '#3b4252',
|
|
79
|
-
red: '#bf616a',
|
|
80
|
-
green: '#a3be8c',
|
|
81
|
-
yellow: '#ebcb8b',
|
|
82
|
-
blue: '#81a1c1',
|
|
83
|
-
magenta: '#b48ead',
|
|
84
|
-
cyan: '#8fbcbb',
|
|
85
|
-
white: '#4c566a',
|
|
86
|
-
brightBlack: '#4c566a',
|
|
87
|
-
brightRed: '#bf616a',
|
|
88
|
-
brightGreen: '#a3be8c',
|
|
89
|
-
brightYellow: '#ebcb8b',
|
|
90
|
-
brightBlue: '#81a1c1',
|
|
91
|
-
brightMagenta: '#b48ead',
|
|
92
|
-
brightCyan: '#8fbcbb',
|
|
93
|
-
brightWhite: '#2e3440',
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Inject the xterm core styles once (idempotent). */
|
|
97
|
-
const XTERM_STYLE_ID = 'dsh-term-xterm-style'
|
|
98
|
-
function adoptXtermStyles(): void {
|
|
99
|
-
if (document.getElementById(XTERM_STYLE_ID) !== null) return
|
|
100
|
-
const tag = document.createElement('style')
|
|
101
|
-
tag.id = XTERM_STYLE_ID
|
|
102
|
-
tag.textContent = XTERM_CSS
|
|
103
|
-
document.head.appendChild(tag)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
term
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
term.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
term
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
})
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
useEffect(() => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
onClick={() =>
|
|
534
|
-
>
|
|
535
|
-
|
|
536
|
-
</button>
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
{
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-term panel: a multi-tab local terminal (real PTY via node-pty on the
|
|
3
|
+
* host, bridged over /dsh-term/*). One xterm instance per tab; output lands
|
|
4
|
+
* through a single EventSource routed by session id.
|
|
5
|
+
*
|
|
6
|
+
* Mounting: this component renders a DOCKED column (not a floating overlay).
|
|
7
|
+
* The client entry (`index.ts`) appends that column as the last grid track of
|
|
8
|
+
* the web shell's frame, beside the file-manager panels (preview/explorer)
|
|
9
|
+
* when present. The panel itself only owns its inner content (header + stage).
|
|
10
|
+
*
|
|
11
|
+
* Features: shell selector with availability detection, terminal reuse
|
|
12
|
+
* (close detaches — PTY keeps running; reopen re-attaches), 16-color ANSI
|
|
13
|
+
* palette, safe cwd fallback, selection → add to conversation.
|
|
14
|
+
* @module dsh-term/client/term/TerminalPanel
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
18
|
+
import type { JSX } from 'react'
|
|
19
|
+
import { Terminal } from 'xterm'
|
|
20
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
21
|
+
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
22
|
+
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
23
|
+
import type { ShellInfo, ShellType, TermEvent, TermSessionInfo } from '../../core/types.ts'
|
|
24
|
+
import type { TermApi } from './api.ts'
|
|
25
|
+
import { appendToConversationDraft } from './chat-helper.ts'
|
|
26
|
+
import { XTERM_CSS } from './xterm-styles.ts'
|
|
27
|
+
import css from './term.module.css'
|
|
28
|
+
|
|
29
|
+
/** Injected props for the panel. */
|
|
30
|
+
interface PanelProps {
|
|
31
|
+
ctx: ClientContext
|
|
32
|
+
api: TermApi
|
|
33
|
+
onClose: () => void
|
|
34
|
+
t: TranslateNS<'dsh-term'>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One open tab: the wire info plus its live xterm handles. */
|
|
38
|
+
interface Tab {
|
|
39
|
+
readonly sessionId: string
|
|
40
|
+
readonly title: string
|
|
41
|
+
readonly term: Terminal
|
|
42
|
+
readonly fit: FitAddon
|
|
43
|
+
readonly wrap: HTMLDivElement
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Floating "Add to chat" button state. */
|
|
47
|
+
interface FloatBtn {
|
|
48
|
+
readonly top: number
|
|
49
|
+
readonly left: number
|
|
50
|
+
readonly content: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** All shell kinds in display order (used when detection fails). */
|
|
54
|
+
const FALLBACK_SHELLS: readonly ShellType[] = ['zsh', 'bash', 'gitbash', 'powershell', 'cmd']
|
|
55
|
+
|
|
56
|
+
/** 16-color ANSI palette for dark backgrounds (One Dark inspired). */
|
|
57
|
+
const DARK_PALETTE: Record<string, string> = {
|
|
58
|
+
black: '#282c34',
|
|
59
|
+
red: '#e06c75',
|
|
60
|
+
green: '#98c379',
|
|
61
|
+
yellow: '#e5c07b',
|
|
62
|
+
blue: '#61afef',
|
|
63
|
+
magenta: '#c678dd',
|
|
64
|
+
cyan: '#56b6c2',
|
|
65
|
+
white: '#abb2bf',
|
|
66
|
+
brightBlack: '#5c6370',
|
|
67
|
+
brightRed: '#ef6e7e',
|
|
68
|
+
brightGreen: '#a3d978',
|
|
69
|
+
brightYellow: '#f0c674',
|
|
70
|
+
brightBlue: '#7ab8f5',
|
|
71
|
+
brightMagenta: '#d68adf',
|
|
72
|
+
brightCyan: '#6fd0dc',
|
|
73
|
+
brightWhite: '#d7dae0',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 16-color ANSI palette for light backgrounds (Nord inspired). */
|
|
77
|
+
const LIGHT_PALETTE: Record<string, string> = {
|
|
78
|
+
black: '#3b4252',
|
|
79
|
+
red: '#bf616a',
|
|
80
|
+
green: '#a3be8c',
|
|
81
|
+
yellow: '#ebcb8b',
|
|
82
|
+
blue: '#81a1c1',
|
|
83
|
+
magenta: '#b48ead',
|
|
84
|
+
cyan: '#8fbcbb',
|
|
85
|
+
white: '#4c566a',
|
|
86
|
+
brightBlack: '#4c566a',
|
|
87
|
+
brightRed: '#bf616a',
|
|
88
|
+
brightGreen: '#a3be8c',
|
|
89
|
+
brightYellow: '#ebcb8b',
|
|
90
|
+
brightBlue: '#81a1c1',
|
|
91
|
+
brightMagenta: '#b48ead',
|
|
92
|
+
brightCyan: '#8fbcbb',
|
|
93
|
+
brightWhite: '#2e3440',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Inject the xterm core styles once (idempotent). */
|
|
97
|
+
const XTERM_STYLE_ID = 'dsh-term-xterm-style'
|
|
98
|
+
function adoptXtermStyles(): void {
|
|
99
|
+
if (document.getElementById(XTERM_STYLE_ID) !== null) return
|
|
100
|
+
const tag = document.createElement('style')
|
|
101
|
+
tag.id = XTERM_STYLE_ID
|
|
102
|
+
tag.textContent = XTERM_CSS
|
|
103
|
+
document.head.appendChild(tag)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve a CSS custom property to its computed value (xterm canvas needs real colors, not var()). */
|
|
107
|
+
function resolveVar(name: string): string {
|
|
108
|
+
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Terminal theme matching the shell's light/dark marker. */
|
|
112
|
+
function themeOf(): Record<string, string> {
|
|
113
|
+
const dark = document.body.dataset.dsDarkTheme !== undefined
|
|
114
|
+
const bg = resolveVar('--dsw-alias-bg-base')
|
|
115
|
+
if (dark) {
|
|
116
|
+
const fg = resolveVar('--dsw-alias-label-secondary')
|
|
117
|
+
return {
|
|
118
|
+
background: bg,
|
|
119
|
+
foreground: fg,
|
|
120
|
+
cursor: fg,
|
|
121
|
+
selectionBackground: 'rgba(255,255,255,0.2)',
|
|
122
|
+
...DARK_PALETTE,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fg = resolveVar('--dsw-alias-label-primary')
|
|
126
|
+
return {
|
|
127
|
+
background: bg,
|
|
128
|
+
foreground: fg,
|
|
129
|
+
cursor: fg,
|
|
130
|
+
selectionBackground: 'rgba(22,93,255,0.25)',
|
|
131
|
+
...LIGHT_PALETTE,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Current workspace cwd from the session list ('' when none). */
|
|
136
|
+
function currentCwd(ctx: ClientContext): string {
|
|
137
|
+
const snapshot = ctx.sessions.list.getSnapshot()
|
|
138
|
+
const sessionId = snapshot.current as SessionId | undefined
|
|
139
|
+
const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
|
|
140
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : ''
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The docked panel: header (title / shell / tabs / new / reopen / collapse) + xterm stage. */
|
|
144
|
+
export function TerminalPanel({
|
|
145
|
+
ctx, api, onClose, t,
|
|
146
|
+
}: PanelProps): JSX.Element {
|
|
147
|
+
// This panel is rendered via createRoot (not a slot outlet), so it must
|
|
148
|
+
// explicitly subscribe to locale revision to re-render on language switch.
|
|
149
|
+
useSyncExternalStore(
|
|
150
|
+
(cb: () => void) => ctx.locale.subscribe(cb),
|
|
151
|
+
() => ctx.locale.getSnapshot().revision,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const [tabs, setTabs] = useState<readonly Tab[]>([])
|
|
155
|
+
const [active, setActive] = useState<string | null>(null)
|
|
156
|
+
const [shells, setShells] = useState<readonly ShellInfo[] | null>(null)
|
|
157
|
+
const [selectedShell, setSelectedShell] = useState<ShellType>('bash')
|
|
158
|
+
const [detachedSessions, setDetachedSessions] = useState<readonly TermSessionInfo[]>([])
|
|
159
|
+
const [showReopen, setShowReopen] = useState(false)
|
|
160
|
+
const [reopenLeft, setReopenLeft] = useState(6)
|
|
161
|
+
const [floatBtn, setFloatBtn] = useState<FloatBtn | null>(null)
|
|
162
|
+
const stageRef = useRef<HTMLDivElement | null>(null)
|
|
163
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
164
|
+
const tabsRef = useRef(tabs)
|
|
165
|
+
tabsRef.current = tabs
|
|
166
|
+
const floatBtnRef = useRef<HTMLButtonElement | null>(null)
|
|
167
|
+
|
|
168
|
+
// xterm needs its core stylesheet; inject once when the panel mounts.
|
|
169
|
+
useEffect(() => { adoptXtermStyles() }, [])
|
|
170
|
+
|
|
171
|
+
// Detect available shells on mount; default to the first available.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
let cancelled = false
|
|
174
|
+
void api.shells().then((result) => {
|
|
175
|
+
if (cancelled) return
|
|
176
|
+
const list = result.shells
|
|
177
|
+
setShells(list)
|
|
178
|
+
if (list.length > 0) {
|
|
179
|
+
const isWin = navigator.userAgent.includes('Windows')
|
|
180
|
+
const preferred: ShellType = isWin ? 'powershell' : 'zsh'
|
|
181
|
+
const initial = list.some((s) => s.id === preferred)
|
|
182
|
+
? preferred
|
|
183
|
+
: list[0].id
|
|
184
|
+
setSelectedShell(initial)
|
|
185
|
+
}
|
|
186
|
+
}).catch(() => {
|
|
187
|
+
if (!cancelled) setShells(FALLBACK_SHELLS.map((id) => ({ id, labelKey: `ui.shell.${id}` })))
|
|
188
|
+
})
|
|
189
|
+
return () => { cancelled = true }
|
|
190
|
+
}, [api])
|
|
191
|
+
|
|
192
|
+
// Keep every open tab's xterm theme in lockstep with the shell dark marker
|
|
193
|
+
// (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const applyTheme = (): void => {
|
|
196
|
+
const theme = themeOf()
|
|
197
|
+
for (const tab of tabsRef.current) tab.term.options.theme = theme
|
|
198
|
+
}
|
|
199
|
+
applyTheme()
|
|
200
|
+
const obs = new MutationObserver(applyTheme)
|
|
201
|
+
obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
|
202
|
+
return () => obs.disconnect()
|
|
203
|
+
}, [])
|
|
204
|
+
|
|
205
|
+
// One global stream subscription; route frames to the right tab by id.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const off = api.subscribe((event: TermEvent) => {
|
|
208
|
+
if (event.kind === 'output') {
|
|
209
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
|
|
210
|
+
tab?.term.write(event.data)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (event.kind === 'exit') {
|
|
214
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
|
|
215
|
+
if (tab !== undefined) tab.term.write(`\r\n${event.message ?? `\r\n[dsh-term] Process exited (code ${event.exitCode})`}\r\n`)
|
|
216
|
+
setDetachedSessions((prev) => prev.filter((s) => s.id !== event.id))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (event.kind === 'detached') {
|
|
220
|
+
// The host marked a session as detached; update our list.
|
|
221
|
+
// The closeTab caller already updates local state; this is a no-op
|
|
222
|
+
// for the tab that initiated the detach, but catches external detaches.
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (event.kind === 'reattached') {
|
|
226
|
+
setDetachedSessions((prev) => prev.filter((s) => s.id !== event.session.id))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
return off
|
|
231
|
+
}, [api])
|
|
232
|
+
|
|
233
|
+
/** Create a tab: xterm instance + host spawn, then attach output routing. */
|
|
234
|
+
const openTab = useCallback(async (shell?: ShellType): Promise<void> => {
|
|
235
|
+
if (stageRef.current === null) return
|
|
236
|
+
const wrap = document.createElement('div')
|
|
237
|
+
wrap.className = css.termWrap
|
|
238
|
+
stageRef.current.appendChild(wrap)
|
|
239
|
+
const term = new Terminal({
|
|
240
|
+
fontSize: 13,
|
|
241
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
242
|
+
cursorBlink: true,
|
|
243
|
+
theme: themeOf(),
|
|
244
|
+
scrollback: 5000,
|
|
245
|
+
})
|
|
246
|
+
const fit = new FitAddon()
|
|
247
|
+
term.loadAddon(fit)
|
|
248
|
+
term.open(wrap)
|
|
249
|
+
try {
|
|
250
|
+
fit.fit()
|
|
251
|
+
} catch {
|
|
252
|
+
// Not yet in the DOM layout; the first resize below will fit it.
|
|
253
|
+
}
|
|
254
|
+
// Route typed input straight to the host.
|
|
255
|
+
term.onData((data) => {
|
|
256
|
+
const tab = tabsRef.current.find((tb) => tb.term === term)
|
|
257
|
+
if (tab !== undefined) void api.write(tab.sessionId, data)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
let session: TermSessionInfo
|
|
261
|
+
try {
|
|
262
|
+
session = await api.spawn({
|
|
263
|
+
shell: shell ?? selectedShell,
|
|
264
|
+
cwd: currentCwd(ctx) || undefined,
|
|
265
|
+
cols: term.cols,
|
|
266
|
+
rows: term.rows,
|
|
267
|
+
})
|
|
268
|
+
} catch (error) {
|
|
269
|
+
term.write(`\r\n${t('msg.spawnFailed', { 0: String(error) })}\r\n`)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
|
|
273
|
+
setTabs((prev) => [...prev, tab])
|
|
274
|
+
setActive(session.id)
|
|
275
|
+
// Sync the first real size back to the PTY (fit() before open may be off).
|
|
276
|
+
void api.resize(session.id, term.cols, term.rows)
|
|
277
|
+
term.focus()
|
|
278
|
+
}, [api, ctx, selectedShell, t])
|
|
279
|
+
|
|
280
|
+
/** Reattach to a detached session: create a fresh xterm and wire it up. */
|
|
281
|
+
const reopenTab = useCallback(async (sessionId: string): Promise<void> => {
|
|
282
|
+
if (stageRef.current === null) return
|
|
283
|
+
let session: TermSessionInfo
|
|
284
|
+
try {
|
|
285
|
+
session = await api.reattach(sessionId)
|
|
286
|
+
} catch {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
const wrap = document.createElement('div')
|
|
290
|
+
wrap.className = css.termWrap
|
|
291
|
+
stageRef.current.appendChild(wrap)
|
|
292
|
+
const term = new Terminal({
|
|
293
|
+
fontSize: 13,
|
|
294
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
295
|
+
cursorBlink: true,
|
|
296
|
+
theme: themeOf(),
|
|
297
|
+
scrollback: 5000,
|
|
298
|
+
})
|
|
299
|
+
const fit = new FitAddon()
|
|
300
|
+
term.loadAddon(fit)
|
|
301
|
+
term.open(wrap)
|
|
302
|
+
try { fit.fit() } catch { /* deferred */ }
|
|
303
|
+
term.onData((data) => {
|
|
304
|
+
const tab = tabsRef.current.find((tb) => tb.term === term)
|
|
305
|
+
if (tab !== undefined) void api.write(tab.sessionId, data)
|
|
306
|
+
})
|
|
307
|
+
const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
|
|
308
|
+
setTabs((prev) => [...prev, tab])
|
|
309
|
+
setActive(session.id)
|
|
310
|
+
void api.resize(session.id, term.cols, term.rows)
|
|
311
|
+
setShowReopen(false)
|
|
312
|
+
term.focus()
|
|
313
|
+
}, [api])
|
|
314
|
+
|
|
315
|
+
// First tab on mount (wait until shells are detected so we use the right one).
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (tabs.length === 0 && shells !== null) void openTab()
|
|
318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
319
|
+
}, [shells])
|
|
320
|
+
|
|
321
|
+
// Refit the active tab whenever the stage gets a size (window resize, tab
|
|
322
|
+
// switch, or the docked column being shown after a collapse). A hidden
|
|
323
|
+
// column reports 0 size, so fit is deferred until it becomes visible.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
const stage = stageRef.current
|
|
326
|
+
if (stage === null) return
|
|
327
|
+
const refit = (): void => {
|
|
328
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === active)
|
|
329
|
+
if (tab === undefined) return
|
|
330
|
+
try {
|
|
331
|
+
tab.fit.fit()
|
|
332
|
+
void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
|
|
333
|
+
} catch {
|
|
334
|
+
// Measurement race on first paint; the next resize retry handles it.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const observer = new ResizeObserver(refit)
|
|
338
|
+
observer.observe(stage)
|
|
339
|
+
return () => observer.disconnect()
|
|
340
|
+
}, [active, api])
|
|
341
|
+
|
|
342
|
+
// Apply the active marker on the DOM wrappers (display toggling).
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
for (const tab of tabsRef.current) {
|
|
345
|
+
tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
|
|
346
|
+
}
|
|
347
|
+
if (active !== null) {
|
|
348
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === active)
|
|
349
|
+
if (tab !== undefined) {
|
|
350
|
+
try {
|
|
351
|
+
tab.fit.fit()
|
|
352
|
+
void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
|
|
353
|
+
} catch {
|
|
354
|
+
// Ignore measurement races on first paint.
|
|
355
|
+
}
|
|
356
|
+
tab.term.focus()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}, [active, api, tabs])
|
|
360
|
+
|
|
361
|
+
/** Close a tab: detach the PTY (keep it alive) and dispose the xterm locally. */
|
|
362
|
+
const closeTab = (sessionId: string): void => {
|
|
363
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
|
|
364
|
+
if (tab !== undefined) {
|
|
365
|
+
void api.detach(sessionId)
|
|
366
|
+
tab.term.dispose()
|
|
367
|
+
tab.wrap.remove()
|
|
368
|
+
}
|
|
369
|
+
const next = tabsRef.current.filter((tb) => tb.sessionId !== sessionId)
|
|
370
|
+
setTabs(next)
|
|
371
|
+
if (active === sessionId) setActive(next[0]?.sessionId ?? null)
|
|
372
|
+
// Track the detached session for the reopen dropdown.
|
|
373
|
+
setDetachedSessions((prev) => {
|
|
374
|
+
if (prev.some((s) => s.id === sessionId)) return prev
|
|
375
|
+
const tab2 = tabsRef.current.find((tb) => tb.sessionId === sessionId)
|
|
376
|
+
if (tab2 === undefined) return prev
|
|
377
|
+
return [...prev, { id: sessionId, title: tab2.title, cwd: '', cols: 80, rows: 24, alive: true, exitCode: null, shell: 'bash', detached: true }]
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Kill a detached session permanently (remove from background list). */
|
|
382
|
+
const killDetached = (sessionId: string): void => {
|
|
383
|
+
void api.close(sessionId)
|
|
384
|
+
setDetachedSessions((prev) => prev.filter((s) => s.id !== sessionId))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Panel teardown: kill every host session and dispose the xterm instances.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
const current = tabsRef.current
|
|
390
|
+
return () => {
|
|
391
|
+
for (const tab of current) {
|
|
392
|
+
void api.close(tab.sessionId)
|
|
393
|
+
tab.term.dispose()
|
|
394
|
+
tab.wrap.remove()
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
}, [])
|
|
399
|
+
|
|
400
|
+
// Selection → floating "Add to chat" button. xterm renders to canvas so
|
|
401
|
+
// there is no DOM selection; we read term.getSelection() on mouseup inside
|
|
402
|
+
// the terminal body and position the button at the pointer.
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const stage = stageRef.current
|
|
405
|
+
const panel = panelRef.current
|
|
406
|
+
if (stage === null || panel === null) return
|
|
407
|
+
|
|
408
|
+
const onMouseUp = (e: MouseEvent): void => {
|
|
409
|
+
if (floatBtnRef.current !== null && floatBtnRef.current.contains(e.target as Node)) return
|
|
410
|
+
requestAnimationFrame(() => {
|
|
411
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === active)
|
|
412
|
+
const sel = tab?.term.getSelection() ?? ''
|
|
413
|
+
if (!sel.trim()) {
|
|
414
|
+
setFloatBtn(null)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const panelRect = panel.getBoundingClientRect()
|
|
418
|
+
const top = Math.max(e.clientY - panelRect.top, 44)
|
|
419
|
+
const left = Math.min(
|
|
420
|
+
Math.max(e.clientX - panelRect.left, 70),
|
|
421
|
+
panelRect.width - 70,
|
|
422
|
+
)
|
|
423
|
+
setFloatBtn({ top, left, content: sel })
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const onDocMouseDown = (e: MouseEvent): void => {
|
|
428
|
+
if (floatBtnRef.current !== null && floatBtnRef.current.contains(e.target as Node)) return
|
|
429
|
+
setFloatBtn(null)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
stage.addEventListener('mouseup', onMouseUp)
|
|
433
|
+
document.addEventListener('mousedown', onDocMouseDown)
|
|
434
|
+
return () => {
|
|
435
|
+
stage.removeEventListener('mouseup', onMouseUp)
|
|
436
|
+
document.removeEventListener('mousedown', onDocMouseDown)
|
|
437
|
+
}
|
|
438
|
+
}, [active])
|
|
439
|
+
|
|
440
|
+
// Hide the float button when the selection is cleared by xterm.
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
const tab = tabs.find((tb) => tb.sessionId === active)
|
|
443
|
+
if (tab === undefined) return
|
|
444
|
+
const d = tab.term.onSelectionChange(() => {
|
|
445
|
+
if (!tab.term.hasSelection()) setFloatBtn(null)
|
|
446
|
+
})
|
|
447
|
+
return () => d.dispose()
|
|
448
|
+
}, [active, tabs])
|
|
449
|
+
|
|
450
|
+
// Close the reopen dropdown on outside click.
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (!showReopen) return
|
|
453
|
+
const onDown = (e: MouseEvent): void => {
|
|
454
|
+
const target = e.target as HTMLElement
|
|
455
|
+
if (target.closest(`[data-reopen-dropdown]`) !== null) return
|
|
456
|
+
if (target.closest(`[data-reopen-btn]`) !== null) return
|
|
457
|
+
setShowReopen(false)
|
|
458
|
+
}
|
|
459
|
+
document.addEventListener('mousedown', onDown)
|
|
460
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
461
|
+
}, [showReopen])
|
|
462
|
+
|
|
463
|
+
const shellOptions = shells ?? FALLBACK_SHELLS.map((id) => ({ id, labelKey: `ui.shell.${id}` }))
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<div className={css.col} data-dsh-term="" ref={panelRef}>
|
|
467
|
+
<div className={css.toolbar}>
|
|
468
|
+
<span className={css.title}>{t('ui.panel.title')}</span>
|
|
469
|
+
<span className={css.tabDivider} />
|
|
470
|
+
<select
|
|
471
|
+
className={css.shellSelect}
|
|
472
|
+
value={selectedShell}
|
|
473
|
+
onChange={(e) => {
|
|
474
|
+
const next = e.target.value as ShellType
|
|
475
|
+
if (next === selectedShell) return
|
|
476
|
+
setSelectedShell(next)
|
|
477
|
+
if (active !== null) closeTab(active)
|
|
478
|
+
void openTab(next)
|
|
479
|
+
}}
|
|
480
|
+
title={t('ui.panel.shellTitle')}
|
|
481
|
+
disabled={shells === null}
|
|
482
|
+
>
|
|
483
|
+
{shellOptions.map((s) => (
|
|
484
|
+
<option key={s.id} value={s.id}>{t(s.labelKey as 'ui.shell.bash')}</option>
|
|
485
|
+
))}
|
|
486
|
+
</select>
|
|
487
|
+
<span className={css.tabDivider} />
|
|
488
|
+
{tabs.map((tab) => (
|
|
489
|
+
<button
|
|
490
|
+
key={tab.sessionId}
|
|
491
|
+
type="button"
|
|
492
|
+
className={`${css.tab}${tab.sessionId === active ? ` ${css.tabActive}` : ''}`}
|
|
493
|
+
onClick={() => setActive(tab.sessionId)}
|
|
494
|
+
title={tab.title}
|
|
495
|
+
>
|
|
496
|
+
<span>{tab.title}</span>
|
|
497
|
+
<span
|
|
498
|
+
role="button"
|
|
499
|
+
aria-label={t('ui.tab.closeAria', { 0: tab.title })}
|
|
500
|
+
className={css.tabClose}
|
|
501
|
+
onClick={(event) => {
|
|
502
|
+
event.stopPropagation()
|
|
503
|
+
closeTab(tab.sessionId)
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
×
|
|
507
|
+
</span>
|
|
508
|
+
</button>
|
|
509
|
+
))}
|
|
510
|
+
<button type="button" className={css.addTab} title={t('ui.panel.addTabTitle')} onClick={() => void openTab()}>+</button>
|
|
511
|
+
{detachedSessions.length > 0 && (
|
|
512
|
+
<button
|
|
513
|
+
type="button"
|
|
514
|
+
data-reopen-btn=""
|
|
515
|
+
className={css.reopenBtn}
|
|
516
|
+
title={t('ui.panel.reopenTitle')}
|
|
517
|
+
onClick={(e) => {
|
|
518
|
+
setReopenLeft(e.currentTarget.offsetLeft)
|
|
519
|
+
setShowReopen((v) => !v)
|
|
520
|
+
}}
|
|
521
|
+
>
|
|
522
|
+
↻{detachedSessions.length}
|
|
523
|
+
</button>
|
|
524
|
+
)}
|
|
525
|
+
{showReopen && detachedSessions.length > 0 && (
|
|
526
|
+
<div data-reopen-dropdown="" className={css.reopenDropdown} style={{ left: reopenLeft }}>
|
|
527
|
+
<div className={css.reopenHeader}>{t('ui.panel.backgroundSessions')}</div>
|
|
528
|
+
{detachedSessions.map((s) => (
|
|
529
|
+
<div key={s.id} className={css.reopenItem}>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className={css.reopenItemBtn}
|
|
533
|
+
onClick={() => void reopenTab(s.id)}
|
|
534
|
+
>
|
|
535
|
+
<span>{s.title}</span>
|
|
536
|
+
</button>
|
|
537
|
+
<button
|
|
538
|
+
type="button"
|
|
539
|
+
className={css.reopenItemKill}
|
|
540
|
+
title="×"
|
|
541
|
+
onClick={() => killDetached(s.id)}
|
|
542
|
+
>
|
|
543
|
+
×
|
|
544
|
+
</button>
|
|
545
|
+
</div>
|
|
546
|
+
))}
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
<span className={css.spacer} />
|
|
550
|
+
<button type="button" className={css.collapse} title={t('ui.panel.collapseTitle')} onClick={onClose}>—</button>
|
|
551
|
+
</div>
|
|
552
|
+
<div className={css.stage} ref={stageRef}>
|
|
553
|
+
{tabs.length === 0 && <div className={css.emptyHint}>{t('ui.panel.emptyHint')}</div>}
|
|
554
|
+
</div>
|
|
555
|
+
{floatBtn !== null && (
|
|
556
|
+
<button
|
|
557
|
+
ref={floatBtnRef}
|
|
558
|
+
className={css.addToChatBtn}
|
|
559
|
+
style={{ top: floatBtn.top, left: floatBtn.left }}
|
|
560
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
561
|
+
onClick={() => {
|
|
562
|
+
appendToConversationDraft(ctx, floatBtn.content.replace(/\s+$/, ''))
|
|
563
|
+
setFloatBtn(null)
|
|
564
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === active)
|
|
565
|
+
tab?.term.clearSelection()
|
|
566
|
+
}}
|
|
567
|
+
title={t('ui.panel.addToChat')}
|
|
568
|
+
>
|
|
569
|
+
{t('ui.panel.addToChat')}
|
|
570
|
+
</button>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
)
|
|
574
|
+
}
|