dsh-oc-tui 0.2.0 → 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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. package/skills/mcp-import/SKILL.md +216 -216
package/lib/i18n.js CHANGED
@@ -1,283 +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
- }
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
+ }
package/lib/image.js CHANGED
@@ -320,7 +320,8 @@ export async function renderImage(source, { protocol, maxCellsW, maxCellsH, cell
320
320
  return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'iterm2', b64, bytes: png.length, pxW, pxH: bandH } }
321
321
  }
322
322
  if (protocol === 'sixel') {
323
- return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'sixel', s: encodeSixel(data, pxW, bandH) } }
323
+ const png = await sharp(data, { raw: { width: pxW, height: bandH, channels: 4 } }).png().toBuffer()
324
+ return { protocol, cellsW, cellsH: rows, hash, payload: { kind: 'sixel', s: encodeSixel(data, pxW, bandH), png } }
324
325
  }
325
326
  const segLines = renderHalfblock(data, pxW, bandH, cellsW, rows, { bgHex })
326
327
  return { protocol: 'halfblock', cellsW, cellsH: rows, hash, segLines }