dsh-live-trace 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Dashboard localization.
3
+ *
4
+ * The board is read by people whose Harness UI may be Chinese or English, so
5
+ * every user-facing string goes through a key. The language is picked from the
6
+ * environment by default and can be pinned on the command line or cycled at
7
+ * runtime with `l`; the choice only affects the chrome, never the trace itself
8
+ * (event text is whatever the model and the Harness wrote).
9
+ *
10
+ * @module dsh-live-trace/i18n
11
+ */
12
+
13
+ /** Languages this build ships. */
14
+ export const LANGUAGES = ['en', 'zh']
15
+
16
+ /** Environment variables consulted for the initial language, in order. */
17
+ const LOCALE_ENV = ['DSH_LIVE_TRACE_LANG', 'LC_ALL', 'LC_MESSAGES', 'LANG']
18
+
19
+ const DICTIONARIES = {
20
+ en: {
21
+ 'header.session': 'Session:',
22
+ 'header.status': 'Status:',
23
+ 'header.noSession': 'no session bound',
24
+ 'header.disconnected': 'disconnected',
25
+
26
+ 'status.idle': 'idle',
27
+ 'status.running': 'running',
28
+ 'status.tool': 'tool running',
29
+ 'status.approval': 'waiting for approval',
30
+ 'status.error': 'error',
31
+
32
+ 'trace.connecting': 'Connecting to the Harness observer…',
33
+ 'trace.noSession': 'Connected. No session yet — send a message in the dsh web UI to start one.',
34
+ 'trace.waiting': 'Connected. Waiting for session activity…',
35
+ 'trace.thinking': 'thinking',
36
+ 'trace.writing': 'writing',
37
+ 'trace.moreLines': '… {n} more lines',
38
+ 'trace.expand': 'e to expand',
39
+ 'trace.collapse': 'e to collapse',
40
+ 'trace.gotoCommands': '(v commands)',
41
+ 'trace.gotoEdits': '(v edits)',
42
+ 'trace.hunks': '± {n} hunks',
43
+ 'trace.hunk': '± {n} hunk',
44
+ 'trace.running': 'running',
45
+
46
+ 'panel.sessions': 'SESSIONS',
47
+ 'panel.edits': 'FILE CHANGES',
48
+ 'panel.commands': 'COMMANDS',
49
+ 'panel.none': 'none',
50
+ 'panel.known': '{n} known · {m} active',
51
+
52
+ 'sessions.empty': 'No sessions yet.',
53
+ 'sessions.emptyHint': 'Send a message in the dsh web UI to start one; it appears here immediately.',
54
+ 'sessions.subagent': 'subagent L{n}',
55
+
56
+ 'edits.empty': 'No file changes recorded for this session yet.',
57
+ 'edits.emptyHint': 'Edits appear here as soon as a write or edit tool reports its applied diff.',
58
+ 'edits.noChanges': 'no changes yet',
59
+ 'edits.fileCount': '{n} file',
60
+ 'edits.filePlural': '{n} files',
61
+ 'edits.moreFiles': '… {n} more files',
62
+ 'edits.moreFile': '… {n} more file',
63
+ 'edits.badge.new': 'new',
64
+ 'edits.badge.mod': 'mod',
65
+ 'edits.badge.del': 'del',
66
+ 'edits.newFile': '(new file)',
67
+
68
+ 'commands.empty': 'No shell commands run in this session yet.',
69
+ 'commands.emptyHint': 'Every command the model runs appears here with its output and exit status.',
70
+ 'commands.running': 'running…',
71
+ 'commands.exit': 'exit {n}',
72
+ 'commands.signal': 'signal {x}',
73
+ 'commands.noOutput': '(no output)',
74
+ 'commands.summaryRun': '{n} run',
75
+ 'commands.summaryFailed': '{n} failed',
76
+ 'commands.summaryRunning': '{n} running',
77
+
78
+ 'footer.paused': 'paused',
79
+ 'footer.tokens': 'Tokens',
80
+ 'footer.older': '↑ older',
81
+ 'footer.above': '↑{n}',
82
+ 'footer.below': '↓{n}',
83
+ 'footer.sessions': '{n} sessions',
84
+ 'footer.files': '{n} files',
85
+
86
+ 'keys.trace': '1-4 views e:think m:md ?:help',
87
+ 'keys.sessions': '↑↓ select enter open esc back ?:help',
88
+ 'keys.edits': '↑↓ file 1:trace ?:help',
89
+ 'keys.commands': '1:trace d:edits ?:help',
90
+
91
+ 'view.trace': 'trace',
92
+ 'view.sessions': 'sessions',
93
+ 'view.edits': 'edits',
94
+ 'view.commands': 'commands',
95
+
96
+ 'work.title': 'working',
97
+ 'work.state.sleep': 'dozing',
98
+ 'work.state.thinking': 'thinking it over',
99
+ 'work.state.typing': 'typing',
100
+ 'work.state.writing': 'writing a file',
101
+ 'work.state.waiting': 'waiting on a command',
102
+ 'work.state.reading': 'reading',
103
+ 'work.state.searching': 'looking it up',
104
+ 'work.state.calling': 'on the phone',
105
+ 'work.bubbleCalling': 'Subagent #{n} on the line',
106
+ 'work.bubbleQueue': 'Subagent #{n} of {total} ({q} queued)',
107
+ 'work.bubbleReply': 'Subagent #{n} calling back',
108
+ 'work.noReply': '(it came back with no text)',
109
+ 'work.state.ringing': 'answering the phone',
110
+ 'work.sceneRoom': 'a desk by a window (b)',
111
+ 'work.sceneNature': 'outdoors (b)',
112
+ 'work.soundOn': 'rain sound on {level}% (n, -/+)',
113
+ 'work.soundOff': 'rain sound off {level}% (n)',
114
+ 'work.soundFixed': 'rain sound on · {player} cannot change a file level (n)',
115
+ 'work.weather.clear': 'clear',
116
+ 'work.weather.cloudy': 'cloudy',
117
+ 'work.weather.rain': 'rain',
118
+ 'work.weather.storm': 'storm',
119
+ 'work.weather.fog': 'fog',
120
+ 'work.weather.snow': 'snow',
121
+ 'work.waitingForSubagents': 'napping — waiting for {n} subagent(s) to call back',
122
+ 'work.bubbleCallingNoInput': 'Subagent #{n} — no instructions yet',
123
+ 'work.noInput': '(no instructions)',
124
+ 'work.helpTitle': 'Keys',
125
+ 'work.help.pin': 'pin an animation (1-8), 0 for automatic',
126
+ 'work.help.language': 'switch language',
127
+ 'work.help.quit': 'quit',
128
+ 'work.pinned': 'pinned',
129
+ 'work.automatic': 'automatic',
130
+ 'work.helpKey': 'close this help',
131
+ 'work.noSession': 'no session bound yet',
132
+ 'work.state': 'state',
133
+ 'work.tooSmall': 'the terminal is too small for the desk scene',
134
+ 'working.pick': 'Which session?',
135
+ 'working.pickKeys': '↑↓ choose enter bind esc cancel',
136
+
137
+ 'age.now': 'now',
138
+
139
+ 'help.title': 'Key bindings',
140
+ 'help.views': 'Views',
141
+ 'help.trace': 'Trace',
142
+ 'help.lists': 'Lists (sessions, edits, commands)',
143
+ 'help.general': '',
144
+ 'help.tracePanel': 'trace — the chronological event log',
145
+ 'help.sessionsPanel': 'sessions — pick between concurrent dsh sessions',
146
+ 'help.editsPanel': 'edits — file changes with a unified diff',
147
+ 'help.commandsPanel': 'commands — every command and its output',
148
+ 'help.cycle': 'cycle panels',
149
+ 'help.scrollLine': 'scroll one line',
150
+ 'help.page': 'scroll one page',
151
+ 'help.jump': 'jump to the oldest / newest',
152
+ 'help.thinking': 'expand or collapse every thinking block',
153
+ 'help.markdown': 'toggle Markdown rendering (raw text)',
154
+ 'help.pause': 'pause or resume following new entries',
155
+ 'help.replay': 'ask the plugin to replay this session',
156
+ 'help.language': 'switch language (English / 中文)',
157
+ 'help.mouse': 'scroll three lines per notch',
158
+ 'help.click': 'select the row under the pointer',
159
+ 'help.select': 'move the selection',
160
+ 'help.bind': 'bind the selected session',
161
+ 'help.leave': 'leave the session picker',
162
+ 'help.helpKey': 'close this help',
163
+ 'help.quit': 'quit',
164
+ 'help.readonly': 'The dashboard is read-only: it never sends anything to the agent.',
165
+ 'help.width': 'Content width here: {n} columns.',
166
+
167
+ 'cli.detached': 'dsh-live-trace: detached from {id}',
168
+ 'cli.noObserver': 'dsh-live-trace: no running Harness observer found.',
169
+ 'cli.lookedIn': 'Looked in: {path}',
170
+ 'cli.installHint': 'The dashboard reads events published by the dsh-live-trace Host plugin.',
171
+ 'cli.installHint2': 'Install and enable it in the profile your Harness runs, then restart (or',
172
+ 'cli.installHint3': 'let hot reload pick it up) and run this command again:',
173
+ 'cli.useList': 'Use `dsh-live-trace --list` to see what was discovered, or `--socket <path>`',
174
+ 'cli.attachDirect': 'to attach to a known socket directly. Add `--wait` to poll instead of exiting.',
175
+ 'cli.runtimeDir': 'runtime dir: {path}',
176
+ 'cli.serversDir': 'servers dir: {path}',
177
+ 'cli.noObservers': 'No running Harness observers.',
178
+ 'cli.noneYet': ' sessions: none yet',
179
+ 'cli.sessionsHeader': ' sessions:',
180
+ 'cli.unknownOption': 'unknown option: {arg}',
181
+ 'cli.unexpectedArg': 'unexpected argument: {arg}',
182
+ 'cli.missingValue': 'missing value for {arg}'
183
+ },
184
+
185
+ zh: {
186
+ 'header.session': '会话:',
187
+ 'header.status': '状态:',
188
+ 'header.noSession': '未绑定会话',
189
+ 'header.disconnected': '已断开',
190
+
191
+ 'status.idle': '空闲',
192
+ 'status.running': '运行中',
193
+ 'status.tool': '执行工具',
194
+ 'status.approval': '等待审批',
195
+ 'status.error': '出错',
196
+
197
+ 'trace.connecting': '正在连接 Harness 观察器…',
198
+ 'trace.noSession': '已连接,暂无会话 — 在 dsh web 界面发一条消息即可开始。',
199
+ 'trace.waiting': '已连接,等待会话活动…',
200
+ 'trace.thinking': '思考',
201
+ 'trace.writing': '输出',
202
+ 'trace.moreLines': '… 还有 {n} 行',
203
+ 'trace.expand': 'e 展开',
204
+ 'trace.collapse': 'e 收起',
205
+ 'trace.gotoCommands': '(v 查看命令)',
206
+ 'trace.gotoEdits': '(v 查看改动)',
207
+ 'trace.hunks': '± {n} 处改动',
208
+ 'trace.hunk': '± {n} 处改动',
209
+ 'trace.running': '执行中',
210
+
211
+ 'panel.sessions': '会话',
212
+ 'panel.edits': '文件改动',
213
+ 'panel.commands': '命令',
214
+ 'panel.none': '无',
215
+ 'panel.known': '{n} 个 · {m} 个活动中',
216
+
217
+ 'sessions.empty': '暂无会话。',
218
+ 'sessions.emptyHint': '在 dsh web 界面发一条消息即可开始,会话会立刻出现在这里。',
219
+ 'sessions.subagent': '子代理 L{n}',
220
+
221
+ 'edits.empty': '本会话暂无文件改动记录。',
222
+ 'edits.emptyHint': '写入或编辑工具上报已应用的差异后,改动会立刻出现在这里。',
223
+ 'edits.noChanges': '暂无改动',
224
+ 'edits.fileCount': '{n} 个文件',
225
+ 'edits.filePlural': '{n} 个文件',
226
+ 'edits.moreFiles': '… 还有 {n} 个文件',
227
+ 'edits.moreFile': '… 还有 {n} 个文件',
228
+ 'edits.badge.new': '新增',
229
+ 'edits.badge.mod': '修改',
230
+ 'edits.badge.del': '删除',
231
+ 'edits.newFile': '(新文件)',
232
+
233
+ 'commands.empty': '本会话尚未执行任何 shell 命令。',
234
+ 'commands.emptyHint': '模型执行的每条命令都会连同输出与退出状态显示在这里。',
235
+ 'commands.running': '执行中…',
236
+ 'commands.exit': '退出 {n}',
237
+ 'commands.signal': '信号 {x}',
238
+ 'commands.noOutput': '(无输出)',
239
+ 'commands.summaryRun': '{n} 条',
240
+ 'commands.summaryFailed': '{n} 条失败',
241
+ 'commands.summaryRunning': '{n} 条执行中',
242
+
243
+ 'footer.paused': '已暂停',
244
+ 'footer.tokens': 'Token',
245
+ 'footer.older': '↑ 更早',
246
+ 'footer.above': '↑{n}',
247
+ 'footer.below': '↓{n}',
248
+ 'footer.sessions': '{n} 个会话',
249
+ 'footer.files': '{n} 个文件',
250
+
251
+ 'keys.trace': '1-4 视图 e:思考 m:原文 ?:帮助',
252
+ 'keys.sessions': '↑↓ 选择 enter 打开 esc 返回 ?:帮助',
253
+ 'keys.edits': '↑↓ 文件 1:轨迹 ?:帮助',
254
+ 'keys.commands': '1:轨迹 d:改动 ?:帮助',
255
+
256
+ 'view.trace': '轨迹',
257
+ 'view.sessions': '会话',
258
+ 'view.edits': '改动',
259
+ 'view.commands': '命令',
260
+
261
+ 'work.title': '工作台',
262
+ 'work.state.sleep': '打盹中',
263
+ 'work.state.thinking': '思考中',
264
+ 'work.state.typing': '敲键盘',
265
+ 'work.state.writing': '写文件中',
266
+ 'work.state.waiting': '等待命令',
267
+ 'work.state.reading': '阅读文件',
268
+ 'work.state.searching': '翻阅资料',
269
+ 'work.state.calling': '打电话',
270
+ 'work.bubbleCalling': '接 {n} 号子代理',
271
+ 'work.bubbleQueue': '接 {n} 号子代理(共 {total} 个,排队 {q} 个)',
272
+ 'work.bubbleReply': '{n} 号子代理回电',
273
+ 'work.noReply': '(它没有带回正文)',
274
+ 'work.state.ringing': '接电话',
275
+ 'work.sceneRoom': '室内·窗边(b)',
276
+ 'work.sceneNature': '全自然环境(b)',
277
+ 'work.soundOn': '雨声 开 {level}%(n, -/+)',
278
+ 'work.soundOff': '雨声 关 {level}%(n)',
279
+ 'work.soundFixed': '雨声 开 · {player} 无法调节文件音量(n)',
280
+ 'work.weather.clear': '晴',
281
+ 'work.weather.cloudy': '多云',
282
+ 'work.weather.rain': '雨',
283
+ 'work.weather.storm': '暴风雨',
284
+ 'work.weather.fog': '雾',
285
+ 'work.weather.snow': '雪',
286
+ 'work.waitingForSubagents': '打盹中 — 等 {n} 个子代理回电',
287
+ 'work.bubbleCallingNoInput': '接 {n} 号子代理,(暂无指令)',
288
+ 'work.noInput': '(对子代理的输入)',
289
+ 'work.helpTitle': '按键',
290
+ 'work.help.pin': '固定动画状态(1-8),0 恢复自动',
291
+ 'work.help.language': '切换语言',
292
+ 'work.help.quit': '退出',
293
+ 'work.pinned': '已固定',
294
+ 'work.automatic': '自动',
295
+ 'work.helpKey': '关闭本帮助',
296
+ 'work.noSession': '尚未绑定会话',
297
+ 'work.state': '状态',
298
+ 'work.tooSmall': '终端太小,放不下这张桌子',
299
+ 'working.pick': '选择会话',
300
+ 'working.pickKeys': '↑↓ 选择 enter 绑定 esc 取消',
301
+
302
+ 'age.now': '刚刚',
303
+
304
+ 'help.title': '按键说明',
305
+ 'help.views': '面板',
306
+ 'help.trace': '轨迹',
307
+ 'help.lists': '列表(会话 / 改动 / 命令)',
308
+ 'help.general': '',
309
+ 'help.tracePanel': '轨迹 — 按时间顺序的事件日志',
310
+ 'help.sessionsPanel': '会话 — 在并发的 dsh 会话之间切换',
311
+ 'help.editsPanel': '改动 — 文件改动与 unified diff',
312
+ 'help.commandsPanel': '命令 — 每条命令及其输出',
313
+ 'help.cycle': '循环切换面板',
314
+ 'help.scrollLine': '滚动一行',
315
+ 'help.page': '翻页',
316
+ 'help.jump': '跳到最早 / 最新',
317
+ 'help.thinking': '展开或收起所有思考块',
318
+ 'help.markdown': '切换 Markdown 渲染(显示原文)',
319
+ 'help.pause': '暂停 / 恢复跟随新事件',
320
+ 'help.replay': '请求插件重放本会话',
321
+ 'help.language': '切换语言(中文 / English)',
322
+ 'help.mouse': '滚轮:每格滚动三行',
323
+ 'help.click': '点击选中指针所在行',
324
+ 'help.select': '移动选中项',
325
+ 'help.bind': '绑定所选会话',
326
+ 'help.leave': '离开会话选择页',
327
+ 'help.helpKey': '关闭本帮助',
328
+ 'help.quit': '退出',
329
+ 'help.readonly': '看板是只读的:它不会向 agent 发送任何内容。',
330
+ 'help.width': '当前内容宽度:{n} 列。',
331
+
332
+ 'cli.detached': 'dsh-live-trace:已断开与 {id} 的连接',
333
+ 'cli.noObserver': 'dsh-live-trace:未找到正在运行的 Harness 观察器。',
334
+ 'cli.lookedIn': '查找位置:{path}',
335
+ 'cli.installHint': '看板读取由 dsh-live-trace Host 插件发布的事件。',
336
+ 'cli.installHint2': '请在你的 Harness 所用 profile 中安装并启用它,然后重启(或',
337
+ 'cli.installHint3': '让热重载自动加载),再重新运行本命令:',
338
+ 'cli.useList': '用 `dsh-live-trace --list` 查看发现了什么,或用 `--socket <path>`',
339
+ 'cli.attachDirect': '直接连接已知 socket。加 `--wait` 可轮询等待而不是直接退出。',
340
+ 'cli.runtimeDir': '运行时目录:{path}',
341
+ 'cli.serversDir': '注册表目录:{path}',
342
+ 'cli.noObservers': '没有正在运行的 Harness 观察器。',
343
+ 'cli.noneYet': ' 会话:暂无',
344
+ 'cli.sessionsHeader': ' 会话:',
345
+ 'cli.unknownOption': '未知选项:{arg}',
346
+ 'cli.unexpectedArg': '多余的参数:{arg}',
347
+ 'cli.missingValue': '{arg} 缺少取值'
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Pick the language from a locale string such as `zh_CN.UTF-8`.
353
+ * @param {unknown} locale
354
+ * @returns {'en' | 'zh' | null}
355
+ */
356
+ function languageFromLocale(locale) {
357
+ if (typeof locale !== 'string') return null
358
+ const normalized = locale.trim().toLowerCase()
359
+ if (normalized.length === 0) return null
360
+ if (normalized.startsWith('zh')) return 'zh'
361
+ if (normalized.startsWith('en')) return 'en'
362
+ return null
363
+ }
364
+
365
+ /**
366
+ * @param {Record<string, string | undefined>} [env]
367
+ * @returns {'en' | 'zh'} the best guess, defaulting to English
368
+ */
369
+ export function detectLanguage(env = process.env) {
370
+ for (const name of LOCALE_ENV) {
371
+ const found = languageFromLocale(env?.[name])
372
+ if (found !== null) return found
373
+ }
374
+ return 'en'
375
+ }
376
+
377
+ /**
378
+ * Resolve the effective language.
379
+ * @param {unknown} option `auto`, a language code, or undefined
380
+ * @param {Record<string, string | undefined>} [env]
381
+ * @returns {'en' | 'zh'}
382
+ */
383
+ export function resolveLanguage(option, env = process.env) {
384
+ if (typeof option === 'string') {
385
+ const normalized = option.trim().toLowerCase()
386
+ if (normalized === 'auto' || normalized.length === 0) return detectLanguage(env)
387
+ const direct = LANGUAGES.find((language) => language === normalized)
388
+ if (direct !== undefined) return direct
389
+ const byLocale = languageFromLocale(normalized)
390
+ if (byLocale !== null) return byLocale
391
+ }
392
+ return detectLanguage(env)
393
+ }
394
+
395
+ /** The next language in the cycle, for a runtime switch. */
396
+ export function nextLanguage(language) {
397
+ const index = LANGUAGES.indexOf(language)
398
+ return LANGUAGES[(index + 1) % LANGUAGES.length]
399
+ }
400
+
401
+ /** Substitution values may be strings or numbers. @typedef {Record<string, string | number>} Params */
402
+
403
+ /**
404
+ * Build a translator.
405
+ *
406
+ * A missing key falls back to English and then to the key itself, so a gap in a
407
+ * translation degrades to readable text instead of an empty line.
408
+ *
409
+ * @param {'en' | 'zh'} language
410
+ * @returns {{ language: string, t: (key: string, params?: Params) => string }}
411
+ */
412
+ export function createTranslator(language) {
413
+ const active = LANGUAGES.includes(language) ? language : 'en'
414
+ const dictionary = DICTIONARIES[active]
415
+ const t = (key, params) => {
416
+ const template = dictionary[key] ?? DICTIONARIES.en[key] ?? key
417
+ if (params === undefined) return template
418
+ return template.replace(/\{(\w+)\}/g, (match, name) =>
419
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
420
+ )
421
+ }
422
+ return { language: active, t }
423
+ }
424
+
425
+ /** English translator used when no language was injected (tests, plain mode). */
426
+ export const DEFAULT_TRANSLATOR = createTranslator('en').t
427
+
428
+ /**
429
+ * Status vocabulary in the active language.
430
+ * @param {(key: string) => string} t
431
+ * @param {string} status
432
+ * @returns {string}
433
+ */
434
+ export function statusText(t, status) {
435
+ switch (status) {
436
+ case 'running':
437
+ return t('status.running')
438
+ case 'tool':
439
+ return t('status.tool')
440
+ case 'waiting-approval':
441
+ return t('status.approval')
442
+ case 'error':
443
+ return t('status.error')
444
+ default:
445
+ return t('status.idle')
446
+ }
447
+ }
448
+
449
+ /** Every key this build defines, for tests that guard against gaps. */
450
+ export function dictionaryKeys(language = 'en') {
451
+ return Object.keys(DICTIONARIES[language] ?? {})
452
+ }
453
+
454
+ /** The raw dictionary, for tests. */
455
+ export function dictionary(language) {
456
+ return DICTIONARIES[language]
457
+ }