@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
@@ -151,3 +151,140 @@
151
151
  color: var(--aion-text-tertiary, #86909c);
152
152
  font-size: 13px;
153
153
  }
154
+
155
+ .shellSelect {
156
+ flex: none;
157
+ height: 22px;
158
+ padding: 0 6px;
159
+ border: 1px solid var(--aion-border-base, #e5e6eb);
160
+ border-radius: 5px;
161
+ background: var(--aion-bg-base, #ffffff);
162
+ color: var(--aion-text-primary, #1f2329);
163
+ font-size: 12px;
164
+ cursor: pointer;
165
+ outline: none;
166
+ }
167
+
168
+ .shellSelect:hover {
169
+ border-color: var(--aion-border-strong, #c9cdd4);
170
+ }
171
+
172
+ .shellSelect:focus {
173
+ border-color: var(--aion-primary, #1677ff);
174
+ }
175
+
176
+ .reopenBtn {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ height: 22px;
181
+ padding: 0 6px;
182
+ border: none;
183
+ border-radius: 5px;
184
+ background: var(--aion-bg-2, #f2f3f5);
185
+ color: var(--aion-text-secondary, #454d5f);
186
+ font-size: 12px;
187
+ cursor: pointer;
188
+ gap: 2px;
189
+ }
190
+
191
+ .reopenBtn:hover {
192
+ background: var(--aion-bg-3, #e5e6eb);
193
+ color: var(--aion-text-primary, #000000);
194
+ }
195
+
196
+ .reopenDropdown {
197
+ position: absolute;
198
+ top: 32px;
199
+ left: 6px;
200
+ z-index: 50;
201
+ min-width: 180px;
202
+ max-width: 280px;
203
+ padding: 4px 0;
204
+ border: 1px solid var(--aion-border-base, #e5e6eb);
205
+ border-radius: 8px;
206
+ background: var(--aion-bg-base, #ffffff);
207
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
208
+ }
209
+
210
+ .reopenHeader {
211
+ padding: 4px 10px;
212
+ font-size: 11px;
213
+ font-weight: 600;
214
+ color: var(--aion-text-tertiary, #86909c);
215
+ user-select: none;
216
+ }
217
+
218
+ .reopenItem {
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 4px;
222
+ padding: 0 6px 0 4px;
223
+ }
224
+
225
+ .reopenItemBtn {
226
+ flex: 1;
227
+ display: flex;
228
+ align-items: center;
229
+ height: 28px;
230
+ padding: 0 8px;
231
+ border: none;
232
+ border-radius: 5px;
233
+ background: transparent;
234
+ color: var(--aion-text-primary, #1f2329);
235
+ font-size: 12px;
236
+ cursor: pointer;
237
+ text-align: left;
238
+ overflow: hidden;
239
+ text-overflow: ellipsis;
240
+ white-space: nowrap;
241
+ }
242
+
243
+ .reopenItemBtn:hover {
244
+ background: var(--aion-bg-2, #f2f3f5);
245
+ }
246
+
247
+ .reopenItemKill {
248
+ flex: none;
249
+ display: inline-flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ width: 20px;
253
+ height: 20px;
254
+ border: none;
255
+ border-radius: 4px;
256
+ background: transparent;
257
+ color: var(--aion-text-tertiary, #86909c);
258
+ font-size: 13px;
259
+ cursor: pointer;
260
+ }
261
+
262
+ .reopenItemKill:hover {
263
+ background: var(--aion-bg-3, #e5e6eb);
264
+ color: var(--aion-text-primary, #000000);
265
+ }
266
+
267
+ .addToChatBtn {
268
+ position: absolute;
269
+ z-index: 60;
270
+ display: inline-flex;
271
+ align-items: center;
272
+ gap: 4px;
273
+ height: 28px;
274
+ padding: 0 10px;
275
+ border: 1px solid var(--aion-border-base, #e5e6eb);
276
+ border-radius: 14px;
277
+ background: var(--aion-bg-base, #ffffff);
278
+ color: var(--aion-text-primary, #1f2329);
279
+ font-size: 12px;
280
+ font-weight: 500;
281
+ cursor: pointer;
282
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
283
+ white-space: nowrap;
284
+ }
285
+
286
+ .addToChatBtn:hover {
287
+ background: var(--aion-bg-2, #f2f3f5);
288
+ border-color: var(--aion-primary, #1677ff);
289
+ color: var(--aion-primary, #1677ff);
290
+ }
package/src/core/types.ts CHANGED
@@ -4,6 +4,16 @@
4
4
  * @module dsh-term/core/types
5
5
  */
6
6
 
7
+ /** Shell kinds the user can pick from in the panel dropdown. */
8
+ export type ShellType = 'bash' | 'zsh' | 'powershell' | 'cmd' | 'gitbash'
9
+
10
+ /** One shell option surfaced to the browser (id + display label key). */
11
+ export interface ShellInfo {
12
+ readonly id: ShellType
13
+ /** i18n key suffix, e.g. 'bash' → t('ui.shell.bash'). */
14
+ readonly labelKey: string
15
+ }
16
+
7
17
  /** One live PTY session as the browser knows it. */
8
18
  export interface TermSessionInfo {
9
19
  /** Stable session id (the wire handle). */
@@ -19,21 +29,27 @@ export interface TermSessionInfo {
19
29
  readonly alive: boolean
20
30
  /** POSIX exit code when the session ended (null while alive). */
21
31
  readonly exitCode: number | null
32
+ /** Shell kind this session was spawned with. */
33
+ readonly shell: ShellType
34
+ /** Whether the session is detached (tab closed but PTY still running). */
35
+ readonly detached: boolean
22
36
  }
23
37
 
24
38
  /** Request: open a new PTY session. */
25
39
  export interface TermSpawnRequest {
26
40
  /** Optional session display name. */
27
41
  name?: string
28
- /** Working directory (defaults to the session's workspace cwd). */
42
+ /** Working directory (defaults to the session's workspace cwd, fallback homedir). */
29
43
  cwd?: string
30
- /** Shell executable (defaults to the platform shell). */
31
- shell?: string
44
+ /** Shell kind (defaults to the platform default). */
45
+ shell?: ShellType
32
46
  /** Shell arguments (defaults to an interactive login-less profile). */
33
47
  args?: string[]
34
48
  /** Initial terminal size. */
35
49
  cols?: number
36
50
  rows?: number
51
+ /** Extra environment variables merged into the PTY env. */
52
+ env?: Record<string, string>
37
53
  }
38
54
 
39
55
  /** Request: deliver terminal input. */
@@ -65,9 +81,11 @@ export interface TermListResponse {
65
81
  /** One output chunk pushed over the SSE change stream. */
66
82
  export type TermEvent =
67
83
  | { readonly kind: 'output'; readonly id: string; readonly data: string }
68
- | { readonly kind: 'exit'; readonly id: string; readonly exitCode: number }
84
+ | { readonly kind: 'exit'; readonly id: string; readonly exitCode: number; readonly message?: string }
69
85
  | { readonly kind: 'start'; readonly session: TermSessionInfo }
70
86
  | { readonly kind: 'closed'; readonly id: string }
87
+ | { readonly kind: 'detached'; readonly id: string }
88
+ | { readonly kind: 'reattached'; readonly session: TermSessionInfo }
71
89
 
72
90
  /** The workspace-gated session id (host-minted; the wire carries only this). */
73
91
  export type TermSessionId = string
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 国际化模块:dsh-term Host 端中英文翻译。
3
+ *
4
+ * 用法:
5
+ * const t = createTranslator('en')
6
+ * t('msg.sessionExited', 0) // → "Session exited (code 0)"
7
+ *
8
+ * @module dsh-term/gateway/i18n
9
+ */
10
+
11
+ export type Lang = 'zh' | 'en'
12
+
13
+ type Dict = Record<string, string>
14
+
15
+ const zh: Dict = {
16
+ // ── pty-service.ts / TerminalPanel.tsx ──
17
+ 'msg.sessionExited': '[dsh-term] 进程已退出(code {0})',
18
+ 'msg.spawnFailed': '[dsh-term] 启动失败: {0}',
19
+ 'ui.panel.title': '终端',
20
+ 'ui.panel.addTabTitle': '新建终端',
21
+ 'ui.panel.collapseTitle': '收起',
22
+ 'ui.panel.emptyHint': '点击 + 新建终端',
23
+ 'ui.dock.label': '终端',
24
+ 'ui.tab.closeAria': '关闭 {0}',
25
+ 'ui.panel.shellTitle': 'Shell',
26
+ 'ui.shell.bash': 'Bash',
27
+ 'ui.shell.zsh': 'Zsh',
28
+ 'ui.shell.powershell': 'PowerShell',
29
+ 'ui.shell.cmd': '命令提示符',
30
+ 'ui.shell.gitbash': 'Git Bash',
31
+ }
32
+
33
+ const en: Dict = {
34
+ 'msg.sessionExited': '[dsh-term] Process exited (code {0})',
35
+ 'msg.spawnFailed': '[dsh-term] Spawn failed: {0}',
36
+ 'ui.panel.title': 'Terminal',
37
+ 'ui.panel.addTabTitle': 'New Terminal',
38
+ 'ui.panel.collapseTitle': 'Collapse',
39
+ 'ui.panel.emptyHint': 'Click + to create a terminal',
40
+ 'ui.dock.label': 'Terminal',
41
+ 'ui.tab.closeAria': 'Close {0}',
42
+ 'ui.panel.shellTitle': 'Shell',
43
+ 'ui.shell.bash': 'Bash',
44
+ 'ui.shell.zsh': 'Zsh',
45
+ 'ui.shell.powershell': 'PowerShell',
46
+ 'ui.shell.cmd': 'Command Prompt',
47
+ 'ui.shell.gitbash': 'Git Bash',
48
+ }
49
+
50
+ const dicts: Record<Lang, Dict> = { zh, en }
51
+
52
+ export class Translator {
53
+ constructor(readonly lang: Lang) {}
54
+
55
+ t(key: string, ...args: (string | number)[]): string {
56
+ const dict = dicts[this.lang] ?? zh
57
+ let s = dict[key] ?? zh[key] ?? key
58
+ for (let i = 0; i < args.length; i++) {
59
+ s = s.replaceAll(`{${i}}`, String(args[i]))
60
+ }
61
+ return s
62
+ }
63
+ }
64
+
65
+ export function createTranslator(lang: Lang): Translator {
66
+ return new Translator(lang)
67
+ }
@@ -13,8 +13,12 @@
13
13
  */
14
14
 
15
15
  import { randomUUID } from 'node:crypto'
16
+ import { existsSync, statSync } from 'node:fs'
17
+ import { exec } from 'node:child_process'
18
+ import { resolve } from 'node:path'
19
+ import { homedir } from 'node:os'
16
20
  import * as nodePty from 'node-pty'
17
- import type { TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
21
+ import type { ShellInfo, ShellType, TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
18
22
 
19
23
  /** Live session plus its pty handle (service-private). */
20
24
  interface LiveSession {
@@ -22,17 +26,89 @@ interface LiveSession {
22
26
  readonly pty: nodePty.IPty
23
27
  }
24
28
 
25
- /** Default interactive shell for the platform. */
26
- export function defaultShell(): string {
27
- if (process.platform === 'win32') return 'powershell.exe'
28
- return process.env.SHELL ?? '/bin/bash'
29
+ const IS_WIN = process.platform === 'win32'
30
+
31
+ /** Windows-only Git Bash locations. */
32
+ const GIT_BASH_CANDIDATES_WIN = [
33
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
34
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
35
+ ]
36
+
37
+ /** macOS / Linux stock shells (always present on a normal desktop install). */
38
+ const UNIX_SHELL_PATHS: Record<'zsh' | 'bash', string> = {
39
+ zsh: '/bin/zsh',
40
+ bash: '/bin/bash',
41
+ }
42
+
43
+ /** All shell kinds in display order. */
44
+ const ALL_SHELLS: readonly ShellType[] = ['zsh', 'bash', 'gitbash', 'powershell', 'cmd']
45
+
46
+ /** True if `name` resolves on PATH. Uses `where` on Windows, `command -v` on Unix. */
47
+ function commandExistsAsync(name: string): Promise<boolean> {
48
+ const probe = IS_WIN ? `where ${name}` : `command -v ${name}`
49
+ return new Promise((r) => {
50
+ exec(probe, { windowsHide: true, timeout: 5000 }, (err) => { r(!err) })
51
+ })
52
+ }
53
+
54
+ /** Git Bash is only relevant on Windows (probed via launcher / bash / git). */
55
+ async function hasGitBash(): Promise<boolean> {
56
+ if (!IS_WIN) return false
57
+ if (await commandExistsAsync('git-bash.exe')) return true
58
+ if (
59
+ (await commandExistsAsync('bash.exe')) &&
60
+ ((await commandExistsAsync('git.exe')) ||
61
+ GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p)))
62
+ ) {
63
+ return true
64
+ }
65
+ return GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p))
66
+ }
67
+
68
+ /** Map a ShellType to { command, args } for node-pty.spawn. */
69
+ function resolveShell(shell: ShellType): { command: string; args: string[] } {
70
+ switch (shell) {
71
+ case 'powershell':
72
+ return { command: 'powershell.exe', args: ['-NoLogo'] }
73
+ case 'cmd':
74
+ return { command: 'cmd.exe', args: [] }
75
+ case 'bash':
76
+ return { command: IS_WIN ? 'bash.exe' : UNIX_SHELL_PATHS.bash, args: ['--login', '-i'] }
77
+ case 'zsh':
78
+ return { command: UNIX_SHELL_PATHS.zsh, args: ['-l', '-i'] }
79
+ case 'gitbash':
80
+ default: {
81
+ if (IS_WIN) {
82
+ for (const c of GIT_BASH_CANDIDATES_WIN) {
83
+ if (existsSync(c)) return { command: c, args: ['--login', '-i'] }
84
+ }
85
+ return { command: 'bash', args: ['--login', '-i'] }
86
+ }
87
+ return { command: UNIX_SHELL_PATHS.bash, args: ['--login', '-i'] }
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Default shell kind for the platform. */
93
+ export function defaultShellType(): ShellType {
94
+ if (IS_WIN) return 'powershell'
95
+ return existsSync(UNIX_SHELL_PATHS.zsh) ? 'zsh' : 'bash'
29
96
  }
30
97
 
31
- /** Default args for an interactive login-less shell. */
32
- export function defaultArgs(shell: string): string[] {
33
- if (process.platform === 'win32') return []
34
- const base = shell.endsWith('bash') ? ['--noprofile', '--norc', '-i'] : ['-i']
35
- return base
98
+ /**
99
+ * Resolve a spawn cwd that is guaranteed to be an existing directory.
100
+ * node-pty fails at spawn time (macOS: "posix_spawnp failed") if the cwd
101
+ * does not exist which happens on a cross-platform machine when a stale
102
+ * Windows-style workspace path is still in the store, or when the renderer
103
+ * sends an empty/null cwd. Fall back to $HOME so the PTY always spawns.
104
+ */
105
+ function resolveCwd(cwd?: string): string {
106
+ const candidate = cwd && cwd.trim().length > 0 ? cwd.trim() : process.cwd()
107
+ try {
108
+ const abs = resolve(candidate)
109
+ if (existsSync(abs) && statSync(abs).isDirectory()) return abs
110
+ } catch { /* fall through to home */ }
111
+ return homedir()
36
112
  }
37
113
 
38
114
  /**
@@ -42,34 +118,66 @@ export function defaultArgs(shell: string): string[] {
42
118
  */
43
119
  export class PtyService {
44
120
  private readonly sessions = new Map<string, LiveSession>()
121
+ /** Cached available shells (computed once on first query). */
122
+ private availableShellsCache: readonly ShellInfo[] | null = null
45
123
 
46
124
  /** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
47
125
  onOutput: (sessionId: string, data: string) => void = () => {}
48
126
  /** Fired once when a session exits. Bound by the route layer. */
49
127
  onExit: (sessionId: string, exitCode: number) => void = () => {}
128
+ /** Fired when a session is detached (tab closed, PTY kept alive). */
129
+ onDetach: (sessionId: string) => void = () => {}
130
+ /** Fired when a session is reattached. */
131
+ onReattach: (session: TermSessionInfo) => void = () => {}
132
+
133
+ /**
134
+ * Returns the shells actually available on this machine, so the browser
135
+ * can hide options the user never installed (Git Bash) instead of letting
136
+ * node-pty fail at spawn time. Results are cached after the first async
137
+ * detection to avoid repeated `where` calls.
138
+ */
139
+ async detectShells(): Promise<readonly ShellInfo[]> {
140
+ if (this.availableShellsCache !== null) return this.availableShellsCache
141
+ const available: ShellType[] = []
142
+ if (await hasGitBash()) available.push('gitbash')
143
+ if (IS_WIN) {
144
+ if (await commandExistsAsync('powershell.exe') || await commandExistsAsync('pwsh.exe')) available.push('powershell')
145
+ if (await commandExistsAsync('cmd.exe')) available.push('cmd')
146
+ } else {
147
+ if (existsSync(UNIX_SHELL_PATHS.zsh)) available.push('zsh')
148
+ if (existsSync(UNIX_SHELL_PATHS.bash)) available.push('bash')
149
+ }
150
+ this.availableShellsCache = available.map((id) => ({ id, labelKey: `ui.shell.${id}` }))
151
+ return this.availableShellsCache
152
+ }
50
153
 
51
154
  /** Open one session; returns the wire info immediately (output streams async). */
52
155
  spawn(req: TermSpawnRequest): TermSessionInfo {
53
156
  const id = randomUUID()
54
- const shell = req.shell ?? defaultShell()
55
- const args = req.args ?? defaultArgs(shell)
157
+ const shellType: ShellType = req.shell ?? defaultShellType()
158
+ const { command, args } = resolveShell(shellType)
159
+ const finalArgs = req.args ?? args
56
160
  const cols = req.cols ?? 80
57
161
  const rows = req.rows ?? 24
58
- const cwd = req.cwd ?? process.cwd()
59
- const pty = nodePty.spawn(shell, args, {
162
+ const cwd = resolveCwd(req.cwd)
163
+ const env = { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1', ...(req.env ?? {}) }
164
+ const pty = nodePty.spawn(command, finalArgs, {
60
165
  name: 'xterm-256color',
61
166
  cols,
62
167
  rows,
63
168
  cwd,
169
+ env,
64
170
  })
65
171
  const info: TermSessionInfo = {
66
172
  id,
67
- title: req.name ?? shell,
173
+ title: req.name ?? shellType,
68
174
  cwd,
69
175
  cols,
70
176
  rows,
71
177
  alive: true,
72
178
  exitCode: null,
179
+ shell: shellType,
180
+ detached: false,
73
181
  }
74
182
  this.sessions.set(id, { info, pty })
75
183
  pty.onData((data) => { this.onOutput(id, data) })
@@ -108,19 +216,54 @@ export class PtyService {
108
216
  return true
109
217
  }
110
218
 
111
- /** Close a session forcefully (SIGHUP semantics via kill). Returns false when unknown. */
219
+ /**
220
+ * Detach a session: mark it as detached but keep the PTY process alive.
221
+ * This lets the user close a tab without losing a running command (e.g.
222
+ * `npm install`); reopening re-attaches to the same session.
223
+ */
224
+ detach(id: string): boolean {
225
+ const live = this.sessions.get(id)
226
+ if (live === undefined) return false
227
+ const newInfo: TermSessionInfo = { ...live.info, detached: true }
228
+ this.sessions.set(id, { info: newInfo, pty: live.pty })
229
+ this.onDetach(id)
230
+ return true
231
+ }
232
+
233
+ /**
234
+ * Reattach to a detached session: mark it as attached and return the info.
235
+ * The caller creates a fresh xterm and starts routing SSE output to it.
236
+ */
237
+ reattach(id: string): TermSessionInfo | null {
238
+ const live = this.sessions.get(id)
239
+ if (live === undefined) return null
240
+ const newInfo: TermSessionInfo = { ...live.info, detached: false }
241
+ this.sessions.set(id, { info: newInfo, pty: live.pty })
242
+ this.onReattach(newInfo)
243
+ return newInfo
244
+ }
245
+
246
+ /** Close a session forcefully (kill the PTY). Returns false when unknown. */
112
247
  close(id: string): boolean {
113
248
  const live = this.sessions.get(id)
114
249
  if (live === undefined) return false
115
250
  try { live.pty.kill() } catch { /* already dead */ }
251
+ this.sessions.delete(id)
116
252
  return true
117
253
  }
118
254
 
119
- /** The full session listing snapshot. */
255
+ /** The full session listing snapshot (including detached sessions). */
120
256
  list(): readonly TermSessionInfo[] {
121
257
  return [...this.sessions.values()].map(({ info }) => ({ ...info }))
122
258
  }
123
259
 
260
+ /** Only the detached sessions (for the "reopen" dropdown). */
261
+ detachedList(): readonly TermSessionInfo[] {
262
+ return [...this.sessions.values()]
263
+ .filter(({ info }) => info.detached)
264
+ .map(({ info }) => ({ ...info }))
265
+ }
266
+
124
267
  /** Close every session (route teardown). */
125
268
  dispose(): void {
126
269
  for (const live of this.sessions.values()) {
@@ -9,9 +9,10 @@
9
9
  import type { IncomingMessage, ServerResponse } from 'node:http'
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import type {} from '@deepseek-ai/dsh-host-webserver'
12
- import type { TermEvent, TermSpawnRequest } from '../core/types.ts'
12
+ import type { ShellType, TermEvent, TermSpawnRequest } from '../core/types.ts'
13
13
  import type { PtyService } from './pty-service.ts'
14
14
  import { isLoopbackRequest } from './loopback.ts'
15
+ import type { Translator } from '../gateway/i18n.ts'
15
16
 
16
17
  /** JSON envelope mirrors the file-manager panel shape. */
17
18
  type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
@@ -63,9 +64,10 @@ function json(res: ServerResponse, envelope: Envelope<unknown>, status = 200): v
63
64
  * Register the /dsh-term routes.
64
65
  * @param ctx - context carrying the webServer service.
65
66
  * @param pty - the session registry.
67
+ * @param getT - lazy translator getter (reflects current language).
66
68
  * @returns route disposers.
67
69
  */
68
- export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
70
+ export function registerTermRoutes(ctx: Context, pty: PtyService, getT: () => Translator): () => void {
69
71
  const subscribers = new Set<Subscriber>()
70
72
  const push = (event: TermEvent): void => {
71
73
  for (const subscriber of subscribers) {
@@ -74,7 +76,12 @@ export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
74
76
  }
75
77
 
76
78
  pty.onOutput = (id, data) => push({ kind: 'output', id, data })
77
- pty.onExit = (id, exitCode) => push({ kind: 'exit', id, exitCode })
79
+ pty.onExit = (id, exitCode) => {
80
+ const msg = getT().t('msg.sessionExited', exitCode)
81
+ push({ kind: 'exit', id, exitCode, message: msg })
82
+ }
83
+ pty.onDetach = (id) => push({ kind: 'detached', id })
84
+ pty.onReattach = (session) => push({ kind: 'reattached', session })
78
85
 
79
86
  const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
80
87
  if (!isLoopbackRequest(req)) {
@@ -87,6 +94,11 @@ export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
87
94
  json(res, OK({ sessions: pty.list() }))
88
95
  return
89
96
  }
97
+ if (req.method === 'GET' && url.pathname === '/dsh-term/shells') {
98
+ const shells = await pty.detectShells()
99
+ json(res, OK({ shells }))
100
+ return
101
+ }
90
102
  if (req.method !== 'POST') {
91
103
  json(res, MALFORMED, 405)
92
104
  return
@@ -105,13 +117,18 @@ export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
105
117
  json(res, MALFORMED, 400)
106
118
  return
107
119
  }
120
+ const validShells: readonly ShellType[] = ['bash', 'zsh', 'powershell', 'cmd', 'gitbash']
108
121
  const session = pty.spawn({
109
122
  name: typeof request.name === 'string' ? request.name : undefined,
110
123
  cwd: typeof request.cwd === 'string' ? request.cwd : undefined,
111
- shell: typeof request.shell === 'string' ? request.shell : undefined,
124
+ shell: typeof request.shell === 'string' && validShells.includes(request.shell as ShellType)
125
+ ? request.shell as ShellType : undefined,
112
126
  args: Array.isArray(request.args) ? request.args.filter((a): a is string => typeof a === 'string') : undefined,
113
127
  cols: typeof request.cols === 'number' ? request.cols : undefined,
114
128
  rows: typeof request.rows === 'number' ? request.rows : undefined,
129
+ env: typeof request.env === 'object' && request.env !== null
130
+ ? Object.fromEntries(Object.entries(request.env).filter(([, v]) => typeof v === 'string')) as Record<string, string>
131
+ : undefined,
115
132
  })
116
133
  push({ kind: 'start', session })
117
134
  json(res, OK(session))
@@ -153,6 +170,29 @@ export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
153
170
  json(res, OK({ ok: pty.close(body.id) }))
154
171
  return
155
172
  }
173
+ case '/dsh-term/detach': {
174
+ const body = payload as { id?: unknown }
175
+ if (typeof body?.id !== 'string') {
176
+ json(res, MALFORMED, 400)
177
+ return
178
+ }
179
+ json(res, OK({ ok: pty.detach(body.id) }))
180
+ return
181
+ }
182
+ case '/dsh-term/reattach': {
183
+ const body = payload as { id?: unknown }
184
+ if (typeof body?.id !== 'string') {
185
+ json(res, MALFORMED, 400)
186
+ return
187
+ }
188
+ const session = pty.reattach(body.id)
189
+ if (session === null) {
190
+ json(res, FAIL('session not found', 'not_found'), 404)
191
+ return
192
+ }
193
+ json(res, OK(session))
194
+ return
195
+ }
156
196
  default:
157
197
  json(res, MALFORMED, 404)
158
198
  }
package/src/index.ts CHANGED
@@ -7,9 +7,11 @@
7
7
 
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import type {} from '@deepseek-ai/dsh-host-webserver'
10
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
10
11
  import { mountOnce } from './mount-once.ts'
11
12
  import { PtyService } from './host/pty-service.ts'
12
13
  import { registerTermRoutes } from './host/routes.ts'
14
+ import { createTranslator, type Lang } from './gateway/i18n.ts'
13
15
 
14
16
  /** Required services: the route registry. */
15
17
  export const inject = ['webServer']
@@ -25,13 +27,41 @@ export const apply = mountOnce('@lijian-ui/dsh-term', applyImpl)
25
27
 
26
28
  function applyImpl(ctx: Context): void {
27
29
  const pty = new PtyService()
30
+
31
+ /** Current language; resolved from dsh global settings. */
32
+ let lang: Lang = 'zh'
33
+ const t = createTranslator(lang)
34
+
35
+ /** Resolve the user's language preference from dsh settings. */
36
+ function resolveLang(): Lang {
37
+ try {
38
+ const settings = ctx.get('settings') as
39
+ | { get(ns: unknown): { preference?: string } | undefined }
40
+ | undefined
41
+ if (!settings) return 'zh'
42
+ const section = settings.get(settingsNamespace('locale'))
43
+ return section?.preference === 'en' ? 'en' : 'zh'
44
+ } catch {
45
+ return 'zh'
46
+ }
47
+ }
48
+
28
49
  // Route registration + pty teardown both ride the effect fiber: the effect
29
50
  // callback runs immediately and its return value is the fiber disposer.
30
51
  ctx.effect(() => {
31
- const disposeRoutes = registerTermRoutes(ctx, pty)
52
+ lang = resolveLang()
53
+ // t is a closure over lang; no separate sync needed.
54
+ const disposeRoutes = registerTermRoutes(ctx, pty, () => createTranslator(lang))
32
55
  return () => {
33
56
  disposeRoutes()
34
57
  pty.dispose()
35
58
  }
36
59
  }, 'dsh-term: routes + pty lifecycle')
60
+
61
+ // Listen for global language changes (dsh settings → General → Language).
62
+ ctx.root.on('settings/updated', (ns, next) => {
63
+ if (ns !== settingsNamespace('locale')) return
64
+ const pref = (next as { preference?: string })?.preference
65
+ lang = pref === 'en' ? 'en' : 'zh'
66
+ })
37
67
  }