@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/settings.js CHANGED
@@ -6,8 +6,9 @@
6
6
  * schemastery schemas → resolved values) and renders it as nested FW list
7
7
  * panels (src/panels.ts SettingsListPanel), one level per schema depth:
8
8
  *
9
- * level 0 category list (searchable): general / models / plugins / agent,
10
- * then `other` for unmapped namespaces. Namespace→category comes
9
+ * level 0 category list (searchable), alphabetical by canonical label:
10
+ * Agent Presets / General / Models / Other / Plugins / TUI,
11
+ * with `other` hidden when no namespace falls into it. Namespace→category comes
11
12
  * from a static mapping (categorizeNamespaces) mirroring the web
12
13
  * settings page: the web client slots namespaces into categories
13
14
  * client-side and the data plane carries no category field, so
@@ -19,9 +20,13 @@
19
20
  * model summary, API-key state) instead of the raw namespace —
20
21
  * the original schema surface is hidden — plus dedicated
21
22
  * llm-deepseek / agent-default-model rows and an add-provider
22
- * flow (built-in directory → one API key → write)
23
+ * flow (built-in directory → one API key → write). The
24
+ * single-namespace dsh-tui category drills straight through to
25
+ * its field list, titled by the category label.
23
26
  * level 2+ schema walk, dispatched on node type:
24
- * - object/dict → drill in (nested list; dicts get an add-key row)
27
+ * - object/dict → drill in (nested list; dicts get an add-key
28
+ * row); at a grouped namespace's root (FIELD_GROUPS) member
29
+ * fields collapse into one virtual group drill row
25
30
  * - boolean / all-literal union → Enter cycles the value
26
31
  * - string/number/mixed union → inline Input editor
27
32
  * - array / literal / const / unknown → read-only viewer
@@ -37,26 +42,125 @@ import { getPath, nodeAtPath, rehydrateSchema } from "./schema-model.js";
37
42
  import { getKeybindings, Input, matchesKey, } from '@earendil-works/pi-tui';
38
43
  import { columnWidths, MARKER_W, padCell, panelThemeFns, SettingsListPanel, TABLE_SEP, tableHeaderLine, tableRuleLine, ViewerPanel, } from "./panels.js";
39
44
  import { ansiFg, BOLD, RESET } from "./theme/index.js";
45
+ import { t } from "./i18n/index.js";
40
46
  import { clipToWidth, visibleWidth } from "./text.js";
41
47
  import { wrapFramedOverlay } from "./frame.js";
42
- import { catalogEntry, deriveKeyRef, directoryProviderEntries, providerProfileFor, providerRowView, unconfiguredCatalogEntries, } from "./provider-catalog.js";
48
+ import { languagePickerPanel } from "./selectors.js";
49
+ import { THEME_SETTINGS_NAMESPACE } from "./theme-settings.js";
50
+ import { catalogEntry, deriveKeyRef, directoryProviderEntries, providerProfileFor, providerRowView, resolveCatalogHint, unconfiguredCatalogEntries, } from "./provider-catalog.js";
43
51
  /**
44
52
  * Static namespace→category mapping, mirroring the web settings page's
45
53
  * client-side slot structure (see the module header): the data plane carries
46
54
  * no category field, so the slots are maintained here by hand. Namespaces not
47
- * listed anywhere fall into the trailing `other` category.
55
+ * listed anywhere fall into the trailing `other` category. Beyond the web
56
+ * page's own slots, the plugins category enumerates the settings namespaces
57
+ * of the dsh plugins installed in this ecosystem (mcp-adapter, topics, vault,
58
+ * model-sync, dsh-feishu, dsh-llm-proxy) so their rows group under Plugins
59
+ * instead of leaking into `other`.
48
60
  *
49
- * Labels are English-only: pi-tui's SettingsList search matches the label
50
- * text, so English queries (model, shell, permission, …) hit directly.
61
+ * The `label`s here are the English source of truth: rows render through
62
+ * `categoryLabel()` (t() at render time, keyed by category id), so the active
63
+ * UI language translates them while search keeps matching whatever is
64
+ * displayed. The first level is ordered by this canonical label
65
+ * (categorizeNamespaces sorts), not by map order.
51
66
  */
52
67
  export const CATEGORY_MAP = [
53
- { id: 'general', label: 'General', namespaces: ['permission', 'dsh-tui'] },
68
+ { id: 'dsh-tui', label: 'TUI', namespaces: ['dsh-tui'] },
69
+ { id: 'general', label: 'General', namespaces: ['permission'] },
54
70
  { id: 'models', label: 'Models', namespaces: ['llm-deepseek', 'llm-pi-ai', 'agent-default-model'] },
55
- { id: 'plugins', label: 'Plugins', namespaces: ['shell', 'agent-loop', 'web-search-deepseek'] },
71
+ {
72
+ id: 'plugins',
73
+ label: 'Plugins',
74
+ namespaces: [
75
+ 'shell',
76
+ 'agent-loop',
77
+ 'web-search-deepseek',
78
+ // Settings namespaces of installed dsh plugins — dsh-llm-proxy
79
+ // registers through settings.installSection, the rest through
80
+ // settings.register; plugins without a namespace never reach the
81
+ // settings browser.
82
+ 'mcp-adapter',
83
+ 'topics',
84
+ 'vault',
85
+ 'model-sync',
86
+ 'dsh-feishu',
87
+ 'dsh-llm-proxy',
88
+ ],
89
+ },
56
90
  { id: 'agent', label: 'Agent Presets', namespaces: ['agent-presets'] },
57
91
  ];
92
+ /**
93
+ * Category id → i18n key resolver for the user-facing label. The static
94
+ * labels in CATEGORY_MAP stay English (module data must not freeze a
95
+ * language at import time); rows resolve through these literal t() call
96
+ * sites at render time and fall back to the static label for an id without
97
+ * an entry.
98
+ */
99
+ const CATEGORY_LABEL_KEYS = {
100
+ 'dsh-tui': () => t('settingsui.category.dsh-tui'),
101
+ general: () => t('settingsui.category.general'),
102
+ models: () => t('settingsui.category.models'),
103
+ plugins: () => t('settingsui.category.plugins'),
104
+ agent: () => t('settingsui.category.agent'),
105
+ other: () => t('settingsui.category.other'),
106
+ };
107
+ /** User-facing label of one category (t()-resolved; English fallback). */
108
+ function categoryLabel(cat) {
109
+ const make = CATEGORY_LABEL_KEYS[cat.id];
110
+ return make === undefined ? cat.label : make();
111
+ }
58
112
  /** Cap for a category row's member-name description line. */
59
113
  export const CATEGORY_DESC_MAX = 60;
114
+ export const FIELD_GROUPS = {
115
+ 'dsh-tui': [
116
+ {
117
+ id: 'subagent',
118
+ labelKey: 'settingsui.group.subagent',
119
+ fields: ['maxAgents', 'maxRounds', 'maxRoundsGrace', 'disableSubagent', 'registeredOnly'],
120
+ },
121
+ ],
122
+ };
123
+ /**
124
+ * Resolve one group's label to display text. The static tables carry i18n
125
+ * KEYS (module data must not freeze a language at import time); this switch
126
+ * enumerates them as literal t() call sites — which is also what the i18n
127
+ * coverage guard matches against. Anything else passes through `t()`
128
+ * unchanged, since a non-key falls back to itself.
129
+ */
130
+ export function resolveFieldGroupLabel(labelKey) {
131
+ switch (labelKey) {
132
+ case 'settingsui.group.subagent': return t('settingsui.group.subagent');
133
+ default: return t(labelKey);
134
+ }
135
+ }
136
+ /**
137
+ * Order schema field keys for display: grouped fields collapse into a single
138
+ * group slot at the position of the group's FIRST member present (members
139
+ * list only the keys actually present, in group-declaration order). Keys in
140
+ * no group pass through in order. Unknown/hidden keys never reach this
141
+ * function — the caller walks already-filtered rows.
142
+ */
143
+ export function slotFieldKeys(ns, keys) {
144
+ const groups = FIELD_GROUPS[ns];
145
+ if (groups === undefined || groups.length === 0) {
146
+ return keys.map(key => ({ kind: 'field', key }));
147
+ }
148
+ const keySet = new Set(keys);
149
+ const emitted = new Set();
150
+ const slots = [];
151
+ for (const key of keys) {
152
+ const group = groups.find(def => def.fields.includes(key));
153
+ if (group === undefined) {
154
+ slots.push({ kind: 'field', key });
155
+ continue;
156
+ }
157
+ if (emitted.has(group.id))
158
+ continue;
159
+ emitted.add(group.id);
160
+ slots.push({ kind: 'group', group, members: group.fields.filter(field => keySet.has(field)) });
161
+ }
162
+ return slots;
163
+ }
60
164
  /**
61
165
  * Namespaces the Models category reads and writes. dsh-settings
62
166
  * 0.1.2-alpha.3 removed the runtime settingsNamespace() helper: plain
@@ -68,8 +172,11 @@ const NS_LLM_PI_AI = 'llm-pi-ai';
68
172
  const NS_LLM_DEEPSEEK = 'llm-deepseek';
69
173
  const NS_AGENT_DEFAULT_MODEL = 'agent-default-model';
70
174
  /**
71
- * Group a describe() namespace list into ordered categories — general, models,
72
- * plugins, agent, then `other` for everything unmapped. Categories with no
175
+ * Group a describe() namespace list into ordered categories. The result is
176
+ * sorted alphabetically by the canonical (English) label — Agent Presets,
177
+ * General, Models, Other, Plugins, TUI — so the first level reads the same
178
+ * regardless of CATEGORY_MAP order or the active UI language (sorting keys on
179
+ * the canonical label, never the t()-translated one). Categories with no
73
180
  * members are dropped, including `other` when nothing falls into it.
74
181
  *
75
182
  * Defensive: duplicate namespaces in the input count once (a namespace
@@ -92,7 +199,11 @@ export function categorizeNamespaces(nses) {
92
199
  const others = [...input].filter(ns => !mapped.has(ns));
93
200
  if (others.length > 0)
94
201
  categories.push({ id: 'other', label: 'Other', namespaces: others });
95
- return categories;
202
+ return categories.sort((a, b) => {
203
+ const la = a.label.toLowerCase();
204
+ const lb = b.label.toLowerCase();
205
+ return la < lb ? -1 : la > lb ? 1 : 0;
206
+ });
96
207
  }
97
208
  /**
98
209
  * Description line for a category row: member names joined with ", ", capped
@@ -109,7 +220,7 @@ export function categoryDescription(namespaces, max = 60) {
109
220
  /** Human display for a resolved value (row value column; never re-scans anything). */
110
221
  export function formatValue(value) {
111
222
  if (value === undefined)
112
- return '(unset)';
223
+ return t('settingsui.value.unset');
113
224
  if (value === null)
114
225
  return 'null';
115
226
  if (typeof value === 'string')
@@ -117,10 +228,10 @@ export function formatValue(value) {
117
228
  if (typeof value === 'number' || typeof value === 'boolean')
118
229
  return String(value);
119
230
  if (Array.isArray(value))
120
- return value.length === 0 ? '[]' : `[${value.length} items]`;
231
+ return value.length === 0 ? '[]' : t('settingsui.value.items', { count: value.length });
121
232
  if (typeof value === 'object') {
122
233
  const keys = Object.keys(value);
123
- return keys.length === 0 ? '{}' : `{${keys.length} keys}`;
234
+ return keys.length === 0 ? '{}' : `{${t('settingsui.value.keys', { count: keys.length })}}`;
124
235
  }
125
236
  return String(value);
126
237
  }
@@ -147,11 +258,11 @@ export function parseNumberInput(text) {
147
258
  // Literal decimal subset: hex/octal/binary prefixes and trailing garbage
148
259
  // are rejected; decimal scientific notation stays (1e3 = 1000).
149
260
  if (!/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(text.trim())) {
150
- return { kind: 'error', error: `expected a number, got "${text}"` };
261
+ return { kind: 'error', error: t('settingsui.parse.number', { text }) };
151
262
  }
152
263
  const n = Number(text);
153
264
  if (!Number.isFinite(n))
154
- return { kind: 'error', error: `expected a number, got "${text}"` };
265
+ return { kind: 'error', error: t('settingsui.parse.number', { text }) };
155
266
  return { kind: 'value', value: n };
156
267
  }
157
268
  export function parseStringInput(text) {
@@ -171,7 +282,7 @@ export function parseUnionInput(text, node) {
171
282
  const hasStringBranch = (node.list ?? []).some(member => member.type === 'string' || (member.type === 'literal' && typeof member.value === 'string'));
172
283
  if (hasStringBranch)
173
284
  return { kind: 'value', value: text };
174
- return { kind: 'error', error: 'expected a JSON value (number, boolean, string, …)' };
285
+ return { kind: 'error', error: t('settingsui.parse.json') };
175
286
  }
176
287
  /** Seed value for a newly added dict key, from the inner schema. */
177
288
  export function defaultValueFor(node) {
@@ -206,27 +317,27 @@ export function fieldDescription(node, userOverride) {
206
317
  parts.push(raw['en'] ?? raw[''] ?? Object.values(raw)[0] ?? '');
207
318
  }
208
319
  if (meta.required === true)
209
- parts.push('required');
320
+ parts.push(t('settingsui.meta.required'));
210
321
  if (meta.default !== undefined)
211
- parts.push(`default: ${formatValue(meta.default)}`);
322
+ parts.push(t('settingsui.meta.default', { value: formatValue(meta.default) }));
212
323
  if (meta.min !== undefined)
213
- parts.push(`min: ${meta.min}`);
324
+ parts.push(t('settingsui.meta.min', { value: meta.min }));
214
325
  if (meta.max !== undefined)
215
- parts.push(`max: ${meta.max}`);
326
+ parts.push(t('settingsui.meta.max', { value: meta.max }));
216
327
  if (meta.step !== undefined)
217
- parts.push(`step: ${meta.step}`);
328
+ parts.push(t('settingsui.meta.step', { value: meta.step }));
218
329
  if (meta.pattern !== undefined)
219
- parts.push(`pattern: ${meta.pattern.source}`);
330
+ parts.push(t('settingsui.meta.pattern', { value: meta.pattern.source }));
220
331
  if (meta.role === 'secret')
221
- parts.push('secret');
332
+ parts.push(t('settingsui.meta.secret'));
222
333
  if (meta.disabled === true)
223
- parts.push('disabled');
334
+ parts.push(t('settingsui.meta.disabled'));
224
335
  for (const badge of meta.badges ?? []) {
225
336
  if (typeof badge.text === 'string')
226
337
  parts.push(badge.text);
227
338
  }
228
339
  if (userOverride)
229
- parts.push('user-set');
340
+ parts.push(t('settingsui.meta.userSet'));
230
341
  return parts.filter(part => part !== '').join(' · ');
231
342
  }
232
343
  /** Inline value editor: title, current-value line, error/notice line, Input. */
@@ -330,7 +441,7 @@ export class EditField {
330
441
  lines.push(...this.input.render(width));
331
442
  }
332
443
  lines.push('');
333
- lines.push(fns.subtle(clipToWidth('Enter save · Esc back', wrap)));
444
+ lines.push(fns.subtle(clipToWidth(t('settingsui.edit.footer'), wrap)));
334
445
  return lines;
335
446
  }
336
447
  /**
@@ -371,7 +482,7 @@ class ConfirmReset {
371
482
  return [
372
483
  fns.accent(BOLD + this.label + RESET),
373
484
  '',
374
- fns.subtle(this.pending ? ' resetting…' : ' Enter: reset to defaults · Esc: cancel'),
485
+ fns.subtle(this.pending ? ` ${t('settingsui.reset.working')}` : ` ${t('settingsui.reset.hints')}`),
375
486
  ];
376
487
  }
377
488
  handleInput(data) {
@@ -452,12 +563,12 @@ export class AddProviderFlow {
452
563
  return;
453
564
  }
454
565
  this.list = new SettingsListPanel(theme, {
455
- title: '⚙ Add provider',
566
+ title: t('settingsui.addProvider.title'),
456
567
  rows: options.entries.map(entry => ({
457
568
  id: entry.id,
458
569
  label: entry.name,
459
570
  value: '',
460
- description: entry.hint,
571
+ description: resolveCatalogHint(entry.hint),
461
572
  submenu: (_current, done) => isCustom(entry)
462
573
  ? options.customFlow(done)
463
574
  : this.keyEditor(entry, options, done),
@@ -472,8 +583,8 @@ export class AddProviderFlow {
472
583
  keyEditor(entry, options, done) {
473
584
  const ref = deriveKeyRef(entry.id);
474
585
  return new EditField(this.tui, {
475
- title: `API key for ${entry.name}`,
476
- subtitle: `stored as ${ref} — never written to settings.yaml`,
586
+ title: t('settingsui.addProvider.keyTitle', { name: entry.name }),
587
+ subtitle: t('settingsui.addProvider.keySubtitle', { ref }),
477
588
  initial: '',
478
589
  // Never echo the key — masked dot row (B2).
479
590
  secret: true,
@@ -481,7 +592,7 @@ export class AddProviderFlow {
481
592
  // Unlike an edit of a stored secret, the key is required here: a
482
593
  // route with no key address cannot serve a request.
483
594
  if (text.trim() === '')
484
- return { kind: 'error', error: 'API key must not be empty' };
595
+ return { kind: 'error', error: t('settingsui.addProvider.keyRequired') };
485
596
  return { kind: 'value', value: text.trim() };
486
597
  },
487
598
  onCommit: async (outcome) => {
@@ -509,11 +620,11 @@ export class AddProviderFlow {
509
620
  if (this.empty) {
510
621
  const fns = panelThemeFns(this.theme);
511
622
  return [
512
- fns.accent(BOLD + '⚙ Add provider' + RESET),
623
+ fns.accent(BOLD + t('settingsui.addProvider.title') + RESET),
513
624
  '',
514
- fns.muted('All built-in providers are already configured.'),
625
+ fns.muted(t('settingsui.addProvider.empty')),
515
626
  '',
516
- fns.subtle(' Esc to close'),
627
+ fns.subtle(` ${t('settingsui.escClose')}`),
517
628
  ];
518
629
  }
519
630
  return this.list.render(width);
@@ -555,7 +666,7 @@ export async function commitProvider(ctx, writeProfile, entry, key) {
555
666
  // No credential store in this process — the row is configured and the
556
667
  // key must come from the environment; this is a success with a hint,
557
668
  // never an error. Enter re-runs the commit idempotently.
558
- return { notice: `provider added — no credentials service in this process: export ${ref} to use it` };
669
+ return { notice: t('settingsui.commit.noCredentials', { ref }) };
559
670
  }
560
671
  try {
561
672
  await credentials.set(ref, key);
@@ -565,8 +676,10 @@ export async function commitProvider(ctx, writeProfile, entry, key) {
565
676
  // counts as configured, so the user needs the manual path. The error
566
677
  // stays retryable in place — Enter re-runs the whole commit (B3).
567
678
  return {
568
- error: `API key not stored: ${cause instanceof Error ? cause.message : String(cause)}`
569
- + ` — provider added; export ${ref}=<key> to use it`,
679
+ error: t('settingsui.commit.keyFailed', {
680
+ cause: cause instanceof Error ? cause.message : String(cause),
681
+ ref,
682
+ }),
570
683
  };
571
684
  }
572
685
  return undefined;
@@ -671,7 +784,7 @@ class SettingsBrowser {
671
784
  return categorizeNamespaces(this.descriptors.map(d => d.ns));
672
785
  }
673
786
  categorySummary(cat) {
674
- return `${cat.namespaces.length} namespaces`;
787
+ return t('settingsui.summary.namespaces', { count: cat.namespaces.length });
675
788
  }
676
789
  categoryDescription(cat) {
677
790
  return categoryDescription(cat.namespaces, CATEGORY_DESC_MAX);
@@ -679,19 +792,30 @@ class SettingsBrowser {
679
792
  categoryList() {
680
793
  const items = this.categories().map(cat => ({
681
794
  id: cat.id,
682
- label: cat.label,
795
+ label: categoryLabel(cat),
683
796
  value: this.categorySummary(cat),
684
797
  description: this.categoryDescription(cat),
685
798
  submenu: cat.id === 'models'
686
799
  ? (_current, done) => this.openModelsSubmenu(done)
687
- : (_current, done) => {
688
- const list = this.namespaceList(cat.label, this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
689
- this.nsList = list;
690
- return list;
691
- },
800
+ : cat.id === 'dsh-tui'
801
+ // Single-namespace category: drill straight into the config
802
+ // fields — no intermediate namespace list repeating the namespace
803
+ // name under the category that names it.
804
+ ? (_current, done) => {
805
+ const section = this.sectionList(THEME_SETTINGS_NAMESPACE, [], () => {
806
+ this.refreshCategoryList();
807
+ done();
808
+ }, categoryLabel(cat));
809
+ return section.list;
810
+ }
811
+ : (_current, done) => {
812
+ const list = this.namespaceList(categoryLabel(cat), this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
813
+ this.nsList = list;
814
+ return list;
815
+ },
692
816
  }));
693
817
  return new SettingsListPanel(this.theme, {
694
- title: '⚙ settings',
818
+ title: t('settingsui.title'),
695
819
  rows: items,
696
820
  maxVisible: 10,
697
821
  enableSearch: true,
@@ -710,14 +834,14 @@ class SettingsBrowser {
710
834
  nsSummary(desc) {
711
835
  const root = this.root(desc.ns);
712
836
  if (root !== undefined && root.type === 'object') {
713
- return `${Object.keys(root.dict ?? {}).length} fields`;
837
+ return t('settingsui.fields.count', { count: Object.keys(root.dict ?? {}).length });
714
838
  }
715
839
  return formatValue(desc.value);
716
840
  }
717
841
  nsDescription(desc) {
718
- const parts = [`applies: ${desc.applies}`];
842
+ const parts = [t('settingsui.ns.applies', { applies: desc.applies })];
719
843
  if (desc.user !== undefined)
720
- parts.push('user-set');
844
+ parts.push(t('settingsui.meta.userSet'));
721
845
  return parts.join(' · ');
722
846
  }
723
847
  /** Namespace list for one category; Esc pops back to the category level. */
@@ -935,7 +1059,7 @@ class SettingsBrowser {
935
1059
  submenu: (_current, done) => new ViewerPanel(this.theme, {
936
1060
  title: `providers.${id}`,
937
1061
  lines: [
938
- 'read-only in the TUI — edit the settings document to change it',
1062
+ t('settingsui.models.readonly'),
939
1063
  '',
940
1064
  ...JSON.stringify(profile, null, 2).split('\n'),
941
1065
  ],
@@ -949,7 +1073,7 @@ class SettingsBrowser {
949
1073
  if (deepseekDesc !== undefined) {
950
1074
  items.push({
951
1075
  id: 'llm-deepseek',
952
- label: 'DeepSeek (official)',
1076
+ label: t('settingsui.models.deepseek'),
953
1077
  value: this.nsSummary(deepseekDesc),
954
1078
  description: this.nsDescription(deepseekDesc),
955
1079
  submenu: (_current, done) => {
@@ -965,7 +1089,7 @@ class SettingsBrowser {
965
1089
  if (agentDesc !== undefined) {
966
1090
  items.push({
967
1091
  id: 'agent-default-model',
968
- label: 'Default model',
1092
+ label: t('settingsui.models.defaultModel'),
969
1093
  value: this.defaultModelSummary(agentDesc),
970
1094
  description: this.nsDescription(agentDesc),
971
1095
  submenu: (_current, done) => {
@@ -982,9 +1106,9 @@ class SettingsBrowser {
982
1106
  const configured = new Set(Object.keys(providers.providers ?? {}));
983
1107
  items.push({
984
1108
  id: '\u0000add-provider',
985
- label: '+ Add provider…',
1109
+ label: t('settingsui.models.add'),
986
1110
  value: '',
987
- description: 'configure a built-in provider with its API key',
1111
+ description: t('settingsui.models.addDesc'),
988
1112
  submenu: (_current, done) => new AddProviderFlow(this.tui, this.theme, {
989
1113
  entries: this.addProviderEntries(configured),
990
1114
  onCommit: (entry, key) => this.commitNewProvider(entry, key),
@@ -997,7 +1121,7 @@ class SettingsBrowser {
997
1121
  });
998
1122
  }
999
1123
  return new SettingsListPanel(this.theme, {
1000
- title: '⚙ Models',
1124
+ title: t('settingsui.models.title'),
1001
1125
  rows: items,
1002
1126
  maxVisible: 12,
1003
1127
  enableSearch: true,
@@ -1014,7 +1138,7 @@ class SettingsBrowser {
1014
1138
  if (typeof provider === 'string' && provider !== '' && typeof model === 'string' && model !== '') {
1015
1139
  const effort = value?.reasoningEffort;
1016
1140
  return typeof effort === 'string' && effort !== ''
1017
- ? `${provider}/${model} · think ${effort}`
1141
+ ? t('settingsui.route.think', { provider, model, effort })
1018
1142
  : `${provider}/${model}`;
1019
1143
  }
1020
1144
  return formatValue(desc.value);
@@ -1023,9 +1147,12 @@ class SettingsBrowser {
1023
1147
  /**
1024
1148
  * Build the FW list panel for one schema node at `path` of `ns`.
1025
1149
  * `onExit` runs when the list is popped (Esc) — it must refresh the parent
1026
- * level and call the parent's submenu `done()`.
1150
+ * level and call the parent's submenu `done()`. `title` overrides the
1151
+ * default panel title (the namespace or path spelling) — the TUI category's
1152
+ * direct drill uses it to title the fields with the category label instead
1153
+ * of the raw namespace name.
1027
1154
  */
1028
- sectionList(ns, path, onExit) {
1155
+ sectionList(ns, path, onExit, title) {
1029
1156
  const root = this.root(ns);
1030
1157
  const desc = this.descriptor(ns);
1031
1158
  const node = root !== undefined && path.length > 0
@@ -1033,9 +1160,82 @@ class SettingsBrowser {
1033
1160
  : root;
1034
1161
  const rows = node === undefined ? [] : this.buildRows(ns, node, path, desc?.value);
1035
1162
  const refresh = () => { this.refreshRows(rows, list); };
1163
+ // Reset/add-key rows always lead; the field rows after them collapse
1164
+ // into virtual groups at a grouped namespace's root (FIELD_GROUPS).
1165
+ const structural = rows.filter(row => row.kind === 'reset' || row.kind === 'addkey');
1166
+ const fields = rows.filter(row => row.kind !== 'reset' && row.kind !== 'addkey');
1167
+ const slots = path.length === 0 && node?.type === 'object'
1168
+ ? slotFieldKeys(ns, fields.map(row => row.path[row.path.length - 1]))
1169
+ : undefined;
1170
+ let fieldItems;
1171
+ if (slots === undefined) {
1172
+ fieldItems = fields.map(row => this.rowItem(row, refresh));
1173
+ }
1174
+ else {
1175
+ const byKey = new Map(fields.map(row => [row.path[row.path.length - 1], row]));
1176
+ fieldItems = [];
1177
+ for (const slot of slots) {
1178
+ if (slot.kind === 'field') {
1179
+ const row = byKey.get(slot.key);
1180
+ if (row !== undefined)
1181
+ fieldItems.push(this.rowItem(row, refresh));
1182
+ }
1183
+ else {
1184
+ fieldItems.push(this.fieldGroupRow(ns, slot));
1185
+ }
1186
+ }
1187
+ }
1188
+ const items = [...structural.map(row => this.rowItem(row, refresh)), ...fieldItems];
1189
+ const list = new SettingsListPanel(this.theme, {
1190
+ title: title ?? (path.length === 0 ? ns : path.join('.')),
1191
+ rows: items,
1192
+ maxVisible: 12,
1193
+ enableSearch: true,
1194
+ onChange: (id, newValue) => { void this.onCycle(rows, list, id, newValue); },
1195
+ onCancel: () => {
1196
+ refresh();
1197
+ onExit();
1198
+ },
1199
+ });
1200
+ return { list, refresh };
1201
+ }
1202
+ /**
1203
+ * The drill row of one virtual field group (see FIELD_GROUPS): value shows
1204
+ * the member count, the description names the members, and Enter opens the
1205
+ * group's field list.
1206
+ */
1207
+ fieldGroupRow(ns, slot) {
1208
+ const label = resolveFieldGroupLabel(slot.group.labelKey);
1209
+ return {
1210
+ id: `group:${slot.group.id}`,
1211
+ label,
1212
+ value: `{${t('settingsui.fields.count', { count: slot.members.length })}}`,
1213
+ description: slot.members.join(' · '),
1214
+ submenu: (_current, done) => {
1215
+ const child = this.fieldGroupList(ns, label, slot.members, done);
1216
+ return child.list;
1217
+ },
1218
+ };
1219
+ }
1220
+ /**
1221
+ * Field list of one virtual group: the member schema fields exactly as
1222
+ * they would render flat, with the same cycle/input editing — minus the
1223
+ * reset/add-key rows (a virtual group has no single path to reset; the
1224
+ * namespace level still resets the whole subtree).
1225
+ */
1226
+ fieldGroupList(ns, label, members, onExit) {
1227
+ const root = this.root(ns);
1228
+ const rows = [];
1229
+ for (const name of members) {
1230
+ const child = root !== undefined && root.type === 'object' ? root.dict?.[name] : undefined;
1231
+ if (child === undefined || fieldMeta(child).hidden === true)
1232
+ continue;
1233
+ rows.push(this.fieldRow(ns, child, [name], name));
1234
+ }
1235
+ const refresh = () => { this.refreshRows(rows, list); };
1036
1236
  const items = rows.map(row => this.rowItem(row, refresh));
1037
1237
  const list = new SettingsListPanel(this.theme, {
1038
- title: path.length === 0 ? ns : path.join('.'),
1238
+ title: label,
1039
1239
  rows: items,
1040
1240
  maxVisible: 12,
1041
1241
  enableSearch: true,
@@ -1054,7 +1254,9 @@ class SettingsBrowser {
1054
1254
  id: '\u0000reset',
1055
1255
  ns,
1056
1256
  path,
1057
- label: `Reset ${path.length === 0 ? 'this namespace' : path.join('.')} to defaults`,
1257
+ label: path.length === 0
1258
+ ? t('settingsui.reset.namespace')
1259
+ : t('settingsui.reset.path', { path: path.join('.') }),
1058
1260
  kind: 'reset',
1059
1261
  node,
1060
1262
  value: undefined,
@@ -1065,7 +1267,7 @@ class SettingsBrowser {
1065
1267
  id: '\u0000add',
1066
1268
  ns,
1067
1269
  path,
1068
- label: '+ Add key…',
1270
+ label: t('settingsui.addKey.label'),
1069
1271
  kind: 'addkey',
1070
1272
  node,
1071
1273
  value: undefined,
@@ -1144,14 +1346,16 @@ class SettingsBrowser {
1144
1346
  }
1145
1347
  computeDisplay(kind, node, value, secret) {
1146
1348
  if (secret)
1147
- return value === undefined || value === null || value === '' ? '(unset)' : '••••••';
1349
+ return value === undefined || value === null || value === '' ? t('settingsui.value.unset') : '••••••';
1148
1350
  switch (kind) {
1149
1351
  case 'drill': {
1150
1352
  if (node.type === 'object')
1151
- return `{${Object.keys(node.dict ?? {}).length} fields}`;
1353
+ return `{${t('settingsui.fields.count', { count: Object.keys(node.dict ?? {}).length })}}`;
1152
1354
  if (node.type === 'dict') {
1153
1355
  const entries = Object.keys((value ?? {}));
1154
- return `dict · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
1356
+ return entries.length === 1
1357
+ ? t('settingsui.dict.one', { count: entries.length })
1358
+ : t('settingsui.dict.many', { count: entries.length });
1155
1359
  }
1156
1360
  return formatValue(value);
1157
1361
  }
@@ -1188,7 +1392,7 @@ class SettingsBrowser {
1188
1392
  submenu: (_current, done) => new ViewerPanel(this.theme, {
1189
1393
  title: row.path.join('.'),
1190
1394
  lines: [
1191
- 'read-only in the TUI — edit the settings document to change it',
1395
+ t('settingsui.models.readonly'),
1192
1396
  '',
1193
1397
  ...JSON.stringify(row.value, null, 2).split('\n'),
1194
1398
  ],
@@ -1305,6 +1509,12 @@ class SettingsBrowser {
1305
1509
  }
1306
1510
  /** Build the edit submenu for a leaf row; commits write to settings. */
1307
1511
  inputSubmenu(row, refresh, done) {
1512
+ // dsh-tui.language: pick from the discovered locales instead of typing a
1513
+ // raw id — the same panel the /language command opens, writing through
1514
+ // the standard mutate path (the watch hook applies the language live).
1515
+ if (row.ns === THEME_SETTINGS_NAMESPACE && row.path.length === 1 && row.path[0] === 'language') {
1516
+ return this.languageSubmenu(row, refresh, done);
1517
+ }
1308
1518
  const meta = fieldMeta(row.node);
1309
1519
  const initial = row.secret === true || row.value === undefined || row.value === null
1310
1520
  ? ''
@@ -1312,8 +1522,10 @@ class SettingsBrowser {
1312
1522
  return new EditField(this.tui, {
1313
1523
  title: row.path.join('.'),
1314
1524
  subtitle: row.secret === true
1315
- ? 'secret — leave empty to keep the current value'
1316
- : `current: ${row.display}${meta.required === true ? ' · required' : ''}`,
1525
+ ? t('settingsui.edit.secret')
1526
+ : t('settingsui.edit.current', {
1527
+ value: row.display + (meta.required === true ? ` · ${t('settingsui.meta.required')}` : ''),
1528
+ }),
1317
1529
  initial,
1318
1530
  // role('secret') rows get the masked dot-row renderer too (B2).
1319
1531
  secret: row.secret === true,
@@ -1323,6 +1535,43 @@ class SettingsBrowser {
1323
1535
  onError: message => this.onError(message),
1324
1536
  }, this.theme);
1325
1537
  }
1538
+ /**
1539
+ * The language picker as the dsh-tui.language edit submenu: Enter picks
1540
+ * and commits through the standard mutate path, Esc pops back. A failed
1541
+ * write keeps the picker open with the error on the status line (the
1542
+ * EditField ✘ contract, transplanted onto a table).
1543
+ */
1544
+ languageSubmenu(row, refresh, done) {
1545
+ let status;
1546
+ // A pick is single-shot: the commit is async, and a second Enter landing
1547
+ // before it resolves would close the submenu twice — the second close
1548
+ // returning to the LIST would then re-open the picker on the same row.
1549
+ let settled = false;
1550
+ // Esc may close the picker while a commit is in flight (the EditField
1551
+ // contract); a late failure then has no status line to land on and goes
1552
+ // through onError instead of vanishing.
1553
+ let closed = false;
1554
+ const cancel = () => { closed = true; done(); };
1555
+ return languagePickerPanel(this.theme, typeof row.value === 'string' ? row.value : '', async (picked) => {
1556
+ if (settled)
1557
+ return;
1558
+ settled = true;
1559
+ const result = await this.commitInput(row, refresh, { kind: 'value', value: picked });
1560
+ if (result?.error !== undefined) {
1561
+ if (closed) {
1562
+ this.onError(result.error);
1563
+ return;
1564
+ }
1565
+ // The write failed with the picker still open — re-arm for a retry.
1566
+ settled = false;
1567
+ status = t('sel.status.saveFailed', { message: result.error });
1568
+ this.tui.requestRender();
1569
+ return;
1570
+ }
1571
+ if (!closed)
1572
+ done();
1573
+ }, cancel, () => status);
1574
+ }
1326
1575
  parseFor(row, text) {
1327
1576
  // Whitespace-only input on a secret also means "keep" — an accidental
1328
1577
  // space must not unset (delete) the stored secret; clearing a secret is
@@ -1335,9 +1584,9 @@ class SettingsBrowser {
1335
1584
  case 'union': return parseUnionInput(text, row.node);
1336
1585
  case 'transform':
1337
1586
  return row.node.inner === undefined
1338
- ? { kind: 'error', error: `cannot edit ${row.node.type} value` }
1587
+ ? { kind: 'error', error: t('settingsui.parse.type', { type: row.node.type }) }
1339
1588
  : this.parseFor({ ...row, node: row.node.inner }, text);
1340
- default: return { kind: 'error', error: `cannot edit ${row.node.type} value` };
1589
+ default: return { kind: 'error', error: t('settingsui.parse.type', { type: row.node.type }) };
1341
1590
  }
1342
1591
  }
1343
1592
  async commitInput(row, refresh, outcome) {
@@ -1356,15 +1605,15 @@ class SettingsBrowser {
1356
1605
  const inner = row.node.inner ?? row.node;
1357
1606
  const existing = (getPath(this.descriptor(row.ns)?.value, row.path) ?? {});
1358
1607
  return new EditField(this.tui, {
1359
- title: `+ key in ${row.path.length === 0 ? '…' : row.path.join('.')}`,
1360
- subtitle: `default: ${formatValue(defaultValueFor(inner))} · Enter to add · Esc to cancel`,
1608
+ title: t('settingsui.addKey.title', { path: row.path.length === 0 ? '…' : row.path.join('.') }),
1609
+ subtitle: t('settingsui.addKey.subtitle', { default: formatValue(defaultValueFor(inner)) }),
1361
1610
  initial: '',
1362
1611
  parse: text => {
1363
1612
  const key = text.trim();
1364
1613
  if (key === '')
1365
- return { kind: 'error', error: 'key must not be empty' };
1614
+ return { kind: 'error', error: t('settingsui.addKey.empty') };
1366
1615
  if (key in existing)
1367
- return { kind: 'error', error: `key "${key}" already exists` };
1616
+ return { kind: 'error', error: t('settingsui.addKey.exists', { key }) };
1368
1617
  return { kind: 'value', value: key };
1369
1618
  },
1370
1619
  onCommit: async (outcome) => {
@@ -1383,8 +1632,8 @@ class SettingsBrowser {
1383
1632
  /** Reset-to-defaults confirmation for group/namespace rows. */
1384
1633
  resetSubmenu(row, refresh, done) {
1385
1634
  return new ConfirmReset(this.theme, row.path.length === 0
1386
- ? `Reset "${row.ns}" to defaults`
1387
- : `Reset ${row.path.join('.')} to defaults`, () => {
1635
+ ? t('settingsui.reset.ns', { ns: row.ns })
1636
+ : t('settingsui.reset.path', { path: row.path.join('.') }), () => {
1388
1637
  void this.write(row.ns, [{ op: 'unset', path: row.path }]).then(error => {
1389
1638
  if (error !== undefined)
1390
1639
  this.onError(error);