@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.
- package/lib/client.js +468 -87
- package/lib/client.js.map +1 -1
- package/lib/index.js +454 -29
- 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.map +1 -1
- 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 +22 -3
- package/src/client/term/AnimatedDock.tsx +3 -1
- package/src/client/term/TerminalPanel.tsx +327 -44
- 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 {
|
|
@@ -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
|
-
|
|
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
|
-
}:
|
|
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((
|
|
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((
|
|
124
|
-
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
|
|
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((
|
|
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
|
|
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((
|
|
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((
|
|
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((
|
|
355
|
+
const tab = tabsRef.current.find((tb) => tb.sessionId === sessionId)
|
|
224
356
|
if (tab !== undefined) {
|
|
225
|
-
void api.
|
|
357
|
+
void api.detach(sessionId)
|
|
226
358
|
tab.term.dispose()
|
|
227
359
|
tab.wrap.remove()
|
|
228
360
|
}
|
|
229
|
-
const next = tabsRef.current.filter((
|
|
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}
|
|
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={
|
|
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=
|
|
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=
|
|
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}
|
|
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
|
}
|
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
|
+
}
|