@aiwayds/dsh-tui-pi 2.18.2 → 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 (106) hide show
  1. package/README.md +12 -43
  2. package/README.zh-CN.md +11 -42
  3. package/lib/agents.js +67 -52
  4. package/lib/agents.js.map +1 -1
  5. package/lib/ask-user.d.ts +122 -3
  6. package/lib/ask-user.js +335 -41
  7. package/lib/ask-user.js.map +1 -1
  8. package/lib/attachments.js +8 -3
  9. package/lib/attachments.js.map +1 -1
  10. package/lib/btw.d.ts +4 -2
  11. package/lib/btw.js +22 -16
  12. package/lib/btw.js.map +1 -1
  13. package/lib/custom-provider.js +34 -28
  14. package/lib/custom-provider.js.map +1 -1
  15. package/lib/footer.d.ts +9 -8
  16. package/lib/footer.js +36 -18
  17. package/lib/footer.js.map +1 -1
  18. package/lib/hard-exit.d.ts +36 -0
  19. package/lib/hard-exit.js +64 -0
  20. package/lib/hard-exit.js.map +1 -0
  21. package/lib/history-fork.d.ts +2 -2
  22. package/lib/history-fork.js +12 -9
  23. package/lib/history-fork.js.map +1 -1
  24. package/lib/history-turns.d.ts +4 -2
  25. package/lib/history-turns.js +7 -3
  26. package/lib/history-turns.js.map +1 -1
  27. package/lib/history.js +57 -45
  28. package/lib/history.js.map +1 -1
  29. package/lib/hotkeys.js +44 -33
  30. package/lib/hotkeys.js.map +1 -1
  31. package/lib/i18n/index.d.ts +54 -0
  32. package/lib/i18n/index.js +98 -0
  33. package/lib/i18n/index.js.map +1 -0
  34. package/lib/i18n/registry.d.ts +65 -0
  35. package/lib/i18n/registry.js +129 -0
  36. package/lib/i18n/registry.js.map +1 -0
  37. package/lib/index.js +219 -109
  38. package/lib/index.js.map +1 -1
  39. package/lib/language-ask.d.ts +98 -0
  40. package/lib/language-ask.js +118 -0
  41. package/lib/language-ask.js.map +1 -0
  42. package/lib/live-widgets.js +9 -8
  43. package/lib/live-widgets.js.map +1 -1
  44. package/lib/login.js +5 -4
  45. package/lib/login.js.map +1 -1
  46. package/lib/messages.d.ts +0 -4
  47. package/lib/messages.js +14 -15
  48. package/lib/messages.js.map +1 -1
  49. package/lib/model-profiles.js +32 -20
  50. package/lib/model-profiles.js.map +1 -1
  51. package/lib/panels.js +18 -17
  52. package/lib/panels.js.map +1 -1
  53. package/lib/preflight-projcache.js +9 -3
  54. package/lib/preflight-projcache.js.map +1 -1
  55. package/lib/preset-dialog.d.ts +3 -3
  56. package/lib/preset-dialog.js +20 -17
  57. package/lib/preset-dialog.js.map +1 -1
  58. package/lib/provider-catalog.d.ts +23 -7
  59. package/lib/provider-catalog.js +90 -68
  60. package/lib/provider-catalog.js.map +1 -1
  61. package/lib/queue-panel.d.ts +2 -2
  62. package/lib/queue-panel.js +13 -9
  63. package/lib/queue-panel.js.map +1 -1
  64. package/lib/repair-dialog.d.ts +4 -4
  65. package/lib/repair-dialog.js +23 -16
  66. package/lib/repair-dialog.js.map +1 -1
  67. package/lib/route-dialog.d.ts +3 -3
  68. package/lib/route-dialog.js +20 -14
  69. package/lib/route-dialog.js.map +1 -1
  70. package/lib/selectors.d.ts +15 -1
  71. package/lib/selectors.js +57 -23
  72. package/lib/selectors.js.map +1 -1
  73. package/lib/sessions.d.ts +47 -14
  74. package/lib/sessions.js +162 -53
  75. package/lib/sessions.js.map +1 -1
  76. package/lib/settings.d.ts +68 -9
  77. package/lib/settings.js +327 -78
  78. package/lib/settings.js.map +1 -1
  79. package/lib/skills-manager.js +27 -21
  80. package/lib/skills-manager.js.map +1 -1
  81. package/lib/skills.js +5 -4
  82. package/lib/skills.js.map +1 -1
  83. package/lib/startup-info.js +9 -3
  84. package/lib/startup-info.js.map +1 -1
  85. package/lib/steer-flow.d.ts +14 -4
  86. package/lib/steer-flow.js +24 -9
  87. package/lib/steer-flow.js.map +1 -1
  88. package/lib/stop-dialog.d.ts +2 -2
  89. package/lib/stop-dialog.js +16 -12
  90. package/lib/stop-dialog.js.map +1 -1
  91. package/lib/subagent-viewer.d.ts +3 -8
  92. package/lib/subagent-viewer.js +38 -62
  93. package/lib/subagent-viewer.js.map +1 -1
  94. package/lib/theme-preview.js +12 -11
  95. package/lib/theme-preview.js.map +1 -1
  96. package/lib/theme-settings.d.ts +44 -2
  97. package/lib/theme-settings.js +97 -54
  98. package/lib/theme-settings.js.map +1 -1
  99. package/lib/tui.js +2 -1
  100. package/lib/tui.js.map +1 -1
  101. package/locales/en.json +646 -0
  102. package/locales/ja.json +646 -0
  103. package/locales/ko.json +646 -0
  104. package/locales/zh-CN.json +646 -0
  105. package/package.json +2 -1
  106. package/skills/dsh-tui-pi-config/SKILL.md +14 -4
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, 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";
@@ -67,7 +69,8 @@ import { startTui } from "./tui.js";
67
69
  import { currentPreset, fetchPresetRoster, findPresetByName, formatPresetLabel, initialPresetIndex, peekNextPreset } from "./preset.js";
68
70
  import { completedTurnSeed, openPresetConfirmDialog, performPresetSwitch } from "./preset-dialog.js";
69
71
  import { openStopConfirmDialog } from "./stop-dialog.js";
70
- import { registerAskUserProvider } from "./ask-user.js";
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 过滤、preset 记忆,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 15 键(theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/registeredOnly/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume)、DSH_TUI_* 环境变量、ask_user_question 快速上手向导、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
  };
@@ -802,6 +812,8 @@ export function apply(ctx) {
802
812
  // Ask-surface claim routing: the panel answers questions asked by the
803
813
  // session this bridge drives (dsh-ask-router fans out, first answer wins).
804
814
  getSessionId: () => bridge.getSessionId(),
815
+ // Auto-answer timeouts (settings > env > defaults), re-read per ask.
816
+ resolveTimeouts: async () => resolveAskUserTimeouts(await readAskUserExplicit(ctx)),
805
817
  restoreFocus: refocusEditor,
806
818
  mount: component => {
807
819
  ui.askUser.addChild(component);
@@ -893,7 +905,7 @@ export function apply(ctx) {
893
905
  stream: options => {
894
906
  const llm = ctx.get('llm');
895
907
  if (llm === undefined)
896
- throw new Error('LLM service is not available.');
908
+ throw new Error(t('tui.btw.noLlm'));
897
909
  return llm.stream({
898
910
  provider: options.provider,
899
911
  model: options.model,
@@ -927,10 +939,10 @@ export function apply(ctx) {
927
939
  if (parsed.kind === 'empty') {
928
940
  const opened = btwController.openReview();
929
941
  return opened === 'live'
930
- ? { kind: 'success', text: 'btw — the running answer is back on screen.' }
942
+ ? { kind: 'success', text: t('tui.btw.backOnScreen') }
931
943
  : opened === 'review'
932
- ? { kind: 'success', text: 'Last btw exchange shown.' }
933
- : { kind: 'success', text: BTW_USAGE };
944
+ ? { kind: 'success', text: t('tui.btw.lastShown') }
945
+ : { kind: 'success', text: btwUsage() };
934
946
  }
935
947
  if (parsed.kind === 'error')
936
948
  return { kind: 'error', text: parsed.error };
@@ -938,7 +950,7 @@ export function apply(ctx) {
938
950
  // this must be the live view, not a read-only remote one) — when idle,
939
951
  // a normal prompt is strictly better (tools, history, full context).
940
952
  if (!(bridge.isRunning() && !bridge.isReadOnlyView())) {
941
- return { kind: 'error', text: BTW_IDLE_NOTICE };
953
+ return { kind: 'error', text: btwIdleNotice() };
942
954
  }
943
955
  const result = btwController.submit({
944
956
  question: parsed.question,
@@ -946,14 +958,14 @@ export function apply(ctx) {
946
958
  });
947
959
  switch (result.kind) {
948
960
  case 'started':
949
- return { kind: 'success', text: 'btw — answering alongside the main task.' };
961
+ return { kind: 'success', text: t('tui.btw.started') };
950
962
  case 'queued':
951
- return { kind: 'success', text: `btw queued (position ${result.position}).` };
963
+ return { kind: 'success', text: t('tui.btw.queued', { position: result.position }) };
952
964
  case 'rejected':
953
- return { kind: 'error', text: `btw rejected ${result.reason}.` };
965
+ return { kind: 'error', text: t('tui.btw.rejected', { reason: result.reason }) };
954
966
  }
955
967
  };
956
- 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);
957
969
  // ------------------------------------------- TUI-owned slash commands --
958
970
  // Web-surface parity: `model` is a browser client contribution there and
959
971
  // `export` a web-only download plugin — the terminal gets native
@@ -964,7 +976,7 @@ export function apply(ctx) {
964
976
  const modelHandler = async () => {
965
977
  const picked = await pickModel(ctx, ui.tui, ui.theme, bridge.getSelection(), refocusEditor);
966
978
  if (picked === undefined)
967
- return { kind: 'success', text: 'Model unchanged.' };
979
+ return { kind: 'success', text: t('tui.model.unchanged') };
968
980
  const llm = ctx.get('llm');
969
981
  if (llm !== undefined) {
970
982
  await llm.resolveCallConfig({ provider: picked.provider, model: picked.model });
@@ -973,14 +985,14 @@ export function apply(ctx) {
973
985
  const persistError = await persistDefaultModel(ctx, picked);
974
986
  ui.requestRender();
975
987
  const modelText = picked.reasoningEffort === undefined
976
- ? `Model: ${picked.provider}/${picked.model}`
977
- : `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) });
978
990
  return {
979
991
  kind: 'success',
980
- text: persistError === undefined ? modelText : `${modelText} · not persisted: ${persistError}`,
992
+ text: persistError === undefined ? modelText : t('tui.persist.failed', { text: modelText, error: persistError }),
981
993
  };
982
994
  };
983
- registerLocalCommand('model', 'Select the model (and think level) for this conversation', modelHandler);
995
+ registerLocalCommand('model', t('tui.cmd.model.description'), modelHandler);
984
996
  // /agents: manage agent definition markdown files (model, think level,
985
997
  // spawn depth) — the terminal counterpart of pi's /fun-agent-cfg. An
986
998
  // optional argument preselects one agent by name.
@@ -988,44 +1000,44 @@ export function apply(ctx) {
988
1000
  const trimmed = rawInput?.trim() ?? '';
989
1001
  const result = await openAgentManager(ctx, ui.tui, ui.theme, refocusEditor, trimmed === '' ? undefined : trimmed, () => subagentPolicy.getStats());
990
1002
  if (result === undefined)
991
- return { kind: 'success', text: 'Agents unchanged.' };
1003
+ return { kind: 'success', text: t('tui.agents.unchanged') };
992
1004
  return { kind: 'success', text: result };
993
1005
  };
994
- registerLocalCommand('agents', 'Manage agent definitions (model, think level, spawn depth) from markdown files', agentsHandler);
1006
+ registerLocalCommand('agents', t('tui.cmd.agents.description'), agentsHandler);
995
1007
  // /subagents: the command twin of Ctrl+G — pick a running (or recently
996
1008
  // settled) subagent and inspect its live transcript in the 80% viewer.
997
1009
  // Same flow as the key path; empty board closes immediately.
998
1010
  const subagentsHandler = async () => {
999
1011
  await openSubagentViewer(ctx, ui.tui, ui.theme, bridge, refocusEditor);
1000
- return { kind: 'success', text: 'Subagent viewer closed.' };
1012
+ return { kind: 'success', text: t('tui.subagents.viewerClosed') };
1001
1013
  };
1002
- registerLocalCommand('subagents', 'Browse subagents and inspect their live transcript', subagentsHandler);
1014
+ registerLocalCommand('subagents', t('tui.cmd.subagents.description'), subagentsHandler);
1003
1015
  // /think: cycle the current model's reasoning effort without re-picking
1004
1016
  // the model. A no-session /think still lands in the selection ref and
1005
1017
  // survives the lazy session creation (bridge seeds only an empty ref).
1006
1018
  const thinkHandler = async () => {
1007
1019
  const current = bridge.getSelection();
1008
1020
  if (current === undefined) {
1009
- return { kind: 'error', text: 'No model selected — pick one with /model first.' };
1021
+ return { kind: 'error', text: t('tui.think.noModel') };
1010
1022
  }
1011
1023
  const result = await pickEffort(ctx, ui.tui, ui.theme, current, refocusEditor);
1012
1024
  if (result.kind === 'unsupported') {
1013
1025
  return {
1014
1026
  kind: 'error',
1015
- text: `${current.provider}/${current.model} exposes no selectable think levels.`,
1027
+ text: t('tui.think.unsupported', { id: `${current.provider}/${current.model}` }),
1016
1028
  };
1017
1029
  }
1018
1030
  if (result.kind === 'cancelled')
1019
- return { kind: 'success', text: 'Think level unchanged.' };
1031
+ return { kind: 'success', text: t('tui.think.unchanged') };
1020
1032
  if (result.effort === 'default') {
1021
1033
  const next = { provider: current.provider, model: current.model };
1022
1034
  bridge.setSelection(next);
1023
1035
  const persistError = await persistDefaultModel(ctx, next);
1024
1036
  ui.requestRender();
1025
- const thinkText = `Think: provider default (${current.provider}/${current.model}).`;
1037
+ const thinkText = t('tui.think.providerDefault', { id: `${current.provider}/${current.model}` });
1026
1038
  return {
1027
1039
  kind: 'success',
1028
- text: persistError === undefined ? thinkText : `${thinkText} · not persisted: ${persistError}`,
1040
+ text: persistError === undefined ? thinkText : t('tui.persist.failed', { text: thinkText, error: persistError }),
1029
1041
  };
1030
1042
  }
1031
1043
  const next = {
@@ -1036,13 +1048,13 @@ export function apply(ctx) {
1036
1048
  bridge.setSelection(next);
1037
1049
  const persistError = await persistDefaultModel(ctx, next);
1038
1050
  ui.requestRender();
1039
- 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}` });
1040
1052
  return {
1041
1053
  kind: 'success',
1042
- text: persistError === undefined ? thinkText : `${thinkText} · not persisted: ${persistError}`,
1054
+ text: persistError === undefined ? thinkText : t('tui.persist.failed', { text: thinkText, error: persistError }),
1043
1055
  };
1044
1056
  };
1045
- registerLocalCommand('think', 'Switch the current model\'s think (reasoning) level', thinkHandler);
1057
+ registerLocalCommand('think', t('tui.cmd.think.description'), thinkHandler);
1046
1058
  /**
1047
1059
  * Record one committed preset as THIS workspace's remembered selection
1048
1060
  * (`$DSH_HOME/workspace-presets.json`, keyed by the session backend's
@@ -1060,7 +1072,7 @@ export function apply(ctx) {
1060
1072
  const doc = withRememberedPreset(loadWorkspacePresets(path), projectKeyFor(process.cwd()), presetId);
1061
1073
  const error = saveWorkspacePresets(path, doc);
1062
1074
  if (error !== undefined)
1063
- renderer.renderNotice(`preset memory not saved: ${error}`, 'error');
1075
+ renderer.renderNotice(t('tui.preset.memorySaveFailed', { error }), 'error');
1064
1076
  }
1065
1077
  catch {
1066
1078
  // A broken store must never fail the switch itself.
@@ -1075,7 +1087,7 @@ export function apply(ctx) {
1075
1087
  // directly (the first submit already creates the session on it).
1076
1088
  const presetHandler = async (rawInput) => {
1077
1089
  if (presetState.roster.length === 0) {
1078
- return { kind: 'error', text: 'No agent presets available.' };
1090
+ return { kind: 'error', text: t('tui.preset.none') };
1079
1091
  }
1080
1092
  const arg = rawInput?.trim() ?? '';
1081
1093
  let target;
@@ -1093,11 +1105,11 @@ export function apply(ctx) {
1093
1105
  else {
1094
1106
  target = findPresetByName(presetState, arg);
1095
1107
  if (target === undefined) {
1096
- return { kind: 'error', text: `Unknown preset: ${arg}` };
1108
+ return { kind: 'error', text: t('tui.preset.unknown', { name: arg }) };
1097
1109
  }
1098
1110
  }
1099
1111
  if (target === undefined)
1100
- return { kind: 'success', text: 'Preset unchanged.' };
1112
+ return { kind: 'success', text: t('tui.preset.unchanged') };
1101
1113
  const outcome = await performPresetSwitch(presetState, target, { hasLiveSession: () => bridge.getAgent() !== undefined,
1102
1114
  confirmSwitch: (name, restart) => openPresetConfirmDialog(ui.tui, ui.theme, name, restart, refocusEditor)
1103
1115
  .then(outcome => (outcome === 'cancelled' ? 'cancel' : outcome)),
@@ -1179,7 +1191,7 @@ export function apply(ctx) {
1179
1191
  persistRememberedPreset(target.id);
1180
1192
  return { kind: 'success', text: outcome.message };
1181
1193
  };
1182
- 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);
1183
1195
  // Model profiles moved to the dsh-profile-switch plugin (ask-user based,
1184
1196
  // surface-agnostic). What stays HERE is the live-selection bridge it
1185
1197
  // consumes: the TUI's /model + /think ride the same selection ref, and
@@ -1196,18 +1208,37 @@ export function apply(ctx) {
1196
1208
  },
1197
1209
  current: () => bridge.getSelection(),
1198
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
+ })();
1199
1228
  const sessionHandler = async () => {
1200
1229
  const agent = bridge.getAgent();
1201
1230
  const stats = bridge.getStats();
1202
1231
  const selection = bridge.getSelection();
1203
1232
  const header = agent?.session.header;
1233
+ const liveSession = agent?.session;
1234
+ const titleOf = liveSession === undefined ? undefined : sessionTitleSeam?.get(liveSession)?.title;
1204
1235
  await showSessionInfo(ui.tui, ui.theme, {
1205
1236
  id: agent === undefined ? undefined : String(agent.session.id),
1206
1237
  cwd: header?.cwd,
1207
1238
  createdAt: header?.createdAt,
1208
1239
  model: selection === undefined ? undefined : `${selection.provider}/${selection.model}`,
1209
1240
  effort: selection === undefined || selection.reasoningEffort === undefined
1210
- ? (selection === undefined ? undefined : 'provider default')
1241
+ ? (selection === undefined ? undefined : t('tui.session.effortDefault'))
1211
1242
  : String(selection.reasoningEffort),
1212
1243
  msgCount: stats.msgCount,
1213
1244
  toolCallCount: stats.toolCallCount,
@@ -1218,10 +1249,28 @@ export function apply(ctx) {
1218
1249
  status: agent === undefined ? 'none' : agentStatus,
1219
1250
  eventCount: agent === undefined ? undefined : agent.session.seq,
1220
1251
  parentSession: header?.parentSession === undefined ? undefined : String(header.parentSession),
1221
- }, refocusEditor);
1222
- 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') };
1223
1272
  };
1224
- registerLocalCommand('session', 'Show the current session\'s info (id, model, stats)', sessionHandler);
1273
+ registerLocalCommand('session', t('tui.cmd.session.description'), sessionHandler);
1225
1274
  // /resume: pick a persisted session, validate its log, swap the live
1226
1275
  // agent for it, and rebuild transcript + stats from the stored events.
1227
1276
  const resumeHandler = async () => {
@@ -1239,7 +1288,7 @@ export function apply(ctx) {
1239
1288
  return { kind: 'error', text: message };
1240
1289
  }
1241
1290
  if (picked.kind === 'empty') {
1242
- return { kind: 'error', text: 'No other persisted sessions to resume.' };
1291
+ return { kind: 'error', text: t('tui.resume.noneOther') };
1243
1292
  }
1244
1293
  if (picked.kind === 'empty-filtered') {
1245
1294
  // Sessions exist but the display window hid them all — name the
@@ -1250,11 +1299,11 @@ export function apply(ctx) {
1250
1299
  : `${picked.minBytes}B`;
1251
1300
  return {
1252
1301
  kind: 'error',
1253
- 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 }),
1254
1303
  };
1255
1304
  }
1256
1305
  if (picked.kind === 'cancelled')
1257
- return { kind: 'success', text: 'Resume cancelled.' };
1306
+ return { kind: 'success', text: t('tui.resume.cancelled') };
1258
1307
  // Narrowed shared handle for the closures below (const narrowing
1259
1308
  // propagates into async closures; `picked`'s does not).
1260
1309
  const target = picked;
@@ -1286,19 +1335,19 @@ export function apply(ctx) {
1286
1335
  ui.requestRender();
1287
1336
  return {
1288
1337
  kind: 'success',
1289
- 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) }),
1290
1339
  };
1291
1340
  }
1292
1341
  catch (watchError) {
1293
1342
  return {
1294
1343
  kind: 'error',
1295
- 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) }),
1296
1345
  };
1297
1346
  }
1298
1347
  }
1299
1348
  return {
1300
1349
  kind: 'error',
1301
- text: `Resume failed: ${message} the previous session was closed; the next prompt starts a new one.`,
1350
+ text: t('tui.resume.failed', { error: message }),
1302
1351
  };
1303
1352
  }
1304
1353
  // Seed the badge cache for the resumed session (its pin event may have
@@ -1326,7 +1375,7 @@ export function apply(ctx) {
1326
1375
  ui.requestRender();
1327
1376
  return {
1328
1377
  kind: 'success',
1329
- 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 }),
1330
1379
  };
1331
1380
  };
1332
1381
  // Corrupt-log branch: the repair rewrites user data on disk, so it is
@@ -1340,13 +1389,13 @@ export function apply(ctx) {
1340
1389
  if (logPath === undefined) {
1341
1390
  return {
1342
1391
  kind: 'error',
1343
- 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) }),
1344
1393
  };
1345
1394
  }
1346
1395
  if (await openRepairConfirmDialog(ui.tui, ui.theme, refocusEditor) !== 'repair') {
1347
1396
  return {
1348
1397
  kind: 'error',
1349
- text: `Cannot resume ${clipToWidth(String(target.id), 8)}: ${message}`,
1398
+ text: t('tui.resume.cannot', { id: clipToWidth(String(target.id), 8), error: message }),
1350
1399
  };
1351
1400
  }
1352
1401
  // repairSessionLog maps its own failures to results; the catch is a
@@ -1357,7 +1406,7 @@ export function apply(ctx) {
1357
1406
  }
1358
1407
  catch (error) {
1359
1408
  const detail = messageOf(error);
1360
- notice = `repair failed: ${detail} log untouched`;
1409
+ notice = t('tui.repair.failed', { error: detail });
1361
1410
  }
1362
1411
  if (notice !== undefined)
1363
1412
  return { kind: 'error', text: notice };
@@ -1375,11 +1424,11 @@ export function apply(ctx) {
1375
1424
  if (isCorruptLogError(message)) {
1376
1425
  return offerCorruptedLogRepair(message);
1377
1426
  }
1378
- 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 }) };
1379
1428
  }
1380
1429
  return resumeAndReplay();
1381
1430
  };
1382
- registerLocalCommand('resume', 'Resume a persisted session', resumeHandler);
1431
+ registerLocalCommand('resume', t('tui.cmd.resume.description'), resumeHandler);
1383
1432
  // /history: the read-only two-pane look-back (ADR 0003) — left pane lists
1384
1433
  // the browsed session's completed turns, right pane shows the selected
1385
1434
  // turn's replies; Enter/c refills the editor with the turn's prompt (a
@@ -1445,7 +1494,7 @@ export function apply(ctx) {
1445
1494
  }, arg === '' ? undefined : arg);
1446
1495
  return { kind: result.error ? 'error' : 'success', text: result.text };
1447
1496
  };
1448
- 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);
1449
1498
  // Auto-resume after a hot-reload: a `/reload` stashes the previously
1450
1499
  // current session id before this fiber's teardown, and the freshly
1451
1500
  // re-imported module consumes it best-effort here so the next prompt
@@ -1494,7 +1543,7 @@ export function apply(ctx) {
1494
1543
  }
1495
1544
  catch (error) {
1496
1545
  const message = messageOf(error);
1497
- renderer.renderNotice(`--resume ${clipToWidth(bootResumeId, 8)}: ${message} — starting fresh.`, 'error');
1546
+ renderer.renderNotice(t('tui.boot.resumeFailed', { id: clipToWidth(bootResumeId, 8), error: message }), 'error');
1498
1547
  }
1499
1548
  })();
1500
1549
  }
@@ -1504,11 +1553,11 @@ export function apply(ctx) {
1504
1553
  // falls back to the dsh path when one does (behavior unchanged there).
1505
1554
  commands.registerLocal('export', async () => ({
1506
1555
  kind: 'error',
1507
- text: 'No active session to export.',
1556
+ text: t('tui.export.noSession'),
1508
1557
  }));
1509
1558
  ctx.effect(() => ctx.commands.register({
1510
1559
  name: 'export',
1511
- description: 'Export this session log as JSONL',
1560
+ description: t('tui.cmd.export.description'),
1512
1561
  input: { hint: '[path]' },
1513
1562
  handler: async (invocation) => {
1514
1563
  const events = invocation.agent.session.snapshotEvents();
@@ -1516,7 +1565,7 @@ export function apply(ctx) {
1516
1565
  const target = invocation.rawInput.trim() === '' ? fallback : resolve(invocation.rawInput.trim());
1517
1566
  await mkdir(dirname(target), { recursive: true });
1518
1567
  await writeFile(target, events.map(event => JSON.stringify(event)).join('\n') + '\n');
1519
- return { kind: 'success', text: `Exported ${events.length} events ${target}` };
1568
+ return { kind: 'success', text: t('tui.export.done', { count: events.length, path: target }) };
1520
1569
  },
1521
1570
  }), 'dsh-tui-pi: /export');
1522
1571
  // /new: detach the live agent (and clear the on-screen transcript) so
@@ -1573,16 +1622,16 @@ export function apply(ctx) {
1573
1622
  };
1574
1623
  const newHandler = async () => {
1575
1624
  await startNewSession();
1576
- return { kind: 'success', text: 'New session started.' };
1625
+ return { kind: 'success', text: t('tui.new.done') };
1577
1626
  };
1578
- registerLocalCommand('new', 'Start a new session', newHandler);
1627
+ registerLocalCommand('new', t('tui.cmd.new.description'), newHandler);
1579
1628
  // /settings: text-based configuration browser — the terminal counterpart
1580
1629
  // of the web GUI's settings surface. Enumerates ctx.settings.describe()
1581
1630
  // and walks each namespace's schema (drill-ins, cycle rows, inline editors,
1582
1631
  // reset-to-defaults), writing through settings.mutate path ops.
1583
1632
  const settingsHandler = async () => {
1584
1633
  if (ctx.get('settings') === undefined) {
1585
- return { kind: 'error', text: 'Settings service is not available.' };
1634
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1586
1635
  }
1587
1636
  const changes = await openSettingsBrowser({
1588
1637
  ctx,
@@ -1596,23 +1645,25 @@ export function apply(ctx) {
1596
1645
  },
1597
1646
  });
1598
1647
  if (changes < 0)
1599
- return { kind: 'error', text: 'No settings namespaces are registered.' };
1648
+ return { kind: 'error', text: t('tui.settings.noNamespaces') };
1600
1649
  return {
1601
1650
  kind: 'success',
1602
1651
  text: changes === 0
1603
- ? 'Settings: no changes.'
1604
- : `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 }),
1605
1656
  };
1606
1657
  };
1607
- registerLocalCommand('settings', 'Browse and edit configuration (namespaces, values, resets)', settingsHandler);
1658
+ registerLocalCommand('settings', t('tui.cmd.settings.description'), settingsHandler);
1608
1659
  // /skills: standalone skill browser with Installed/Available dual mode.
1609
1660
  // Enter/Space toggles or symlinks; Tab switches views; Esc exits.
1610
1661
  const skillsHandler = async () => {
1611
1662
  const agent = bridge.getAgent();
1612
1663
  openSkillsManagerPanel(ctx, ui.tui, ui.theme, refocusEditor, agent ?? undefined, () => { });
1613
- return { kind: 'success', text: 'Skills manager opened.' };
1664
+ return { kind: 'success', text: t('tui.skills.opened') };
1614
1665
  };
1615
- registerLocalCommand('skills', 'Manage user skills (installed and available)', skillsHandler);
1666
+ registerLocalCommand('skills', t('tui.cmd.skills.description'), skillsHandler);
1616
1667
  // /theme: pick a color scheme and apply it immediately — the choice is
1617
1668
  // persisted to the dsh-tui settings namespace (`applies: 'live'`, so the
1618
1669
  // watch hook would re-apply the same change anyway) and hot-swapped into
@@ -1621,7 +1672,7 @@ export function apply(ctx) {
1621
1672
  // mirrors /settings: without the service there is nowhere to write.
1622
1673
  const themeHandler = async () => {
1623
1674
  if (ctx.get('settings') === undefined) {
1624
- return { kind: 'error', text: 'Settings service is not available.' };
1675
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1625
1676
  }
1626
1677
  // Preselect from the live settings value (which may have changed since
1627
1678
  // startup via the /settings browser), not the startup snapshot. The
@@ -1632,7 +1683,7 @@ export function apply(ctx) {
1632
1683
  userNames: themeUserNames,
1633
1684
  });
1634
1685
  if (picked === undefined)
1635
- return { kind: 'success', text: 'Theme unchanged.' };
1686
+ return { kind: 'success', text: t('tui.theme.unchanged') };
1636
1687
  const writeError = await writeThemePreference(ctx, picked);
1637
1688
  if (writeError !== undefined)
1638
1689
  return { kind: 'error', text: writeError };
@@ -1647,12 +1698,70 @@ export function apply(ctx) {
1647
1698
  if (expected !== undefined && applied !== expected) {
1648
1699
  return {
1649
1700
  kind: 'success',
1650
- 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) }),
1651
1702
  };
1652
1703
  }
1653
- 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') };
1654
1763
  };
1655
- registerLocalCommand('theme', 'Set the terminal color scheme (applies immediately)', themeHandler);
1764
+ registerLocalCommand('language', t('command.language.description'), languageHandler);
1656
1765
  // /reload: hot-reload this plugin from the current source — re-imports the
1657
1766
  // module and its dependencies (picking up src changes after `pnpm build`)
1658
1767
  // and swaps the plugin runtime. The old fiber is disposed, so the TUI and
@@ -1662,7 +1771,7 @@ export function apply(ctx) {
1662
1771
  kind: 'success',
1663
1772
  text: await reloadPlugin(ctx, import.meta.url),
1664
1773
  });
1665
- 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);
1666
1775
  // /hotkeys — pi's keybinding browser, in the /agents select-panel style:
1667
1776
  // a field list of the app keys, Enter opens an editor that writes
1668
1777
  // ~/.dsh/keybindings.json and live-applies the change (no /reload).
@@ -1672,9 +1781,9 @@ export function apply(ctx) {
1672
1781
  apply: bindings => ui.setKeyBindings(bindings),
1673
1782
  restoreFocus: refocusEditor,
1674
1783
  });
1675
- return { kind: 'success', text: summary ?? 'Keybindings unchanged.' };
1784
+ return { kind: 'success', text: summary ?? t('tui.hotkeys.unchanged') };
1676
1785
  };
1677
- registerLocalCommand('hotkeys', 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)', hotkeysHandler);
1786
+ registerLocalCommand('hotkeys', t('tui.cmd.hotkeys.description'), hotkeysHandler);
1678
1787
  // /login: register provider credentials — the terminal counterpart of
1679
1788
  // pi-agent's /login, on the Models category's add-provider flow. Opens a
1680
1789
  // searchable provider directory (including already-configured routes, so
@@ -1685,7 +1794,7 @@ export function apply(ctx) {
1685
1794
  // filtered picker otherwise.
1686
1795
  const loginHandler = async (rawInput) => {
1687
1796
  if (ctx.get('settings') === undefined) {
1688
- return { kind: 'error', text: 'Settings service is not available.' };
1797
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1689
1798
  }
1690
1799
  const result = await openLoginFlow({
1691
1800
  ctx,
@@ -1698,14 +1807,14 @@ export function apply(ctx) {
1698
1807
  if (result.kind === 'unknown') {
1699
1808
  return {
1700
1809
  kind: 'error',
1701
- text: `Unknown provider '${result.target}' run /login to pick from the directory.`,
1810
+ text: t('tui.login.unknownProvider', { name: result.target }),
1702
1811
  };
1703
1812
  }
1704
1813
  if (result.kind === 'cancelled')
1705
- return { kind: 'success', text: 'Login cancelled.' };
1706
- 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 }) };
1707
1816
  };
1708
- registerLocalCommand('login', 'Register provider credentials (API key) through the provider directory', loginHandler);
1817
+ registerLocalCommand('login', t('tui.cmd.login.description'), loginHandler);
1709
1818
  // /logout: unsubscribe a provider — pi-agent's /logout on the dsh side.
1710
1819
  // Lists the providers with a stored credential; on selection removes the
1711
1820
  // key AND the settings.yaml provider entry, so the provider's models
@@ -1714,7 +1823,7 @@ export function apply(ctx) {
1714
1823
  // the installed catalog's current model list.
1715
1824
  const logoutHandler = async () => {
1716
1825
  if (ctx.get('settings') === undefined) {
1717
- return { kind: 'error', text: 'Settings service is not available.' };
1826
+ return { kind: 'error', text: t('tui.settings.unavailable') };
1718
1827
  }
1719
1828
  const result = await openLogoutFlow({
1720
1829
  ctx,
@@ -1724,34 +1833,32 @@ export function apply(ctx) {
1724
1833
  onError: message => renderer.renderNotice(message, 'error'),
1725
1834
  });
1726
1835
  if (result.kind === 'none') {
1727
- return { kind: 'success', text: 'No stored credentials to remove.' };
1836
+ return { kind: 'success', text: t('tui.logout.none') };
1728
1837
  }
1729
1838
  if (result.kind === 'cancelled')
1730
- return { kind: 'success', text: 'Logout cancelled.' };
1839
+ return { kind: 'success', text: t('tui.logout.cancelled') };
1731
1840
  if (result.kind === 'failed') {
1732
1841
  const cause = result.cause === undefined ? '' : `: ${result.cause}`;
1733
- 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 }) };
1734
1843
  }
1735
1844
  if (result.kind === 'removed-incomplete') {
1736
1845
  return {
1737
1846
  kind: 'error',
1738
- text: `Removed the API key for ${result.name}, but its provider configuration stays: ${result.error}`
1739
- + ' — remove the provider in /settings to finish the logout.',
1847
+ text: t('tui.logout.incomplete', { name: result.name, error: result.error }),
1740
1848
  };
1741
1849
  }
1742
1850
  if (result.kind === 'removed-key-only') {
1743
1851
  return {
1744
1852
  kind: 'success',
1745
- text: `Removed the API key for ${result.name}. Hand-declared route — its configuration stays;`
1746
- + ' remove the provider in /settings to also drop its models.',
1853
+ text: t('tui.logout.keyOnly', { name: result.name }),
1747
1854
  };
1748
1855
  }
1749
1856
  return {
1750
1857
  kind: 'success',
1751
- text: `Logged out ${result.name} — API key and provider configuration removed.`,
1858
+ text: t('tui.logout.done', { name: result.name }),
1752
1859
  };
1753
1860
  };
1754
- registerLocalCommand('logout', 'Log out a provider (removes its API key and provider configuration)', logoutHandler);
1861
+ registerLocalCommand('logout', t('tui.cmd.logout.description'), logoutHandler);
1755
1862
  // ------------------------------------------------- powerline footer + git --
1756
1863
  const git = new GitBranchWatcher(process.cwd());
1757
1864
  git.onChange = () => ui.requestRender();
@@ -1890,6 +1997,9 @@ export function apply(ctx) {
1890
1997
  * (model/effort pickers, settings browser, session panel, resume list),
1891
1998
  * and plugin flows hold the editor just as long: /wiki onboard parks the
1892
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.
1893
2003
  * The generic guard would fire mid-flow and echo a spurious
1894
2004
  * "aborted due to timeout" — those run with a never-aborting signal
1895
2005
  * instead.
@@ -1899,7 +2009,7 @@ export function apply(ctx) {
1899
2009
  * through this dispatcher, so their names belong here all the same
1900
2010
  * (dropping them in 2.16.0 re-introduced the timeout).
1901
2011
  */
1902
- 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']);
1903
2013
  /** Route one submitted line: dsh slash command first, model prompt second. */
1904
2014
  const submit = async (text) => {
1905
2015
  const line = text.trim();
@@ -1941,7 +2051,7 @@ export function apply(ctx) {
1941
2051
  // Cancelled — or the derived custom state, which is display-only
1942
2052
  // and not a switch target (mirrors the web client's popup
1943
2053
  // filtering). Either way nothing changed.
1944
- renderer.renderCommandEcho(line, undefined, 'Permission unchanged.');
2054
+ renderer.renderCommandEcho(line, undefined, t('tui.permission.unchanged'));
1945
2055
  return;
1946
2056
  }
1947
2057
  executeLine = `/permission ${picked}`;
@@ -1991,7 +2101,7 @@ export function apply(ctx) {
1991
2101
  try {
1992
2102
  await bridge.prompt(line);
1993
2103
  if (wasWatching)
1994
- emitNotice('Queued follow-up — sends automatically once the write lock frees up.');
2104
+ emitNotice(t('tui.steer.queuedFollowup'));
1995
2105
  }
1996
2106
  catch (error) {
1997
2107
  // Buffered notice: the failure line is the only on-screen record and
@@ -2019,7 +2129,7 @@ export function apply(ctx) {
2019
2129
  if (agent === undefined) {
2020
2130
  // Running without a handle cannot deliver — say so instead of
2021
2131
  // dropping the message silently.
2022
- renderer.renderNotice('No active session — the message was not delivered.', 'error');
2132
+ renderer.renderNotice(t('tui.steer.noSession'), 'error');
2023
2133
  return;
2024
2134
  }
2025
2135
  const message = buildUserPrompt(raw);
@@ -2029,7 +2139,7 @@ export function apply(ctx) {
2029
2139
  const outcome = deliverToAgent(agent, message, route);
2030
2140
  if (outcome.outcome === 'degraded') {
2031
2141
  renderer.rebadgePendingEcho({ id: message.id }, 'queued');
2032
- renderer.renderNotice(TURN_ENDED_QUEUED_NOTICE, 'info');
2142
+ renderer.renderNotice(turnEndedQueuedNotice(), 'info');
2033
2143
  }
2034
2144
  else if (outcome.outcome === 'error') {
2035
2145
  renderer.resolvePendingEcho({ id: message.id }, 'failed');
@@ -2070,9 +2180,9 @@ export function apply(ctx) {
2070
2180
  if (exitSessionId !== undefined) {
2071
2181
  // DIM SGR (not a theme color): the hint prints after the TUI's
2072
2182
  // theme machinery is gone; dim is universally supported.
2073
- 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`);
2074
2184
  }
2075
- process.exit(code);
2185
+ hardExit(code);
2076
2186
  })();
2077
2187
  return exitTask;
2078
2188
  };