dsh-oc-tui 0.1.4 → 0.2.0-pre.13

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/dsh-plugin.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "$schema": "https://raw.githubusercontent.com/Yan-Zero/dsh-std/main/packages/manifest/schema/dsh-plugin-0.15.schema.json",
3
- "manifestVersion": "0.15",
4
- "id": "io.github.rayafriandion.dsh-oc-tui",
5
- "name": "dsh-oc-tui",
6
- "version": "0.1.4",
7
- "license": "LGPL-3.0-or-later",
8
- "source": { "repository": "https://github.com/rayafriandion/dsh-oc-tui" },
9
- "facets": { "host": { "entry": "lib/facet.js", "apiVersion": "v1alpha1" } },
10
- "requires": {
11
- "contracts": [
12
- { "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "optional": true }
13
- ]
14
- },
15
- "permissions": [
16
- { "name": "storage.local.read", "scope": "io.github.rayafriandion.dsh-oc-tui",
17
- "reason": "Read TUI settings from the host settings store." },
18
- { "name": "storage.local.write", "scope": "io.github.rayafriandion.dsh-oc-tui",
19
- "reason": "Persist TUI settings chosen in the Settings pages." }
20
- ],
21
- "contributes": {
22
- "x-dev.dsh-std.extensions": [
23
- { "id": "io.github.rayafriandion.dsh-oc-tui.settings",
24
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "settings",
25
- "spec": { "title": "Open the TUI settings pages",
26
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
27
- { "id": "io.github.rayafriandion.dsh-oc-tui.help",
28
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "help",
29
- "spec": { "title": "Show the TUI key and command reference",
30
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
31
- { "id": "io.github.rayafriandion.dsh-oc-tui.stats",
32
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "stats",
33
- "spec": { "title": "Show session token and cache statistics",
34
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
35
- { "id": "io.github.rayafriandion.dsh-oc-tui.new",
36
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "new",
37
- "spec": { "title": "Start a new session",
38
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
39
- { "id": "io.github.rayafriandion.dsh-oc-tui.resume",
40
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "resume",
41
- "spec": { "title": "Resume a persisted session",
42
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
43
- { "id": "io.github.rayafriandion.dsh-oc-tui.clear",
44
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "clear",
45
- "spec": { "title": "Clear the transcript",
46
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
47
- { "id": "io.github.rayafriandion.dsh-oc-tui.cancel",
48
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "cancel",
49
- "spec": { "title": "Cancel the running turn",
50
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
51
- { "id": "io.github.rayafriandion.dsh-oc-tui.rewind",
52
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "rewind",
53
- "spec": { "title": "Rewind the session to an earlier point",
54
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
55
- { "id": "io.github.rayafriandion.dsh-oc-tui.quit",
56
- "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "quit",
57
- "spec": { "title": "Leave the TUI", "aliases": ["exit"],
58
- "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } }
59
- ]
60
- },
61
- "overrides": [
62
- { "target": "@deepseek-ai/dsh-base", "kind": "patch",
63
- "description": "cordis.patch.yml inserts the tui-startup, tui-app, agent-presets and tool-ask-user rows." }
64
- ]
65
- }
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/Yan-Zero/dsh-std/main/packages/manifest/schema/dsh-plugin-0.15.schema.json",
3
+ "manifestVersion": "0.15",
4
+ "id": "io.github.rayafriandion.dsh-oc-tui",
5
+ "name": "dsh-oc-tui",
6
+ "version": "0.2.0-pre.13",
7
+ "license": "LGPL-3.0-or-later",
8
+ "source": { "repository": "https://github.com/rayafriandion/dsh-oc-tui" },
9
+ "facets": { "host": { "entry": "lib/facet.js", "apiVersion": "v1alpha1" } },
10
+ "requires": {
11
+ "contracts": [
12
+ { "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "optional": true }
13
+ ]
14
+ },
15
+ "permissions": [
16
+ { "name": "storage.local.read", "scope": "io.github.rayafriandion.dsh-oc-tui",
17
+ "reason": "Read TUI settings from the host settings store." },
18
+ { "name": "storage.local.write", "scope": "io.github.rayafriandion.dsh-oc-tui",
19
+ "reason": "Persist TUI settings chosen in the Settings pages." }
20
+ ],
21
+ "contributes": {
22
+ "x-dev.dsh-std.extensions": [
23
+ { "id": "io.github.rayafriandion.dsh-oc-tui.settings",
24
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "settings",
25
+ "spec": { "title": "Open the TUI settings pages",
26
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
27
+ { "id": "io.github.rayafriandion.dsh-oc-tui.help",
28
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "help",
29
+ "spec": { "title": "Show the TUI key and command reference",
30
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
31
+ { "id": "io.github.rayafriandion.dsh-oc-tui.stats",
32
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "stats",
33
+ "spec": { "title": "Show session token and cache statistics",
34
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
35
+ { "id": "io.github.rayafriandion.dsh-oc-tui.new",
36
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "new",
37
+ "spec": { "title": "Start a new session",
38
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
39
+ { "id": "io.github.rayafriandion.dsh-oc-tui.resume",
40
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "resume",
41
+ "spec": { "title": "Resume a persisted session",
42
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
43
+ { "id": "io.github.rayafriandion.dsh-oc-tui.clear",
44
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "clear",
45
+ "spec": { "title": "Clear the transcript",
46
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
47
+ { "id": "io.github.rayafriandion.dsh-oc-tui.cancel",
48
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "cancel",
49
+ "spec": { "title": "Cancel the running turn",
50
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
51
+ { "id": "io.github.rayafriandion.dsh-oc-tui.rewind",
52
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "rewind",
53
+ "spec": { "title": "Rewind the session to an earlier point",
54
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } },
55
+ { "id": "io.github.rayafriandion.dsh-oc-tui.quit",
56
+ "apiVersion": "commands.dsh/v1alpha1", "kind": "Command", "name": "quit",
57
+ "spec": { "title": "Leave the TUI", "aliases": ["exit"],
58
+ "placements": [{ "apiVersion": "tui.dsh/v1alpha1", "kind": "CommandLine" }] } }
59
+ ]
60
+ },
61
+ "overrides": [
62
+ { "target": "@deepseek-ai/dsh-base", "kind": "patch",
63
+ "description": "cordis.patch.yml inserts the tui-startup, tui-app, agent-presets and tool-ask-user rows." }
64
+ ]
65
+ }
package/lib/caps.js ADDED
@@ -0,0 +1,83 @@
1
+ // Terminal capability probing. Every probe is an escape sequence the *client*
2
+ // terminal answers on stdin, so detection works unchanged over SSH where
3
+ // environment variables do not propagate. Env vars serve only as hints.
4
+ // A probe run that resolves with no graphics support at all is a valid
5
+ // outcome: the caller falls back to ANSI halfblock rendering, which needs no
6
+ // terminal support beyond truecolor.
7
+ export function parseDA1(body) {
8
+ const params = body.replace(/^\?/, '').split(';').map(Number)
9
+ return { sixel: params.includes(4) }
10
+ }
11
+
12
+ export function parseCellSize(body) {
13
+ const m = /^8;(\d+);(\d+)$/.exec(body)
14
+ if (!m) return null
15
+ const h = Number(m[1])
16
+ const w = Number(m[2])
17
+ if (!h || !w) return null
18
+ return { h, w }
19
+ }
20
+
21
+ // The XTVERSION reply names the terminal, optionally with a version. Used both
22
+ // for aliases (WezTerm also speaks OSC 1337) and for version-gated features
23
+ // (Windows Terminal grew sixel in 1.22).
24
+ export function parseTerminalVersion(name) {
25
+ const m = /(\d+)\.(\d+)/.exec(name ?? '')
26
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null
27
+ }
28
+
29
+ const DEFAULT_CELL = { w: 8, h: 16 }
30
+
31
+ // Probe the terminal and resolve once every answer had its chance to arrive
32
+ // (or the timeout expired — terminals that answer nothing stay text-only).
33
+ // Listening on the same decoded key stream as the app guarantees the replies
34
+ // can never be mistaken for user input, whatever arrives interleaved.
35
+ export async function probeCapabilities(term, { timeoutMs = 600 } = {}) {
36
+ const caps = {
37
+ kitty: false,
38
+ iterm2: false,
39
+ sixel: false,
40
+ cellW: DEFAULT_CELL.w,
41
+ cellH: DEFAULT_CELL.h,
42
+ name: '',
43
+ resolved: false,
44
+ }
45
+ const onKey = (key) => {
46
+ if (key?.name !== 'terminal-reply') return
47
+ const { kind, body } = key.reply
48
+ if (kind === 'da1') caps.sixel = parseDA1(body).sixel
49
+ else if (kind === 'apc' && body.startsWith('Gi=31')) {
50
+ // Any graphics-protocol answer counts, but an explicit "not supported"
51
+ // error code does not (some VTE forks echo the query with ENOSYS).
52
+ caps.kitty = !body.includes('ENOSYS') && !body.includes('ENOTSUP')
53
+ } else if (kind === 'dcs' && body.startsWith('>|')) caps.name = body.slice(2)
54
+ else if (kind === 'windowops') {
55
+ const c = parseCellSize(body)
56
+ if (c) { caps.cellW = c.w; caps.cellH = c.h }
57
+ }
58
+ }
59
+ term.on('key', onKey)
60
+ term.write('\x1b[c') // DA1: attribute list (sixel = "4")
61
+ term.write('\x1b[>0q') // XTVERSION: name + version
62
+ term.write('\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\') // kitty graphics query
63
+ term.write('\x1b[16t') // cell size in pixels
64
+ await new Promise((resolve) => setTimeout(resolve, timeoutMs))
65
+ term.off('key', onKey)
66
+ if (caps.name) {
67
+ if (/\bwezterm\b/i.test(caps.name)) caps.iterm2 = true // WezTerm implements OSC 1337
68
+ if (/\bghostty\b/i.test(caps.name)) caps.kitty = true
69
+ if (/\bwindows terminal\b/i.test(caps.name)) {
70
+ const v = parseTerminalVersion(caps.name)
71
+ if (v && (v.major > 1 || (v.major === 1 && v.minor >= 22))) caps.sixel = true
72
+ }
73
+ }
74
+ // Environment hints: honest only when local (they do not survive SSH), and
75
+ // only able to confirm support the probes could not see, never to deny it.
76
+ const env = process.env
77
+ if (env.TERM_PROGRAM === 'iTerm.app') caps.iterm2 = true
78
+ if (env.TERM_PROGRAM === 'WezTerm' || env.WEZTERM_EXECUTABLE) caps.iterm2 = true
79
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') caps.kitty = true
80
+ if (env.GHOSTTY_RESOURCES_DIR) caps.kitty = true
81
+ caps.resolved = true
82
+ return caps
83
+ }
package/lib/i18n.js ADDED
@@ -0,0 +1,283 @@
1
+ // The TUI's message catalog and locale resolution. Every user-facing string the
2
+ // TUI paints or toasts comes from here: `lib/locales/<id>.js` holds the
3
+ // translations, English is the source catalog (and the fallback for any key a
4
+ // translation has not covered yet).
5
+ //
6
+ // A catalog is a flat `{ 'area.key': 'text' }` map. Interpolation uses `{name}`
7
+ // placeholders, and a plural entry is a family of keys suffixed with the CLDR
8
+ // plural categories: `key.one`, `key.other`, `key.few`, `key.many`, `key.zero`,
9
+ // `key.two`. `tn()` picks the category with Intl.PluralRules for the active
10
+ // locale, falling back to `key.other` and then to the base key.
11
+ import en from './locales/en.js'
12
+ import zhCN from './locales/zh-CN.js'
13
+ import zhTW from './locales/zh-TW.js'
14
+ import ja from './locales/ja.js'
15
+ import ko from './locales/ko.js'
16
+ import de from './locales/de.js'
17
+ import fr from './locales/fr.js'
18
+ import es from './locales/es.js'
19
+ import ptBR from './locales/pt-BR.js'
20
+ import ru from './locales/ru.js'
21
+ import it from './locales/it.js'
22
+ import tr from './locales/tr.js'
23
+ import vi from './locales/vi.js'
24
+ import id from './locales/id.js'
25
+ import pl from './locales/pl.js'
26
+ import nl from './locales/nl.js'
27
+ import ar from './locales/ar.js'
28
+
29
+ // The languages offered by Settings -> General -> Language, in the order the
30
+ // picker lists them. `native` is what the row shows (a user looking for their
31
+ // own language finds it in its own script); `english` is only used by the docs
32
+ // and the tests. `bcp47` feeds Intl (plural rules, number formatting).
33
+ export const LOCALES = Object.freeze([
34
+ { id: 'en', native: 'English', english: 'English', bcp47: 'en' },
35
+ { id: 'zh-CN', native: '简体中文', english: 'Simplified Chinese', bcp47: 'zh-Hans' },
36
+ { id: 'zh-TW', native: '繁體中文', english: 'Traditional Chinese', bcp47: 'zh-Hant' },
37
+ { id: 'ja', native: '日本語', english: 'Japanese', bcp47: 'ja' },
38
+ { id: 'ko', native: '한국어', english: 'Korean', bcp47: 'ko' },
39
+ { id: 'de', native: 'Deutsch', english: 'German', bcp47: 'de' },
40
+ { id: 'fr', native: 'Français', english: 'French', bcp47: 'fr' },
41
+ { id: 'es', native: 'Español', english: 'Spanish', bcp47: 'es' },
42
+ { id: 'pt-BR', native: 'Português (Brasil)', english: 'Portuguese (Brazil)', bcp47: 'pt-BR' },
43
+ { id: 'ru', native: 'Русский', english: 'Russian', bcp47: 'ru' },
44
+ { id: 'it', native: 'Italiano', english: 'Italian', bcp47: 'it' },
45
+ { id: 'tr', native: 'Türkçe', english: 'Turkish', bcp47: 'tr' },
46
+ { id: 'vi', native: 'Tiếng Việt', english: 'Vietnamese', bcp47: 'vi' },
47
+ { id: 'id', native: 'Bahasa Indonesia', english: 'Indonesian', bcp47: 'id' },
48
+ { id: 'pl', native: 'Polski', english: 'Polish', bcp47: 'pl' },
49
+ { id: 'nl', native: 'Nederlands', english: 'Dutch', bcp47: 'nl' },
50
+ { id: 'ar', native: 'العربية', english: 'Arabic', bcp47: 'ar' },
51
+ ])
52
+
53
+ const CATALOGS = {
54
+ en,
55
+ 'zh-CN': zhCN,
56
+ 'zh-TW': zhTW,
57
+ ja,
58
+ ko,
59
+ de,
60
+ fr,
61
+ es,
62
+ 'pt-BR': ptBR,
63
+ ru,
64
+ it,
65
+ tr,
66
+ vi,
67
+ id,
68
+ pl,
69
+ nl,
70
+ ar,
71
+ }
72
+
73
+ const LOCALE_IDS = LOCALES.map((entry) => entry.id)
74
+ const LOCALE_BY_ID = new Map(LOCALES.map((entry) => [entry.id, entry]))
75
+ // Lookup is case-insensitive (`pt-br` from `pt_BR`, `zh-tw` from `zh-TW`), but
76
+ // the value handed back is always the catalog's own spelling.
77
+ const LOCALE_ID_BY_LOWER = new Map(LOCALES.map((entry) => [entry.id.toLowerCase(), entry.id]))
78
+
79
+ // Aliases a locale can arrive under: POSIX env values (zh_CN.UTF-8), the
80
+ // WebUI's two-letter ids (zh), and regional variants that have no catalog of
81
+ // their own (en-US, pt-PT, es-419...).
82
+ const ALIASES = {
83
+ zh: 'zh-CN',
84
+ 'zh-hans': 'zh-CN',
85
+ 'zh-sg': 'zh-CN',
86
+ 'zh-hant': 'zh-TW',
87
+ 'zh-hk': 'zh-TW',
88
+ 'zh-mo': 'zh-TW',
89
+ pt: 'pt-BR',
90
+ in: 'id',
91
+ 'pt-pt': 'pt-BR',
92
+ 'en-us': 'en',
93
+ 'en-gb': 'en',
94
+ 'es-419': 'es',
95
+ }
96
+
97
+ const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other']
98
+
99
+ // English until the plugin resolves the configured language at boot. Tests and
100
+ // modules that never boot the plugin therefore render English.
101
+ let currentLocale = 'en'
102
+ const pluralRules = new Map()
103
+
104
+ function normalizeId(raw) {
105
+ if (typeof raw !== 'string') return null
106
+ const value = raw.trim().replace(/_/g, '-').toLowerCase()
107
+ if (value === '' || value === 'system' || value === 'auto' || value === 'c' || value === 'posix') return null
108
+ const exact = LOCALE_ID_BY_LOWER.get(value)
109
+ if (exact) return exact
110
+ if (ALIASES[value]) return ALIASES[value]
111
+ // `ja-JP`, `de-DE`, `ru-RU`: the bare language id is the catalog.
112
+ const base = value.split('-')[0]
113
+ const byBase = LOCALE_ID_BY_LOWER.get(base)
114
+ if (byBase) return byBase
115
+ if (ALIASES[base]) return ALIASES[base]
116
+ return null
117
+ }
118
+
119
+ // The POSIX/Windows language variables, most specific first. `DSH_TUI_LOCALE`
120
+ // wins so a single run can be pinned without touching the settings file.
121
+ export function detectSystemLocale(env = process.env) {
122
+ for (const name of ['DSH_TUI_LOCALE', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG']) {
123
+ const value = env?.[name]
124
+ if (typeof value !== 'string' || value.trim() === '') continue
125
+ // LANGUAGE can be a colon-separated priority list.
126
+ for (const part of value.split(':')) {
127
+ const id = normalizeId(part)
128
+ if (id) return id
129
+ }
130
+ }
131
+ // Windows exports none of the above: its locale lives in the OS regional
132
+ // settings, which Node's ICU default reflects. Only the real environment is
133
+ // consulted — a caller that passes an explicit table is asking about that
134
+ // table, not about this machine.
135
+ if (env !== process.env) return null
136
+ try {
137
+ return normalizeId(Intl.DateTimeFormat().resolvedOptions().locale)
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+
143
+ // Resolve a stored preference ('system' or a locale id) to a catalog id.
144
+ export function resolveLocale(preference, env = process.env) {
145
+ if (preference !== undefined && preference !== null) {
146
+ const direct = normalizeId(preference)
147
+ if (direct) return direct
148
+ }
149
+ return detectSystemLocale(env) ?? 'en'
150
+ }
151
+
152
+ export function isLocaleId(id) {
153
+ return typeof id === 'string' && LOCALE_BY_ID.has(id)
154
+ }
155
+
156
+ export function localeInfo(id) {
157
+ return LOCALE_BY_ID.get(id) ?? LOCALE_BY_ID.get('en')
158
+ }
159
+
160
+ export function getLocale() {
161
+ return currentLocale
162
+ }
163
+
164
+ // Switch the active catalog. Returns the id actually in effect (an unknown id
165
+ // falls back to English instead of throwing — a stale settings value must not
166
+ // take the TUI down).
167
+ export function setLocale(id) {
168
+ const next = normalizeId(id) ?? 'en'
169
+ currentLocale = next
170
+ return currentLocale
171
+ }
172
+
173
+ function interpolate(template, params) {
174
+ if (!params) return template
175
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
176
+ const value = params[name]
177
+ return value === undefined || value === null ? match : String(value)
178
+ })
179
+ }
180
+
181
+ function lookup(catalog, key) {
182
+ const value = catalog?.[key]
183
+ return typeof value === 'string' ? value : undefined
184
+ }
185
+
186
+ // The message for `key` in the active locale. A missing translation falls back
187
+ // to English; a key missing from both returns the key itself, which makes an
188
+ // uncovered string visible in the UI instead of painting "undefined".
189
+ export function t(key, params) {
190
+ const catalog = CATALOGS[currentLocale] ?? en
191
+ const message = lookup(catalog, key) ?? lookup(en, key) ?? key
192
+ return interpolate(message, params)
193
+ }
194
+
195
+ function categoryFor(localeId, count) {
196
+ const info = localeInfo(localeId)
197
+ let rules = pluralRules.get(info.bcp47)
198
+ if (!rules) {
199
+ try {
200
+ rules = new Intl.PluralRules(info.bcp47)
201
+ } catch {
202
+ rules = new Intl.PluralRules('en')
203
+ }
204
+ pluralRules.set(info.bcp47, rules)
205
+ }
206
+ try {
207
+ return rules.select(count)
208
+ } catch {
209
+ return 'other'
210
+ }
211
+ }
212
+
213
+ // A pluralized message: `tn('transcript.turns', 3, { count: 3 })` selects
214
+ // `transcript.turns.other` (or `.one`, `.few`... per the locale's CLDR rules).
215
+ // The base key itself is the last fallback so a catalog may store a single
216
+ // form for languages that do not inflect.
217
+ export function tn(key, count, params) {
218
+ const catalog = CATALOGS[currentLocale] ?? en
219
+ const category = categoryFor(currentLocale, count)
220
+ const candidates = [key + '.' + category, key + '.other', key]
221
+ for (const candidate of candidates) {
222
+ const message = lookup(catalog, candidate) ?? lookup(en, candidate)
223
+ if (message !== undefined) return interpolate(message, { count, n: count, ...params })
224
+ }
225
+ return key
226
+ }
227
+
228
+ // Stored ids whose label key is not simply `value.<id>`.
229
+ const VALUE_KEY_ALIASES = { 'text only': 'value.textOnly', 'text + image': 'value.textImage' }
230
+
231
+ // The label of a stored choice value (`auto`, `queue`, `halfblock`...). A value
232
+ // with no catalog entry is shown verbatim, which is what keeps ids such as a
233
+ // preset name or a provider id readable — and what lets the technical values
234
+ // (`halfblock`, `chip`, `stdio`, `http`, `sse`) stay untranslated on purpose.
235
+ export function tv(value) {
236
+ const id = String(value)
237
+ const catalog = CATALOGS[currentLocale] ?? en
238
+ for (const key of [VALUE_KEY_ALIASES[id] ?? 'value.' + id, 'common.' + id]) {
239
+ const message = lookup(catalog, key) ?? lookup(en, key)
240
+ if (message !== undefined) return message
241
+ }
242
+ return id
243
+ }
244
+
245
+ // Choice rows for a settings field: translated labels, unchanged values.
246
+ export function valueChoices(values) {
247
+ return values.map((value) => {
248
+ const label = tv(value)
249
+ return label === String(value) ? value : { label, value }
250
+ })
251
+ }
252
+
253
+ // The keys a catalog is missing relative to English (used by the i18n tests and
254
+ // by translators to check their work).
255
+ export function catalogGaps(localeId) {
256
+ const catalog = CATALOGS[localeId]
257
+ if (!catalog) return null
258
+ return Object.keys(en).filter((key) => typeof catalog[key] !== 'string')
259
+ }
260
+
261
+ // Extra keys a catalog carries that English does not know about (a typo in a
262
+ // translation file, or a stale key left behind by an English rename). A CLDR
263
+ // plural form English happens not to use (`ru` needs `.few`/`.many`, `ar` also
264
+ // `.zero`/`.two`) is not an extra: it is a form of a family English does define.
265
+ export function catalogExtras(localeId) {
266
+ const catalog = CATALOGS[localeId]
267
+ if (!catalog) return null
268
+ return Object.keys(catalog).filter((key) => {
269
+ if (typeof en[key] === 'string') return false
270
+ const dot = key.lastIndexOf('.')
271
+ if (dot < 0) return true
272
+ if (!PLURAL_CATEGORIES.includes(key.slice(dot + 1))) return true
273
+ return typeof en[key.slice(0, dot) + '.other'] !== 'string'
274
+ })
275
+ }
276
+
277
+ export function catalogKeys() {
278
+ return Object.keys(en)
279
+ }
280
+
281
+ export function hasLocale(id) {
282
+ return Object.hasOwn(CATALOGS, id)
283
+ }