@lijian-ui/dsh-term 0.1.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/client.js +7824 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +434 -0
  7. package/lib/tsconfig.client.tsbuildinfo +1 -0
  8. package/lib/tsconfig.host.tsbuildinfo +1 -0
  9. package/lib/types/client/index.d.ts +26 -0
  10. package/lib/types/client/index.d.ts.map +1 -0
  11. package/lib/types/client/term/TerminalPanel.d.ts +24 -0
  12. package/lib/types/client/term/TerminalPanel.d.ts.map +1 -0
  13. package/lib/types/client/term/api.d.ts +34 -0
  14. package/lib/types/client/term/api.d.ts.map +1 -0
  15. package/lib/types/client/term/xterm-styles.d.ts +9 -0
  16. package/lib/types/client/term/xterm-styles.d.ts.map +1 -0
  17. package/lib/types/core/types.d.ts +76 -0
  18. package/lib/types/core/types.d.ts.map +1 -0
  19. package/lib/types/host/loopback.d.ts +25 -0
  20. package/lib/types/host/loopback.d.ts.map +1 -0
  21. package/lib/types/host/pty-service.d.ts +45 -0
  22. package/lib/types/host/pty-service.d.ts.map +1 -0
  23. package/lib/types/host/routes.d.ts +17 -0
  24. package/lib/types/host/routes.d.ts.map +1 -0
  25. package/lib/types/index.d.ts +19 -0
  26. package/lib/types/index.d.ts.map +1 -0
  27. package/lib/types/mount-once.d.ts +25 -0
  28. package/lib/types/mount-once.d.ts.map +1 -0
  29. package/package.json +79 -0
  30. package/src/client/css-modules.d.ts +4 -0
  31. package/src/client/index.ts +223 -0
  32. package/src/client/term/TerminalPanel.tsx +259 -0
  33. package/src/client/term/api.ts +66 -0
  34. package/src/client/term/term.module.css +153 -0
  35. package/src/client/term/xterm-styles.ts +182 -0
  36. package/src/core/types.ts +73 -0
  37. package/src/host/loopback.ts +63 -0
  38. package/src/host/pty-service.ts +131 -0
  39. package/src/host/routes.ts +198 -0
  40. package/src/index.ts +37 -0
  41. package/src/mount-once.ts +48 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * dsh-term browser half: mounts the terminal as a DOCKED column in the web
3
+ * shell's right side — appended as the 6th grid track of the frame, beside
4
+ * the file-manager panels (preview/explorer) when present. This mirrors how
5
+ * file-manager extends the frame grid, so the two panels sit side by side.
6
+ *
7
+ * The frame is a CSS grid with `grid-auto-flow: row` (the default). A bare
8
+ * `appendChild` of a 6th child would WRAP to a 2nd row (off-screen) — so we
9
+ * must EXTEND `grid-template-columns` explicitly, the same way file-manager
10
+ * does. We append a `[dsh-term]` named 6th track after file-manager's 5
11
+ * tracks; the center column is `minmax(0,1fr)`, so it absorbs the 300px and
12
+ * the grid never overflows on wide screens. file-manager ignores any grid
13
+ * string that is not 3 (shell) or 5 (its own) tracks, so our 6-track write is
14
+ * left alone by it — we just re-assert it whenever file-manager rewrites the
15
+ * grid (drag / resize / collapse).
16
+ *
17
+ * Every DOM/runtime failure is logged, never thrown — the web shell fails the
18
+ * whole boot when a plugin apply throws.
19
+ * @module dsh-term/client
20
+ */
21
+
22
+ import { createElement } from 'react'
23
+ import { createRoot } from 'react-dom/client'
24
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
25
+ // Type-only: pulls the ui-slots SlotMap merge (keeps bundle purity gates calm).
26
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
27
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
28
+ import { TermApi } from './term/api.ts'
29
+ import { TerminalPanel } from './term/TerminalPanel.tsx'
30
+
31
+ /** Required services: sessions (for the workspace cwd). */
32
+ export const inject = ['sessions']
33
+
34
+ /** Width of the docked terminal column, in px (the 6th grid track). */
35
+ const TERMINAL_WIDTH = 300
36
+
37
+ /** Locate the frame grid (same heuristic file-manager uses). */
38
+ function findFrame(): HTMLElement | null {
39
+ const stamped = document.querySelector<HTMLElement>('[data-dsh-frame]')
40
+ if (stamped !== null) return stamped
41
+ return document.querySelector<HTMLElement>('[class*="sidebarCol"]')?.parentElement ?? null
42
+ }
43
+
44
+ /**
45
+ * Run `cb` once the frame exists. The shell mounts late (and may rebuild), so
46
+ * wait via a body-level MutationObserver when it is not present yet.
47
+ */
48
+ function whenFrameReady(cb: (frame: HTMLElement) => void): () => void {
49
+ const existing = findFrame()
50
+ if (existing !== null) {
51
+ cb(existing)
52
+ return () => {}
53
+ }
54
+ const obs = new MutationObserver(() => {
55
+ const frame = findFrame()
56
+ if (frame !== null) {
57
+ obs.disconnect()
58
+ cb(frame)
59
+ }
60
+ })
61
+ obs.observe(document.body, { childList: true, subtree: true })
62
+ return () => obs.disconnect()
63
+ }
64
+
65
+ /** Inject the launcher button styles once (the button is a raw DOM node). */
66
+ function adoptLauncherStyles(): void {
67
+ const id = 'dsh-term-launcher-style'
68
+ if (document.getElementById(id) !== null) return
69
+ const tag = document.createElement('style')
70
+ tag.id = id
71
+ tag.textContent = `
72
+ .dsh-term-launcher {
73
+ position: fixed;
74
+ right: 12px;
75
+ bottom: 12px;
76
+ z-index: 40;
77
+ height: 30px;
78
+ padding: 0 14px;
79
+ border-radius: 6px;
80
+ border: 1px solid var(--aion-bg-3, #e5e6eb);
81
+ background: var(--aion-bg-1, #ffffff);
82
+ color: var(--aion-fg-1, #1f2329);
83
+ font-size: 13px;
84
+ font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
85
+ cursor: pointer;
86
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
87
+ }
88
+ .dsh-term-launcher:hover {
89
+ background: var(--aion-bg-2, #f2f3f5);
90
+ }`
91
+ document.head.appendChild(tag)
92
+ }
93
+
94
+ /**
95
+ * Parse a `grid-template-columns` string into raw tokens. Named lines
96
+ * (`[name]`) and sizes are separate tokens — we keep them split so the
97
+ * terminal marker can be stripped reliably. Spaces inside `minmax(...)` /
98
+ * `repeat(...)` are preserved (not split).
99
+ */
100
+ function tokenizeGrid(input: string): string[] {
101
+ const tokens: string[] = []
102
+ let depth = 0
103
+ let current = ''
104
+ for (const char of input) {
105
+ if (char === '(') depth += 1
106
+ if (char === ')') depth = Math.max(0, depth - 1)
107
+ if (char === ' ' && depth === 0) {
108
+ if (current !== '') {
109
+ tokens.push(current)
110
+ current = ''
111
+ }
112
+ continue
113
+ }
114
+ current += char
115
+ }
116
+ if (current !== '') tokens.push(current)
117
+ return tokens
118
+ }
119
+
120
+ /** Remove the terminal's `[dsh-term]` marker line and its following size token. */
121
+ function stripTerminalTrack(tokens: readonly string[]): string[] {
122
+ const out: string[] = []
123
+ for (let i = 0; i < tokens.length; i += 1) {
124
+ if (tokens[i] === '[dsh-term]') {
125
+ i += 1 // skip the size that follows the marker
126
+ continue
127
+ }
128
+ out.push(tokens[i])
129
+ }
130
+ return out
131
+ }
132
+
133
+ /** Apply the browser half. */
134
+ export function apply(ctx: ClientContext): void {
135
+ ctx.effect(() => {
136
+ let disposeFrame: (() => void) | undefined
137
+ const offWait = whenFrameReady((frame) => {
138
+ // The terminal column: appended as the LAST child of the frame grid, so
139
+ // it docks to the far right, beside file-manager's explorer. It becomes
140
+ // the 6th explicit grid track (sized by our `[dsh-term] 300px` write),
141
+ // not an implicit row — that is what makes it visible on the same row.
142
+ const col = document.createElement('div')
143
+ col.dataset.dshTermCol = ''
144
+ col.style.minWidth = '0'
145
+ col.style.height = '100%'
146
+ frame.appendChild(col)
147
+
148
+ // Keep the terminal the rightmost column even if file-manager appends
149
+ // its preview/explorer columns after this plugin mounts.
150
+ const keepLast = new MutationObserver(() => {
151
+ if (col !== frame.lastElementChild) frame.appendChild(col)
152
+ })
153
+ keepLast.observe(frame, { childList: true })
154
+
155
+ // Re-open button (after a collapse). Hidden while the column is open.
156
+ adoptLauncherStyles()
157
+ const launcher = document.createElement('button')
158
+ launcher.type = 'button'
159
+ launcher.className = 'dsh-term-launcher'
160
+ launcher.textContent = '终端'
161
+ launcher.setAttribute('aria-label', '打开终端')
162
+ launcher.style.display = 'none'
163
+ document.body.appendChild(launcher)
164
+
165
+ // Open/closed state. Closed by default (the column is a docked track).
166
+ let terminalOpen = false
167
+
168
+ /**
169
+ * Re-assert the frame grid: keep file-manager's tracks, append our
170
+ * `[dsh-term] 300px` track when open, strip it when collapsed.
171
+ * file-manager only acts on 3- or 5-track grids, so our 6-track write is
172
+ * never clobbered by it — but file-manager DOES overwrite us on every
173
+ * drag/resize/collapse, so we re-run on any frame style change.
174
+ */
175
+ const reconcileGrid = (): void => {
176
+ const inline = frame.style.gridTemplateColumns
177
+ if (inline.trim() === '') return
178
+ let tokens = tokenizeGrid(inline)
179
+ tokens = stripTerminalTrack(tokens)
180
+ const hasFilemgr = frame.querySelector('[data-filemgr-explorer-col]') !== null
181
+ const baseLen = hasFilemgr ? 5 : 3
182
+ // Wait while file-manager has not finalized its 5-track grid yet
183
+ // (e.g. the shell's own 3-track write landed before file-manager
184
+ // re-applied). Touching it now would desync file-manager's width math.
185
+ if (tokens.length !== baseLen) return
186
+ if (terminalOpen) tokens.push('[dsh-term]', `${TERMINAL_WIDTH}px`)
187
+ const next = tokens.join(' ')
188
+ if (next !== frame.style.gridTemplateColumns) {
189
+ frame.style.gridTemplateColumns = next
190
+ }
191
+ }
192
+
193
+ // Re-assert after file-manager rewrites the grid (drag / resize / collapse).
194
+ const styleObserver = new MutationObserver(() => reconcileGrid())
195
+ styleObserver.observe(frame, { attributes: true, attributeFilter: ['style'] })
196
+ // First pass (frame may already carry file-manager's 5-track grid).
197
+ reconcileGrid()
198
+
199
+ const api = new TermApi()
200
+ const root = createRoot(col)
201
+ const setVisible = (open: boolean): void => {
202
+ terminalOpen = open
203
+ col.style.display = open ? 'flex' : 'none'
204
+ launcher.style.display = open ? 'none' : 'flex'
205
+ reconcileGrid()
206
+ }
207
+ launcher.addEventListener('click', () => setVisible(true))
208
+ root.render(createElement(TerminalPanel, { ctx, api, onClose: () => setVisible(false) }))
209
+
210
+ disposeFrame = () => {
211
+ keepLast.disconnect()
212
+ styleObserver.disconnect()
213
+ root.unmount()
214
+ col.remove()
215
+ launcher.remove()
216
+ }
217
+ })
218
+ return () => {
219
+ offWait()
220
+ disposeFrame?.()
221
+ }
222
+ }, 'dsh-term: panel mount')
223
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * dsh-term panel: a multi-tab local terminal (real PTY via node-pty on the
3
+ * host, bridged over /dsh-term/*). One xterm instance per tab; output lands
4
+ * through a single EventSource routed by session id.
5
+ *
6
+ * Mounting: this component renders a DOCKED column (not a floating overlay).
7
+ * The client entry (`index.ts`) appends that column as the last grid track of
8
+ * the web shell's frame, beside the file-manager panels (preview/explorer)
9
+ * when present. The panel itself only owns its inner content (header + stage).
10
+ *
11
+ * A-version scope: pure user terminal. No agent integration, no SSH targets
12
+ * yet — those are the B-version.
13
+ * @module dsh-term/client/term/TerminalPanel
14
+ */
15
+
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+ import type { JSX } from 'react'
18
+ import { Terminal } from 'xterm'
19
+ import { FitAddon } from '@xterm/addon-fit'
20
+ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
21
+ import type { TermEvent, TermSessionInfo } from '../../core/types.ts'
22
+ import type { TermApi } from './api.ts'
23
+ import { XTERM_CSS } from './xterm-styles.ts'
24
+ import css from './term.module.css'
25
+
26
+ /** One open tab: the wire info plus its live xterm handles. */
27
+ interface Tab {
28
+ readonly sessionId: string
29
+ readonly title: string
30
+ readonly term: Terminal
31
+ readonly fit: FitAddon
32
+ readonly wrap: HTMLDivElement
33
+ }
34
+
35
+ /** Inject the xterm core styles once (idempotent). */
36
+ const XTERM_STYLE_ID = 'dsh-term-xterm-style'
37
+ function adoptXtermStyles(): void {
38
+ if (document.getElementById(XTERM_STYLE_ID) !== null) return
39
+ const tag = document.createElement('style')
40
+ tag.id = XTERM_STYLE_ID
41
+ tag.textContent = XTERM_CSS
42
+ document.head.appendChild(tag)
43
+ }
44
+
45
+ /** Terminal theme matching the shell's light/dark marker. */
46
+ function themeOf(): Record<string, string> {
47
+ const dark = document.body.dataset.dsDarkTheme !== undefined
48
+ 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)',
53
+ }
54
+ }
55
+
56
+ /** Current workspace cwd from the session list ('' when none). */
57
+ function currentCwd(ctx: ClientContext): string {
58
+ const snapshot = ctx.sessions.list.getSnapshot()
59
+ const sessionId = snapshot.current as SessionId | undefined
60
+ const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
61
+ return typeof cwd === 'string' && cwd !== '' ? cwd : ''
62
+ }
63
+
64
+ /** The docked panel: header (title / tabs / new / collapse) + xterm stage. */
65
+ export function TerminalPanel({
66
+ ctx, api, onClose,
67
+ }: { ctx: ClientContext; api: TermApi; onClose: () => void }): JSX.Element {
68
+ const [tabs, setTabs] = useState<readonly Tab[]>([])
69
+ const [active, setActive] = useState<string | null>(null)
70
+ const stageRef = useRef<HTMLDivElement | null>(null)
71
+ const tabsRef = useRef(tabs)
72
+ tabsRef.current = tabs
73
+
74
+ // xterm needs its core stylesheet; inject once when the panel mounts.
75
+ useEffect(() => { adoptXtermStyles() }, [])
76
+
77
+ // Keep every open tab's xterm theme in lockstep with the shell dark marker
78
+ // (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
79
+ useEffect(() => {
80
+ const applyTheme = (): void => {
81
+ const theme = themeOf()
82
+ for (const tab of tabsRef.current) tab.term.options.theme = theme
83
+ }
84
+ applyTheme()
85
+ const obs = new MutationObserver(applyTheme)
86
+ obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
87
+ return () => obs.disconnect()
88
+ }, [])
89
+
90
+ // One global stream subscription; route frames to the right tab by id.
91
+ useEffect(() => {
92
+ const off = api.subscribe((event: TermEvent) => {
93
+ if (event.kind === 'output') {
94
+ const tab = tabsRef.current.find((t) => t.sessionId === event.id)
95
+ tab?.term.write(event.data)
96
+ return
97
+ }
98
+ 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`)
101
+ }
102
+ })
103
+ return off
104
+ }, [api])
105
+
106
+ /** Create a tab: xterm instance + host spawn, then attach output routing. */
107
+ const openTab = useCallback(async (): Promise<void> => {
108
+ if (stageRef.current === null) return
109
+ const wrap = document.createElement('div')
110
+ wrap.className = css.termWrap
111
+ stageRef.current.appendChild(wrap)
112
+ const term = new Terminal({
113
+ fontSize: 13,
114
+ fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
115
+ cursorBlink: true,
116
+ theme: themeOf(),
117
+ scrollback: 5000,
118
+ })
119
+ const fit = new FitAddon()
120
+ term.loadAddon(fit)
121
+ term.open(wrap)
122
+ try {
123
+ fit.fit()
124
+ } catch {
125
+ // Not yet in the DOM layout; the first resize below will fit it.
126
+ }
127
+ // Route typed input straight to the host.
128
+ term.onData((data) => {
129
+ const tab = tabsRef.current.find((t) => t.term === term)
130
+ if (tab !== undefined) void api.write(tab.sessionId, data)
131
+ })
132
+
133
+ let session: TermSessionInfo
134
+ try {
135
+ session = await api.spawn({
136
+ cwd: currentCwd(ctx) || undefined,
137
+ cols: term.cols,
138
+ rows: term.rows,
139
+ })
140
+ } catch (error) {
141
+ term.write(`\r\n[dsh-term] 启动失败: ${String(error)}\r\n`)
142
+ return
143
+ }
144
+ const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
145
+ setTabs((prev) => [...prev, tab])
146
+ setActive(session.id)
147
+ // Sync the first real size back to the PTY (fit() before open may be off).
148
+ void api.resize(session.id, term.cols, term.rows)
149
+ term.focus()
150
+ }, [api, ctx])
151
+
152
+ // First tab on mount.
153
+ useEffect(() => {
154
+ if (tabs.length === 0) void openTab()
155
+ // eslint-disable-next-line react-hooks/exhaustive-deps
156
+ }, [])
157
+
158
+ // Refit the active tab whenever the stage gets a size (window resize, tab
159
+ // switch, or the docked column being shown after a collapse). A hidden
160
+ // column reports 0 size, so fit is deferred until it becomes visible.
161
+ useEffect(() => {
162
+ const stage = stageRef.current
163
+ if (stage === null) return
164
+ const refit = (): void => {
165
+ const tab = tabsRef.current.find((t) => t.sessionId === active)
166
+ if (tab === undefined) return
167
+ try {
168
+ tab.fit.fit()
169
+ void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
170
+ } catch {
171
+ // Measurement race on first paint; the next resize retry handles it.
172
+ }
173
+ }
174
+ const observer = new ResizeObserver(refit)
175
+ observer.observe(stage)
176
+ return () => observer.disconnect()
177
+ }, [active, api])
178
+
179
+ // Apply the active marker on the DOM wrappers (display toggling).
180
+ useEffect(() => {
181
+ for (const tab of tabsRef.current) {
182
+ tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
183
+ }
184
+ if (active !== null) {
185
+ const tab = tabsRef.current.find((t) => t.sessionId === active)
186
+ if (tab !== undefined) {
187
+ try {
188
+ tab.fit.fit()
189
+ void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
190
+ } catch {
191
+ // Ignore measurement races on first paint.
192
+ }
193
+ tab.term.focus()
194
+ }
195
+ }
196
+ }, [active, api, tabs])
197
+
198
+ const closeTab = (sessionId: string): void => {
199
+ const tab = tabsRef.current.find((t) => t.sessionId === sessionId)
200
+ if (tab !== undefined) {
201
+ void api.close(sessionId)
202
+ tab.term.dispose()
203
+ tab.wrap.remove()
204
+ }
205
+ const next = tabsRef.current.filter((t) => t.sessionId !== sessionId)
206
+ setTabs(next)
207
+ if (active === sessionId) setActive(next[0]?.sessionId ?? null)
208
+ }
209
+
210
+ // Panel teardown: kill every host session and dispose the xterm instances.
211
+ useEffect(() => {
212
+ const current = tabsRef.current
213
+ return () => {
214
+ for (const tab of current) {
215
+ void api.close(tab.sessionId)
216
+ tab.term.dispose()
217
+ tab.wrap.remove()
218
+ }
219
+ }
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [])
222
+
223
+ return (
224
+ <div className={css.col} data-dsh-term="">
225
+ <div className={css.toolbar}>
226
+ <span className={css.title}>终端</span>
227
+ <span className={css.tabDivider} />
228
+ {tabs.map((tab) => (
229
+ <button
230
+ key={tab.sessionId}
231
+ type="button"
232
+ className={`${css.tab}${tab.sessionId === active ? ` ${css.tabActive}` : ''}`}
233
+ onClick={() => setActive(tab.sessionId)}
234
+ title={tab.title}
235
+ >
236
+ <span>{tab.title}</span>
237
+ <span
238
+ role="button"
239
+ aria-label={`关闭 ${tab.title}`}
240
+ className={css.tabClose}
241
+ onClick={(event) => {
242
+ event.stopPropagation()
243
+ closeTab(tab.sessionId)
244
+ }}
245
+ >
246
+ ×
247
+ </span>
248
+ </button>
249
+ ))}
250
+ <button type="button" className={css.addTab} title="新建终端" onClick={() => void openTab()}>+</button>
251
+ <span className={css.spacer} />
252
+ <button type="button" className={css.collapse} title="收起" onClick={onClose}>—</button>
253
+ </div>
254
+ <div className={css.stage} ref={stageRef}>
255
+ {tabs.length === 0 && <div className={css.emptyHint}>点击 + 新建终端</div>}
256
+ </div>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Browser-half bridge to the /dsh-term/* host routes: fetch envelope calls
3
+ * plus one EventSource for the output/exit stream. Thin and stateless — the
4
+ * panel owns all session state.
5
+ * @module dsh-term/client/term/api
6
+ */
7
+
8
+ import type { TermEvent, TermSessionInfo, TermSpawnRequest } from '../../core/types.ts'
9
+
10
+ /** Envelope mirror of the host route layer. */
11
+ type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
12
+
13
+ async function call<T>(path: string, body?: unknown): Promise<T> {
14
+ const response = await fetch(path, {
15
+ method: body === undefined ? 'GET' : 'POST',
16
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
17
+ body: body === undefined ? undefined : JSON.stringify(body),
18
+ })
19
+ const envelope = await response.json() as Envelope<T>
20
+ if (!envelope.ok) throw new Error(envelope.error.message)
21
+ return envelope.value
22
+ }
23
+
24
+ /** The stateless client bridge. */
25
+ export class TermApi {
26
+ /** Open a session; returns the wire info. */
27
+ spawn(request: TermSpawnRequest): Promise<TermSessionInfo> {
28
+ return call<TermSessionInfo>('/dsh-term/spawn', request)
29
+ }
30
+
31
+ /** Deliver terminal input. */
32
+ write(id: string, data: string): Promise<{ ok: boolean }> {
33
+ return call<{ ok: boolean }>('/dsh-term/write', { id, data })
34
+ }
35
+
36
+ /** Resize one session. */
37
+ resize(id: string, cols: number, rows: number): Promise<{ ok: boolean }> {
38
+ return call<{ ok: boolean }>('/dsh-term/resize', { id, cols, rows })
39
+ }
40
+
41
+ /** Close one session. */
42
+ close(id: string): Promise<{ ok: boolean }> {
43
+ return call<{ ok: boolean }>('/dsh-term/close', { id })
44
+ }
45
+
46
+ /** Current session listing (used on reconnect). */
47
+ list(): Promise<{ sessions: readonly TermSessionInfo[] }> {
48
+ return call<{ sessions: readonly TermSessionInfo[] }>('/dsh-term/list')
49
+ }
50
+
51
+ /**
52
+ * Subscribe to the output/exit stream. Returns the unsubscribe.
53
+ * @param onEvent - receives every pushed event.
54
+ */
55
+ subscribe(onEvent: (event: TermEvent) => void): () => void {
56
+ const source = new EventSource('/dsh-term/events')
57
+ source.addEventListener('term', (event) => {
58
+ try {
59
+ onEvent(JSON.parse((event as MessageEvent).data) as TermEvent)
60
+ } catch {
61
+ // Malformed frame: drop it; the stream stays alive.
62
+ }
63
+ })
64
+ return () => { source.close() }
65
+ }
66
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * dsh-term panel styles. The :global block inlines the required xterm.js
3
+ * core styles (MIT, xterm 5.3.0) because the client build only compiles
4
+ * *.module.css; the shell's --aion tokens drive the panel chrome.
5
+ * @module dsh-term/client/term/term.module
6
+ */
7
+
8
+ .col {
9
+ width: 100%;
10
+ height: 100%;
11
+ display: flex;
12
+ flex-direction: column;
13
+ border-left: 1px solid var(--aion-border-base, #e5e6eb);
14
+ background: var(--aion-bg-1, #f9fafb);
15
+ overflow: hidden;
16
+ font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
17
+ }
18
+
19
+ .title {
20
+ flex: none;
21
+ padding: 0 8px;
22
+ font-size: 12px;
23
+ font-weight: 600;
24
+ color: var(--aion-text-primary, #1f2329);
25
+ user-select: none;
26
+ }
27
+
28
+ .tabDivider {
29
+ flex: none;
30
+ width: 1px;
31
+ height: 16px;
32
+ margin: 0 4px;
33
+ background: var(--aion-border-base, #e5e6eb);
34
+ }
35
+
36
+ .toolbar {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 4px;
40
+ height: 32px;
41
+ padding: 0 6px;
42
+ border-bottom: 1px solid var(--aion-border-base, #e5e6eb);
43
+ background: var(--aion-bg-base, #ffffff);
44
+ flex: none;
45
+ }
46
+
47
+ .tab {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ gap: 6px;
51
+ height: 22px;
52
+ padding: 0 8px;
53
+ border-radius: 5px;
54
+ border: none;
55
+ background: transparent;
56
+ color: var(--aion-text-secondary, #454d5f);
57
+ font-size: 12px;
58
+ cursor: pointer;
59
+ max-width: 200px;
60
+ }
61
+
62
+ .tabActive {
63
+ background: var(--aion-bg-2, #f2f3f5);
64
+ color: var(--aion-text-primary, #000000);
65
+ }
66
+
67
+ .tabClose {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ width: 14px;
72
+ height: 14px;
73
+ border: none;
74
+ border-radius: 3px;
75
+ background: transparent;
76
+ color: var(--aion-text-tertiary, #86909c);
77
+ font-size: 11px;
78
+ line-height: 1;
79
+ cursor: pointer;
80
+ }
81
+
82
+ .tabClose:hover {
83
+ background: var(--aion-bg-3, #e5e6eb);
84
+ color: var(--aion-text-primary, #000000);
85
+ }
86
+
87
+ .addTab {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ width: 22px;
92
+ height: 22px;
93
+ border: none;
94
+ border-radius: 5px;
95
+ background: transparent;
96
+ color: var(--aion-text-secondary, #454d5f);
97
+ font-size: 14px;
98
+ cursor: pointer;
99
+ }
100
+
101
+ .addTab:hover {
102
+ background: var(--aion-bg-2, #f2f3f5);
103
+ }
104
+
105
+ .spacer {
106
+ flex: 1;
107
+ }
108
+
109
+ .collapse {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 22px;
114
+ height: 22px;
115
+ border: none;
116
+ border-radius: 5px;
117
+ background: transparent;
118
+ color: var(--aion-text-secondary, #454d5f);
119
+ font-size: 13px;
120
+ cursor: pointer;
121
+ }
122
+
123
+ .collapse:hover {
124
+ background: var(--aion-bg-2, #f2f3f5);
125
+ }
126
+
127
+ .stage {
128
+ position: relative;
129
+ flex: 1;
130
+ min-height: 0;
131
+ background: var(--aion-bg-base, #ffffff);
132
+ }
133
+
134
+ .termWrap {
135
+ position: absolute;
136
+ inset: 0;
137
+ padding: 4px;
138
+ display: none;
139
+ }
140
+
141
+ .termWrapActive {
142
+ display: block;
143
+ }
144
+
145
+ .emptyHint {
146
+ position: absolute;
147
+ inset: 0;
148
+ display: flex;
149
+ align-items: center;
150
+ justify-content: center;
151
+ color: var(--aion-text-tertiary, #86909c);
152
+ font-size: 13px;
153
+ }