@lijian-ui/dsh-term 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/client.js +698 -43875
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +455 -30
  4. package/lib/tsconfig.client.tsbuildinfo +1 -1
  5. package/lib/tsconfig.host.tsbuildinfo +1 -1
  6. package/lib/types/client/client-i18n.d.ts +29 -0
  7. package/lib/types/client/client-i18n.d.ts.map +1 -0
  8. package/lib/types/client/i18n-seat.d.ts +15 -0
  9. package/lib/types/client/i18n-seat.d.ts.map +1 -0
  10. package/lib/types/client/index.d.ts +7 -0
  11. package/lib/types/client/index.d.ts.map +1 -1
  12. package/lib/types/client/term/AnimatedDock.d.ts +10 -20
  13. package/lib/types/client/term/AnimatedDock.d.ts.map +1 -1
  14. package/lib/types/client/term/DockItem.d.ts +15 -0
  15. package/lib/types/client/term/DockItem.d.ts.map +1 -0
  16. package/lib/types/client/term/TerminalPanel.d.ts +8 -3
  17. package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
  18. package/lib/types/core/types.d.ts +1 -0
  19. package/lib/types/core/types.d.ts.map +1 -1
  20. package/lib/types/gateway/i18n.d.ts +17 -0
  21. package/lib/types/gateway/i18n.d.ts.map +1 -0
  22. package/lib/types/host/routes.d.ts +3 -1
  23. package/lib/types/host/routes.d.ts.map +1 -1
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/client/client-i18n.ts +68 -0
  27. package/src/client/i18n-seat.ts +27 -0
  28. package/src/client/index.ts +139 -46
  29. package/src/client/term/AnimatedDock.tsx +27 -114
  30. package/src/client/term/DockItem.tsx +42 -0
  31. package/src/client/term/TerminalPanel.tsx +339 -32
  32. package/src/client/term/api.ts +17 -2
  33. package/src/client/term/chat-helper.ts +28 -0
  34. package/src/client/term/term.module.css +137 -0
  35. package/src/core/types.ts +22 -4
  36. package/src/gateway/i18n.ts +67 -0
  37. package/src/host/pty-service.ts +160 -17
  38. package/src/host/routes.ts +44 -4
  39. package/src/index.ts +31 -1
@@ -8,21 +8,32 @@
8
8
  * the web shell's frame, beside the file-manager panels (preview/explorer)
9
9
  * when present. The panel itself only owns its inner content (header + stage).
10
10
  *
11
- * A-version scope: pure user terminal. No agent integration, no SSH targets
12
- * yetthose are the B-version.
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.
13
14
  * @module dsh-term/client/term/TerminalPanel
14
15
  */
15
16
 
16
- import { useCallback, useEffect, useRef, useState } from 'react'
17
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
17
18
  import type { JSX } from 'react'
18
19
  import { Terminal } from 'xterm'
19
20
  import { FitAddon } from '@xterm/addon-fit'
20
21
  import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
21
- import type { TermEvent, TermSessionInfo } from '../../core/types.ts'
22
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
23
+ import type { ShellInfo, ShellType, TermEvent, TermSessionInfo } from '../../core/types.ts'
22
24
  import type { TermApi } from './api.ts'
25
+ import { appendToConversationDraft } from './chat-helper.ts'
23
26
  import { XTERM_CSS } from './xterm-styles.ts'
24
27
  import css from './term.module.css'
25
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
+
26
37
  /** One open tab: the wire info plus its live xterm handles. */
27
38
  interface Tab {
28
39
  readonly sessionId: string
@@ -32,6 +43,56 @@ interface Tab {
32
43
  readonly wrap: HTMLDivElement
33
44
  }
34
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
+
35
96
  /** Inject the xterm core styles once (idempotent). */
36
97
  const XTERM_STYLE_ID = 'dsh-term-xterm-style'
37
98
  function adoptXtermStyles(): void {
@@ -45,11 +106,21 @@ function adoptXtermStyles(): void {
45
106
  /** Terminal theme matching the shell's light/dark marker. */
46
107
  function themeOf(): Record<string, string> {
47
108
  const dark = document.body.dataset.dsDarkTheme !== undefined
109
+ if (dark) {
110
+ return {
111
+ background: '#0e0e0e',
112
+ foreground: '#ced3da',
113
+ cursor: '#ced3da',
114
+ selectionBackground: 'rgba(255,255,255,0.2)',
115
+ ...DARK_PALETTE,
116
+ }
117
+ }
48
118
  return {
49
- background: dark ? '#0e0e0e' : '#ffffff',
50
- foreground: dark ? '#ced3da' : '#1f2329',
51
- cursor: dark ? '#ced3da' : '#1f2329',
52
- selectionBackground: dark ? 'rgba(255,255,255,0.2)' : 'rgba(22,93,255,0.25)',
119
+ background: '#ffffff',
120
+ foreground: '#1f2329',
121
+ cursor: '#1f2329',
122
+ selectionBackground: 'rgba(22,93,255,0.25)',
123
+ ...LIGHT_PALETTE,
53
124
  }
54
125
  }
55
126
 
@@ -61,19 +132,55 @@ function currentCwd(ctx: ClientContext): string {
61
132
  return typeof cwd === 'string' && cwd !== '' ? cwd : ''
62
133
  }
63
134
 
64
- /** The docked panel: header (title / tabs / new / collapse) + xterm stage. */
135
+ /** The docked panel: header (title / shell / tabs / new / reopen / collapse) + xterm stage. */
65
136
  export function TerminalPanel({
66
- ctx, api, onClose,
67
- }: { ctx: ClientContext; api: TermApi; onClose: () => void }): JSX.Element {
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
+
68
146
  const [tabs, setTabs] = useState<readonly Tab[]>([])
69
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)
70
154
  const stageRef = useRef<HTMLDivElement | null>(null)
155
+ const panelRef = useRef<HTMLDivElement | null>(null)
71
156
  const tabsRef = useRef(tabs)
72
157
  tabsRef.current = tabs
158
+ const floatBtnRef = useRef<HTMLButtonElement | null>(null)
73
159
 
74
160
  // xterm needs its core stylesheet; inject once when the panel mounts.
75
161
  useEffect(() => { adoptXtermStyles() }, [])
76
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
+
77
184
  // Keep every open tab's xterm theme in lockstep with the shell dark marker
78
185
  // (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
79
186
  useEffect(() => {
@@ -91,20 +198,32 @@ export function TerminalPanel({
91
198
  useEffect(() => {
92
199
  const off = api.subscribe((event: TermEvent) => {
93
200
  if (event.kind === 'output') {
94
- const tab = tabsRef.current.find((t) => t.sessionId === event.id)
201
+ const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
95
202
  tab?.term.write(event.data)
96
203
  return
97
204
  }
98
205
  if (event.kind === 'exit') {
99
- const tab = tabsRef.current.find((t) => t.sessionId === event.id)
100
- if (tab !== undefined) tab.term.write(`\r\n[dsh-term] 进程已退出(code ${event.exitCode})\r\n`)
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
101
220
  }
102
221
  })
103
222
  return off
104
223
  }, [api])
105
224
 
106
225
  /** Create a tab: xterm instance + host spawn, then attach output routing. */
107
- const openTab = useCallback(async (): Promise<void> => {
226
+ const openTab = useCallback(async (shell?: ShellType): Promise<void> => {
108
227
  if (stageRef.current === null) return
109
228
  const wrap = document.createElement('div')
110
229
  wrap.className = css.termWrap
@@ -126,19 +245,20 @@ export function TerminalPanel({
126
245
  }
127
246
  // Route typed input straight to the host.
128
247
  term.onData((data) => {
129
- const tab = tabsRef.current.find((t) => t.term === term)
248
+ const tab = tabsRef.current.find((tb) => tb.term === term)
130
249
  if (tab !== undefined) void api.write(tab.sessionId, data)
131
250
  })
132
251
 
133
252
  let session: TermSessionInfo
134
253
  try {
135
254
  session = await api.spawn({
255
+ shell: shell ?? selectedShell,
136
256
  cwd: currentCwd(ctx) || undefined,
137
257
  cols: term.cols,
138
258
  rows: term.rows,
139
259
  })
140
260
  } catch (error) {
141
- term.write(`\r\n[dsh-term] 启动失败: ${String(error)}\r\n`)
261
+ term.write(`\r\n${t('msg.spawnFailed', { 0: String(error) })}\r\n`)
142
262
  return
143
263
  }
144
264
  const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
@@ -147,13 +267,48 @@ export function TerminalPanel({
147
267
  // Sync the first real size back to the PTY (fit() before open may be off).
148
268
  void api.resize(session.id, term.cols, term.rows)
149
269
  term.focus()
150
- }, [api, ctx])
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])
151
306
 
152
- // First tab on mount.
307
+ // First tab on mount (wait until shells are detected so we use the right one).
153
308
  useEffect(() => {
154
- if (tabs.length === 0) void openTab()
309
+ if (tabs.length === 0 && shells !== null) void openTab()
155
310
  // eslint-disable-next-line react-hooks/exhaustive-deps
156
- }, [])
311
+ }, [shells])
157
312
 
158
313
  // Refit the active tab whenever the stage gets a size (window resize, tab
159
314
  // switch, or the docked column being shown after a collapse). A hidden
@@ -162,7 +317,7 @@ export function TerminalPanel({
162
317
  const stage = stageRef.current
163
318
  if (stage === null) return
164
319
  const refit = (): void => {
165
- const tab = tabsRef.current.find((t) => t.sessionId === active)
320
+ const tab = tabsRef.current.find((tb) => tb.sessionId === active)
166
321
  if (tab === undefined) return
167
322
  try {
168
323
  tab.fit.fit()
@@ -182,7 +337,7 @@ export function TerminalPanel({
182
337
  tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
183
338
  }
184
339
  if (active !== null) {
185
- const tab = tabsRef.current.find((t) => t.sessionId === active)
340
+ const tab = tabsRef.current.find((tb) => tb.sessionId === active)
186
341
  if (tab !== undefined) {
187
342
  try {
188
343
  tab.fit.fit()
@@ -195,16 +350,30 @@ export function TerminalPanel({
195
350
  }
196
351
  }, [active, api, tabs])
197
352
 
353
+ /** Close a tab: detach the PTY (keep it alive) and dispose the xterm locally. */
198
354
  const closeTab = (sessionId: string): void => {
199
- const tab = tabsRef.current.find((t) => t.sessionId === sessionId)
355
+ const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
200
356
  if (tab !== undefined) {
201
- void api.close(sessionId)
357
+ void api.detach(sessionId)
202
358
  tab.term.dispose()
203
359
  tab.wrap.remove()
204
360
  }
205
- const next = tabsRef.current.filter((t) => t.sessionId !== sessionId)
361
+ const next = tabsRef.current.filter((tb) => tb.sessionId !== sessionId)
206
362
  setTabs(next)
207
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))
208
377
  }
209
378
 
210
379
  // Panel teardown: kill every host session and dispose the xterm instances.
@@ -220,10 +389,93 @@ export function TerminalPanel({
220
389
  // eslint-disable-next-line react-hooks/exhaustive-deps
221
390
  }, [])
222
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
+
223
457
  return (
224
- <div className={css.col} data-dsh-term="">
458
+ <div className={css.col} data-dsh-term="" ref={panelRef}>
225
459
  <div className={css.toolbar}>
226
- <span className={css.title}>终端</span>
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>
227
479
  <span className={css.tabDivider} />
228
480
  {tabs.map((tab) => (
229
481
  <button
@@ -236,7 +488,7 @@ export function TerminalPanel({
236
488
  <span>{tab.title}</span>
237
489
  <span
238
490
  role="button"
239
- aria-label={`关闭 ${tab.title}`}
491
+ aria-label={t('ui.tab.closeAria', { 0: tab.title })}
240
492
  className={css.tabClose}
241
493
  onClick={(event) => {
242
494
  event.stopPropagation()
@@ -247,13 +499,68 @@ export function TerminalPanel({
247
499
  </span>
248
500
  </button>
249
501
  ))}
250
- <button type="button" className={css.addTab} title="新建终端" onClick={() => void openTab()}>+</button>
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
+ )}
251
541
  <span className={css.spacer} />
252
- <button type="button" className={css.collapse} title="收起" onClick={onClose}>—</button>
542
+ <button type="button" className={css.collapse} title={t('ui.panel.collapseTitle')} onClick={onClose}>—</button>
253
543
  </div>
254
544
  <div className={css.stage} ref={stageRef}>
255
- {tabs.length === 0 && <div className={css.emptyHint}>点击 + 新建终端</div>}
545
+ {tabs.length === 0 && <div className={css.emptyHint}>{t('ui.panel.emptyHint')}</div>}
256
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
+ )}
257
564
  </div>
258
565
  )
259
566
  }
@@ -5,7 +5,7 @@
5
5
  * @module dsh-term/client/term/api
6
6
  */
7
7
 
8
- import type { TermEvent, TermSessionInfo, TermSpawnRequest } from '../../core/types.ts'
8
+ import type { ShellInfo, TermEvent, TermSessionInfo, TermSpawnRequest } from '../../core/types.ts'
9
9
 
10
10
  /** Envelope mirror of the host route layer. */
11
11
  type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
@@ -38,11 +38,26 @@ export class TermApi {
38
38
  return call<{ ok: boolean }>('/dsh-term/resize', { id, cols, rows })
39
39
  }
40
40
 
41
- /** Close one session. */
41
+ /** Close one session (kill the PTY). */
42
42
  close(id: string): Promise<{ ok: boolean }> {
43
43
  return call<{ ok: boolean }>('/dsh-term/close', { id })
44
44
  }
45
45
 
46
+ /** Detach a session (keep the PTY alive, mark as detached). */
47
+ detach(id: string): Promise<{ ok: boolean }> {
48
+ return call<{ ok: boolean }>('/dsh-term/detach', { id })
49
+ }
50
+
51
+ /** Reattach to a detached session; returns the wire info. */
52
+ reattach(id: string): Promise<TermSessionInfo> {
53
+ return call<TermSessionInfo>('/dsh-term/reattach', { id })
54
+ }
55
+
56
+ /** List available shells on the host. */
57
+ shells(): Promise<{ shells: readonly ShellInfo[] }> {
58
+ return call<{ shells: readonly ShellInfo[] }>('/dsh-term/shells')
59
+ }
60
+
46
61
  /** Current session listing (used on reconnect). */
47
62
  list(): Promise<{ sessions: readonly TermSessionInfo[] }> {
48
63
  return call<{ sessions: readonly TermSessionInfo[] }>('/dsh-term/list')
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Conversation integration helper: append terminal selection text to the
3
+ * active session's composer draft. Mirrors file-manager's appendToDraft
4
+ * pattern — resolves the session-scoped input facade and calls setDraft.
5
+ * @module dsh-term/client/term/chat-helper
6
+ */
7
+
8
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
9
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
10
+
11
+ /**
12
+ * Append `text` to the current session's composer draft.
13
+ * Returns false when there is no active session or the conversation service
14
+ * is unavailable.
15
+ */
16
+ export function appendToConversationDraft(ctx: ClientContext, text: string): boolean {
17
+ const snapshot = ctx.sessions.list.getSnapshot()
18
+ const sessionId = snapshot.current as SessionId | undefined
19
+ if (sessionId === undefined) return false
20
+ const actx = ctx.sessions.scope(sessionId)
21
+ if (actx === undefined) return false
22
+ const conversation = (ctx as unknown as { conversation?: { input: { for(c: unknown): { state: { getSnapshot(): { draft: string } }; setDraft(s: string): void } } } }).conversation
23
+ if (conversation === undefined) return false
24
+ const input = conversation.input.for(actx as unknown as ClientContext)
25
+ const draft = input.state.getSnapshot().draft
26
+ input.setDraft(draft.trim() === '' ? text : `${draft}\n${text}`)
27
+ return true
28
+ }