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/README.md +179 -28
- package/bin/apply-update.cjs +187 -0
- package/bin/mcp-sse-bridge.mjs +499 -0
- package/cordis.patch.yml +46 -32
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +560 -424
- package/dsh-plugin.json +65 -65
- package/lib/caps.js +83 -0
- package/lib/i18n.js +283 -0
- package/lib/image.js +326 -0
- package/lib/index.js +980 -185
- package/lib/locales/ar.js +561 -0
- package/lib/locales/de.js +530 -0
- package/lib/locales/en.js +548 -0
- package/lib/locales/es.js +530 -0
- package/lib/locales/fr.js +530 -0
- package/lib/locales/id.js +532 -0
- package/lib/locales/it.js +530 -0
- package/lib/locales/ja.js +530 -0
- package/lib/locales/ko.js +530 -0
- package/lib/locales/nl.js +530 -0
- package/lib/locales/pl.js +544 -0
- package/lib/locales/pt-BR.js +530 -0
- package/lib/locales/ru.js +544 -0
- package/lib/locales/tr.js +530 -0
- package/lib/locales/vi.js +532 -0
- package/lib/locales/zh-CN.js +531 -0
- package/lib/locales/zh-TW.js +531 -0
- package/lib/markdown.js +560 -297
- package/lib/mcp-skill.js +93 -0
- package/lib/mcp.js +838 -0
- package/lib/mermaid-ascii.js +138 -0
- package/lib/mermaid.js +137 -0
- package/lib/rewind.js +15 -14
- package/lib/splash-opencode.js +108 -0
- package/lib/startup.js +16 -10
- package/lib/term.js +242 -10
- package/lib/ui.js +1265 -406
- package/lib/updates.js +1028 -697
- package/lib/util.js +85 -27
- package/lib/web-settings.js +846 -454
- package/package.json +145 -100
- package/skills/mcp-import/SKILL.md +216 -0
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.
|
|
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
|
+
}
|