@lijian-ui/dsh-term 0.2.0 → 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 (35) hide show
  1. package/lib/client.js +468 -87
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +454 -29
  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.map +1 -1
  13. package/lib/types/client/term/TerminalPanel.d.ts +8 -3
  14. package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
  15. package/lib/types/core/types.d.ts +1 -0
  16. package/lib/types/core/types.d.ts.map +1 -1
  17. package/lib/types/gateway/i18n.d.ts +17 -0
  18. package/lib/types/gateway/i18n.d.ts.map +1 -0
  19. package/lib/types/host/routes.d.ts +3 -1
  20. package/lib/types/host/routes.d.ts.map +1 -1
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/client-i18n.ts +68 -0
  24. package/src/client/i18n-seat.ts +27 -0
  25. package/src/client/index.ts +22 -3
  26. package/src/client/term/AnimatedDock.tsx +3 -1
  27. package/src/client/term/TerminalPanel.tsx +327 -44
  28. package/src/client/term/api.ts +17 -2
  29. package/src/client/term/chat-helper.ts +28 -0
  30. package/src/client/term/term.module.css +137 -0
  31. package/src/core/types.ts +22 -4
  32. package/src/gateway/i18n.ts +67 -0
  33. package/src/host/pty-service.ts +160 -17
  34. package/src/host/routes.ts +44 -4
  35. 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 {
@@ -51,6 +112,7 @@ function themeOf(): Record<string, string> {
51
112
  foreground: '#ced3da',
52
113
  cursor: '#ced3da',
53
114
  selectionBackground: 'rgba(255,255,255,0.2)',
115
+ ...DARK_PALETTE,
54
116
  }
55
117
  }
56
118
  return {
@@ -58,22 +120,7 @@ function themeOf(): Record<string, string> {
58
120
  foreground: '#1f2329',
59
121
  cursor: '#1f2329',
60
122
  selectionBackground: 'rgba(22,93,255,0.25)',
61
- black: '#1f2329',
62
- red: '#d4393b',
63
- green: '#167c2e',
64
- yellow: '#b58900',
65
- blue: '#0a4d8c',
66
- magenta: '#a020a0',
67
- cyan: '#0379a6',
68
- white: '#5a5f66',
69
- brightBlack: '#6a6f76',
70
- brightRed: '#e5585a',
71
- brightGreen: '#1a9c3a',
72
- brightYellow: '#d6a200',
73
- brightBlue: '#1a6fcf',
74
- brightMagenta: '#c040c0',
75
- brightCyan: '#1aa0d6',
76
- brightWhite: '#2f353b',
123
+ ...LIGHT_PALETTE,
77
124
  }
78
125
  }
79
126
 
@@ -85,19 +132,55 @@ function currentCwd(ctx: ClientContext): string {
85
132
  return typeof cwd === 'string' && cwd !== '' ? cwd : ''
86
133
  }
87
134
 
88
- /** The docked panel: header (title / tabs / new / collapse) + xterm stage. */
135
+ /** The docked panel: header (title / shell / tabs / new / reopen / collapse) + xterm stage. */
89
136
  export function TerminalPanel({
90
- ctx, api, onClose,
91
- }: { 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
+
92
146
  const [tabs, setTabs] = useState<readonly Tab[]>([])
93
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)
94
154
  const stageRef = useRef<HTMLDivElement | null>(null)
155
+ const panelRef = useRef<HTMLDivElement | null>(null)
95
156
  const tabsRef = useRef(tabs)
96
157
  tabsRef.current = tabs
158
+ const floatBtnRef = useRef<HTMLButtonElement | null>(null)
97
159
 
98
160
  // xterm needs its core stylesheet; inject once when the panel mounts.
99
161
  useEffect(() => { adoptXtermStyles() }, [])
100
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
+
101
184
  // Keep every open tab's xterm theme in lockstep with the shell dark marker
102
185
  // (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
103
186
  useEffect(() => {
@@ -115,20 +198,32 @@ export function TerminalPanel({
115
198
  useEffect(() => {
116
199
  const off = api.subscribe((event: TermEvent) => {
117
200
  if (event.kind === 'output') {
118
- const tab = tabsRef.current.find((t) => t.sessionId === event.id)
201
+ const tab = tabsRef.current.find((tb) => tb.sessionId === event.id)
119
202
  tab?.term.write(event.data)
120
203
  return
121
204
  }
122
205
  if (event.kind === 'exit') {
123
- const tab = tabsRef.current.find((t) => t.sessionId === event.id)
124
- 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
125
220
  }
126
221
  })
127
222
  return off
128
223
  }, [api])
129
224
 
130
225
  /** Create a tab: xterm instance + host spawn, then attach output routing. */
131
- const openTab = useCallback(async (): Promise<void> => {
226
+ const openTab = useCallback(async (shell?: ShellType): Promise<void> => {
132
227
  if (stageRef.current === null) return
133
228
  const wrap = document.createElement('div')
134
229
  wrap.className = css.termWrap
@@ -150,19 +245,20 @@ export function TerminalPanel({
150
245
  }
151
246
  // Route typed input straight to the host.
152
247
  term.onData((data) => {
153
- const tab = tabsRef.current.find((t) => t.term === term)
248
+ const tab = tabsRef.current.find((tb) => tb.term === term)
154
249
  if (tab !== undefined) void api.write(tab.sessionId, data)
155
250
  })
156
251
 
157
252
  let session: TermSessionInfo
158
253
  try {
159
254
  session = await api.spawn({
255
+ shell: shell ?? selectedShell,
160
256
  cwd: currentCwd(ctx) || undefined,
161
257
  cols: term.cols,
162
258
  rows: term.rows,
163
259
  })
164
260
  } catch (error) {
165
- term.write(`\r\n[dsh-term] 启动失败: ${String(error)}\r\n`)
261
+ term.write(`\r\n${t('msg.spawnFailed', { 0: String(error) })}\r\n`)
166
262
  return
167
263
  }
168
264
  const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
@@ -171,13 +267,48 @@ export function TerminalPanel({
171
267
  // Sync the first real size back to the PTY (fit() before open may be off).
172
268
  void api.resize(session.id, term.cols, term.rows)
173
269
  term.focus()
174
- }, [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])
175
306
 
176
- // First tab on mount.
307
+ // First tab on mount (wait until shells are detected so we use the right one).
177
308
  useEffect(() => {
178
- if (tabs.length === 0) void openTab()
309
+ if (tabs.length === 0 && shells !== null) void openTab()
179
310
  // eslint-disable-next-line react-hooks/exhaustive-deps
180
- }, [])
311
+ }, [shells])
181
312
 
182
313
  // Refit the active tab whenever the stage gets a size (window resize, tab
183
314
  // switch, or the docked column being shown after a collapse). A hidden
@@ -186,7 +317,7 @@ export function TerminalPanel({
186
317
  const stage = stageRef.current
187
318
  if (stage === null) return
188
319
  const refit = (): void => {
189
- const tab = tabsRef.current.find((t) => t.sessionId === active)
320
+ const tab = tabsRef.current.find((tb) => tb.sessionId === active)
190
321
  if (tab === undefined) return
191
322
  try {
192
323
  tab.fit.fit()
@@ -206,7 +337,7 @@ export function TerminalPanel({
206
337
  tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
207
338
  }
208
339
  if (active !== null) {
209
- const tab = tabsRef.current.find((t) => t.sessionId === active)
340
+ const tab = tabsRef.current.find((tb) => tb.sessionId === active)
210
341
  if (tab !== undefined) {
211
342
  try {
212
343
  tab.fit.fit()
@@ -219,16 +350,30 @@ export function TerminalPanel({
219
350
  }
220
351
  }, [active, api, tabs])
221
352
 
353
+ /** Close a tab: detach the PTY (keep it alive) and dispose the xterm locally. */
222
354
  const closeTab = (sessionId: string): void => {
223
- const tab = tabsRef.current.find((t) => t.sessionId === sessionId)
355
+ const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
224
356
  if (tab !== undefined) {
225
- void api.close(sessionId)
357
+ void api.detach(sessionId)
226
358
  tab.term.dispose()
227
359
  tab.wrap.remove()
228
360
  }
229
- const next = tabsRef.current.filter((t) => t.sessionId !== sessionId)
361
+ const next = tabsRef.current.filter((tb) => tb.sessionId !== sessionId)
230
362
  setTabs(next)
231
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))
232
377
  }
233
378
 
234
379
  // Panel teardown: kill every host session and dispose the xterm instances.
@@ -244,10 +389,93 @@ export function TerminalPanel({
244
389
  // eslint-disable-next-line react-hooks/exhaustive-deps
245
390
  }, [])
246
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
+
247
457
  return (
248
- <div className={css.col} data-dsh-term="">
458
+ <div className={css.col} data-dsh-term="" ref={panelRef}>
249
459
  <div className={css.toolbar}>
250
- <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>
251
479
  <span className={css.tabDivider} />
252
480
  {tabs.map((tab) => (
253
481
  <button
@@ -260,7 +488,7 @@ export function TerminalPanel({
260
488
  <span>{tab.title}</span>
261
489
  <span
262
490
  role="button"
263
- aria-label={`关闭 ${tab.title}`}
491
+ aria-label={t('ui.tab.closeAria', { 0: tab.title })}
264
492
  className={css.tabClose}
265
493
  onClick={(event) => {
266
494
  event.stopPropagation()
@@ -271,13 +499,68 @@ export function TerminalPanel({
271
499
  </span>
272
500
  </button>
273
501
  ))}
274
- <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
+ )}
275
541
  <span className={css.spacer} />
276
- <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>
277
543
  </div>
278
544
  <div className={css.stage} ref={stageRef}>
279
- {tabs.length === 0 && <div className={css.emptyHint}>点击 + 新建终端</div>}
545
+ {tabs.length === 0 && <div className={css.emptyHint}>{t('ui.panel.emptyHint')}</div>}
280
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
+ )}
281
564
  </div>
282
565
  )
283
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
+ }