@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.
- package/lib/client.js +698 -43875
- package/lib/client.js.map +1 -1
- package/lib/index.js +455 -30
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/client-i18n.d.ts +29 -0
- package/lib/types/client/client-i18n.d.ts.map +1 -0
- package/lib/types/client/i18n-seat.d.ts +15 -0
- package/lib/types/client/i18n-seat.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/AnimatedDock.d.ts +10 -20
- package/lib/types/client/term/AnimatedDock.d.ts.map +1 -1
- package/lib/types/client/term/DockItem.d.ts +15 -0
- package/lib/types/client/term/DockItem.d.ts.map +1 -0
- package/lib/types/client/term/TerminalPanel.d.ts +8 -3
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
- package/lib/types/core/types.d.ts +1 -0
- package/lib/types/core/types.d.ts.map +1 -1
- package/lib/types/gateway/i18n.d.ts +17 -0
- package/lib/types/gateway/i18n.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +3 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/client-i18n.ts +68 -0
- package/src/client/i18n-seat.ts +27 -0
- package/src/client/index.ts +139 -46
- package/src/client/term/AnimatedDock.tsx +27 -114
- package/src/client/term/DockItem.tsx +42 -0
- package/src/client/term/TerminalPanel.tsx +339 -32
- package/src/client/term/api.ts +17 -2
- package/src/client/term/chat-helper.ts +28 -0
- package/src/client/term/term.module.css +137 -0
- package/src/core/types.ts +22 -4
- package/src/gateway/i18n.ts +67 -0
- package/src/host/pty-service.ts +160 -17
- package/src/host/routes.ts +44 -4
- 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
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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:
|
|
50
|
-
foreground:
|
|
51
|
-
cursor:
|
|
52
|
-
selectionBackground:
|
|
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
|
-
}:
|
|
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((
|
|
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((
|
|
100
|
-
if (tab !== undefined) tab.term.write(`\r\n[dsh-term]
|
|
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((
|
|
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
|
|
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((
|
|
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((
|
|
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((
|
|
355
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
|
|
200
356
|
if (tab !== undefined) {
|
|
201
|
-
void api.
|
|
357
|
+
void api.detach(sessionId)
|
|
202
358
|
tab.term.dispose()
|
|
203
359
|
tab.wrap.remove()
|
|
204
360
|
}
|
|
205
|
-
const next = tabsRef.current.filter((
|
|
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}
|
|
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={
|
|
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=
|
|
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=
|
|
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}
|
|
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
|
}
|
package/src/client/term/api.ts
CHANGED
|
@@ -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
|
+
}
|