@lijian-ui/dsh-term 0.2.0 → 0.3.1

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 (37) hide show
  1. package/lib/client.js +475 -94
  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/DockItem.tsx +1 -1
  28. package/src/client/term/TerminalPanel.tsx +333 -50
  29. package/src/client/term/animated-dock.module.css +3 -3
  30. package/src/client/term/api.ts +17 -2
  31. package/src/client/term/chat-helper.ts +28 -0
  32. package/src/client/term/term.module.css +155 -18
  33. package/src/core/types.ts +22 -4
  34. package/src/gateway/i18n.ts +67 -0
  35. package/src/host/pty-service.ts +160 -17
  36. package/src/host/routes.ts +44 -4
  37. package/src/index.ts +31 -1
package/lib/index.js CHANGED
@@ -1,5 +1,163 @@
1
+ import { Service } from "@deepseek-ai/cordis";
1
2
  import { randomUUID } from "node:crypto";
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { exec } from "node:child_process";
5
+ import { resolve } from "node:path";
6
+ import { homedir } from "node:os";
2
7
  import * as nodePty from "node-pty";
8
+ //#region node_modules/@deepseek-ai/dsh-settings/lib/index.js
9
+ /**
10
+ * Structural secret redaction for settings values. `role('secret')` fields are
11
+ * removed from a value before it crosses a wire boundary; a sidecar records
12
+ * each schema-declared secret position and whether it currently holds a value,
13
+ * so a configuration surface can render a write-only input without ever
14
+ * receiving the secret itself.
15
+ * @module @deepseek-ai/dsh-settings/redact
16
+ */
17
+ /** Whether a value is a plain data object the walker may recurse into. */
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function walk(node, value, path, secrets) {
22
+ if (node === void 0) return value;
23
+ if (node.meta?.role === "secret") {
24
+ secrets.push({
25
+ path,
26
+ set: value !== void 0
27
+ });
28
+ return;
29
+ }
30
+ switch (node.type) {
31
+ case "object": {
32
+ const properties = node.dict ?? {};
33
+ const source = isRecord(value) ? value : void 0;
34
+ const rebuilt = {};
35
+ if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
36
+ if (key in properties) continue;
37
+ rebuilt[key] = entry;
38
+ }
39
+ for (const [key, child] of Object.entries(properties)) {
40
+ const stripped = walk(child, source?.[key], [...path, key], secrets);
41
+ if (stripped !== void 0) rebuilt[key] = stripped;
42
+ }
43
+ return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
44
+ }
45
+ case "dict": {
46
+ if (!isRecord(value)) return value;
47
+ const rebuilt = {};
48
+ for (const [key, entry] of Object.entries(value)) {
49
+ const stripped = walk(node.inner, entry, [...path, key], secrets);
50
+ if (stripped !== void 0) rebuilt[key] = stripped;
51
+ }
52
+ return rebuilt;
53
+ }
54
+ case "array":
55
+ if (!Array.isArray(value)) return value;
56
+ return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
57
+ default: return value;
58
+ }
59
+ }
60
+ /**
61
+ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
62
+ * per-namespace sections; plugins register a namespace schema and read the
63
+ * resolved value, which layers schema defaults, the registrant's composition
64
+ * `base`, and the user document section, in that order.
65
+ * @module @deepseek-ai/dsh-settings
66
+ */
67
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
68
+ /**
69
+ * Brand a raw string as a {@link SettingsNamespace}.
70
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
71
+ * @returns the branded namespace.
72
+ */
73
+ function settingsNamespace(value) {
74
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
75
+ return value;
76
+ }
77
+ /**
78
+ * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
79
+ * Service Definition's single change-detection predicate, exported so the invariant
80
+ * companion checks exactly the implementation's relation.
81
+ * @param a - one JSON-compatible value.
82
+ * @param b - the other JSON-compatible value.
83
+ * @returns whether the two values are structurally equal.
84
+ */
85
+ function deepEqualJson(a, b) {
86
+ if (a === b) return true;
87
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
88
+ if (Array.isArray(a) || Array.isArray(b)) {
89
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
90
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
91
+ }
92
+ const left = a;
93
+ const right = b;
94
+ const keys = Object.keys(left);
95
+ if (keys.length !== Object.keys(right).length) return false;
96
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
97
+ }
98
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
99
+ function isPlainObject(value) {
100
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
101
+ const proto = Object.getPrototypeOf(value);
102
+ return proto === Object.prototype || proto === null;
103
+ }
104
+ /** Apply one path op to a detached section, returning the next section. */
105
+ function applyPathOp(section, op) {
106
+ const [head, ...rest] = op.path;
107
+ if (head === void 0) {
108
+ if (op.op === "unset") return {};
109
+ if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
110
+ return { ...op.value };
111
+ }
112
+ if (rest.length === 0) {
113
+ if (op.op === "set") return {
114
+ ...section,
115
+ [head]: op.value
116
+ };
117
+ const { [head]: _removed, ...kept } = section;
118
+ return kept;
119
+ }
120
+ const child = section[head];
121
+ if (!isPlainObject(child)) {
122
+ if (op.op === "unset") return section;
123
+ return {
124
+ ...section,
125
+ [head]: applyPathOp({}, {
126
+ ...op,
127
+ path: rest
128
+ })
129
+ };
130
+ }
131
+ return {
132
+ ...section,
133
+ [head]: applyPathOp(child, {
134
+ ...op,
135
+ path: rest
136
+ })
137
+ };
138
+ }
139
+ /**
140
+ * Layer `over` onto `under`: plain objects merge recursively, every other
141
+ * value (arrays included) replaces the lower layer wholesale. `over` never
142
+ * carries `undefined` entries — sections come from parsed documents and write
143
+ * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
144
+ * cannot erase lower keys.
145
+ */
146
+ function mergeLayers(under, over) {
147
+ if (over === void 0) return under;
148
+ if (!isPlainObject(under) || !isPlainObject(over)) return over;
149
+ const merged = { ...under };
150
+ for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
151
+ return merged;
152
+ }
153
+ /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
154
+ function deepFreeze(value) {
155
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
156
+ for (const entry of Object.values(value)) deepFreeze(entry);
157
+ return Object.freeze(value);
158
+ }
159
+ Service.init;
160
+ //#endregion
3
161
  //#region src/mount-once.ts
4
162
  /**
5
163
  * Host single-instance guard shared by the plugin family. The family bundle
@@ -55,19 +213,88 @@ function mountOnce(packageName, fn) {
55
213
  * gives full control over multi-tab local shells.
56
214
  * @module dsh-term/host/pty-service
57
215
  */
58
- /** Default interactive shell for the platform. */
59
- function defaultShell() {
60
- if (process.platform === "win32") return "powershell.exe";
61
- return process.env.SHELL ?? "/bin/bash";
216
+ const IS_WIN = process.platform === "win32";
217
+ /** Windows-only Git Bash locations. */
218
+ const GIT_BASH_CANDIDATES_WIN = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"];
219
+ /** macOS / Linux stock shells (always present on a normal desktop install). */
220
+ const UNIX_SHELL_PATHS = {
221
+ zsh: "/bin/zsh",
222
+ bash: "/bin/bash"
223
+ };
224
+ /** True if `name` resolves on PATH. Uses `where` on Windows, `command -v` on Unix. */
225
+ function commandExistsAsync(name) {
226
+ const probe = IS_WIN ? `where ${name}` : `command -v ${name}`;
227
+ return new Promise((r) => {
228
+ exec(probe, {
229
+ windowsHide: true,
230
+ timeout: 5e3
231
+ }, (err) => {
232
+ r(!err);
233
+ });
234
+ });
235
+ }
236
+ /** Git Bash is only relevant on Windows (probed via launcher / bash / git). */
237
+ async function hasGitBash() {
238
+ if (!IS_WIN) return false;
239
+ if (await commandExistsAsync("git-bash.exe")) return true;
240
+ if (await commandExistsAsync("bash.exe") && (await commandExistsAsync("git.exe") || GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p)))) return true;
241
+ return GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p));
242
+ }
243
+ /** Map a ShellType to { command, args } for node-pty.spawn. */
244
+ function resolveShell(shell) {
245
+ switch (shell) {
246
+ case "powershell": return {
247
+ command: "powershell.exe",
248
+ args: ["-NoLogo"]
249
+ };
250
+ case "cmd": return {
251
+ command: "cmd.exe",
252
+ args: []
253
+ };
254
+ case "bash": return {
255
+ command: IS_WIN ? "bash.exe" : UNIX_SHELL_PATHS.bash,
256
+ args: ["--login", "-i"]
257
+ };
258
+ case "zsh": return {
259
+ command: UNIX_SHELL_PATHS.zsh,
260
+ args: ["-l", "-i"]
261
+ };
262
+ default:
263
+ if (IS_WIN) {
264
+ for (const c of GIT_BASH_CANDIDATES_WIN) if (existsSync(c)) return {
265
+ command: c,
266
+ args: ["--login", "-i"]
267
+ };
268
+ return {
269
+ command: "bash",
270
+ args: ["--login", "-i"]
271
+ };
272
+ }
273
+ return {
274
+ command: UNIX_SHELL_PATHS.bash,
275
+ args: ["--login", "-i"]
276
+ };
277
+ }
62
278
  }
63
- /** Default args for an interactive login-less shell. */
64
- function defaultArgs(shell) {
65
- if (process.platform === "win32") return [];
66
- return shell.endsWith("bash") ? [
67
- "--noprofile",
68
- "--norc",
69
- "-i"
70
- ] : ["-i"];
279
+ /** Default shell kind for the platform. */
280
+ function defaultShellType() {
281
+ if (IS_WIN) return "powershell";
282
+ return existsSync(UNIX_SHELL_PATHS.zsh) ? "zsh" : "bash";
283
+ }
284
+ /**
285
+ * Resolve a spawn cwd that is guaranteed to be an existing directory.
286
+ * node-pty fails at spawn time (macOS: "posix_spawnp failed") if the cwd
287
+ * does not exist — which happens on a cross-platform machine when a stale
288
+ * Windows-style workspace path is still in the store, or when the renderer
289
+ * sends an empty/null cwd. Fall back to $HOME so the PTY always spawns.
290
+ */
291
+ function resolveCwd(cwd) {
292
+ const candidate = cwd && cwd.trim().length > 0 ? cwd.trim() : process.cwd();
293
+ try {
294
+ const abs = resolve(candidate);
295
+ if (existsSync(abs) && statSync(abs).isDirectory()) return abs;
296
+ } catch {}
297
+ return homedir();
71
298
  }
72
299
  /**
73
300
  * The PTY registry. Every mutation goes through this class so the route
@@ -76,32 +303,71 @@ function defaultArgs(shell) {
76
303
  */
77
304
  var PtyService = class {
78
305
  sessions = /* @__PURE__ */ new Map();
306
+ /** Cached available shells (computed once on first query). */
307
+ availableShellsCache = null;
79
308
  /** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
80
309
  onOutput = () => {};
81
310
  /** Fired once when a session exits. Bound by the route layer. */
82
311
  onExit = () => {};
312
+ /** Fired when a session is detached (tab closed, PTY kept alive). */
313
+ onDetach = () => {};
314
+ /** Fired when a session is reattached. */
315
+ onReattach = () => {};
316
+ /**
317
+ * Returns the shells actually available on this machine, so the browser
318
+ * can hide options the user never installed (Git Bash) instead of letting
319
+ * node-pty fail at spawn time. Results are cached after the first async
320
+ * detection to avoid repeated `where` calls.
321
+ */
322
+ async detectShells() {
323
+ if (this.availableShellsCache !== null) return this.availableShellsCache;
324
+ const available = [];
325
+ if (await hasGitBash()) available.push("gitbash");
326
+ if (IS_WIN) {
327
+ if (await commandExistsAsync("powershell.exe") || await commandExistsAsync("pwsh.exe")) available.push("powershell");
328
+ if (await commandExistsAsync("cmd.exe")) available.push("cmd");
329
+ } else {
330
+ if (existsSync(UNIX_SHELL_PATHS.zsh)) available.push("zsh");
331
+ if (existsSync(UNIX_SHELL_PATHS.bash)) available.push("bash");
332
+ }
333
+ this.availableShellsCache = available.map((id) => ({
334
+ id,
335
+ labelKey: `ui.shell.${id}`
336
+ }));
337
+ return this.availableShellsCache;
338
+ }
83
339
  /** Open one session; returns the wire info immediately (output streams async). */
84
340
  spawn(req) {
85
341
  const id = randomUUID();
86
- const shell = req.shell ?? defaultShell();
87
- const args = req.args ?? defaultArgs(shell);
342
+ const shellType = req.shell ?? defaultShellType();
343
+ const { command, args } = resolveShell(shellType);
344
+ const finalArgs = req.args ?? args;
88
345
  const cols = req.cols ?? 80;
89
346
  const rows = req.rows ?? 24;
90
- const cwd = req.cwd ?? process.cwd();
91
- const pty = nodePty.spawn(shell, args, {
347
+ const cwd = resolveCwd(req.cwd);
348
+ const env = {
349
+ ...process.env,
350
+ TERM: "xterm-256color",
351
+ FORCE_COLOR: "1",
352
+ ...req.env ?? {}
353
+ };
354
+ const pty = nodePty.spawn(command, finalArgs, {
92
355
  name: "xterm-256color",
93
356
  cols,
94
357
  rows,
95
- cwd
358
+ cwd,
359
+ env
96
360
  });
97
361
  const info = {
98
362
  id,
99
- title: req.name ?? shell,
363
+ title: req.name ?? shellType,
100
364
  cwd,
101
365
  cols,
102
366
  rows,
103
367
  alive: true,
104
- exitCode: null
368
+ exitCode: null,
369
+ shell: shellType,
370
+ detached: false
105
371
  };
106
372
  this.sessions.set(id, {
107
373
  info,
@@ -151,19 +417,61 @@ var PtyService = class {
151
417
  }
152
418
  return true;
153
419
  }
154
- /** Close a session forcefully (SIGHUP semantics via kill). Returns false when unknown. */
420
+ /**
421
+ * Detach a session: mark it as detached but keep the PTY process alive.
422
+ * This lets the user close a tab without losing a running command (e.g.
423
+ * `npm install`); reopening re-attaches to the same session.
424
+ */
425
+ detach(id) {
426
+ const live = this.sessions.get(id);
427
+ if (live === void 0) return false;
428
+ const newInfo = {
429
+ ...live.info,
430
+ detached: true
431
+ };
432
+ this.sessions.set(id, {
433
+ info: newInfo,
434
+ pty: live.pty
435
+ });
436
+ this.onDetach(id);
437
+ return true;
438
+ }
439
+ /**
440
+ * Reattach to a detached session: mark it as attached and return the info.
441
+ * The caller creates a fresh xterm and starts routing SSE output to it.
442
+ */
443
+ reattach(id) {
444
+ const live = this.sessions.get(id);
445
+ if (live === void 0) return null;
446
+ const newInfo = {
447
+ ...live.info,
448
+ detached: false
449
+ };
450
+ this.sessions.set(id, {
451
+ info: newInfo,
452
+ pty: live.pty
453
+ });
454
+ this.onReattach(newInfo);
455
+ return newInfo;
456
+ }
457
+ /** Close a session forcefully (kill the PTY). Returns false when unknown. */
155
458
  close(id) {
156
459
  const live = this.sessions.get(id);
157
460
  if (live === void 0) return false;
158
461
  try {
159
462
  live.pty.kill();
160
463
  } catch {}
464
+ this.sessions.delete(id);
161
465
  return true;
162
466
  }
163
- /** The full session listing snapshot. */
467
+ /** The full session listing snapshot (including detached sessions). */
164
468
  list() {
165
469
  return [...this.sessions.values()].map(({ info }) => ({ ...info }));
166
470
  }
471
+ /** Only the detached sessions (for the "reopen" dropdown). */
472
+ detachedList() {
473
+ return [...this.sessions.values()].filter(({ info }) => info.detached).map(({ info }) => ({ ...info }));
474
+ }
167
475
  /** Close every session (route teardown). */
168
476
  dispose() {
169
477
  for (const live of this.sessions.values()) try {
@@ -267,9 +575,10 @@ function json(res, envelope, status = 200) {
267
575
  * Register the /dsh-term routes.
268
576
  * @param ctx - context carrying the webServer service.
269
577
  * @param pty - the session registry.
578
+ * @param getT - lazy translator getter (reflects current language).
270
579
  * @returns route disposers.
271
580
  */
272
- function registerTermRoutes(ctx, pty) {
581
+ function registerTermRoutes(ctx, pty, getT) {
273
582
  const subscribers = /* @__PURE__ */ new Set();
274
583
  const push = (event) => {
275
584
  for (const subscriber of subscribers) subscriber.res.write(`event: term\ndata: ${JSON.stringify(event)}\n\n`);
@@ -279,10 +588,22 @@ function registerTermRoutes(ctx, pty) {
279
588
  id,
280
589
  data
281
590
  });
282
- pty.onExit = (id, exitCode) => push({
283
- kind: "exit",
284
- id,
285
- exitCode
591
+ pty.onExit = (id, exitCode) => {
592
+ const msg = getT().t("msg.sessionExited", exitCode);
593
+ push({
594
+ kind: "exit",
595
+ id,
596
+ exitCode,
597
+ message: msg
598
+ });
599
+ };
600
+ pty.onDetach = (id) => push({
601
+ kind: "detached",
602
+ id
603
+ });
604
+ pty.onReattach = (session) => push({
605
+ kind: "reattached",
606
+ session
286
607
  });
287
608
  const handler = async (req, res) => {
288
609
  if (!isLoopbackRequest(req)) {
@@ -295,6 +616,11 @@ function registerTermRoutes(ctx, pty) {
295
616
  json(res, OK({ sessions: pty.list() }));
296
617
  return;
297
618
  }
619
+ if (req.method === "GET" && url.pathname === "/dsh-term/shells") {
620
+ const shells = await pty.detectShells();
621
+ json(res, OK({ shells }));
622
+ return;
623
+ }
298
624
  if (req.method !== "POST") {
299
625
  json(res, MALFORMED, 405);
300
626
  return;
@@ -316,10 +642,17 @@ function registerTermRoutes(ctx, pty) {
316
642
  const session = pty.spawn({
317
643
  name: typeof request.name === "string" ? request.name : void 0,
318
644
  cwd: typeof request.cwd === "string" ? request.cwd : void 0,
319
- shell: typeof request.shell === "string" ? request.shell : void 0,
645
+ shell: typeof request.shell === "string" && [
646
+ "bash",
647
+ "zsh",
648
+ "powershell",
649
+ "cmd",
650
+ "gitbash"
651
+ ].includes(request.shell) ? request.shell : void 0,
320
652
  args: Array.isArray(request.args) ? request.args.filter((a) => typeof a === "string") : void 0,
321
653
  cols: typeof request.cols === "number" ? request.cols : void 0,
322
- rows: typeof request.rows === "number" ? request.rows : void 0
654
+ rows: typeof request.rows === "number" ? request.rows : void 0,
655
+ env: typeof request.env === "object" && request.env !== null ? Object.fromEntries(Object.entries(request.env).filter(([, v]) => typeof v === "string")) : void 0
323
656
  });
324
657
  push({
325
658
  kind: "start",
@@ -364,6 +697,29 @@ function registerTermRoutes(ctx, pty) {
364
697
  json(res, OK({ ok: pty.close(body.id) }));
365
698
  return;
366
699
  }
700
+ case "/dsh-term/detach": {
701
+ const body = payload;
702
+ if (typeof body?.id !== "string") {
703
+ json(res, MALFORMED, 400);
704
+ return;
705
+ }
706
+ json(res, OK({ ok: pty.detach(body.id) }));
707
+ return;
708
+ }
709
+ case "/dsh-term/reattach": {
710
+ const body = payload;
711
+ if (typeof body?.id !== "string") {
712
+ json(res, MALFORMED, 400);
713
+ return;
714
+ }
715
+ const session = pty.reattach(body.id);
716
+ if (session === null) {
717
+ json(res, FAIL("session not found", "not_found"), 404);
718
+ return;
719
+ }
720
+ json(res, OK(session));
721
+ return;
722
+ }
367
723
  default: json(res, MALFORMED, 404);
368
724
  }
369
725
  } catch (error) {
@@ -410,6 +766,57 @@ function registerTermRoutes(ctx, pty) {
410
766
  };
411
767
  }
412
768
  //#endregion
769
+ //#region src/gateway/i18n.ts
770
+ const zh = {
771
+ "msg.sessionExited": "[dsh-term] 进程已退出(code {0})",
772
+ "msg.spawnFailed": "[dsh-term] 启动失败: {0}",
773
+ "ui.panel.title": "终端",
774
+ "ui.panel.addTabTitle": "新建终端",
775
+ "ui.panel.collapseTitle": "收起",
776
+ "ui.panel.emptyHint": "点击 + 新建终端",
777
+ "ui.dock.label": "终端",
778
+ "ui.tab.closeAria": "关闭 {0}",
779
+ "ui.panel.shellTitle": "Shell",
780
+ "ui.shell.bash": "Bash",
781
+ "ui.shell.zsh": "Zsh",
782
+ "ui.shell.powershell": "PowerShell",
783
+ "ui.shell.cmd": "命令提示符",
784
+ "ui.shell.gitbash": "Git Bash"
785
+ };
786
+ const dicts = {
787
+ zh,
788
+ en: {
789
+ "msg.sessionExited": "[dsh-term] Process exited (code {0})",
790
+ "msg.spawnFailed": "[dsh-term] Spawn failed: {0}",
791
+ "ui.panel.title": "Terminal",
792
+ "ui.panel.addTabTitle": "New Terminal",
793
+ "ui.panel.collapseTitle": "Collapse",
794
+ "ui.panel.emptyHint": "Click + to create a terminal",
795
+ "ui.dock.label": "Terminal",
796
+ "ui.tab.closeAria": "Close {0}",
797
+ "ui.panel.shellTitle": "Shell",
798
+ "ui.shell.bash": "Bash",
799
+ "ui.shell.zsh": "Zsh",
800
+ "ui.shell.powershell": "PowerShell",
801
+ "ui.shell.cmd": "Command Prompt",
802
+ "ui.shell.gitbash": "Git Bash"
803
+ }
804
+ };
805
+ var Translator = class {
806
+ lang;
807
+ constructor(lang) {
808
+ this.lang = lang;
809
+ }
810
+ t(key, ...args) {
811
+ let s = (dicts[this.lang] ?? zh)[key] ?? zh[key] ?? key;
812
+ for (let i = 0; i < args.length; i++) s = s.replaceAll(`{${i}}`, String(args[i]));
813
+ return s;
814
+ }
815
+ };
816
+ function createTranslator(lang) {
817
+ return new Translator(lang);
818
+ }
819
+ //#endregion
413
820
  //#region src/index.ts
414
821
  /** Required services: the route registry. */
415
822
  const inject = ["webServer"];
@@ -422,13 +829,31 @@ const DSH_TERM_GUIDANCE = "本机已安装 dsh-term 插件(DSH Web GUI 的面
422
829
  const apply = mountOnce("@lijian-ui/dsh-term", applyImpl);
423
830
  function applyImpl(ctx) {
424
831
  const pty = new PtyService();
832
+ /** Current language; resolved from dsh global settings. */
833
+ let lang = "zh";
834
+ createTranslator(lang);
835
+ /** Resolve the user's language preference from dsh settings. */
836
+ function resolveLang() {
837
+ try {
838
+ const settings = ctx.get("settings");
839
+ if (!settings) return "zh";
840
+ return settings.get(settingsNamespace("locale"))?.preference === "en" ? "en" : "zh";
841
+ } catch {
842
+ return "zh";
843
+ }
844
+ }
425
845
  ctx.effect(() => {
426
- const disposeRoutes = registerTermRoutes(ctx, pty);
846
+ lang = resolveLang();
847
+ const disposeRoutes = registerTermRoutes(ctx, pty, () => createTranslator(lang));
427
848
  return () => {
428
849
  disposeRoutes();
429
850
  pty.dispose();
430
851
  };
431
852
  }, "dsh-term: routes + pty lifecycle");
853
+ ctx.root.on("settings/updated", (ns, next) => {
854
+ if (ns !== settingsNamespace("locale")) return;
855
+ lang = next?.preference === "en" ? "en" : "zh";
856
+ });
432
857
  }
433
858
  //#endregion
434
859
  export { DSH_TERM_GUIDANCE, apply, inject };