feat(cli): add terminal-UI i18n runtime and upgrade Ink to 7 (U6)

Add a synchronous Node-side i18next instance built from the generated
@fusion/i18n CLI catalog map (no async backend, first frame localized), with
locale precedence --lang flag -> GlobalSettings.language -> env (LC_ALL/LANG/..)
-> en. Wrap the Ink DashboardApp render in <I18nextProvider> and thread a
--lang flag through runDashboard.

Upgrade ink 6.8 -> 7.0 (native CJK double-width measurement) and raise the
react/@types/react peer floor to ^19.2.0. A spike test confirms react-i18next
works under Ink's custom reconciler: localized first frame + re-render on
changeLanguage (including CJK), retiring the KTD1 unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 08:28:52 -07:00
parent 677fb9c46c
commit 454b6cd0e9
7 changed files with 255 additions and 103 deletions

View File

@@ -60,13 +60,13 @@
"dockerode": "^4.0.12",
"express": "^5.1.0",
"i18next": "^26.3.1",
"ink": "^6.8.0",
"ink": "^7.0.5",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.6.0",
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.0.0",
"react": "^19.2.0",
"react-i18next": "^17.0.8"
},
"peerDependencies": {
@@ -93,7 +93,7 @@
"@fusion/pi-claude-cli": "workspace:*",
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react": "^19.2.0",
"@vitest/coverage-v8": "^3.1.0",
"cross-env": "^7.0.0",
"esbuild": "^0.25.12",

View File

@@ -733,7 +733,9 @@ async function main() {
const noAuth = args.includes("--no-auth");
const dashTokenIdx = args.indexOf("--token");
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host, noAuth, token });
const dashLangIdx = args.indexOf("--lang");
const lang = dashLangIdx !== -1 && dashLangIdx + 1 < args.length ? args[dashLangIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host, noAuth, token, lang });
break;
}

View File

@@ -157,6 +157,9 @@ export class DashboardTUI {
// will reapply the auto policy.
mouseEnabled: boolean = false;
// Optional `--lang` override; highest-precedence locale source for the TUI.
lang?: string;
constructor() {
this.logBuffer = new LogRingBuffer();
}
@@ -669,6 +672,20 @@ export class DashboardTUI {
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp } = await import("./app.js");
const { I18nextProvider } = await import("react-i18next");
const { initCliI18n, resolveCliLocale } = await import("../../i18n/index.js");
const { GlobalSettingsStore } = await import("@fusion/core");
// Resolve locale: --lang flag → persisted GlobalSettings → env → en.
let settingLanguage: string | undefined;
try {
settingLanguage = (await new GlobalSettingsStore().getSettings())?.language;
} catch {
// Settings unreadable — fall back to env/default.
}
const i18n = initCliI18n(
resolveCliLocale({ flag: this.lang, setting: settingLanguage, env: process.env }),
);
// Enter the terminal's alternate-screen buffer before mounting Ink so
// the TUI gets a dedicated fullscreen surface that doesn't share
@@ -683,7 +700,7 @@ export class DashboardTUI {
}
this.inkInstance = render(
createElement(DashboardApp, { controller: this }),
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })),
);
// Mouse mode must be enabled AFTER Ink mounts (which calls

View File

@@ -686,7 +686,7 @@ async function resolveDashboardAuthToken(opts: { noAuth?: boolean; token?: strin
return tokenManager.generateToken();
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string; lang?: string } = {}) {
// Default to localhost so the dashboard (and its shell-capable terminal API)
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
const selectedHost = opts.host ?? "127.0.0.1";
@@ -752,6 +752,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (isTTY) {
tui = new DashboardTUI();
tui.lang = opts.lang;
void startupUpdateStatusPromise.then((updateStatus) => {
tui?.setUpdateStatus(updateStatus);
});

View File

@@ -0,0 +1,69 @@
import { Text } from "ink";
import { render } from "ink-testing-library";
import { createElement } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import { describe, expect, it } from "vitest";
import { cliI18n, detectEnvLocale, initCliI18n, resolveCliLocale } from "../index.js";
describe("detectEnvLocale", () => {
it("parses POSIX locale env values to a supported locale", () => {
expect(detectEnvLocale({ LANG: "fr_FR.UTF-8" })).toBe("fr");
expect(detectEnvLocale({ LC_ALL: "es_ES.UTF-8" })).toBe("es");
expect(detectEnvLocale({ LANG: "zh_CN.UTF-8" })).toBe("zh-CN");
expect(detectEnvLocale({ LANG: "zh_TW" })).toBe("zh-TW");
});
it("honors precedence LC_ALL > LC_MESSAGES > LANG > LANGUAGE", () => {
expect(detectEnvLocale({ LC_ALL: "fr_FR", LANG: "es_ES" })).toBe("fr");
expect(detectEnvLocale({ LC_MESSAGES: "es_ES", LANG: "fr_FR" })).toBe("es");
});
it("falls back to a bare language and undefined for unsupported", () => {
expect(detectEnvLocale({ LANG: "zh" })).toBe("zh-CN");
expect(detectEnvLocale({ LANG: "de_DE.UTF-8" })).toBeUndefined();
expect(detectEnvLocale({})).toBeUndefined();
});
});
describe("resolveCliLocale precedence", () => {
it("flag overrides setting and env", () => {
expect(resolveCliLocale({ flag: "zh-TW", setting: "fr", env: { LANG: "es_ES" } })).toBe("zh-TW");
});
it("setting overrides env", () => {
expect(resolveCliLocale({ setting: "fr", env: { LANG: "es_ES" } })).toBe("fr");
});
it("env used when no flag/setting", () => {
expect(resolveCliLocale({ env: { LANG: "es_ES.UTF-8" } })).toBe("es");
});
it("defaults to en", () => {
expect(resolveCliLocale({ env: {} })).toBe("en");
expect(resolveCliLocale({ flag: "de", env: {} })).toBe("en");
});
});
// The load-bearing spike: react-i18next must work under Ink's custom reconciler.
function Loading() {
const { t } = useTranslation("cli");
return createElement(Text, null, t("tui.loading", "Loading…"));
}
describe("react-i18next under the Ink reconciler", () => {
it("renders a localized first frame synchronously", () => {
const i18n = initCliI18n("en");
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(Loading)),
);
expect(lastFrame()).toContain("Loading…");
});
it("re-renders on changeLanguage", async () => {
const i18n = initCliI18n("en");
cliI18n.addResourceBundle("zh-CN", "cli", { tui: { loading: "加载中…" } }, true, true);
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(Loading)),
);
expect(lastFrame()).toContain("Loading…");
await i18n.changeLanguage("zh-CN");
expect(lastFrame()).toContain("加载中…");
});
});

View File

@@ -0,0 +1,78 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from "@fusion/core";
import {
baseInitOptions,
CLI_NAMESPACES,
cliResources,
DEFAULT_NAMESPACE,
} from "@fusion/i18n";
import i18next, { type i18n as I18nInstance, type Resource } from "i18next";
import { initReactI18next } from "react-i18next";
/**
* Terminal-UI i18next instance.
*
* Unlike the dashboard, the CLI bundles all catalogs statically (via the
* generated @fusion/i18n cli map) and initializes synchronously (inline
* resources, no async backend) so the very first rendered Ink frame is
* already localized — no flash of untranslated keys.
*/
/**
* Parse a POSIX locale environment value (e.g. `fr_FR.UTF-8`, `zh_CN`,
* `zh-Hant`) into a supported {@link Locale}, or undefined when none matches.
*/
export function detectEnvLocale(env: NodeJS.ProcessEnv = process.env): Locale | undefined {
const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE;
if (!raw) return undefined;
// Strip encoding/modifier (`.UTF-8`, `@euro`) and normalize `_` to `-`.
const code = raw.split(/[.:@\s]/)[0].replace("_", "-");
if (isLocale(code)) return code;
const lang = code.split("-")[0].toLowerCase();
if (isLocale(lang)) return lang;
// A bare `zh` resolves to Simplified, mirroring the shared fallback chain.
if (lang === "zh") return "zh-CN";
return undefined;
}
/**
* Resolve the active CLI locale with precedence:
* `--lang flag → persisted GlobalSettings.language → environment → en`.
*/
export function resolveCliLocale(opts: {
flag?: string | undefined;
setting?: string | undefined;
env?: NodeJS.ProcessEnv;
} = {}): Locale {
const { flag, setting, env = process.env } = opts;
if (isLocale(flag)) return flag;
if (isLocale(setting)) return setting;
return detectEnvLocale(env) ?? DEFAULT_LOCALE;
}
let initialized = false;
/** Initialize (synchronously) or switch the CLI i18next instance to `locale`. */
export function initCliI18n(locale: Locale): I18nInstance {
if (!initialized) {
void i18next.use(initReactI18next).init({
...baseInitOptions(),
lng: locale,
ns: [...CLI_NAMESPACES],
defaultNS: DEFAULT_NAMESPACE,
// Inline resources + no async backend => init completes synchronously,
// so the first rendered Ink frame is already localized (i18next v26
// dropped the old `initImmediate` flag; this is now the default for
// backend-less, resource-inlined init).
resources: cliResources as unknown as Resource,
react: { useSuspense: false },
});
initialized = true;
} else if (i18next.language !== locale) {
void i18next.changeLanguage(locale);
}
return i18next;
}
export { i18next as cliI18n };