@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.
@@ -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
- /** Terminal theme matching the shell's light/dark marker. */
107
- function themeOf(): Record<string, string> {
108
- const dark = document.body.dataset.dsDarkTheme !== undefined
109
- if (dark) {
110
- return {
111
- background: 'var(--dsw-alias-bg-base)',
112
- foreground: 'var(--dsw-alias-label-secondary)',
113
- cursor: 'var(--dsw-alias-label-secondary)',
114
- selectionBackground: 'rgba(255,255,255,0.2)',
115
- ...DARK_PALETTE,
116
- }
117
- }
118
- return {
119
- background: 'var(--dsw-alias-bg-base)',
120
- foreground: 'var(--dsw-alias-label-primary)',
121
- cursor: 'var(--dsw-alias-label-primary)',
122
- selectionBackground: 'rgba(22,93,255,0.25)',
123
- ...LIGHT_PALETTE,
124
- }
125
- }
126
-
127
- /** Current workspace cwd from the session list ('' when none). */
128
- function currentCwd(ctx: ClientContext): string {
129
- const snapshot = ctx.sessions.list.getSnapshot()
130
- const sessionId = snapshot.current as SessionId | undefined
131
- const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
132
- return typeof cwd === 'string' && cwd !== '' ? cwd : ''
133
- }
134
-
135
- /** The docked panel: header (title / shell / tabs / new / reopen / collapse) + xterm stage. */
136
- export function TerminalPanel({
137
- ctx, api, onClose, t,
138
- }: PanelProps): JSX.Element {
139
- // This panel is rendered via createRoot (not a slot outlet), so it must
140
- // explicitly subscribe to locale revision to re-render on language switch.
141
- useSyncExternalStore(
142
- (cb: () => void) => ctx.locale.subscribe(cb),
143
- () => ctx.locale.getSnapshot().revision,
144
- )
145
-
146
- const [tabs, setTabs] = useState<readonly Tab[]>([])
147
- const [active, setActive] = useState<string | null>(null)
148
- const [shells, setShells] = useState<readonly ShellInfo[] | null>(null)
149
- const [selectedShell, setSelectedShell] = useState<ShellType>('bash')
150
- const [detachedSessions, setDetachedSessions] = useState<readonly TermSessionInfo[]>([])
151
- const [showReopen, setShowReopen] = useState(false)
152
- const [reopenLeft, setReopenLeft] = useState(6)
153
- const [floatBtn, setFloatBtn] = useState<FloatBtn | null>(null)
154
- const stageRef = useRef<HTMLDivElement | null>(null)
155
- const panelRef = useRef<HTMLDivElement | null>(null)
156
- const tabsRef = useRef(tabs)
157
- tabsRef.current = tabs
158
- const floatBtnRef = useRef<HTMLButtonElement | null>(null)
159
-
160
- // xterm needs its core stylesheet; inject once when the panel mounts.
161
- useEffect(() => { adoptXtermStyles() }, [])
162
-
163
- // Detect available shells on mount; default to the first available.
164
- useEffect(() => {
165
- let cancelled = false
166
- void api.shells().then((result) => {
167
- if (cancelled) return
168
- const list = result.shells
169
- setShells(list)
170
- if (list.length > 0) {
171
- const isWin = navigator.userAgent.includes('Windows')
172
- const preferred: ShellType = isWin ? 'powershell' : 'zsh'
173
- const initial = list.some((s) => s.id === preferred)
174
- ? preferred
175
- : list[0].id
176
- setSelectedShell(initial)
177
- }
178
- }).catch(() => {
179
- if (!cancelled) setShells(FALLBACK_SHELLS.map((id) => ({ id, labelKey: `ui.shell.${id}` })))
180
- })
181
- return () => { cancelled = true }
182
- }, [api])
183
-
184
- // Keep every open tab's xterm theme in lockstep with the shell dark marker
185
- // (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
186
- useEffect(() => {
187
- const applyTheme = (): void => {
188
- const theme = themeOf()
189
- for (const tab of tabsRef.current) tab.term.options.theme = theme
190
- }
191
- applyTheme()
192
- const obs = new MutationObserver(applyTheme)
193
- obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
194
- return () => obs.disconnect()
195
- }, [])
196
-
197
- // One global stream subscription; route frames to the right tab by id.
198
- useEffect(() => {
199
- const off = api.subscribe((event: TermEvent) => {
200
- if (event.kind === 'output') {
201
- const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
202
- tab?.term.write(event.data)
203
- return
204
- }
205
- if (event.kind === 'exit') {
206
- const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
207
- if (tab !== undefined) tab.term.write(`\r\n${event.message ?? `\r\n[dsh-term] Process exited (code ${event.exitCode})`}\r\n`)
208
- setDetachedSessions((prev) => prev.filter((s) => s.id !== event.id))
209
- return
210
- }
211
- if (event.kind === 'detached') {
212
- // The host marked a session as detached; update our list.
213
- // The closeTab caller already updates local state; this is a no-op
214
- // for the tab that initiated the detach, but catches external detaches.
215
- return
216
- }
217
- if (event.kind === 'reattached') {
218
- setDetachedSessions((prev) => prev.filter((s) => s.id !== event.session.id))
219
- return
220
- }
221
- })
222
- return off
223
- }, [api])
224
-
225
- /** Create a tab: xterm instance + host spawn, then attach output routing. */
226
- const openTab = useCallback(async (shell?: ShellType): Promise<void> => {
227
- if (stageRef.current === null) return
228
- const wrap = document.createElement('div')
229
- wrap.className = css.termWrap
230
- stageRef.current.appendChild(wrap)
231
- const term = new Terminal({
232
- fontSize: 13,
233
- fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
234
- cursorBlink: true,
235
- theme: themeOf(),
236
- scrollback: 5000,
237
- })
238
- const fit = new FitAddon()
239
- term.loadAddon(fit)
240
- term.open(wrap)
241
- try {
242
- fit.fit()
243
- } catch {
244
- // Not yet in the DOM layout; the first resize below will fit it.
245
- }
246
- // Route typed input straight to the host.
247
- term.onData((data) => {
248
- const tab = tabsRef.current.find((tb) => tb.term === term)
249
- if (tab !== undefined) void api.write(tab.sessionId, data)
250
- })
251
-
252
- let session: TermSessionInfo
253
- try {
254
- session = await api.spawn({
255
- shell: shell ?? selectedShell,
256
- cwd: currentCwd(ctx) || undefined,
257
- cols: term.cols,
258
- rows: term.rows,
259
- })
260
- } catch (error) {
261
- term.write(`\r\n${t('msg.spawnFailed', { 0: String(error) })}\r\n`)
262
- return
263
- }
264
- const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
265
- setTabs((prev) => [...prev, tab])
266
- setActive(session.id)
267
- // Sync the first real size back to the PTY (fit() before open may be off).
268
- void api.resize(session.id, term.cols, term.rows)
269
- term.focus()
270
- }, [api, ctx, selectedShell, t])
271
-
272
- /** Reattach to a detached session: create a fresh xterm and wire it up. */
273
- const reopenTab = useCallback(async (sessionId: string): Promise<void> => {
274
- if (stageRef.current === null) return
275
- let session: TermSessionInfo
276
- try {
277
- session = await api.reattach(sessionId)
278
- } catch {
279
- return
280
- }
281
- const wrap = document.createElement('div')
282
- wrap.className = css.termWrap
283
- stageRef.current.appendChild(wrap)
284
- const term = new Terminal({
285
- fontSize: 13,
286
- fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
287
- cursorBlink: true,
288
- theme: themeOf(),
289
- scrollback: 5000,
290
- })
291
- const fit = new FitAddon()
292
- term.loadAddon(fit)
293
- term.open(wrap)
294
- try { fit.fit() } catch { /* deferred */ }
295
- term.onData((data) => {
296
- const tab = tabsRef.current.find((tb) => tb.term === term)
297
- if (tab !== undefined) void api.write(tab.sessionId, data)
298
- })
299
- const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
300
- setTabs((prev) => [...prev, tab])
301
- setActive(session.id)
302
- void api.resize(session.id, term.cols, term.rows)
303
- setShowReopen(false)
304
- term.focus()
305
- }, [api])
306
-
307
- // First tab on mount (wait until shells are detected so we use the right one).
308
- useEffect(() => {
309
- if (tabs.length === 0 && shells !== null) void openTab()
310
- // eslint-disable-next-line react-hooks/exhaustive-deps
311
- }, [shells])
312
-
313
- // Refit the active tab whenever the stage gets a size (window resize, tab
314
- // switch, or the docked column being shown after a collapse). A hidden
315
- // column reports 0 size, so fit is deferred until it becomes visible.
316
- useEffect(() => {
317
- const stage = stageRef.current
318
- if (stage === null) return
319
- const refit = (): void => {
320
- const tab = tabsRef.current.find((tb) => tb.sessionId === active)
321
- if (tab === undefined) return
322
- try {
323
- tab.fit.fit()
324
- void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
325
- } catch {
326
- // Measurement race on first paint; the next resize retry handles it.
327
- }
328
- }
329
- const observer = new ResizeObserver(refit)
330
- observer.observe(stage)
331
- return () => observer.disconnect()
332
- }, [active, api])
333
-
334
- // Apply the active marker on the DOM wrappers (display toggling).
335
- useEffect(() => {
336
- for (const tab of tabsRef.current) {
337
- tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
338
- }
339
- if (active !== null) {
340
- const tab = tabsRef.current.find((tb) => tb.sessionId === active)
341
- if (tab !== undefined) {
342
- try {
343
- tab.fit.fit()
344
- void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
345
- } catch {
346
- // Ignore measurement races on first paint.
347
- }
348
- tab.term.focus()
349
- }
350
- }
351
- }, [active, api, tabs])
352
-
353
- /** Close a tab: detach the PTY (keep it alive) and dispose the xterm locally. */
354
- const closeTab = (sessionId: string): void => {
355
- const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
356
- if (tab !== undefined) {
357
- void api.detach(sessionId)
358
- tab.term.dispose()
359
- tab.wrap.remove()
360
- }
361
- const next = tabsRef.current.filter((tb) => tb.sessionId !== sessionId)
362
- setTabs(next)
363
- if (active === sessionId) setActive(next[0]?.sessionId ?? null)
364
- // Track the detached session for the reopen dropdown.
365
- setDetachedSessions((prev) => {
366
- if (prev.some((s) => s.id === sessionId)) return prev
367
- const tab2 = tabsRef.current.find((tb) => tb.sessionId === sessionId)
368
- if (tab2 === undefined) return prev
369
- return [...prev, { id: sessionId, title: tab2.title, cwd: '', cols: 80, rows: 24, alive: true, exitCode: null, shell: 'bash', detached: true }]
370
- })
371
- }
372
-
373
- /** Kill a detached session permanently (remove from background list). */
374
- const killDetached = (sessionId: string): void => {
375
- void api.close(sessionId)
376
- setDetachedSessions((prev) => prev.filter((s) => s.id !== sessionId))
377
- }
378
-
379
- // Panel teardown: kill every host session and dispose the xterm instances.
380
- useEffect(() => {
381
- const current = tabsRef.current
382
- return () => {
383
- for (const tab of current) {
384
- void api.close(tab.sessionId)
385
- tab.term.dispose()
386
- tab.wrap.remove()
387
- }
388
- }
389
- // eslint-disable-next-line react-hooks/exhaustive-deps
390
- }, [])
391
-
392
- // Selection → floating "Add to chat" button. xterm renders to canvas so
393
- // there is no DOM selection; we read term.getSelection() on mouseup inside
394
- // the terminal body and position the button at the pointer.
395
- useEffect(() => {
396
- const stage = stageRef.current
397
- const panel = panelRef.current
398
- if (stage === null || panel === null) return
399
-
400
- const onMouseUp = (e: MouseEvent): void => {
401
- if (floatBtnRef.current !== null && floatBtnRef.current.contains(e.target as Node)) return
402
- requestAnimationFrame(() => {
403
- const tab = tabsRef.current.find((tb) => tb.sessionId === active)
404
- const sel = tab?.term.getSelection() ?? ''
405
- if (!sel.trim()) {
406
- setFloatBtn(null)
407
- return
408
- }
409
- const panelRect = panel.getBoundingClientRect()
410
- const top = Math.max(e.clientY - panelRect.top, 44)
411
- const left = Math.min(
412
- Math.max(e.clientX - panelRect.left, 70),
413
- panelRect.width - 70,
414
- )
415
- setFloatBtn({ top, left, content: sel })
416
- })
417
- }
418
-
419
- const onDocMouseDown = (e: MouseEvent): void => {
420
- if (floatBtnRef.current !== null && floatBtnRef.current.contains(e.target as Node)) return
421
- setFloatBtn(null)
422
- }
423
-
424
- stage.addEventListener('mouseup', onMouseUp)
425
- document.addEventListener('mousedown', onDocMouseDown)
426
- return () => {
427
- stage.removeEventListener('mouseup', onMouseUp)
428
- document.removeEventListener('mousedown', onDocMouseDown)
429
- }
430
- }, [active])
431
-
432
- // Hide the float button when the selection is cleared by xterm.
433
- useEffect(() => {
434
- const tab = tabs.find((tb) => tb.sessionId === active)
435
- if (tab === undefined) return
436
- const d = tab.term.onSelectionChange(() => {
437
- if (!tab.term.hasSelection()) setFloatBtn(null)
438
- })
439
- return () => d.dispose()
440
- }, [active, tabs])
441
-
442
- // Close the reopen dropdown on outside click.
443
- useEffect(() => {
444
- if (!showReopen) return
445
- const onDown = (e: MouseEvent): void => {
446
- const target = e.target as HTMLElement
447
- if (target.closest(`[data-reopen-dropdown]`) !== null) return
448
- if (target.closest(`[data-reopen-btn]`) !== null) return
449
- setShowReopen(false)
450
- }
451
- document.addEventListener('mousedown', onDown)
452
- return () => document.removeEventListener('mousedown', onDown)
453
- }, [showReopen])
454
-
455
- const shellOptions = shells ?? FALLBACK_SHELLS.map((id) => ({ id, labelKey: `ui.shell.${id}` }))
456
-
457
- return (
458
- <div className={css.col} data-dsh-term="" ref={panelRef}>
459
- <div className={css.toolbar}>
460
- <span className={css.title}>{t('ui.panel.title')}</span>
461
- <span className={css.tabDivider} />
462
- <select
463
- className={css.shellSelect}
464
- value={selectedShell}
465
- onChange={(e) => {
466
- const next = e.target.value as ShellType
467
- if (next === selectedShell) return
468
- setSelectedShell(next)
469
- if (active !== null) closeTab(active)
470
- void openTab(next)
471
- }}
472
- title={t('ui.panel.shellTitle')}
473
- disabled={shells === null}
474
- >
475
- {shellOptions.map((s) => (
476
- <option key={s.id} value={s.id}>{t(s.labelKey as 'ui.shell.bash')}</option>
477
- ))}
478
- </select>
479
- <span className={css.tabDivider} />
480
- {tabs.map((tab) => (
481
- <button
482
- key={tab.sessionId}
483
- type="button"
484
- className={`${css.tab}${tab.sessionId === active ? ` ${css.tabActive}` : ''}`}
485
- onClick={() => setActive(tab.sessionId)}
486
- title={tab.title}
487
- >
488
- <span>{tab.title}</span>
489
- <span
490
- role="button"
491
- aria-label={t('ui.tab.closeAria', { 0: tab.title })}
492
- className={css.tabClose}
493
- onClick={(event) => {
494
- event.stopPropagation()
495
- closeTab(tab.sessionId)
496
- }}
497
- >
498
- ×
499
- </span>
500
- </button>
501
- ))}
502
- <button type="button" className={css.addTab} title={t('ui.panel.addTabTitle')} onClick={() => void openTab()}>+</button>
503
- {detachedSessions.length > 0 && (
504
- <button
505
- type="button"
506
- data-reopen-btn=""
507
- className={css.reopenBtn}
508
- title={t('ui.panel.reopenTitle')}
509
- onClick={(e) => {
510
- setReopenLeft(e.currentTarget.offsetLeft)
511
- setShowReopen((v) => !v)
512
- }}
513
- >
514
- ↻{detachedSessions.length}
515
- </button>
516
- )}
517
- {showReopen && detachedSessions.length > 0 && (
518
- <div data-reopen-dropdown="" className={css.reopenDropdown} style={{ left: reopenLeft }}>
519
- <div className={css.reopenHeader}>{t('ui.panel.backgroundSessions')}</div>
520
- {detachedSessions.map((s) => (
521
- <div key={s.id} className={css.reopenItem}>
522
- <button
523
- type="button"
524
- className={css.reopenItemBtn}
525
- onClick={() => void reopenTab(s.id)}
526
- >
527
- <span>{s.title}</span>
528
- </button>
529
- <button
530
- type="button"
531
- className={css.reopenItemKill}
532
- title="×"
533
- onClick={() => killDetached(s.id)}
534
- >
535
- ×
536
- </button>
537
- </div>
538
- ))}
539
- </div>
540
- )}
541
- <span className={css.spacer} />
542
- <button type="button" className={css.collapse} title={t('ui.panel.collapseTitle')} onClick={onClose}>—</button>
543
- </div>
544
- <div className={css.stage} ref={stageRef}>
545
- {tabs.length === 0 && <div className={css.emptyHint}>{t('ui.panel.emptyHint')}</div>}
546
- </div>
547
- {floatBtn !== null && (
548
- <button
549
- ref={floatBtnRef}
550
- className={css.addToChatBtn}
551
- style={{ top: floatBtn.top, left: floatBtn.left }}
552
- onMouseDown={(e) => e.preventDefault()}
553
- onClick={() => {
554
- appendToConversationDraft(ctx, floatBtn.content.replace(/\s+$/, ''))
555
- setFloatBtn(null)
556
- const tab = tabsRef.current.find((tb) => tb.sessionId === active)
557
- tab?.term.clearSelection()
558
- }}
559
- title={t('ui.panel.addToChat')}
560
- >
561
- {t('ui.panel.addToChat')}
562
- </button>
563
- )}
564
- </div>
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
+ }