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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
69
packages/cli/src/i18n/__tests__/i18n.test.tsx
Normal file
69
packages/cli/src/i18n/__tests__/i18n.test.tsx
Normal 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("加载中…");
|
||||
});
|
||||
});
|
||||
78
packages/cli/src/i18n/index.ts
Normal file
78
packages/cli/src/i18n/index.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user