dsh-working-activity 0.2.6 → 0.3.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.
package/src/lang.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Language resolution + copy dictionary for the working-activity plugin.
3
+ *
4
+ * The plugin follows the dsh-tui UI language without importing it: the same
5
+ * chain dsh-tui resolves (`DSH_TUI_LANG` env → `~/.dsh-tui/lang.json` → OS
6
+ * locale → zh) is read here directly, so a `/lang en|zh` switch (or the
7
+ * /settings language pick) hot-swaps this plugin's narration and status-line
8
+ * copy on the next render tick.
9
+ *
10
+ * Resolution order:
11
+ * 1. `setLangOverride()` — a plugin-level `lang: zh|en` config key
12
+ * (cordis.yml) or an explicit test pin.
13
+ * 2. `DSH_TUI_LANG` env var — pinned at process start.
14
+ * 3. `~/.dsh-tui/lang.json` — the persisted dsh-tui choice, mtime-cached
15
+ * so the per-tick status render never re-reads an unchanged file.
16
+ * 4. OS locale guess (`LC_ALL` / `LC_MESSAGES` / `LANG`); POSIX/C means
17
+ * "no locale selected" and maps to English.
18
+ * 5. `zh` — the original hard-coded language.
19
+ *
20
+ * The dictionary is a flat key → per-language string map; `t(key, params)`
21
+ * substitutes `{{name}}` placeholders. Missing keys render the key itself so
22
+ * a typo is visible instead of silently blank.
23
+ * @module @deepseek-ai/dsh-working-activity/lang
24
+ */
25
+
26
+ import { readFileSync, statSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ export type Lang = 'zh' | 'en'
31
+
32
+ /** The languages shipped with the plugin, in display order. */
33
+ export const LANGS = ['zh', 'en'] as const
34
+
35
+ /** The dsh-tui prefs file this plugin mirrors (shared language contract). */
36
+ const LANG_FILE = join(homedir(), '.dsh-tui', 'lang.json')
37
+
38
+ const dict = {
39
+ // ── ⏵ self-narration contract (system prompt, /lang aware) ──────────
40
+ 'narrate-instruction': {
41
+ zh: '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。',
42
+ en: '[Status line] You have a status line visible to the user. [Required] At the start of each step or subtask (not only before tool calls), write exactly one standalone line at the very beginning of your response: ⏵ a concrete description of what you are doing (20 words max), then continue with the normal response on the next line. Write only one ⏵ line per response and do not repeat it. Prioritize information so the user can understand the current work at a glance; keep the style natural and optionally playful. Examples: ⏵ Fixing the login page styles, ⏵ Investigating the error, ⏵ Running validation for the patch. Update it when the task changes.',
43
+ },
44
+
45
+ // ── status-line structural copy ─────────────────────────────────────
46
+ /** Plain (non-playful) phase labels. */
47
+ 'waiting-label': { zh: '等待模型响应', en: 'Waiting for model' },
48
+ 'thinking-label': { zh: '思考中', en: 'Thinking' },
49
+ /** Elapsed suffix: zh glues the char, en keeps a space. */
50
+ 'line-elapsed': { zh: '总{{elapsed}}', en: 'total {{elapsed}}' },
51
+ /** Plain-mode completion prefix (playful pools pick their own). */
52
+ 'done-prefix': { zh: '搞定 ✓', en: 'Finished' },
53
+ /** Turn-completion summary. */
54
+ 'done-summary': {
55
+ zh: '{{tools}} · 想{{thinking}} 干{{tooling}}',
56
+ en: '{{tools}} · thought {{thinking}} worked {{tooling}}',
57
+ },
58
+ 'tool-count-one': { zh: '{{count}} 工具', en: '{{count}} tool' },
59
+ 'tool-count-many': { zh: '{{count}} 工具', en: '{{count}} tools' },
60
+ } as const
61
+
62
+ export type I18nKey = keyof typeof dict
63
+ export type I18nParams = Record<string, string | number>
64
+
65
+ /** Explicit pin set by plugin config or tests; `auto` restores the chain. */
66
+ let override: Lang | 'auto' = 'auto'
67
+
68
+ /** Force (or release) the active language; `auto` re-enables the chain. */
69
+ export function setLangOverride(lang: Lang | 'auto'): void {
70
+ override = lang
71
+ }
72
+
73
+ /** The currently active language. */
74
+ export function langNow(): Lang {
75
+ if (override !== 'auto') return override
76
+ const env = process.env.DSH_TUI_LANG
77
+ if (env === 'zh' || env === 'en') return env
78
+ return readLangFile() ?? detectLocaleLang()
79
+ }
80
+
81
+ /** Translate a dictionary key, substituting `{{name}}` placeholders. */
82
+ export function t(key: I18nKey, params: I18nParams = {}): string {
83
+ const entry = dict[key] as { zh: string; en: string } | undefined
84
+ const template = entry?.[langNow()] ?? key
85
+ return template.replace(/\{\{(\w+)\}\}/g, (match, name: string) =>
86
+ name in params ? String(params[name]) : match,
87
+ )
88
+ }
89
+
90
+ /** Is a value a valid shipped language code? */
91
+ export function isLang(value: unknown): value is Lang {
92
+ return value === 'zh' || value === 'en'
93
+ }
94
+
95
+ /**
96
+ * Read the persisted dsh-tui language choice, mtime-cached so the per-tick
97
+ * status render never re-reads an unchanged file. `undefined` when the file
98
+ * is absent or holds no valid `{ lang }` value.
99
+ */
100
+ export function readLangFile(): Lang | undefined {
101
+ let mtimeMs: number
102
+ try {
103
+ mtimeMs = statSync(LANG_FILE).mtimeMs
104
+ } catch {
105
+ return undefined
106
+ }
107
+ if (cachedMtime === mtimeMs) return cachedLang
108
+ try {
109
+ const parsed: unknown = JSON.parse(readFileSync(LANG_FILE, 'utf8'))
110
+ const lang = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
111
+ ? (parsed as Record<string, unknown>).lang
112
+ : undefined
113
+ cachedLang = isLang(lang) ? lang : undefined
114
+ } catch {
115
+ cachedLang = undefined
116
+ }
117
+ cachedMtime = mtimeMs
118
+ return cachedLang
119
+ }
120
+
121
+ let cachedMtime = -1
122
+ let cachedLang: Lang | undefined
123
+
124
+ /**
125
+ * Guess the language from the OS locale (`LC_ALL`, `LC_MESSAGES`, `LANG`),
126
+ * defaulting to `zh`. POSIX/C means "no locale selected" and conventionally
127
+ * maps to English (what CI runners report). An absent locale variable
128
+ * (typical on Windows) defaults to `zh`.
129
+ */
130
+ export function detectLocaleLang(): Lang {
131
+ const raw =
132
+ process.env.LC_ALL ||
133
+ process.env.LC_MESSAGES ||
134
+ process.env.LANG ||
135
+ ''
136
+ const locale = raw.split('.')[0]?.toLowerCase() ?? ''
137
+ if (locale.startsWith('zh')) return 'zh'
138
+ if (locale.startsWith('en')) return 'en'
139
+ if (locale === 'c' || locale === 'posix') return 'en'
140
+ return 'zh'
141
+ }