@aiwayds/dsh-tui-pi 2.19.0 → 2.20.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 (105) hide show
  1. package/README.md +12 -48
  2. package/README.zh-CN.md +11 -47
  3. package/lib/agents.js +67 -52
  4. package/lib/agents.js.map +1 -1
  5. package/lib/ask-user.js +30 -30
  6. package/lib/ask-user.js.map +1 -1
  7. package/lib/attachments.js +8 -3
  8. package/lib/attachments.js.map +1 -1
  9. package/lib/btw.d.ts +4 -2
  10. package/lib/btw.js +22 -16
  11. package/lib/btw.js.map +1 -1
  12. package/lib/custom-provider.js +34 -28
  13. package/lib/custom-provider.js.map +1 -1
  14. package/lib/footer.d.ts +9 -8
  15. package/lib/footer.js +36 -18
  16. package/lib/footer.js.map +1 -1
  17. package/lib/hard-exit.d.ts +36 -0
  18. package/lib/hard-exit.js +64 -0
  19. package/lib/hard-exit.js.map +1 -0
  20. package/lib/history-fork.d.ts +2 -2
  21. package/lib/history-fork.js +12 -9
  22. package/lib/history-fork.js.map +1 -1
  23. package/lib/history-turns.d.ts +4 -2
  24. package/lib/history-turns.js +7 -3
  25. package/lib/history-turns.js.map +1 -1
  26. package/lib/history.js +57 -45
  27. package/lib/history.js.map +1 -1
  28. package/lib/hotkeys.js +44 -33
  29. package/lib/hotkeys.js.map +1 -1
  30. package/lib/i18n/index.d.ts +54 -0
  31. package/lib/i18n/index.js +98 -0
  32. package/lib/i18n/index.js.map +1 -0
  33. package/lib/i18n/registry.d.ts +65 -0
  34. package/lib/i18n/registry.js +129 -0
  35. package/lib/i18n/registry.js.map +1 -0
  36. package/lib/index.js +216 -108
  37. package/lib/index.js.map +1 -1
  38. package/lib/language-ask.d.ts +98 -0
  39. package/lib/language-ask.js +118 -0
  40. package/lib/language-ask.js.map +1 -0
  41. package/lib/live-widgets.js +9 -8
  42. package/lib/live-widgets.js.map +1 -1
  43. package/lib/login.js +5 -4
  44. package/lib/login.js.map +1 -1
  45. package/lib/messages.d.ts +0 -4
  46. package/lib/messages.js +14 -15
  47. package/lib/messages.js.map +1 -1
  48. package/lib/model-profiles.js +32 -20
  49. package/lib/model-profiles.js.map +1 -1
  50. package/lib/panels.js +18 -17
  51. package/lib/panels.js.map +1 -1
  52. package/lib/preflight-projcache.js +9 -3
  53. package/lib/preflight-projcache.js.map +1 -1
  54. package/lib/preset-dialog.d.ts +3 -3
  55. package/lib/preset-dialog.js +20 -17
  56. package/lib/preset-dialog.js.map +1 -1
  57. package/lib/provider-catalog.d.ts +23 -7
  58. package/lib/provider-catalog.js +90 -68
  59. package/lib/provider-catalog.js.map +1 -1
  60. package/lib/queue-panel.d.ts +2 -2
  61. package/lib/queue-panel.js +13 -9
  62. package/lib/queue-panel.js.map +1 -1
  63. package/lib/repair-dialog.d.ts +4 -4
  64. package/lib/repair-dialog.js +23 -16
  65. package/lib/repair-dialog.js.map +1 -1
  66. package/lib/route-dialog.d.ts +3 -3
  67. package/lib/route-dialog.js +20 -14
  68. package/lib/route-dialog.js.map +1 -1
  69. package/lib/selectors.d.ts +15 -1
  70. package/lib/selectors.js +57 -23
  71. package/lib/selectors.js.map +1 -1
  72. package/lib/sessions.d.ts +47 -14
  73. package/lib/sessions.js +162 -53
  74. package/lib/sessions.js.map +1 -1
  75. package/lib/settings.d.ts +68 -9
  76. package/lib/settings.js +327 -78
  77. package/lib/settings.js.map +1 -1
  78. package/lib/skills-manager.js +27 -21
  79. package/lib/skills-manager.js.map +1 -1
  80. package/lib/skills.js +5 -4
  81. package/lib/skills.js.map +1 -1
  82. package/lib/startup-info.js +9 -3
  83. package/lib/startup-info.js.map +1 -1
  84. package/lib/steer-flow.d.ts +14 -4
  85. package/lib/steer-flow.js +24 -9
  86. package/lib/steer-flow.js.map +1 -1
  87. package/lib/stop-dialog.d.ts +2 -2
  88. package/lib/stop-dialog.js +16 -12
  89. package/lib/stop-dialog.js.map +1 -1
  90. package/lib/subagent-viewer.d.ts +3 -6
  91. package/lib/subagent-viewer.js +38 -36
  92. package/lib/subagent-viewer.js.map +1 -1
  93. package/lib/theme-preview.js +12 -11
  94. package/lib/theme-preview.js.map +1 -1
  95. package/lib/theme-settings.d.ts +23 -2
  96. package/lib/theme-settings.js +68 -61
  97. package/lib/theme-settings.js.map +1 -1
  98. package/lib/tui.js +2 -1
  99. package/lib/tui.js.map +1 -1
  100. package/locales/en.json +646 -0
  101. package/locales/ja.json +646 -0
  102. package/locales/ko.json +646 -0
  103. package/locales/zh-CN.json +646 -0
  104. package/package.json +2 -1
  105. package/skills/dsh-tui-pi-config/SKILL.md +9 -2
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { hardExit } from "./hard-exit.js";
15
16
  /** Mirrors dsh-skill's bundled-skill rank (a non-load-bearing ordering hint;
16
17
  * the constant is hardcoded there too). Local copy — see the type-import
17
18
  * note above for why dsh-skill is not loaded at runtime here. */
@@ -27,7 +28,8 @@ import { AGENT_TICK_MS, LiveWidgets } from "./live-widgets.js";
27
28
  import { displayPermissionPreset } from "./permission.js";
28
29
  import { pickEffort, pickModel, pickPermission, pickPreset, pickTheme } from "./selectors.js";
29
30
  import { DshSessionBridge, persistDefaultModel, stashSessionIdForReload, takeStashedSessionId } from "./session.js";
30
- import { currentCacheHitMode, currentRememberPreset, currentThemePreference, readAskUserExplicit, readFooterHintsPreference, readIconSetPreference, readPanelHeightPreference, readRememberPreset, readSessionManagementExplicit, readSubagentLimits, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
31
+ import { currentCacheHitMode, currentRememberPreset, currentThemePreference, readAskUserExplicit, readFooterHintsPreference, readIconSetPreference, readLanguagePreference, readPanelHeightPreference, readRememberPreset, readSessionManagementExplicit, readSubagentLimits, readThemePreference, registerThemeSettings, writeLanguagePreference, writeThemePreference, } from "./theme-settings.js";
32
+ import { currentLocale, initI18n, listLocales, setLocale, t } from "./i18n/index.js";
31
33
  import { loadWorkspacePresets, rememberedPresetFor, saveWorkspacePresets, withRememberedPreset, workspacePresetsPath, } from "./workspace-presets.js";
32
34
  import { applyIconSet, resolveIconSet, stopIcon } from "./icons.js";
33
35
  import { readAgentMaxRounds } from "./agent-runtime.js";
@@ -52,9 +54,9 @@ import { openSubagentViewer } from "./subagent-viewer.js";
52
54
  import { openHistoryBrowser } from "./history.js";
53
55
  import { openForkAtTurnDialog } from "./history-fork.js";
54
56
  import { commandUsagePath, CommandUsageTracker } from "./usage.js";
55
- import { buildUserPrompt, decideSubmitPath, deliverToAgent, promotePending, removeFromInbox, STEER_UNAVAILABLE_NOTICE, TURN_ENDED_QUEUED_NOTICE, } from "./steer-flow.js";
57
+ import { buildUserPrompt, decideSubmitPath, deliverToAgent, promotePending, removeFromInbox, steerUnavailableNotice, turnEndedQueuedNotice, } from "./steer-flow.js";
56
58
  import { openSubmitRouteDialog } from "./route-dialog.js";
57
- import { BtwController, BTW_IDLE_NOTICE, BTW_USAGE, buildBtwSnapshot, parseBtwInput, resolveSnapshotLimit, } from "./btw.js";
59
+ import { BtwController, btwIdleNotice, btwUsage, buildBtwSnapshot, parseBtwInput, resolveSnapshotLimit, } from "./btw.js";
58
60
  import { BtwOverlayWire } from "./btw-overlay.js";
59
61
  import { openPendingQueuePanel } from "./queue-panel.js";
60
62
  import { ansiFg, BOLD, RESET, resolveTheme } from "./theme/index.js";
@@ -68,6 +70,7 @@ import { currentPreset, fetchPresetRoster, findPresetByName, formatPresetLabel,
68
70
  import { completedTurnSeed, openPresetConfirmDialog, performPresetSwitch } from "./preset-dialog.js";
69
71
  import { openStopConfirmDialog } from "./stop-dialog.js";
70
72
  import { registerAskUserProvider, resolveAskUserTimeouts } from "./ask-user.js";
73
+ import { askLanguageChoice } from "./language-ask.js";
71
74
  import { checkHostSupport } from "./host-version.js";
72
75
  export const name = 'dsh-tui-pi';
73
76
  /**
@@ -99,7 +102,7 @@ const SKILL_RESOURCE_BASE = {
99
102
  };
100
103
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
101
104
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
102
- const SKILL_DESCRIPTION = 'dsh TUI 增强套件(@aiwayds/dsh-tui-pi)使用与配置指南。凡涉及 TUI 主题/面板/footer、子代理并发与轮数限制、模型收藏与隐藏、会话保留清理与 /resume 过滤、ask_user_question 超时、preset 记忆,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 17 键(theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/registeredOnly/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume/askUser)、DSH_TUI_* 环境变量、快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、收藏模型、隐藏模型、保留策略、panelHeight、resume、preset。';
105
+ const SKILL_DESCRIPTION = 'dsh TUI 增强套件使用与配置指南。凡涉及 TUI 主题/面板/footer、界面语言、子代理并发与轮数限制、模型收藏与隐藏、会话保留与 /resume 过滤、ask_user 超时、preset 记忆,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 18 键(language/theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/registeredOnly/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume/askUser)、DSH_TUI_* 环境变量、快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、语言、language、收藏模型、隐藏模型、保留策略、panelHeight、resume、preset。';
103
106
  const SKILL_CANDIDATE = {
104
107
  name: SKILL_PROVIDER_NAME,
105
108
  description: SKILL_DESCRIPTION,
@@ -164,7 +167,7 @@ export function apply(ctx) {
164
167
  const check = checkHostSupport();
165
168
  if (!check.ok) {
166
169
  console.warn(check.message);
167
- process.exit(1);
170
+ hardExit(1);
168
171
  }
169
172
  if (check.version === undefined) {
170
173
  console.warn('[dsh-tui-pi] could not resolve the dsh host version next to the plugin; skipping the host floor check.');
@@ -247,7 +250,8 @@ export function apply(ctx) {
247
250
  // setTheme replay that follows already renders at that new height.
248
251
  // applyTheme carries the theme-bundle identity guard, so a height-only
249
252
  // commit never triggers a second rebuild.
250
- registerThemeSettings(ctx, (pref, height, footerHints, iconSet) => {
253
+ registerThemeSettings(ctx, (pref, height, footerHints, iconSet, language) => {
254
+ setLocale(language);
251
255
  applyPanelHeightRef?.(height);
252
256
  applyThemeRef?.(pref);
253
257
  applyFooterHintsRef?.(footerHints);
@@ -286,6 +290,12 @@ export function apply(ctx) {
286
290
  const themePreference = await readThemePreference(ctx);
287
291
  const panelHeight = await readPanelHeightPreference(ctx);
288
292
  const footerHints = await readFooterHintsPreference(ctx);
293
+ // UI language (`dsh-tui.language`, default 'en'): discover the language
294
+ // files and activate the persisted id BEFORE the TUI's first frame, so
295
+ // the boot UI speaks it from the start. The registration's watch hook
296
+ // re-applies later commits live via setLocale. An unknown id degrades to
297
+ // 'en' inside initI18n.
298
+ initI18n({ id: await readLanguagePreference(ctx), warn: message => emitNotice(message) });
289
299
  // Icon-set self-adaptation: probe the platform once at startup (the
290
300
  // memoised snapshot shared with every later 'auto' resolution), resolve
291
301
  // the persisted mode against it and push the result into src/icons.ts
@@ -401,7 +411,7 @@ export function apply(ctx) {
401
411
  // so a stray Esc can never kill a running task. The notice keeps
402
412
  // the armed first press from feeling dead and states the
403
413
  // contract explicitly.
404
- renderer.renderNotice('Press Esc again to stop all LLM work', 'info');
414
+ renderer.renderNotice(t('tui.esc.armStop'), 'info');
405
415
  break;
406
416
  case 'interrupt-cancel': {
407
417
  // Second Esc while LLM work runs (main turn and/or subagents):
@@ -444,7 +454,7 @@ export function apply(ctx) {
444
454
  void disposeAndExit(0);
445
455
  break;
446
456
  }
447
- renderer.renderNotice(`${stopIcon()} Ctrl+C ×2 — quitting…`, 'info');
457
+ renderer.renderNotice(t('tui.esc.quitArmed', { icon: stopIcon() }), 'info');
448
458
  quitConfirmTimer = setTimeout(() => {
449
459
  quitConfirmTimer = undefined;
450
460
  void disposeAndExit(0);
@@ -459,7 +469,7 @@ export function apply(ctx) {
459
469
  if (action.key === 'ctrl-c' && quitConfirmTimer !== undefined) {
460
470
  clearTimeout(quitConfirmTimer);
461
471
  quitConfirmTimer = undefined;
462
- renderer.renderNotice('quit aborted — Ctrl+C was held, not double-pressed', 'info');
472
+ renderer.renderNotice(t('tui.esc.quitAborted'), 'info');
463
473
  }
464
474
  break;
465
475
  case 'ctrl-d-quit':
@@ -486,7 +496,7 @@ export function apply(ctx) {
486
496
  onRemove: item => {
487
497
  const agent = bridge.getAgent();
488
498
  if (agent === undefined)
489
- return { kind: 'error', error: 'No active session.' };
499
+ return { kind: 'error', error: t('tui.session.none') };
490
500
  const result = removeFromInbox(agent.inbox, item);
491
501
  // Review B1: a successful revoke must retire the transcript
492
502
  // badge — the echo bubble becomes an explicit canceled line
@@ -499,7 +509,7 @@ export function apply(ctx) {
499
509
  onPromote: item => {
500
510
  const agent = bridge.getAgent();
501
511
  if (agent === undefined)
502
- return { kind: 'error', error: 'No active session.' };
512
+ return { kind: 'error', error: t('tui.session.none') };
503
513
  const result = promotePending(agent, item);
504
514
  if (result.kind === 'promoted' && result.degraded) {
505
515
  // Review S3: the steer degraded back into the queue — flip
@@ -516,7 +526,7 @@ export function apply(ctx) {
516
526
  },
517
527
  onOutcome: (result) => {
518
528
  if (result.kind === 'promoted' && result.degraded) {
519
- renderer.renderNotice(STEER_UNAVAILABLE_NOTICE, 'info');
529
+ renderer.renderNotice(steerUnavailableNotice(), 'info');
520
530
  }
521
531
  else if (result.kind === 'error') {
522
532
  renderer.renderNotice(result.error, 'error');
@@ -586,7 +596,7 @@ export function apply(ctx) {
586
596
  * dialog's Stop choice (double-Esc) and the first Ctrl+C mid-task.
587
597
  */
588
598
  const stopTask = async () => {
589
- renderer.renderNotice(`${stopIcon()} stopping all LLM work…`, 'info');
599
+ renderer.renderNotice(t('tui.stop.stopping', { icon: stopIcon() }), 'info');
590
600
  // The stop gesture is the everything-stop: side calls die with the turn.
591
601
  btwController.cancelAll();
592
602
  const cancelled = await bridge.cancelActiveTurn();
@@ -594,7 +604,7 @@ export function apply(ctx) {
594
604
  // State raced idle between the decision and the cancel calls — nothing
595
605
  // to cancel (e.g. the task settled while the dialog was up).
596
606
  if (!cancelled && stoppedChildren === 0)
597
- renderer.renderNotice('Nothing running to cancel.', 'info');
607
+ renderer.renderNotice(t('tui.stop.nothingRunning'), 'info');
598
608
  };
599
609
  // Arm the settings watch sink now that the renderer exists (see apply()).
600
610
  applyThemeRef = (pref) => {
@@ -608,7 +618,7 @@ export function apply(ctx) {
608
618
  // Startup notices for a broken/misleading keybindings file — the TUI keeps
609
619
  // running with defaults; the panel shows the same warnings via /hotkeys.
610
620
  for (const warning of keyBindings.warnings) {
611
- renderer.renderNotice(`keybindings: ${warning}`, 'error');
621
+ renderer.renderNotice(t('tui.keys.warningPrefix', { warning }), 'error');
612
622
  }
613
623
  // Arm the shared notice sink now that the TUI is up. Every operator
614
624
  // trace that fired before this point — the settings-namespace
@@ -663,7 +673,7 @@ export function apply(ctx) {
663
673
  agentStatus = status;
664
674
  if (status === 'running') {
665
675
  if (loader === undefined) {
666
- loader = new Loader(ui.tui, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, 'working…');
676
+ loader = new Loader(ui.tui, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, t('tui.status.working'));
667
677
  ui.status.addChild(loader);
668
678
  loader.start();
669
679
  }
@@ -738,7 +748,7 @@ export function apply(ctx) {
738
748
  for (const text of bridge.takePendingRemoteFollowups()) {
739
749
  await bridge.prompt(text);
740
750
  }
741
- emitNotice('Write lock acquired — follow-ups sent.');
751
+ emitNotice(t('tui.takeover.writeLock'));
742
752
  ui.requestRender();
743
753
  },
744
754
  };
@@ -895,7 +905,7 @@ export function apply(ctx) {
895
905
  stream: options => {
896
906
  const llm = ctx.get('llm');
897
907
  if (llm === undefined)
898
- throw new Error('LLM service is not available.');
908
+ throw new Error(t('tui.btw.noLlm'));
899
909
  return llm.stream({
900
910
  provider: options.provider,
901
911
  model: options.model,
@@ -929,10 +939,10 @@ export function apply(ctx) {
929
939
  if (parsed.kind === 'empty') {
930
940
  const opened = btwController.openReview();
931
941
  return opened === 'live'
932
- ? { kind: 'success', text: 'btw — the running answer is back on screen.' }
942
+ ? { kind: 'success', text: t('tui.btw.backOnScreen') }
933
943
  : opened === 'review'
934
- ? { kind: 'success', text: 'Last btw exchange shown.' }
935
- : { kind: 'success', text: BTW_USAGE };
944
+ ? { kind: 'success', text: t('tui.btw.lastShown') }
945
+ : { kind: 'success', text: btwUsage() };
936
946
  }
937
947
  if (parsed.kind === 'error')
938
948
  return { kind: 'error', text: parsed.error };
@@ -940,7 +950,7 @@ export function apply(ctx) {
940
950
  // this must be the live view, not a read-only remote one) — when idle,
941
951
  // a normal prompt is strictly better (tools, history, full context).
942
952
  if (!(bridge.isRunning() && !bridge.isReadOnlyView())) {
943
- return { kind: 'error', text: BTW_IDLE_NOTICE };
953
+ return { kind: 'error', text: btwIdleNotice() };
944
954
  }
945
955
  const result = btwController.submit({
946
956
  question: parsed.question,
@@ -948,14 +958,14 @@ export function apply(ctx) {
948
958
  });
949
959
  switch (result.kind) {
950
960
  case 'started':
951
- return { kind: 'success', text: 'btw — answering alongside the main task.' };
961
+ return { kind: 'success', text: t('tui.btw.started') };
952
962
  case 'queued':
953
- return { kind: 'success', text: `btw queued (position ${result.position}).` };
963
+ return { kind: 'success', text: t('tui.btw.queued', { position: result.position }) };
954
964
  case 'rejected':
955
- return { kind: 'error', text: `btw rejected — ${result.reason}.` };
965
+ return { kind: 'error', text: t('tui.btw.rejected', { reason: result.reason }) };
956
966
  }
957
967
  };
958
- registerLocalCommand('btw', 'Ask a side question while the main task runs (temp overlay, not kept)', btwHandler);
968
+ registerLocalCommand('btw', t('tui.cmd.btw.description'), btwHandler);
959
969
  // ------------------------------------------- TUI-owned slash commands --
960
970
  // Web-surface parity: `model` is a browser client contribution there and
961
971
  // `export` a web-only download plugin — the terminal gets native
@@ -966,7 +976,7 @@ export function apply(ctx) {
966
976
  const modelHandler = async () => {
967
977
  const picked = await pickModel(ctx, ui.tui, ui.theme, bridge.getSelection(), refocusEditor);
968
978
  if (picked === undefined)
969
- return { kind: 'success', text: 'Model unchanged.' };
979
+ return { kind: 'success', text: t('tui.model.unchanged') };
970
980
  const llm = ctx.get('llm');
971
981
  if (llm !== undefined) {
972
982
  await llm.resolveCallConfig({ provider: picked.provider, model: picked.model });
@@ -975,14 +985,14 @@ export function apply(ctx) {
975
985
  const persistError = await persistDefaultModel(ctx, picked);
976
986
  ui.requestRender();
977
987
  const modelText = picked.reasoningEffort === undefined
978
- ? `Model: ${picked.provider}/${picked.model}`
979
- : `Model: ${picked.provider}/${picked.model} · think ${String(picked.reasoningEffort)}`;
988
+ ? t('tui.model.selected', { id: `${picked.provider}/${picked.model}` })
989
+ : t('tui.model.selectedThink', { id: `${picked.provider}/${picked.model}`, effort: String(picked.reasoningEffort) });
980
990
  return {
981
991
  kind: 'success',
982
- text: persistError === undefined ? modelText : `${modelText} · ⚠ not persisted: ${persistError}`,
992
+ text: persistError === undefined ? modelText : t('tui.persist.failed', { text: modelText, error: persistError }),
983
993
  };
984
994
  };
985
- registerLocalCommand('model', 'Select the model (and think level) for this conversation', modelHandler);
995
+ registerLocalCommand('model', t('tui.cmd.model.description'), modelHandler);
986
996
  // /agents: manage agent definition markdown files (model, think level,
987
997
  // spawn depth) — the terminal counterpart of pi's /fun-agent-cfg. An
988
998
  // optional argument preselects one agent by name.
@@ -990,44 +1000,44 @@ export function apply(ctx) {
990
1000
  const trimmed = rawInput?.trim() ?? '';
991
1001
  const result = await openAgentManager(ctx, ui.tui, ui.theme, refocusEditor, trimmed === '' ? undefined : trimmed, () => subagentPolicy.getStats());
992
1002
  if (result === undefined)
993
- return { kind: 'success', text: 'Agents unchanged.' };
1003
+ return { kind: 'success', text: t('tui.agents.unchanged') };
994
1004
  return { kind: 'success', text: result };
995
1005
  };
996
- registerLocalCommand('agents', 'Manage agent definitions (model, think level, spawn depth) from markdown files', agentsHandler);
1006
+ registerLocalCommand('agents', t('tui.cmd.agents.description'), agentsHandler);
997
1007
  // /subagents: the command twin of Ctrl+G — pick a running (or recently
998
1008
  // settled) subagent and inspect its live transcript in the 80% viewer.
999
1009
  // Same flow as the key path; empty board closes immediately.
1000
1010
  const subagentsHandler = async () => {
1001
1011
  await openSubagentViewer(ctx, ui.tui, ui.theme, bridge, refocusEditor);
1002
- return { kind: 'success', text: 'Subagent viewer closed.' };
1012
+ return { kind: 'success', text: t('tui.subagents.viewerClosed') };
1003
1013
  };
1004
- registerLocalCommand('subagents', 'Browse subagents and inspect their live transcript', subagentsHandler);
1014
+ registerLocalCommand('subagents', t('tui.cmd.subagents.description'), subagentsHandler);
1005
1015
  // /think: cycle the current model's reasoning effort without re-picking
1006
1016
  // the model. A no-session /think still lands in the selection ref and
1007
1017
  // survives the lazy session creation (bridge seeds only an empty ref).
1008
1018
  const thinkHandler = async () => {
1009
1019
  const current = bridge.getSelection();
1010
1020
  if (current === undefined) {
1011
- return { kind: 'error', text: 'No model selected — pick one with /model first.' };
1021
+ return { kind: 'error', text: t('tui.think.noModel') };
1012
1022
  }
1013
1023
  const result = await pickEffort(ctx, ui.tui, ui.theme, current, refocusEditor);
1014
1024
  if (result.kind === 'unsupported') {
1015
1025
  return {
1016
1026
  kind: 'error',
1017
- text: `${current.provider}/${current.model} exposes no selectable think levels.`,
1027
+ text: t('tui.think.unsupported', { id: `${current.provider}/${current.model}` }),
1018
1028
  };
1019
1029
  }
1020
1030
  if (result.kind === 'cancelled')
1021
- return { kind: 'success', text: 'Think level unchanged.' };
1031
+ return { kind: 'success', text: t('tui.think.unchanged') };
1022
1032
  if (result.effort === 'default') {
1023
1033
  const next = { provider: current.provider, model: current.model };
1024
1034
  bridge.setSelection(next);
1025
1035
  const persistError = await persistDefaultModel(ctx, next);
1026
1036
  ui.requestRender();
1027
- const thinkText = `Think: provider default (${current.provider}/${current.model}).`;
1037
+ const thinkText = t('tui.think.providerDefault', { id: `${current.provider}/${current.model}` });
1028
1038
  return {
1029
1039
  kind: 'success',
1030
- text: persistError === undefined ? thinkText : `${thinkText} · ⚠ not persisted: ${persistError}`,
1040
+ text: persistError === undefined ? thinkText : t('tui.persist.failed', { text: thinkText, error: persistError }),
1031
1041
  };
1032
1042
  }
1033
1043
  const next = {
@@ -1038,13 +1048,13 @@ export function apply(ctx) {
1038
1048
  bridge.setSelection(next);
1039
1049
  const persistError = await persistDefaultModel(ctx, next);
1040
1050
  ui.requestRender();
1041
- const thinkText = `Think: ${String(result.effort)} (${current.provider}/${current.model}).`;
1051
+ const thinkText = t('tui.think.set', { effort: String(result.effort), id: `${current.provider}/${current.model}` });
1042
1052
  return {
1043
1053
  kind: 'success',
1044
- text: persistError === undefined ? thinkText : `${thinkText} · ⚠ not persisted: ${persistError}`,
1054
+ text: persistError === undefined ? thinkText : t('tui.persist.failed', { text: thinkText, error: persistError }),
1045
1055
  };
1046
1056
  };
1047
- registerLocalCommand('think', 'Switch the current model\'s think (reasoning) level', thinkHandler);
1057
+ registerLocalCommand('think', t('tui.cmd.think.description'), thinkHandler);
1048
1058
  /**
1049
1059
  * Record one committed preset as THIS workspace's remembered selection
1050
1060
  * (`$DSH_HOME/workspace-presets.json`, keyed by the session backend's
@@ -1062,7 +1072,7 @@ export function apply(ctx) {
1062
1072
  const doc = withRememberedPreset(loadWorkspacePresets(path), projectKeyFor(process.cwd()), presetId);
1063
1073
  const error = saveWorkspacePresets(path, doc);
1064
1074
  if (error !== undefined)
1065
- renderer.renderNotice(`preset memory not saved: ${error}`, 'error');
1075
+ renderer.renderNotice(t('tui.preset.memorySaveFailed', { error }), 'error');
1066
1076
  }
1067
1077
  catch {
1068
1078
  // A broken store must never fail the switch itself.
@@ -1077,7 +1087,7 @@ export function apply(ctx) {
1077
1087
  // directly (the first submit already creates the session on it).
1078
1088
  const presetHandler = async (rawInput) => {
1079
1089
  if (presetState.roster.length === 0) {
1080
- return { kind: 'error', text: 'No agent presets available.' };
1090
+ return { kind: 'error', text: t('tui.preset.none') };
1081
1091
  }
1082
1092
  const arg = rawInput?.trim() ?? '';
1083
1093
  let target;
@@ -1095,11 +1105,11 @@ export function apply(ctx) {
1095
1105
  else {
1096
1106
  target = findPresetByName(presetState, arg);
1097
1107
  if (target === undefined) {
1098
- return { kind: 'error', text: `Unknown preset: ${arg}` };
1108
+ return { kind: 'error', text: t('tui.preset.unknown', { name: arg }) };
1099
1109
  }
1100
1110
  }
1101
1111
  if (target === undefined)
1102
- return { kind: 'success', text: 'Preset unchanged.' };
1112
+ return { kind: 'success', text: t('tui.preset.unchanged') };
1103
1113
  const outcome = await performPresetSwitch(presetState, target, { hasLiveSession: () => bridge.getAgent() !== undefined,
1104
1114
  confirmSwitch: (name, restart) => openPresetConfirmDialog(ui.tui, ui.theme, name, restart, refocusEditor)
1105
1115
  .then(outcome => (outcome === 'cancelled' ? 'cancel' : outcome)),
@@ -1181,7 +1191,7 @@ export function apply(ctx) {
1181
1191
  persistRememberedPreset(target.id);
1182
1192
  return { kind: 'success', text: outcome.message };
1183
1193
  };
1184
- registerLocalCommand('preset', 'Switch the agent preset (starts a new session on it; /preset next cycles)', presetHandler);
1194
+ registerLocalCommand('preset', t('tui.cmd.preset.description'), presetHandler);
1185
1195
  // Model profiles moved to the dsh-profile-switch plugin (ask-user based,
1186
1196
  // surface-agnostic). What stays HERE is the live-selection bridge it
1187
1197
  // consumes: the TUI's /model + /think ride the same selection ref, and
@@ -1198,18 +1208,37 @@ export function apply(ctx) {
1198
1208
  },
1199
1209
  current: () => bridge.getSelection(),
1200
1210
  });
1211
+ /**
1212
+ * The host session-title seam (`@deepseek-ai/dsh-session-title`), read
1213
+ * structurally like every other optional host service: the host base
1214
+ * bundle mounts it (with the first-prompt LLM provider), but a stripped
1215
+ * profile without it must only degrade the /session panel to read-only,
1216
+ * never crash it. The rename error contract is duck-typed by name — the
1217
+ * class itself lives behind a module graph (zod) this plugin
1218
+ * deliberately does not import.
1219
+ */
1220
+ const sessionTitleSeam = (() => {
1221
+ try {
1222
+ return ctx.get('sessionTitle');
1223
+ }
1224
+ catch {
1225
+ return undefined;
1226
+ }
1227
+ })();
1201
1228
  const sessionHandler = async () => {
1202
1229
  const agent = bridge.getAgent();
1203
1230
  const stats = bridge.getStats();
1204
1231
  const selection = bridge.getSelection();
1205
1232
  const header = agent?.session.header;
1233
+ const liveSession = agent?.session;
1234
+ const titleOf = liveSession === undefined ? undefined : sessionTitleSeam?.get(liveSession)?.title;
1206
1235
  await showSessionInfo(ui.tui, ui.theme, {
1207
1236
  id: agent === undefined ? undefined : String(agent.session.id),
1208
1237
  cwd: header?.cwd,
1209
1238
  createdAt: header?.createdAt,
1210
1239
  model: selection === undefined ? undefined : `${selection.provider}/${selection.model}`,
1211
1240
  effort: selection === undefined || selection.reasoningEffort === undefined
1212
- ? (selection === undefined ? undefined : 'provider default')
1241
+ ? (selection === undefined ? undefined : t('tui.session.effortDefault'))
1213
1242
  : String(selection.reasoningEffort),
1214
1243
  msgCount: stats.msgCount,
1215
1244
  toolCallCount: stats.toolCallCount,
@@ -1220,10 +1249,28 @@ export function apply(ctx) {
1220
1249
  status: agent === undefined ? 'none' : agentStatus,
1221
1250
  eventCount: agent === undefined ? undefined : agent.session.seq,
1222
1251
  parentSession: header?.parentSession === undefined ? undefined : String(header.parentSession),
1223
- }, refocusEditor);
1224
- return { kind: 'success', text: agent === undefined ? 'No active session.' : 'Session info shown.' };
1252
+ title: titleOf,
1253
+ canRename: liveSession !== undefined && sessionTitleSeam !== undefined,
1254
+ }, refocusEditor, liveSession === undefined || sessionTitleSeam === undefined
1255
+ ? undefined
1256
+ : (title) => {
1257
+ try {
1258
+ const snapshot = sessionTitleSeam.rename(liveSession, title);
1259
+ return { ok: true, note: t('tui.session.renamed', { title: snapshot.title }), title: snapshot.title };
1260
+ }
1261
+ catch (error) {
1262
+ // The one input-blaming failure gets a panel-friendly phrase;
1263
+ // anything else (disposed service, non-live session) rides its
1264
+ // own message into the panel's note line.
1265
+ if (error instanceof Error && error.name === 'SessionTitleInvalidError') {
1266
+ return { ok: false, note: t('tui.session.titleEmpty') };
1267
+ }
1268
+ throw error;
1269
+ }
1270
+ });
1271
+ return { kind: 'success', text: agent === undefined ? t('tui.session.none') : t('tui.session.infoShown') };
1225
1272
  };
1226
- registerLocalCommand('session', 'Show the current session\'s info (id, model, stats)', sessionHandler);
1273
+ registerLocalCommand('session', t('tui.cmd.session.description'), sessionHandler);
1227
1274
  // /resume: pick a persisted session, validate its log, swap the live
1228
1275
  // agent for it, and rebuild transcript + stats from the stored events.
1229
1276
  const resumeHandler = async () => {
@@ -1241,7 +1288,7 @@ export function apply(ctx) {
1241
1288
  return { kind: 'error', text: message };
1242
1289
  }
1243
1290
  if (picked.kind === 'empty') {
1244
- return { kind: 'error', text: 'No other persisted sessions to resume.' };
1291
+ return { kind: 'error', text: t('tui.resume.noneOther') };
1245
1292
  }
1246
1293
  if (picked.kind === 'empty-filtered') {
1247
1294
  // Sessions exist but the display window hid them all — name the
@@ -1252,11 +1299,11 @@ export function apply(ctx) {
1252
1299
  : `${picked.minBytes}B`;
1253
1300
  return {
1254
1301
  kind: 'error',
1255
- text: `No sessions within the resume window (${picked.maxAgeDays}d, ≥${floor}) — adjust dsh-tui.resume.* to see more.`,
1302
+ text: t('tui.resume.windowEmpty', { days: picked.maxAgeDays, floor }),
1256
1303
  };
1257
1304
  }
1258
1305
  if (picked.kind === 'cancelled')
1259
- return { kind: 'success', text: 'Resume cancelled.' };
1306
+ return { kind: 'success', text: t('tui.resume.cancelled') };
1260
1307
  // Narrowed shared handle for the closures below (const narrowing
1261
1308
  // propagates into async closures; `picked`'s does not).
1262
1309
  const target = picked;
@@ -1288,19 +1335,19 @@ export function apply(ctx) {
1288
1335
  ui.requestRender();
1289
1336
  return {
1290
1337
  kind: 'success',
1291
- text: `Watching ${clipToWidth(String(target.id), 8)} (read-only · driven by another process). /resume or /new to switch.`,
1338
+ text: t('tui.resume.watching', { id: clipToWidth(String(target.id), 8) }),
1292
1339
  };
1293
1340
  }
1294
1341
  catch (watchError) {
1295
1342
  return {
1296
1343
  kind: 'error',
1297
- text: `Cannot watch ${clipToWidth(String(target.id), 8)} read-only: ${messageOf(watchError)}`,
1344
+ text: t('tui.resume.watchFailed', { id: clipToWidth(String(target.id), 8), error: messageOf(watchError) }),
1298
1345
  };
1299
1346
  }
1300
1347
  }
1301
1348
  return {
1302
1349
  kind: 'error',
1303
- text: `Resume failed: ${message} — the previous session was closed; the next prompt starts a new one.`,
1350
+ text: t('tui.resume.failed', { error: message }),
1304
1351
  };
1305
1352
  }
1306
1353
  // Seed the badge cache for the resumed session (its pin event may have
@@ -1328,7 +1375,7 @@ export function apply(ctx) {
1328
1375
  ui.requestRender();
1329
1376
  return {
1330
1377
  kind: 'success',
1331
- text: `Resumed ${clipToWidth(String(target.id), 8)} · ${session.seq} events.`,
1378
+ text: t('tui.resume.done', { id: clipToWidth(String(target.id), 8), count: session.seq }),
1332
1379
  };
1333
1380
  };
1334
1381
  // Corrupt-log branch: the repair rewrites user data on disk, so it is
@@ -1342,13 +1389,13 @@ export function apply(ctx) {
1342
1389
  if (logPath === undefined) {
1343
1390
  return {
1344
1391
  kind: 'error',
1345
- text: `repair failed: cannot locate the log of ${clipToWidth(String(target.id), 8)} on disk — log untouched`,
1392
+ text: t('tui.repair.logMissing', { id: clipToWidth(String(target.id), 8) }),
1346
1393
  };
1347
1394
  }
1348
1395
  if (await openRepairConfirmDialog(ui.tui, ui.theme, refocusEditor) !== 'repair') {
1349
1396
  return {
1350
1397
  kind: 'error',
1351
- text: `Cannot resume ${clipToWidth(String(target.id), 8)}: ${message}`,
1398
+ text: t('tui.resume.cannot', { id: clipToWidth(String(target.id), 8), error: message }),
1352
1399
  };
1353
1400
  }
1354
1401
  // repairSessionLog maps its own failures to results; the catch is a
@@ -1359,7 +1406,7 @@ export function apply(ctx) {
1359
1406
  }
1360
1407
  catch (error) {
1361
1408
  const detail = messageOf(error);
1362
- notice = `repair failed: ${detail} — log untouched`;
1409
+ notice = t('tui.repair.failed', { error: detail });
1363
1410
  }
1364
1411
  if (notice !== undefined)
1365
1412
  return { kind: 'error', text: notice };
@@ -1377,11 +1424,11 @@ export function apply(ctx) {
1377
1424
  if (isCorruptLogError(message)) {
1378
1425
  return offerCorruptedLogRepair(message);
1379
1426
  }
1380
- return { kind: 'error', text: `Cannot resume ${clipToWidth(String(picked.id), 8)}: ${message}` };
1427
+ return { kind: 'error', text: t('tui.resume.cannot', { id: clipToWidth(String(picked.id), 8), error: message }) };
1381
1428
  }
1382
1429
  return resumeAndReplay();
1383
1430
  };
1384
- registerLocalCommand('resume', 'Resume a persisted session', resumeHandler);
1431
+ registerLocalCommand('resume', t('tui.cmd.resume.description'), resumeHandler);
1385
1432
  // /history: the read-only two-pane look-back (ADR 0003) — left pane lists
1386
1433
  // the browsed session's completed turns, right pane shows the selected
1387
1434
  // turn's replies; Enter/c refills the editor with the turn's prompt (a
@@ -1447,7 +1494,7 @@ export function apply(ctx) {
1447
1494
  }, arg === '' ? undefined : arg);
1448
1495
  return { kind: result.error ? 'error' : 'success', text: result.text };
1449
1496
  };
1450
- registerLocalCommand('history', 'Browse past turns of a session (read-only; /history <id> for a stored one)', historyHandler);
1497
+ registerLocalCommand('history', t('tui.cmd.history.description'), historyHandler);
1451
1498
  // Auto-resume after a hot-reload: a `/reload` stashes the previously
1452
1499
  // current session id before this fiber's teardown, and the freshly
1453
1500
  // re-imported module consumes it best-effort here so the next prompt
@@ -1496,7 +1543,7 @@ export function apply(ctx) {
1496
1543
  }
1497
1544
  catch (error) {
1498
1545
  const message = messageOf(error);
1499
- renderer.renderNotice(`--resume ${clipToWidth(bootResumeId, 8)}: ${message} — starting fresh.`, 'error');
1546
+ renderer.renderNotice(t('tui.boot.resumeFailed', { id: clipToWidth(bootResumeId, 8), error: message }), 'error');
1500
1547
  }
1501
1548
  })();
1502
1549
  }
@@ -1506,11 +1553,11 @@ export function apply(ctx) {
1506
1553
  // falls back to the dsh path when one does (behavior unchanged there).
1507
1554
  commands.registerLocal('export', async () => ({
1508
1555
  kind: 'error',
1509
- text: 'No active session to export.',
1556
+ text: t('tui.export.noSession'),
1510
1557
  }));
1511
1558
  ctx.effect(() => ctx.commands.register({
1512
1559
  name: 'export',
1513
- description: 'Export this session log as JSONL',
1560
+ description: t('tui.cmd.export.description'),
1514
1561
  input: { hint: '[path]' },
1515
1562
  handler: async (invocation) => {
1516
1563
  const events = invocation.agent.session.snapshotEvents();
@@ -1518,7 +1565,7 @@ export function apply(ctx) {
1518
1565
  const target = invocation.rawInput.trim() === '' ? fallback : resolve(invocation.rawInput.trim());
1519
1566
  await mkdir(dirname(target), { recursive: true });
1520
1567
  await writeFile(target, events.map(event => JSON.stringify(event)).join('\n') + '\n');
1521
- return { kind: 'success', text: `Exported ${events.length} events → ${target}` };
1568
+ return { kind: 'success', text: t('tui.export.done', { count: events.length, path: target }) };
1522
1569
  },
1523
1570
  }), 'dsh-tui-pi: /export');
1524
1571
  // /new: detach the live agent (and clear the on-screen transcript) so
@@ -1575,16 +1622,16 @@ export function apply(ctx) {
1575
1622
  };
1576
1623
  const newHandler = async () => {
1577
1624
  await startNewSession();
1578
- return { kind: 'success', text: 'New session started.' };
1625
+ return { kind: 'success', text: t('tui.new.done') };
1579
1626
  };
1580
- registerLocalCommand('new', 'Start a new session', newHandler);
1627
+ registerLocalCommand('new', t('tui.cmd.new.description'), newHandler);
1581
1628
  // /settings: text-based configuration browser — the terminal counterpart
1582
1629
  // of the web GUI's settings surface. Enumerates ctx.settings.describe()
1583
1630
  // and walks each namespace's schema (drill-ins, cycle rows, inline editors,
1584
1631
  // reset-to-defaults), writing through settings.mutate path ops.
1585
1632
  const settingsHandler = async () => {
1586
1633
  if (ctx.get('settings') === undefined) {
1587
- return { kind: 'error', text: 'Settings service is not available.' };
1634
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1588
1635
  }
1589
1636
  const changes = await openSettingsBrowser({
1590
1637
  ctx,
@@ -1598,23 +1645,25 @@ export function apply(ctx) {
1598
1645
  },
1599
1646
  });
1600
1647
  if (changes < 0)
1601
- return { kind: 'error', text: 'No settings namespaces are registered.' };
1648
+ return { kind: 'error', text: t('tui.settings.noNamespaces') };
1602
1649
  return {
1603
1650
  kind: 'success',
1604
1651
  text: changes === 0
1605
- ? 'Settings: no changes.'
1606
- : `Settings: ${changes} change${changes === 1 ? '' : 's'} applied.`,
1652
+ ? t('tui.settings.noChanges')
1653
+ : changes === 1
1654
+ ? t('tui.settings.appliedOne', { count: changes })
1655
+ : t('tui.settings.appliedMany', { count: changes }),
1607
1656
  };
1608
1657
  };
1609
- registerLocalCommand('settings', 'Browse and edit configuration (namespaces, values, resets)', settingsHandler);
1658
+ registerLocalCommand('settings', t('tui.cmd.settings.description'), settingsHandler);
1610
1659
  // /skills: standalone skill browser with Installed/Available dual mode.
1611
1660
  // Enter/Space toggles or symlinks; Tab switches views; Esc exits.
1612
1661
  const skillsHandler = async () => {
1613
1662
  const agent = bridge.getAgent();
1614
1663
  openSkillsManagerPanel(ctx, ui.tui, ui.theme, refocusEditor, agent ?? undefined, () => { });
1615
- return { kind: 'success', text: 'Skills manager opened.' };
1664
+ return { kind: 'success', text: t('tui.skills.opened') };
1616
1665
  };
1617
- registerLocalCommand('skills', 'Manage user skills (installed and available)', skillsHandler);
1666
+ registerLocalCommand('skills', t('tui.cmd.skills.description'), skillsHandler);
1618
1667
  // /theme: pick a color scheme and apply it immediately — the choice is
1619
1668
  // persisted to the dsh-tui settings namespace (`applies: 'live'`, so the
1620
1669
  // watch hook would re-apply the same change anyway) and hot-swapped into
@@ -1623,7 +1672,7 @@ export function apply(ctx) {
1623
1672
  // mirrors /settings: without the service there is nowhere to write.
1624
1673
  const themeHandler = async () => {
1625
1674
  if (ctx.get('settings') === undefined) {
1626
- return { kind: 'error', text: 'Settings service is not available.' };
1675
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1627
1676
  }
1628
1677
  // Preselect from the live settings value (which may have changed since
1629
1678
  // startup via the /settings browser), not the startup snapshot. The
@@ -1634,7 +1683,7 @@ export function apply(ctx) {
1634
1683
  userNames: themeUserNames,
1635
1684
  });
1636
1685
  if (picked === undefined)
1637
- return { kind: 'success', text: 'Theme unchanged.' };
1686
+ return { kind: 'success', text: t('tui.theme.unchanged') };
1638
1687
  const writeError = await writeThemePreference(ctx, picked);
1639
1688
  if (writeError !== undefined)
1640
1689
  return { kind: 'error', text: writeError };
@@ -1649,12 +1698,70 @@ export function apply(ctx) {
1649
1698
  if (expected !== undefined && applied !== expected) {
1650
1699
  return {
1651
1700
  kind: 'success',
1652
- text: `Theme preference saved — display is pinned by DSH_TUI_THEME=${process.env.DSH_TUI_THEME}`,
1701
+ text: t('tui.theme.pinned', { value: String(process.env.DSH_TUI_THEME) }),
1653
1702
  };
1654
1703
  }
1655
- return { kind: 'success', text: `Theme: ${picked} — applied.` };
1704
+ return { kind: 'success', text: t('tui.theme.applied', { name: picked }) };
1705
+ };
1706
+ registerLocalCommand('theme', t('tui.cmd.theme.description'), themeHandler);
1707
+ // /language — switch the UI language. Language files live in the bundled
1708
+ // locales/ directory plus the user's ~/.dsh/locales (one JSON file per
1709
+ // language; adding one needs no code). Bare `/language` asks through the
1710
+ // native ask-user panel (src/language-ask.ts) — the same docked question
1711
+ // UI the model uses, so it answers on any registered surface (the TUI, a
1712
+ // phone through dsh-feishu) with first answer wins. `/language <id>`
1713
+ // switches directly. Either way the switch applies live (the next repaint
1714
+ // speaks it) and persists the choice to `dsh-tui.language`.
1715
+ const installedLanguages = () => listLocales().map(locale => `${locale.id} (${locale.name})`).join(', ');
1716
+ const applyLanguage = async (id) => {
1717
+ if (!setLocale(id)) {
1718
+ return { kind: 'error', text: t('command.language.unknown', { id, list: installedLanguages() }) };
1719
+ }
1720
+ const persistError = await writeLanguagePreference(ctx, id);
1721
+ const active = currentLocale();
1722
+ const switched = t('command.language.switched', { name: active.name, id: active.id });
1723
+ return {
1724
+ kind: 'success',
1725
+ text: persistError === undefined
1726
+ ? switched
1727
+ : `${switched} ${t('command.language.persistFailed', { error: persistError })}`,
1728
+ };
1729
+ };
1730
+ const languageHandler = async (rawInput, signal) => {
1731
+ const arg = rawInput.trim();
1732
+ if (arg !== '')
1733
+ return applyLanguage(arg);
1734
+ const ask = ctx.get('userQuestions');
1735
+ if (ask === undefined) {
1736
+ return {
1737
+ kind: 'error',
1738
+ text: t('command.language.unavailable', { list: installedLanguages() }),
1739
+ };
1740
+ }
1741
+ // Scope the ask to the live session when there is one (only the
1742
+ // surfaces driving it are asked); a fresh TUI asks agentless.
1743
+ let outcome;
1744
+ try {
1745
+ outcome = await askLanguageChoice(ask, { agent: bridge.getAgent(), signal });
1746
+ }
1747
+ catch (error) {
1748
+ return {
1749
+ kind: 'error',
1750
+ text: t('command.language.askFailed', {
1751
+ error: error instanceof Error ? error.message : String(error),
1752
+ }),
1753
+ };
1754
+ }
1755
+ if (outcome.kind === 'picked')
1756
+ return applyLanguage(outcome.id);
1757
+ if (outcome.kind === 'unknown') {
1758
+ return { kind: 'error', text: t('command.language.unknown', { id: outcome.text, list: installedLanguages() }) };
1759
+ }
1760
+ // Picked the active language, declined the question, or it was never
1761
+ // answered — nothing changed.
1762
+ return { kind: 'success', text: t('command.language.unchanged') };
1656
1763
  };
1657
- registerLocalCommand('theme', 'Set the terminal color scheme (applies immediately)', themeHandler);
1764
+ registerLocalCommand('language', t('command.language.description'), languageHandler);
1658
1765
  // /reload: hot-reload this plugin from the current source — re-imports the
1659
1766
  // module and its dependencies (picking up src changes after `pnpm build`)
1660
1767
  // and swaps the plugin runtime. The old fiber is disposed, so the TUI and
@@ -1664,7 +1771,7 @@ export function apply(ctx) {
1664
1771
  kind: 'success',
1665
1772
  text: await reloadPlugin(ctx, import.meta.url),
1666
1773
  });
1667
- registerLocalCommand('reload', 'Reload the TUI from the current source (apply code changes without restarting dsh)', reloadHandler);
1774
+ registerLocalCommand('reload', t('tui.cmd.reload.description'), reloadHandler);
1668
1775
  // /hotkeys — pi's keybinding browser, in the /agents select-panel style:
1669
1776
  // a field list of the app keys, Enter opens an editor that writes
1670
1777
  // ~/.dsh/keybindings.json and live-applies the change (no /reload).
@@ -1674,9 +1781,9 @@ export function apply(ctx) {
1674
1781
  apply: bindings => ui.setKeyBindings(bindings),
1675
1782
  restoreFocus: refocusEditor,
1676
1783
  });
1677
- return { kind: 'success', text: summary ?? 'Keybindings unchanged.' };
1784
+ return { kind: 'success', text: summary ?? t('tui.hotkeys.unchanged') };
1678
1785
  };
1679
- registerLocalCommand('hotkeys', 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)', hotkeysHandler);
1786
+ registerLocalCommand('hotkeys', t('tui.cmd.hotkeys.description'), hotkeysHandler);
1680
1787
  // /login: register provider credentials — the terminal counterpart of
1681
1788
  // pi-agent's /login, on the Models category's add-provider flow. Opens a
1682
1789
  // searchable provider directory (including already-configured routes, so
@@ -1687,7 +1794,7 @@ export function apply(ctx) {
1687
1794
  // filtered picker otherwise.
1688
1795
  const loginHandler = async (rawInput) => {
1689
1796
  if (ctx.get('settings') === undefined) {
1690
- return { kind: 'error', text: 'Settings service is not available.' };
1797
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1691
1798
  }
1692
1799
  const result = await openLoginFlow({
1693
1800
  ctx,
@@ -1700,14 +1807,14 @@ export function apply(ctx) {
1700
1807
  if (result.kind === 'unknown') {
1701
1808
  return {
1702
1809
  kind: 'error',
1703
- text: `Unknown provider '${result.target}' — run /login to pick from the directory.`,
1810
+ text: t('tui.login.unknownProvider', { name: result.target }),
1704
1811
  };
1705
1812
  }
1706
1813
  if (result.kind === 'cancelled')
1707
- return { kind: 'success', text: 'Login cancelled.' };
1708
- return { kind: 'success', text: `Provider ${result.name} configured.` };
1814
+ return { kind: 'success', text: t('tui.login.cancelled') };
1815
+ return { kind: 'success', text: t('tui.login.configured', { name: result.name }) };
1709
1816
  };
1710
- registerLocalCommand('login', 'Register provider credentials (API key) through the provider directory', loginHandler);
1817
+ registerLocalCommand('login', t('tui.cmd.login.description'), loginHandler);
1711
1818
  // /logout: unsubscribe a provider — pi-agent's /logout on the dsh side.
1712
1819
  // Lists the providers with a stored credential; on selection removes the
1713
1820
  // key AND the settings.yaml provider entry, so the provider's models
@@ -1716,7 +1823,7 @@ export function apply(ctx) {
1716
1823
  // the installed catalog's current model list.
1717
1824
  const logoutHandler = async () => {
1718
1825
  if (ctx.get('settings') === undefined) {
1719
- return { kind: 'error', text: 'Settings service is not available.' };
1826
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1720
1827
  }
1721
1828
  const result = await openLogoutFlow({
1722
1829
  ctx,
@@ -1726,34 +1833,32 @@ export function apply(ctx) {
1726
1833
  onError: message => renderer.renderNotice(message, 'error'),
1727
1834
  });
1728
1835
  if (result.kind === 'none') {
1729
- return { kind: 'success', text: 'No stored credentials to remove.' };
1836
+ return { kind: 'success', text: t('tui.logout.none') };
1730
1837
  }
1731
1838
  if (result.kind === 'cancelled')
1732
- return { kind: 'success', text: 'Logout cancelled.' };
1839
+ return { kind: 'success', text: t('tui.logout.cancelled') };
1733
1840
  if (result.kind === 'failed') {
1734
1841
  const cause = result.cause === undefined ? '' : `: ${result.cause}`;
1735
- return { kind: 'error', text: `Failed to remove stored API key for ${result.name}${cause}.` };
1842
+ return { kind: 'error', text: t('tui.logout.failed', { name: result.name, cause }) };
1736
1843
  }
1737
1844
  if (result.kind === 'removed-incomplete') {
1738
1845
  return {
1739
1846
  kind: 'error',
1740
- text: `Removed the API key for ${result.name}, but its provider configuration stays: ${result.error}`
1741
- + ' — remove the provider in /settings to finish the logout.',
1847
+ text: t('tui.logout.incomplete', { name: result.name, error: result.error }),
1742
1848
  };
1743
1849
  }
1744
1850
  if (result.kind === 'removed-key-only') {
1745
1851
  return {
1746
1852
  kind: 'success',
1747
- text: `Removed the API key for ${result.name}. Hand-declared route — its configuration stays;`
1748
- + ' remove the provider in /settings to also drop its models.',
1853
+ text: t('tui.logout.keyOnly', { name: result.name }),
1749
1854
  };
1750
1855
  }
1751
1856
  return {
1752
1857
  kind: 'success',
1753
- text: `Logged out ${result.name} — API key and provider configuration removed.`,
1858
+ text: t('tui.logout.done', { name: result.name }),
1754
1859
  };
1755
1860
  };
1756
- registerLocalCommand('logout', 'Log out a provider (removes its API key and provider configuration)', logoutHandler);
1861
+ registerLocalCommand('logout', t('tui.cmd.logout.description'), logoutHandler);
1757
1862
  // ------------------------------------------------- powerline footer + git --
1758
1863
  const git = new GitBranchWatcher(process.cwd());
1759
1864
  git.onChange = () => ui.requestRender();
@@ -1892,6 +1997,9 @@ export function apply(ctx) {
1892
1997
  * (model/effort pickers, settings browser, session panel, resume list),
1893
1998
  * and plugin flows hold the editor just as long: /wiki onboard parks the
1894
1999
  * user in ask-user panels, /vault backup|restore push over the network.
2000
+ * /language rides the same ask-user panel (src/language-ask.ts) — its
2001
+ * auto-answer window is minutes, and with the timeouts disabled it waits
2002
+ * indefinitely, so the generic guard must not cut it off mid-question.
1895
2003
  * The generic guard would fire mid-flow and echo a spurious
1896
2004
  * "aborted due to timeout" — those run with a never-aborting signal
1897
2005
  * instead.
@@ -1901,7 +2009,7 @@ export function apply(ctx) {
1901
2009
  * through this dispatcher, so their names belong here all the same
1902
2010
  * (dropping them in 2.16.0 re-introduced the timeout).
1903
2011
  */
1904
- const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'history', 'theme', 'permission', 'agents', 'subagents', 'login', 'logout', 'skills', 'preset', 'profile-switch', 'profile-cfg', 'wiki', 'vault']);
2012
+ const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'history', 'theme', 'language', 'permission', 'agents', 'subagents', 'login', 'logout', 'skills', 'preset', 'profile-switch', 'profile-cfg', 'wiki', 'vault']);
1905
2013
  /** Route one submitted line: dsh slash command first, model prompt second. */
1906
2014
  const submit = async (text) => {
1907
2015
  const line = text.trim();
@@ -1943,7 +2051,7 @@ export function apply(ctx) {
1943
2051
  // Cancelled — or the derived custom state, which is display-only
1944
2052
  // and not a switch target (mirrors the web client's popup
1945
2053
  // filtering). Either way nothing changed.
1946
- renderer.renderCommandEcho(line, undefined, 'Permission unchanged.');
2054
+ renderer.renderCommandEcho(line, undefined, t('tui.permission.unchanged'));
1947
2055
  return;
1948
2056
  }
1949
2057
  executeLine = `/permission ${picked}`;
@@ -1993,7 +2101,7 @@ export function apply(ctx) {
1993
2101
  try {
1994
2102
  await bridge.prompt(line);
1995
2103
  if (wasWatching)
1996
- emitNotice('Queued follow-up — sends automatically once the write lock frees up.');
2104
+ emitNotice(t('tui.steer.queuedFollowup'));
1997
2105
  }
1998
2106
  catch (error) {
1999
2107
  // Buffered notice: the failure line is the only on-screen record and
@@ -2021,7 +2129,7 @@ export function apply(ctx) {
2021
2129
  if (agent === undefined) {
2022
2130
  // Running without a handle cannot deliver — say so instead of
2023
2131
  // dropping the message silently.
2024
- renderer.renderNotice('No active session — the message was not delivered.', 'error');
2132
+ renderer.renderNotice(t('tui.steer.noSession'), 'error');
2025
2133
  return;
2026
2134
  }
2027
2135
  const message = buildUserPrompt(raw);
@@ -2031,7 +2139,7 @@ export function apply(ctx) {
2031
2139
  const outcome = deliverToAgent(agent, message, route);
2032
2140
  if (outcome.outcome === 'degraded') {
2033
2141
  renderer.rebadgePendingEcho({ id: message.id }, 'queued');
2034
- renderer.renderNotice(TURN_ENDED_QUEUED_NOTICE, 'info');
2142
+ renderer.renderNotice(turnEndedQueuedNotice(), 'info');
2035
2143
  }
2036
2144
  else if (outcome.outcome === 'error') {
2037
2145
  renderer.resolvePendingEcho({ id: message.id }, 'failed');
@@ -2072,9 +2180,9 @@ export function apply(ctx) {
2072
2180
  if (exitSessionId !== undefined) {
2073
2181
  // DIM SGR (not a theme color): the hint prints after the TUI's
2074
2182
  // theme machinery is gone; dim is universally supported.
2075
- process.stdout.write(`\n\x1b[2mTo resume this session: ${formatResumeCommand(resolveProfileName(ctx), String(exitSessionId))}\x1b[0m\n`);
2183
+ process.stdout.write(`\n\x1b[2m${t('tui.exit.resumeHint', { command: formatResumeCommand(resolveProfileName(ctx), String(exitSessionId)) })}\x1b[0m\n`);
2076
2184
  }
2077
- process.exit(code);
2185
+ hardExit(code);
2078
2186
  })();
2079
2187
  return exitTask;
2080
2188
  };