diff --git a/AGENTS.md b/AGENTS.md index 8d3bf21..cc70462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,91 @@ The way to land there on Windows is **Git Bash**: MSYS2/mintty pipes stdin throu **The `Boolean` in `useCanType` is load-bearing.** Node leaves `isTTY` **undefined** on a pipe rather than setting it false, while `useInput` skips raw mode only on `options.isActive === false` — a strict comparison. Forwarding the raw `undefined` reads as "active" and throws the very error the gate exists to prevent. No unit test can catch it, since `ink-testing-library`'s fake stdin sets a real boolean; it was found by running the built CLI with `< /dev/null`, which is the only way to reproduce it. `tests/lib/tty.test.ts` pins `toBe(false)` rather than falsiness for that reason. **Do the same for any new prompt: gate on `useCanType()`, and smoke-test it with piped stdin, not only under vitest.** +## Localization + +`src/lib/i18n.ts` plus `src/lib/locales/{en,vi}.ts` render the CLI in English or +Vietnamese. `en.ts` is the source of truth; every other catalog is typed +`Record`, so **a key added to `en.ts` and not to `vi.ts` is a +`pnpm typecheck` failure** — that type annotation *is* the completeness +guarantee, and it is the reason the catalogs are `.ts` modules rather than JSON. +They are statically imported, so esbuild inlines them and `build.ts` needs no +change (nothing outside `dist/` ships). + +**Resolution is lazy and memoized on first `t()` call — deliberately not an +`initLocale()` the dispatcher calls.** ESM imports evaluate before `index.tsx`'s +body runs, so any module-level string would read the locale before an explicit +init could set it; the Node-version gate at the top of `index.tsx` fires before +argv is even destructured and still calls `t()` safely. Every input is an +environment variable, fixed before the process starts, so there is no ordering +hazard at all. This is also why **there is no `--lang` flag**: argv would put +that hazard back, and a stray `--lang` would have to be stripped before +`parsePullArgs` (which errors on unknown flags) and before the passthrough +`default:` case forwards argv verbatim to the `codev` agent. + +Precedence: `CODEV_LANG` → `LC_ALL` → `LC_MESSAGES` → `LANG` → `Intl` (the +Windows path, where `LANG` is normally unset) → `en`. Each spelling is +normalized **independently** — a plain `??` chain over raw values would let an +exported-but-empty `LC_ALL` mask a good `LANG`, the same trap `lib/proxy.ts` +documents for `HTTP_PROXY`/`http_proxy`. Unshipped values fall *through* to the +next source rather than pinning themselves. `resetLocaleCache()` is the test +hook, alongside `resetLogging()` / `resetModelLimitsCache()`. + +**Scope is UI only, and the boundary is load-bearing rather than laziness.** +Translated: `src/components/*`, the `src/*App.tsx` roots, `help.ts`, and +`index.tsx`'s console output. Still English: all `src/lib/` diagnostic prose +(`doctor.ts`, `tty.ts`, `proxy.ts`, `remove.ts`, `shims.ts`, `upload.ts`, +`restore.ts`, `logs.ts`), the markdown export prose in `tool-render.ts` / +`markdown.ts` / `providers/*` (archival structure — keep exports diffable), and +every log message. The reason to leave lib alone is concrete: +`upload.ts#isRefreshableError` matches on `msg.includes("Missing supabase_")` +and `/failed \((\d{3})\)/` — **English message text** produced by `const.ts` and +the `backend.ts` / `auth.ts` throwers. Translating lib prose silently breaks the +upload retry. Replace that coupling with typed error codes *before* any +lib-prose round. + +Rules for new strings: + +- **Never translate** brand names (`CoDev Code`, `Claude Code`, `VS Code`), + command/flag names, env var names, provider and model ids, URLs, status-union + literals, server-side role/status values (`ADMIN`, `DRAFT`, `PUBLIC`), the + `[Y/n]` / `(y/N)` letters (they are matched against typed input), or the + control-flow `new Error("aborted")` sentinels the Ink apps throw to force a + non-zero exit. +- **No sentence assembly from grammatical fragments.** `TaskList` used to take + `verb: {infinitive, present, past}` and substitute into English word order; + nothing outside English conjugates that way, so it is now a `TaskVerb` id + selecting complete per-state messages. Same for `toolSelectTitle`, which is + one full sentence per mode rather than a verb dropped into a shared frame. +- **List joins go through `formatList` / `formatListParts`** (`Intl.ListFormat`), + never hand-written `", and "`. `formatListParts` exists so `Confirm` can style + the items and the separators differently. `text.ts#formatToolList` is now a + thin delegate kept for the non-UI callers. +- **Plurals** are explicit `_one` / `_other` pairs read through + `tCount`. Vietnamese does not inflect, so both halves match there; that is + intentional, not a copy-paste slip. No plural-rules engine until a locale with + a real `few`/`many` category arrives. +- **No module-level resolved strings.** A `const` label is correct at runtime + (the locale never changes mid-process) but freezes before `resetLocaleCache()` + can reach it in a test. Hold message *keys* at module level and call `t()` + during render — see `ToolSelect`, `AdminLogin`, `ManualCredentials`, + `DoctorApp`'s `GROUP_TITLE_KEYS`, `SkillPushApp`'s `STEP_LABEL_KEYS`. +- **Width gutters derive from the active locale.** `CheckList`'s diagnosis + labels and both credential forms size their column from `t(...)` rather than a + hard-coded constant, and render it as an Ink `` rather than + `String.padEnd` — Yoga measures display width, `padEnd` counts UTF-16 code + units. `.length` is accurate while the shipped locales are Latin-script + (Vietnamese is precomposed NFC and single-width); a CJK locale would need a + real `stringWidth()`, which Ink already carries transitively. +- **Don't shadow `t`.** `tools.map((t) => …)` in a file that imports `t` compiles + fine and is a live trap for the next edit; use `tool`, `task`, `target`. + +`vitest.config.ts` pins `env: { CODEV_LANG: "en" }`. Hundreds of assertions +match English literals, and without the pin a developer whose machine is +`LANG=vi_VN` gets a red suite for no reason. Tests that exercise another locale +stub `CODEV_LANG` themselves and call `resetLocaleCache()`. +`tests/lib/locales.test.ts` covers what types cannot see: blank values, +mismatched `{placeholder}` sets between locales, and half-declared plurals. + ## Diagnostic logging `~/.codev-hub` has two log homes — don't mix them up: diff --git a/README.md b/README.md index 413dea0..dd2d988 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,32 @@ npm uninstall -g codev-ai Then restart your terminal. +## Language + +The CLI speaks **English** and **Vietnamese**. It picks the language from your +operating system automatically, so on a Vietnamese machine there is nothing to +configure. + +To choose explicitly, set `CODEV_LANG`: + +```bash +CODEV_LANG=vi codevhub install # one command +export CODEV_LANG=vi # every command in this shell +``` + +Accepted values are `vi` and `en`, in any of the usual spellings (`vi`, +`vi-VN`, `vi_VN.UTF-8`). Anything unrecognized falls back to English rather than +erroring. Without `CODEV_LANG`, the standard `LC_ALL` / `LC_MESSAGES` / `LANG` +variables are consulted in that order, then the OS locale. + +Two things stay in English on purpose: + +- **Diagnostic prose** — `codevhub doctor`'s findings, network error + explanations and remediation steps. These are the messages most often pasted + into a ticket or a search box, and keeping one wording makes them matchable. +- **Names you type or that identify things** — command and flag names, agent and + model names, config keys, and the diagnostic log. + ## Diagnostic logs Every codevhub command appends a structured diagnostic log to `~/.codev-hub/logs/codev-YYYYMMDD.ndjson` — one [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html) JSON document per line. If a command misbehaves, this file shows what actually happened: each network request with its status and duration, every child process with its exit code and stderr tail, step-by-step flow progress, and any crash with a stack trace. diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index ff02e25..c7af3bf 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -35,6 +35,7 @@ import { startCommandRecording, writeDoctorReport, } from "@/lib/doctor.js"; +import { type MessageKey, t } from "@/lib/i18n.js"; import { logDebug, type RequestRecord } from "@/lib/log.js"; import type { CommandRecord } from "@/lib/npm.js"; import { readProxyEnv } from "@/lib/proxy.js"; @@ -50,13 +51,15 @@ type Phase = | "state" | "done"; -const GROUP_TITLES: Record = { - environment: "Environment", - network: "Network", - account: "Account & credentials", - llm: "LLM access", - state: "This machine", -}; +// Message keys, not resolved titles — a Record of strings here would freeze +// the English text at import time. +const GROUP_TITLE_KEYS = { + environment: "doctor.group.environment", + network: "doctor.group.network", + account: "doctor.group.account", + llm: "doctor.group.llm", + state: "doctor.group.state", +} as const satisfies Record; const GROUP_CHECKS: Record = { environment: ENVIRONMENT_CHECKS, @@ -346,8 +349,8 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {phase === "preparing" && ( - Signing out previous session}> - Revoking tokens... + {t("login.signing_out")}}> + {t("login.revoking")} )} @@ -357,7 +360,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {GROUP_TITLES[group]}} + title={{t(GROUP_TITLE_KEYS[group])}} > @@ -395,7 +398,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {GROUP_TITLES[group]}} + title={{t(GROUP_TITLE_KEYS[group])}} > @@ -409,13 +412,13 @@ export function DoctorApp({ force = false }: DoctorAppProps) { the numbered next steps and the report path. A list of every command and endpoint printed after those would scroll them away. */} {phase === "done" && ( - Activity}> + {t("doctor.step.activity")}}> )} {phase === "done" && ( - Result}> + {t("doctor.step.result")}}> {failed === 0 && warned === 0 && ( - - {"✓ Everything checks out. You're ready to run `codevhub install`."} - + {t("doctor.summary.ok")} )} {failed === 0 && warned > 0 && ( - - {`▲ ${warned} warning(s). \`codevhub install\` should work, but read the notes below first.`} - + {t("doctor.summary.warned", { warned })} )} {failed > 0 && ( // Name the warnings too: the Next steps list below numbers failures // and warnings together, so a bare "5 failed" above 7 numbered // items reads as a contradiction. - {`✗ ${failed} check(s) failed${ - warned > 0 ? `, ${warned} warning(s)` : "" - }. Fix these before running \`codevhub install\`.`} + {warned > 0 + ? t("doctor.summary.failed_with_warnings", { failed, warned }) + : t("doctor.summary.failed", { failed })} )} {nextSteps.length > 0 && ( - {"Next steps"} + {t("doctor.next_steps")} {nextSteps.map((line, i) => ( {line} @@ -490,7 +489,7 @@ function Summary({ {reportPath && ( - {`Full report saved to ${abbreviateHome(reportPath)} — attach it to a support ticket.`} + {t("doctor.report_saved", { path: abbreviateHome(reportPath) })} )} diff --git a/src/LoginApp.tsx b/src/LoginApp.tsx index 5439062..c2bd98a 100644 --- a/src/LoginApp.tsx +++ b/src/LoginApp.tsx @@ -18,6 +18,7 @@ import { refreshCodevConfig, saveSkillhubCookie, } from "@/lib/auth.js"; +import { t } from "@/lib/i18n.js"; import { type SkillhubUser, skillhubSignIn } from "@/lib/skillhub.js"; import { describeNetworkError } from "@/lib/tls.js"; @@ -111,17 +112,22 @@ function AdminLoginApp({ let content: ReactNode; if (user) { content = ( - {`✓ Logged in as ${user.username} (${user.role})`} + + {t("login.admin.logged_in_as", { + username: user.username, + role: user.role, + })} + ); } else if (error) { - content = {`Login failed: ${error}`}; + content = {t("login.failed", { reason: error })}; } else if (nonInteractive) { content = ( - {" Signing in..."} + {` ${t("admin_login.signing_in")}`} ); } else { @@ -172,8 +178,8 @@ function SsoLoginApp({ force = false }: { force?: boolean }) { {phase === "preparing" && ( - Signing out previous session}> - Revoking tokens... + {t("login.signing_out")}}> + {t("login.revoking")} )} {phase !== "preparing" && ( diff --git a/src/ModelApp.tsx b/src/ModelApp.tsx index 157f6b8..9ac427c 100644 --- a/src/ModelApp.tsx +++ b/src/ModelApp.tsx @@ -25,9 +25,9 @@ import { type Tool, } from "@/lib/configure.js"; import { FALLBACK_MODEL } from "@/lib/const.js"; +import { formatList, t } from "@/lib/i18n.js"; import { logError, logInfo, logWarn } from "@/lib/log.js"; import { providerFromName } from "@/lib/provider.js"; -import { formatToolList } from "@/lib/text.js"; // ModelSelect handles its own /v1/models fetch — we don't duplicate it here. // The "model-choice" phase mounts ModelSelect (which shows its own spinner @@ -112,9 +112,7 @@ export function ModelApp() { if (!isInvalidKeyError(err)) return; logWarn("saved API key rejected by gateway; re-authenticating", { err }); if (reAuthed.current) { - setError( - `${err.message}\nRe-authentication did not produce a valid key. Run 'codevhub install' to refresh credentials.`, - ); + setError(`${err.message}\n${t("model.reauth_failed")}`); setPhase("failed"); return; } @@ -255,23 +253,23 @@ export function ModelApp() { - Loading saved credentials... + {` ${t("model.loading")}`} )} {phase === "no-creds" && ( - {"No CoDev credentials found. Run "} + {t("model.no_creds_prefix")} codevhub install - {" first."} + {t("model.run_install_suffix")} )} {phase === "no-tools" && ( - {"No CoDev-configured AI tools found. Run "} + {t("model.no_tools_prefix")} codevhub install - {" first."} + {t("model.run_install_suffix")} )} @@ -279,11 +277,7 @@ export function ModelApp() { phase === "re-auth-fetch-key" || phase === "re-auth-manual") && ( - - { - "Saved API key was rejected — refreshing credentials before continuing." - } - + {t("model.re_auth")} )} @@ -336,37 +330,42 @@ export function ModelApp() { {(phase === "configuring" || phase === "done") && ( Update tool configs} + title={{t("model.update_configs_title")}} > {phase === "configuring" && ( - Updating tool configs... + {` ${t("model.updating")}`} )} {phase === "done" && chosenModel && ( - {"Default model updated to "} + {t("model.updated_prefix")} {chosenModel} - {" for "} - {formatToolList(tools.map((t) => TOOL_LABEL[t]))} + {t("model.updated_middle")} + {formatList(tools.map((tool) => TOOL_LABEL[tool]))} {"."} {/* Their configs carry no model pin (see configureOpenCodeKind), so a model already selected in-CLI keeps winning there — say so rather than let the line above imply otherwise. */} - {tools.some((t) => t === "opencode" || t === "codev-code") && ( + {tools.some( + (tool) => tool === "opencode" || tool === "codev-code", + ) && ( - {"In "} - {formatToolList( + {t("model.opencode_prefix")} + {formatList( tools - .filter((t) => t === "opencode" || t === "codev-code") - .map((t) => TOOL_LABEL[t]), + .filter( + (tool) => + tool === "opencode" || tool === "codev-code", + ) + .map((tool) => TOOL_LABEL[tool]), )} - {", switch models anytime with /models."} + {t("model.opencode_suffix")} )} diff --git a/src/RemoveApp.tsx b/src/RemoveApp.tsx index 925811a..b4eb556 100644 --- a/src/RemoveApp.tsx +++ b/src/RemoveApp.tsx @@ -2,6 +2,7 @@ import { Box, Text, useApp } from "ink"; import Spinner from "ink-spinner"; import { useCallback, useEffect, useRef, useState } from "react"; import { YesNo } from "@/components/YesNo.js"; +import { t, tCount } from "@/lib/i18n.js"; import { type RemoveResult, runRemove } from "@/lib/remove.js"; type Phase = "confirm" | "running" | "done" | "aborted"; @@ -37,6 +38,8 @@ export function RemoveApp({ setResult({ steps: [ { + // Sits alongside step labels produced by lib/remove.ts, which are + // still English — translating only this one would split the list. label: "Remove", detail: err instanceof Error ? err.message : String(err), status: "failed", @@ -72,8 +75,7 @@ export function RemoveApp({ return ( - Everything will be reverted to the pre-CoDev state. Do you want to - proceed? + {t("remove.confirm")} @@ -83,7 +85,7 @@ export function RemoveApp({ } if (phase === "aborted") { - return Abort.; + return {t("remove.aborted")}; } if (phase === "running" || !result) { @@ -92,7 +94,7 @@ export function RemoveApp({ - Removing CoDev components... + {` ${t("remove.running")}`} ); } @@ -113,9 +115,7 @@ export function RemoveApp({ result.keptPaths.length > 0 ? ( - Kept {result.keptPaths.length} config file - {result.keptPaths.length === 1 ? "" : "s"} CoDev didn't write — your - own settings were left untouched: + {tCount("remove.kept", result.keptPaths.length)} {result.keptPaths.map((p) => ( @@ -130,7 +130,7 @@ export function RemoveApp({ return ( {warningRows} - ✗ Some steps failed: + {t("remove.some_failed")} {failures.map((s) => ( - {s.label}: {s.detail} @@ -145,9 +145,9 @@ export function RemoveApp({ {warningRows} - {"Removed successfully. You can now run "} + {t("remove.success_prefix")} npm uninstall -g codev-ai - {" to remove the CoDev package. Restart your terminal to apply."} + {t("remove.success_suffix")} {keptHint} diff --git a/src/SetupApp.tsx b/src/SetupApp.tsx index 887eb37..8ac6f62 100644 --- a/src/SetupApp.tsx +++ b/src/SetupApp.tsx @@ -62,6 +62,7 @@ import { PREFLIGHT_CHECKS, runChecks, } from "@/lib/doctor.js"; +import { t } from "@/lib/i18n.js"; import { logApiKeyConfigured, logDebug, logError, logWarn } from "@/lib/log.js"; import { providerFromName } from "@/lib/provider.js"; import { installRipgrep } from "@/lib/ripgrep.js"; @@ -284,7 +285,7 @@ export function SetupApp({ mode }: SetupAppProps) { const handleConfirmProceed = useCallback( (proceed: boolean) => { if (!proceed) { - process.stderr.write("Abort.\n"); + process.stderr.write(`${t("setup.abort")}\n`); exit(new Error("aborted")); return; } @@ -306,13 +307,13 @@ export function SetupApp({ mode }: SetupAppProps) { if (ok) { setExistingValid(true); } else { - setExistingMessage( - "Saved API key is no longer valid; choose another method.", - ); + setExistingMessage(t("setup.saved_key.invalid")); } }) .catch((err: Error) => { - setExistingMessage(`Could not verify saved API key: ${err.message}`); + setExistingMessage( + t("setup.saved_key.unverifiable", { error: err.message }), + ); }) .finally(() => { setStep("key-choice"); @@ -489,7 +490,10 @@ export function SetupApp({ mode }: SetupAppProps) { extra: { fallback_model: FALLBACK_MODEL }, }); setModelWarning( - `Couldn't fetch the model list (${err.message}); using fallback model ${FALLBACK_MODEL}.`, + t("setup.model_list.fallback", { + error: err.message, + model: FALLBACK_MODEL, + }), ); }, []); @@ -606,9 +610,9 @@ export function SetupApp({ mode }: SetupAppProps) { ? installRipgrep().catch((err: unknown) => { logError("ripgrep staging failed during finalize", { err }); setRipgrepWarning( - `Could not stage ripgrep for CoDev Code: ${ - err instanceof Error ? err.message : String(err) - }. File search may be empty on Windows — install ripgrep (winget install BurntSushi.ripgrep.MSVC) and restart the agent.`, + t("setup.ripgrep.failed", { + error: err instanceof Error ? err.message : String(err), + }), ); }) : Promise.resolve(); @@ -648,7 +652,7 @@ export function SetupApp({ mode }: SetupAppProps) { {preflight.length > 0 && ( Checking your environment} + title={{t("setup.preflight.title")}} > {/* A clean environment costs one line; only warnings expand. `codevhub doctor` shows every row. */} @@ -659,11 +663,7 @@ export function SetupApp({ mode }: SetupAppProps) { collapsePasses /> {preflight.some((o) => o.status !== "pass") && ( - - { - "Run `codevhub doctor` for the full check — npm, network, sign-in and LLM access — plus setup instructions." - } - + {t("setup.preflight.hint")} )} )} @@ -714,9 +714,11 @@ export function SetupApp({ mode }: SetupAppProps) { active={step === "installing"} title={ - {mode === "install" - ? "Installing packages" - : "Installing CodeGraph"} + {t( + mode === "install" + ? "setup.installing.packages" + : "setup.installing.codegraph", + )} } > @@ -728,7 +730,7 @@ export function SetupApp({ mode }: SetupAppProps) { )} {POST_REFRESH.includes(step) && refreshWarning && ( - Refresh CoDev config}> + {t("setup.refresh.title")}}> {` ${refreshWarning}`} @@ -738,19 +740,19 @@ export function SetupApp({ mode }: SetupAppProps) { {POST_REFRESH.includes(step) && savedCreds && ( Checking saved API key} + title={{t("setup.saved_key.title")}} > {step === "validating-existing" ? ( - Verifying with gateway... + {` ${t("setup.saved_key.verifying")}`} ) : ( {existingValid - ? "Saved API key is valid." + ? t("setup.saved_key.valid") : (existingMessage ?? "")} )} @@ -794,7 +796,7 @@ export function SetupApp({ mode }: SetupAppProps) { {POST_KEY_CHOICE.includes(step) && authMethod !== "skip" && modelWarning && ( - Model list}> + {t("setup.model_list.title")}}> {` ${modelWarning}`} @@ -824,14 +826,18 @@ export function SetupApp({ mode }: SetupAppProps) { {POST_VERIFY_GATEWAY.includes(step) && creds && ( Verifying gateway access} + title={{t("setup.gateway.title")}} > {step === "verifying-gateway" ? ( - {` Sending a test request to ${chosenModel ?? "the model"}…`} + + {` ${t("setup.gateway.sending", { + model: chosenModel ?? t("setup.gateway.the_model"), + })}`} + ) : smokeWarning ? ( @@ -839,14 +845,10 @@ export function SetupApp({ mode }: SetupAppProps) { {` ${smokeWarning}`} - - { - "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch." - } - + {t("setup.gateway.warning_hint")} ) : ( - {"Gateway accepted a test request."} + {t("setup.gateway.ok")} )} )} @@ -876,31 +878,35 @@ export function SetupApp({ mode }: SetupAppProps) { codegraphResult?.status !== "skipped" && ( Set up CodeGraph} + title={{t("setup.codegraph.title")}} > {codegraphResult === null ? ( - Setting up CodeGraph… + {` ${t("setup.codegraph.running")}`} ) : codegraphResult.status === "warning" ? ( - {` ${codegraphResult.message ?? "CodeGraph setup did not complete."}`} + {` ${codegraphResult.message ?? t("setup.codegraph.incomplete")}`} ) : ( - {`Wired CodeGraph into ${formatCodegraphTargets(codegraphResult.targets)}.`} + {t("setup.codegraph.wired", { + targets: formatCodegraphTargets( + codegraphResult.targets, + ), + })} )} )} {(step === "finalizing" || step === "done") && ripgrepWarning && ( - File search}> + {t("setup.ripgrep.title")}}> {` ${ripgrepWarning}`} diff --git a/src/SkillPullApp.tsx b/src/SkillPullApp.tsx index ebb5e67..34c6136 100644 --- a/src/SkillPullApp.tsx +++ b/src/SkillPullApp.tsx @@ -5,6 +5,7 @@ import { Banner } from "@/components/Banner.js"; import { Frame } from "@/components/Frame.js"; import { Step } from "@/components/Step.js"; import { useCanType } from "@/components/useCanType.js"; +import { t } from "@/lib/i18n.js"; import { stripControlChars } from "@/lib/sanitize.js"; import { AGENT_LABELS, @@ -41,10 +42,14 @@ type Phase = | "done" | "error"; -const LOCATIONS: { key: InstallLocation; label: string }[] = [ - { key: "current", label: "Current directory (recommended)" }, - { key: "global", label: "Global" }, -]; +// Labels resolved per render rather than frozen at import time. +const LOCATIONS = [ + { + key: "current" as InstallLocation, + labelKey: "skill_pull.location.current", + }, + { key: "global" as InstallLocation, labelKey: "skill_pull.location.global" }, +] as const; export function SkillPullApp({ target, @@ -127,10 +132,7 @@ export function SkillPullApp({ // unlike the ungated case this is a silent hang, not a throw. useEffect(() => { if ((phase !== "select" && phase !== "agents") || canType) return; - setError( - "This terminal cannot supply keystrokes, so the install prompts cannot be shown.\n" + - "Pass --here, --global, or --dir to choose a location without them.", - ); + setError(t("skill_pull.no_keyboard")); setPhase("error"); finish(false); }, [phase, canType, finish]); @@ -186,7 +188,9 @@ export function SkillPullApp({ // Sanitize the hub-sourced name before rendering it to the terminal. const skillName = meta ? stripControlChars(meta.name) : null; - const title = skillName ? `Install ${skillName} skill` : "Install skill"; + const title = skillName + ? t("skill_pull.title", { name: skillName }) + : t("skill_pull.title_generic"); return ( @@ -198,22 +202,24 @@ export function SkillPullApp({ - {" Resolving skill..."} + {` ${t("skill_pull.resolving")}`} )} {phase === "select" && meta && ( - {`Install ${skillName} to:`} + + {t("skill_pull.install_to", { name: skillName ?? "" })} + {LOCATIONS.map((loc, i) => ( - {`${i === index ? "❯ " : " "}${loc.label}`} + {`${i === index ? "❯ " : " "}${t(loc.labelKey)}`} ))} )} {phase === "agents" && ( - For which agents? + {t("skill_pull.which_agents")} {SKILL_AGENTS.map((agent, i) => { const locked = agent === ALWAYS_AGENT; const checked = locked || picked.has(agent); @@ -227,7 +233,7 @@ export function SkillPullApp({ ); })} - space toggles · enter confirms + {t("skill_pull.toggle_hint")} )} {phase === "installing" && ( @@ -235,7 +241,7 @@ export function SkillPullApp({ - {" Installing..."} + {` ${t("skill_pull.installing")}`} )} {phase === "done" && result && ( diff --git a/src/SkillPushApp.tsx b/src/SkillPushApp.tsx index e64129f..349aa75 100644 --- a/src/SkillPushApp.tsx +++ b/src/SkillPushApp.tsx @@ -7,6 +7,7 @@ import { Login, loginTitle } from "@/components/Login.js"; import { Step } from "@/components/Step.js"; import { useCanType } from "@/components/useCanType.js"; import { type AuthData, loadAuth } from "@/lib/auth.js"; +import { type MessageKey, t, tCount } from "@/lib/i18n.js"; import { formatPublishResult, type PublishArchive, @@ -40,12 +41,14 @@ type Phase = | "error"; type StepState = "pending" | "running" | "done" | "failed"; -const STEP_LABELS: Record = { - upload: "Uploading", - metadata: "Saving metadata", - submit: "Submitting for review", - approve: "Approving (admin)", -}; +// Message keys rather than resolved labels: a Record of strings here would +// freeze the English text at import time. +const STEP_LABEL_KEYS = { + upload: "skill_push.step.uploading", + metadata: "skill_push.step.saving", + submit: "skill_push.step.submitting", + approve: "skill_push.step.approving", +} as const satisfies Record; // Marker glyph/color for a non-running step ("running" renders a spinner // instead, so its entries here are unused placeholders). @@ -71,11 +74,9 @@ function formatBytes(n: number): string { } function actionSummary(opts: PublishOpts): string { - if (opts.draftOnly) return "Save as a DRAFT (not submitted)."; - if (opts.autoApprove) { - return "Upload, submit, and auto-approve to PUBLIC (admin only)."; - } - return "Upload and submit for review."; + if (opts.draftOnly) return t("skill_push.mode.draft"); + if (opts.autoApprove) return t("skill_push.mode.auto_approve"); + return t("skill_push.mode.submit"); } export function SkillPushApp({ @@ -185,10 +186,7 @@ export function SkillPushApp({ // user and an upload. useEffect(() => { if (phase !== "confirm" || canType) return; - setError( - "This terminal cannot supply keystrokes, so the confirmation prompt cannot be shown.\n" + - "Re-run with --json to publish without confirming.", - ); + setError(t("skill_push.no_keyboard")); setPhase("error"); finish(false); }, [phase, canType, finish]); @@ -246,21 +244,22 @@ export function SkillPushApp({ login and publishing so the user always sees what they're shipping. */} Publish skill to the hub} + title={{t("skill_push.title")}} > {phase === "preparing" ? ( - {" Preparing archive..."} + {` ${t("skill_push.preparing")}`} ) : archive ? ( - {`${archive.fileName} (${archive.files.length} file${ - archive.files.length === 1 ? "" : "s" - }, ${formatBytes(archive.totalBytes)})`} + {tCount("skill_push.archive", archive.files.length, { + fileName: archive.fileName, + size: formatBytes(archive.totalBytes), + })} {archive.files.slice(0, MAX_PREVIEW_FILES).map((f) => ( @@ -269,19 +268,24 @@ export function SkillPushApp({ ))} {archive.files.length > MAX_PREVIEW_FILES && ( - {` … and ${archive.files.length - MAX_PREVIEW_FILES} more`} + {t("skill_push.and_more", { + count: archive.files.length - MAX_PREVIEW_FILES, + })} )} {archive.skipped.length > 0 && ( - {`Excluded: ${archive.skipped.join(", ")}`} + {t("skill_push.excluded", { + list: archive.skipped.join(", "), + })} )} {actionSummary(opts)} {phase === "confirm" && ( - Publish this skill? (y/N) + {`${t("skill_push.confirm")} `} + (y/N) )} @@ -306,13 +310,15 @@ export function SkillPushApp({ - {" Checking sign-in..."} + {` ${t("skill_push.checking_signin")}`} ) : phase === "login" ? ( ) : ( - {`✓ Signed in${loginEmail ? ` as ${loginEmail}` : ""}`} + {loginEmail + ? t("login.signed_in_as", { email: loginEmail }) + : t("login.signed_in")} )} @@ -322,7 +328,7 @@ export function SkillPushApp({ {(phase === "publishing" || phase === "done" || started) && ( Publishing} + title={{t("skill_push.publishing")}} > {steps.map((step) => { const state = stepState[step]; @@ -343,7 +349,7 @@ export function SkillPushApp({ ? "gray" : undefined } - >{` ${STEP_LABELS[step]}`} + >{` ${t(STEP_LABEL_KEYS[step])}`} ); })} @@ -359,7 +365,7 @@ export function SkillPushApp({ {phase === "cancelled" && ( - Cancelled. + {t("skill_push.cancelled")} )} {phase === "error" && error && ( diff --git a/src/UpdateApp.tsx b/src/UpdateApp.tsx index 85fe793..009a0fc 100644 --- a/src/UpdateApp.tsx +++ b/src/UpdateApp.tsx @@ -4,7 +4,7 @@ import { Banner } from "@/components/Banner.js"; import { Frame } from "@/components/Frame.js"; import { Step } from "@/components/Step.js"; import { Update } from "@/components/Update.js"; -import { HAPPY_CODING, HELP_HINT } from "@/lib/const.js"; +import { t } from "@/lib/i18n.js"; type Phase = "running" | "success" | "fail"; @@ -29,13 +29,13 @@ export function UpdateApp() { - Updating packages}> + {t("update.title")}}> {phase === "success" && ( - {HELP_HINT} + {t("common.hint.help")} - {HAPPY_CODING} + {t("common.happy_coding")} )} diff --git a/src/UploadApp.tsx b/src/UploadApp.tsx index 1129579..7871c72 100644 --- a/src/UploadApp.tsx +++ b/src/UploadApp.tsx @@ -3,6 +3,7 @@ import Spinner from "ink-spinner"; import { useEffect, useRef, useState } from "react"; import { PasteBackPrompt, usePasteBack } from "@/components/PasteBack.js"; import { useCanType } from "@/components/useCanType.js"; +import { t } from "@/lib/i18n.js"; import { runUpload, type UploadSummary } from "@/lib/upload.js"; type Phase = "running" | "done" | "error"; @@ -11,7 +12,7 @@ export function UploadApp({ force = false }: { force?: boolean }) { const { exit } = useApp(); const canType = useCanType(); const [phase, setPhase] = useState("running"); - const [status, setStatus] = useState("Uploading logs..."); + const [status, setStatus] = useState(t("upload.uploading")); const [loginUrl, setLoginUrl] = useState(null); const [summary, setSummary] = useState(null); const [error, setError] = useState(null); @@ -64,9 +65,7 @@ export function UploadApp({ force = false }: { force?: boolean }) { {loginUrl && !paste.submitting && ( - - {"If the browser didn't open, visit this URL manually:"} - + {t("upload.browser_url")} {loginUrl} {/* Without raw mode the hook ignores keystrokes (lib/tty.ts), so the field would be inert. The URL above still completes sign-in @@ -78,11 +77,7 @@ export function UploadApp({ force = false }: { force?: boolean }) { submitting={paste.submitting} /> ) : ( - - { - "This terminal can't accept keyboard input — finish sign-in in the browser." - } - + {t("upload.no_keyboard")} )} )} @@ -93,8 +88,8 @@ export function UploadApp({ force = false }: { force?: boolean }) { if (phase === "error") { return ( - ✗ Upload failed - {error ?? "unknown error"} + {t("upload.failed")} + {error ?? t("tasklist.unknown_error")} ); } @@ -107,23 +102,20 @@ export function UploadApp({ force = false }: { force?: boolean }) { const targets = summary.targets ?? []; return ( - No conversations found for this project. + {t("upload.none_found")} {targets.length > 0 && ( - codevhub looked in: - {targets.map((t) => ( - + {t("upload.looked_in")} + {targets.map((target) => ( + {" • "} - {t.agent}: {t.path} + {target.agent}: {target.path} ))} )} - - If you used an AI agent here, make sure you launched it from this - directory. - + {t("upload.launch_hint")} ); @@ -131,23 +123,30 @@ export function UploadApp({ force = false }: { force?: boolean }) { return ( 0 ? "yellow" : "green"}> - ✓ Uploaded {summary.uploaded}/{summary.found} conversation logs + {t("upload.uploaded", { + uploaded: summary.uploaded, + found: summary.found, + })} - Skipped {summary.skipped} unchanged logs + {t("upload.skipped", { count: summary.skipped })} {summary.failed > 0 && ( - Failed {summary.failed} logs: + + {t("upload.failed_logs", { count: summary.failed })} + {summary.errors.slice(0, 5).map((err) => ( - {err.file}: {err.message} ))} {summary.errors.length > 5 && ( - (+{summary.errors.length - 5} more) + + {t("upload.more", { count: summary.errors.length - 5 })} + )} )} - Source: {summary.outDir} + {t("upload.source", { dir: summary.outDir })} ); } diff --git a/src/components/ActivityLog.tsx b/src/components/ActivityLog.tsx index 1dbdb56..98cfa25 100644 --- a/src/components/ActivityLog.tsx +++ b/src/components/ActivityLog.tsx @@ -1,4 +1,5 @@ import { Box, Text } from "ink"; +import { t } from "@/lib/i18n.js"; import type { RequestRecord } from "@/lib/log.js"; import type { CommandRecord } from "@/lib/npm.js"; @@ -31,7 +32,7 @@ export function ActivityLog({ commands, requests }: ActivityLogProps) { {commands.length > 0 && ( - {"Commands run"} + {t("activity.commands")} {commands.map((c, i) => ( 0 && ( 0 ? 1 : 0}> - {"Endpoints contacted"} + {t("activity.endpoints")} {requests.map((r, i) => ( ))} diff --git a/src/components/AdminLogin.tsx b/src/components/AdminLogin.tsx index 4c3d921..1161bb9 100644 --- a/src/components/AdminLogin.tsx +++ b/src/components/AdminLogin.tsx @@ -2,6 +2,7 @@ import { Box, Text, useInput } from "ink"; import Spinner from "ink-spinner"; import { useCallback, useRef, useState } from "react"; import { saveSkillhubCookie } from "@/lib/auth.js"; +import { t } from "@/lib/i18n.js"; import { type SkillhubUser, skillhubSignIn } from "@/lib/skillhub.js"; import { describeNetworkError } from "@/lib/tls.js"; @@ -17,11 +18,20 @@ interface AdminLoginProps { type FieldKey = "username" | "password"; -const FIELDS: { key: FieldKey; label: string; mask: boolean }[] = [ - { key: "username", label: "Username", mask: false }, - { key: "password", label: "Password", mask: true }, -]; -const LABEL_WIDTH = Math.max(...FIELDS.map((f) => f.label.length)); +// Labels are looked up per render rather than baked into a module constant — +// see the same note in ManualCredentials. +const FIELDS = [ + { + key: "username" as FieldKey, + labelKey: "admin_login.field.username", + mask: false, + }, + { + key: "password" as FieldKey, + labelKey: "admin_login.field.password", + mask: true, + }, +] as const; // "input" accepts a fresh attempt (a failed sign-in below the cap drops // straight back here with the fields cleared); "failed" is the terminal state @@ -92,7 +102,7 @@ export function AdminLogin({ const raw = values[current.key]; const value = current.key === "username" ? raw.trim() : raw; if (!value) { - setError(`${current.label} is required`); + setError(t("common.field_required", { field: t(current.labelKey) })); return; } setError(null); @@ -133,23 +143,28 @@ export function AdminLogin({ - {" Signing in..."} + {` ${t("admin_login.signing_in")}`} ); } + const labelWidth = Math.max(...FIELDS.map((f) => t(f.labelKey).length)) + 2; + return ( {FIELDS.map((field, i) => { const isActive = phase === "input" && i === index; const value = values[field.key]; const shown = field.mask ? "•".repeat(value.length) : value; - const label = field.label.padEnd(LABEL_WIDTH, " "); return ( - - {`${label}: `} - + {/* rather than padEnd: Yoga measures display width, + String.padEnd counts UTF-16 code units. */} + + + {`${t(field.labelKey)}: `} + + {shown} {isActive && } @@ -159,22 +174,20 @@ export function AdminLogin({ {error} {phase === "input" && attempts > 0 && ( - {` (attempt ${attempts} of ${maxAttempts})`} + + {` ${t("admin_login.attempt", { n: attempts, max: maxAttempts })}`} + )} {phase === "failed" && ( - {` (${maxAttempts} failed attempts — giving up)`} + + {` ${t("admin_login.gave_up", { max: maxAttempts })}`} + )} )} {phase === "input" && ( - - { - "Only ADMIN/SUPERADMIN accounts can sign in here — regular users use `codevhub login`." - } - + {t("admin_login.only_admin")} )} @@ -182,5 +195,5 @@ export function AdminLogin({ } export function adminLoginTitle() { - return {"Admin login"}; + return {t("admin_login.title")}; } diff --git a/src/components/AuthMethod.tsx b/src/components/AuthMethod.tsx index d52ff12..478da02 100644 --- a/src/components/AuthMethod.tsx +++ b/src/components/AuthMethod.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; +import { t } from "@/lib/i18n.js"; export type AuthMethodChoice = "existing" | "new" | "manual" | "skip"; @@ -8,22 +9,19 @@ interface Option { value: AuthMethodChoice; } -const NEW_OPTION: Option = { - label: "Get a new API Key", - value: "new", -}; -const MANUAL_OPTION: Option = { - label: "I have my own API Key", - value: "manual", -}; -const EXISTING_OPTION: Option = { - label: "Reuse existing API Key", - value: "existing", -}; -const SKIP_OPTION: Option = { - label: "Skip configuration", - value: "skip", -}; +// Built per render rather than held in module-level constants. A `const` here +// would resolve its label at import time, which is correct in production (the +// locale comes from the environment and never changes mid-process) but freezes +// the English text before a test can call resetLocaleCache(). +function authOptions(hasExisting: boolean): Option[] { + const options: Option[] = [ + { label: t("auth_method.new"), value: "new" }, + { label: t("auth_method.manual"), value: "manual" }, + { label: t("auth_method.skip"), value: "skip" }, + ]; + if (!hasExisting) return options; + return [{ label: t("auth_method.existing"), value: "existing" }, ...options]; +} interface AuthMethodProps { onSelect: (choice: AuthMethodChoice) => void; @@ -38,9 +36,7 @@ export function AuthMethod({ selected = null, hasExisting = false, }: AuthMethodProps) { - const options: Option[] = hasExisting - ? [EXISTING_OPTION, NEW_OPTION, MANUAL_OPTION, SKIP_OPTION] - : [NEW_OPTION, MANUAL_OPTION, SKIP_OPTION]; + const options = authOptions(hasExisting); const [cursor, setCursor] = useState(0); useInput( @@ -81,8 +77,8 @@ export function AuthMethod({ export function configurationMethodTitle(readOnly = false) { return ( - {"Choose configuration method "} - {!readOnly && (↑/↓ to move, Enter to confirm)} + {`${t("auth_method.title")} `} + {!readOnly && {t("common.hint.move_confirm")}} ); } diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 02f6502..cfc6bcd 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import { VERSION } from "@/lib/const.js"; +import { t } from "@/lib/i18n.js"; import { terminalIsLight } from "@/lib/terminal-theme.js"; // CoDev Code's lowercase "codev" pixel wordmark (codev-code @@ -33,7 +34,7 @@ export function Banner() { ))} - {"AI Coding Agent Hub "} + {`${t("banner.tagline")} `} v{VERSION} diff --git a/src/components/CheckList.tsx b/src/components/CheckList.tsx index 40eab79..aa57c3f 100644 --- a/src/components/CheckList.tsx +++ b/src/components/CheckList.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import Spinner from "ink-spinner"; import type { CheckOutcome, CheckStatus, Diagnosis } from "@/lib/doctor.js"; +import { t, tCount } from "@/lib/i18n.js"; interface CheckListProps { /** Labels of checks that have not produced an outcome yet, in order. */ @@ -42,7 +43,9 @@ export function CheckList({ {collapsePasses && passes.length > 0 && ( - {` ${passes.length} environment checks passed`} + + {` ${tCount("checklist.env_passed", passes.length)}`} + )} {shown.map((outcome) => ( @@ -123,15 +126,38 @@ function CheckRow({ outcome }: { outcome: CheckOutcome }) { ); } -const LABEL_WIDTH = 15; +const FIELD_KEYS = [ + "checklist.field.what", + "checklist.field.cause", + "checklist.field.fix", + "checklist.field.context", + "checklist.field.raw", +] as const; + +/** + * Width of the diagnosis label gutter, derived from the active locale rather + * than the hard-coded 15 this used to be — every one of these labels is longer + * in some language than in English, and an under-sized `width` leaves the column + * ragged (the box has flexShrink={0}, so the label wraps inside it instead of + * overflowing). + * + * `.length` is the right measure while the shipped locales are Latin-script: + * Vietnamese is precomposed NFC and single-width. A CJK locale would need a real + * `stringWidth()` — Ink carries one transitively — since those characters are + * two cells wide. + */ +function labelWidth(): number { + return Math.max(...FIELD_KEYS.map((key) => t(key).length)) + 2; +} function Field({ name, children }: { name: string; children: string[] }) { if (children.length === 0) return null; + const width = labelWidth(); return ( {children.map((line, i) => ( - + {i === 0 ? name : ""} @@ -148,11 +174,13 @@ function Field({ name, children }: { name: string; children: string[] }) { function DiagnosisBlock({ diagnosis }: { diagnosis: Diagnosis }) { return ( - {[diagnosis.what]} - {[diagnosis.cause]} - {diagnosis.fix.split("\n")} - {diagnosis.context} - {diagnosis.raw} + {/* The field *labels* are translated; the diagnosis prose they carry + comes from lib/doctor.ts and is deliberately still English. */} + {[diagnosis.what]} + {[diagnosis.cause]} + {diagnosis.fix.split("\n")} + {diagnosis.context} + {diagnosis.raw} ); } diff --git a/src/components/Configure.tsx b/src/components/Configure.tsx index d4e2215..e3a3f52 100644 --- a/src/components/Configure.tsx +++ b/src/components/Configure.tsx @@ -13,6 +13,7 @@ import { kindForTool, type Tool, } from "@/lib/configure.js"; +import { t } from "@/lib/i18n.js"; import { logError, logInfo } from "@/lib/log.js"; interface ConfigureProps { @@ -91,7 +92,11 @@ export function Configure({ tools, creds, onDone }: ConfigureProps) { // its side-effects, but renders no rows — SetupApp hides the whole // Step on that path. Only the configure path emits visible output. if (creds !== null) { - setLogs(results.map((r) => `Configured ${LABEL[r.kind]}`)); + setLogs( + results.map((r) => + t("configure.configured", { tool: LABEL[r.kind] }), + ), + ); } setPhase("done"); // Hand off immediately. SetupApp's finalize Phase owns the visible @@ -116,11 +121,11 @@ export function Configure({ tools, creds, onDone }: ConfigureProps) { {logs.map((log, i) => ( {log} ))} - {error && {`Configure failed: ${error}`}} + {error && {t("configure.failed", { error })}} ); } export function configureTitle() { - return Configure tools; + return {t("configure.title")}; } diff --git a/src/components/Confirm.tsx b/src/components/Confirm.tsx index 95aac27..c0dc07b 100644 --- a/src/components/Confirm.tsx +++ b/src/components/Confirm.tsx @@ -2,6 +2,7 @@ import { Box, Text } from "ink"; import type { ReactNode } from "react"; import { YesNo } from "@/components/YesNo.js"; import { type BackupKind, kindForTool, type Tool } from "@/lib/configure.js"; +import { formatListParts, t } from "@/lib/i18n.js"; interface ConfirmProps { tools: Tool[]; @@ -24,33 +25,26 @@ const KIND_RESTORE_CMD: Record = { "continue-config": "codevhub restore continue", }; -// Render the restore-command list with cyan emphasis on each command, -// matching `formatToolList`'s Oxford-comma / "and" join rules: -// 1 → X -// 2 → X and Y -// 3+ → X, Y, and Z +// Render the restore-command list with cyan emphasis on each command. +// +// The separators come from `formatListParts` (Intl.ListFormat) rather than the +// hand-written " and " / ", and " / ", " ladder this used to carry: those rules +// are English's, and a locale that joins differently had no way to express it. +// Splitting into parts is what lets the commands stay cyan while the separators +// between them render plain. function renderCommandList(cmds: string[]): ReactNode { if (cmds.length === 0) return null; - const nodes: ReactNode[] = []; - // Cmds are deduped by BackupKind upstream, so each command string is - // unique within `cmds` and safe to use as a React key. Separators key - // off the trailing command to inherit that uniqueness. - cmds.forEach((cmd, i) => { - if (i > 0) { - const isLast = i === cmds.length - 1; - let sep: string; - if (cmds.length === 2) sep = " and "; - else if (isLast) sep = ", and "; - else sep = ", "; - nodes.push({sep}); - } - nodes.push( - - {cmd} - , - ); - }); - return nodes; + // Cmds are deduped by BackupKind upstream, so each command string is unique + // within `cmds`; part indices key the separators, which are not. + return formatListParts(cmds).map((part, i) => + part.type === "element" ? ( + + {part.value} + + ) : ( + {part.value} + ), + ); } export function Confirm({ tools, onConfirm, readOnly = false }: ConfirmProps) { @@ -69,7 +63,7 @@ export function Confirm({ tools, onConfirm, readOnly = false }: ConfirmProps) { {cmds.length > 0 && ( - {"To revert to your pre-CoDev state, run "} + {t("confirm.revert_prefix")} {renderCommandList(cmds)} {"."} @@ -86,7 +80,7 @@ export function Confirm({ tools, onConfirm, readOnly = false }: ConfirmProps) { export function confirmTitle() { return ( - {"Heads up — CoDev will change your settings."} + {t("confirm.title")} ); } diff --git a/src/components/EditorSelect.tsx b/src/components/EditorSelect.tsx index 41869ef..de0585d 100644 --- a/src/components/EditorSelect.tsx +++ b/src/components/EditorSelect.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; +import { t } from "@/lib/i18n.js"; // Editor-agnostic identifier returned from the sub-select. The InstallApp // handler maps these to extension-specific Tool values (e.g. @@ -79,9 +80,9 @@ export function EditorSelect({ export function editorSelectTitle(readOnly = false) { return ( - {"Select the editor(s) to install extensions in "} + {`${t("editor_select.title")} `} {!readOnly && ( - (↑/↓ to move, Space to select, Enter to confirm) + {t("common.hint.move_select_confirm")} )} ); diff --git a/src/components/FetchApiKey.tsx b/src/components/FetchApiKey.tsx index 2bf8d14..7dff227 100644 --- a/src/components/FetchApiKey.tsx +++ b/src/components/FetchApiKey.tsx @@ -5,6 +5,7 @@ import type { AuthData } from "@/lib/auth.js"; import { fetchApiKey } from "@/lib/backend.js"; import { BACKEND_URL } from "@/lib/const.js"; import { describeFailure } from "@/lib/doctor.js"; +import { t } from "@/lib/i18n.js"; interface FetchApiKeyProps { auth: AuthData; @@ -76,17 +77,17 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { - Fetching API key from gateway... + {` ${t("fetch_key.pending")}`} )} - {succeeded && ( - {"✓ API key obtained successfully."} - )} + {succeeded && {t("fetch_key.success")}} {error && ( <> {/* One-line reasons stay inline; a multi-line transport diagnosis keeps its structure on following lines. */} - {`Failed to fetch API key: ${error.split("\n")[0] ?? ""}`} + + {t("fetch_key.failed", { reason: error.split("\n")[0] ?? "" })} + {error .split("\n") .slice(1) @@ -95,23 +96,19 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { {line} ))} - {"Press Enter to retry, Ctrl-C to quit"} + {t("common.retry_hint")} )} {!pending && !error && emptyCount === 1 && ( <> - {"Gateway returned an empty API key."} - {"Press Enter to retry, Ctrl-C to quit"} + {t("fetch_key.empty")} + {t("common.retry_hint")} )} {!pending && !error && emptyCount >= 2 && ( <> - - {"Gateway returned an empty API key again."} - - - {"Press Enter to enter credentials manually, Ctrl-C to quit"} - + {t("fetch_key.empty_again")} + {t("fetch_key.manual_hint")} )} @@ -119,5 +116,5 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { } export function fetchApiKeyTitle() { - return {"Fetching new API Key"}; + return {t("fetch_key.title")}; } diff --git a/src/components/Install.tsx b/src/components/Install.tsx index 31a7fba..bd5dfbd 100644 --- a/src/components/Install.tsx +++ b/src/components/Install.tsx @@ -6,6 +6,7 @@ import { ensureCodegraphInstalled, } from "@/lib/codegraph.js"; import type { Tool } from "@/lib/configure.js"; +import { t } from "@/lib/i18n.js"; import { CLAUDE_CODE_INTELLIJ_PLUGIN_ID, CONTINUE_INTELLIJ_PLUGIN_ID, @@ -26,14 +27,13 @@ import { // downloading from the marketplace page, etc.) and naming one risks reading // like "this is what CoDev was trying to do." We just confirm a manual // fallback exists. -const VSCODE_CONTINUE_HINT = - "You can install the Continue extension yourself later."; -const JETBRAINS_CONTINUE_HINT = - "You can install the Continue plugin yourself later."; -const VSCODE_CLAUDE_CODE_HINT = - "You can install the Claude Code extension yourself later."; -const JETBRAINS_CLAUDE_CODE_HINT = - "You can install the Claude Code plugin yourself later."; +// Looked up per call rather than held in module constants, so the locale is +// read at render time rather than frozen at import. +const VSCODE_CONTINUE_HINT = () => t("install.hint.vscode_continue"); +const JETBRAINS_CONTINUE_HINT = () => t("install.hint.jetbrains_continue"); +const VSCODE_CLAUDE_CODE_HINT = () => t("install.hint.vscode_claude_code"); +const JETBRAINS_CLAUDE_CODE_HINT = () => + t("install.hint.jetbrains_claude_code"); interface InstallProps { tools: Tool[]; @@ -64,7 +64,7 @@ export function Install({ tools, onDone, includeAgents = true }: InstallProps) { run: async () => { const r = await installClaudeCodeExtension(); if (r === null) return null; - return { warning: `${r.warning}. ${VSCODE_CLAUDE_CODE_HINT}` }; + return { warning: `${r.warning}. ${VSCODE_CLAUDE_CODE_HINT()}` }; }, }; } @@ -75,7 +75,7 @@ export function Install({ tools, onDone, includeAgents = true }: InstallProps) { run: async () => { const r = await installClaudeCodePlugin(); if (r === null) return null; - return { warning: `${r.warning}. ${JETBRAINS_CLAUDE_CODE_HINT}` }; + return { warning: `${r.warning}. ${JETBRAINS_CLAUDE_CODE_HINT()}` }; }, }; } @@ -86,7 +86,7 @@ export function Install({ tools, onDone, includeAgents = true }: InstallProps) { run: async () => { const r = await installContinueExtension(); if (r === null) return null; - return { warning: `${r.warning}. ${VSCODE_CONTINUE_HINT}` }; + return { warning: `${r.warning}. ${VSCODE_CONTINUE_HINT()}` }; }, }; } @@ -97,7 +97,7 @@ export function Install({ tools, onDone, includeAgents = true }: InstallProps) { run: async () => { const r = await installContinuePlugin(); if (r === null) return null; - return { warning: `${r.warning}. ${JETBRAINS_CONTINUE_HINT}` }; + return { warning: `${r.warning}. ${JETBRAINS_CONTINUE_HINT()}` }; }, }; }); @@ -115,16 +115,12 @@ export function Install({ tools, onDone, includeAgents = true }: InstallProps) { label: CODEGRAPH_PKG, run: async () => { const err = await ensureCodegraphInstalled(); - return err ? { warning: `CodeGraph not installed: ${err}` } : null; + return err + ? { warning: t("install.codegraph_failed", { error: err }) } + : null; }, }); } - return ( - - ); + return ; } diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 7b09018..aca60a8 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -7,6 +7,7 @@ import { type AuthData, login } from "@/lib/auth.js"; import { clipboard } from "@/lib/clipboard.js"; import { SSO_URL } from "@/lib/const.js"; import { describeFailure } from "@/lib/doctor.js"; +import { t } from "@/lib/i18n.js"; interface LoginProps { onDone: (auth: AuthData) => void; @@ -138,15 +139,13 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { const [first = "", ...rest] = lines; return ( - {`Login failed: ${first}`} + {t("login.failed", { reason: first })} {rest.map((line, i) => ( {line} ))} - {!onError && canType && ( - {"Press Enter to retry, Ctrl-C to quit"} - )} + {!onError && canType && {t("common.retry_hint")}} ); } @@ -160,7 +159,9 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { if (completed) { return ( - {`✓ Signed in${doneAuth ? ` as ${doneAuth.user.email}` : ""}`} + {doneAuth + ? t("login.signed_in_as", { email: doneAuth.user.email }) + : t("login.signed_in")} ); } @@ -181,7 +182,7 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { - {" Starting sign-in..."} + {` ${t("login.starting")}`} ); @@ -202,7 +203,7 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { )} - {" Waiting for sign-in to complete in your browser..."} + {` ${t("login.waiting")}`} {showFallback && ( @@ -216,11 +217,11 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { press-c-to-copy shortcut sidesteps manual selection entirely. */} - {"Browser didn't open? Sign in here "} + {t("login.browser_didnt_open")} {copied ? ( - {"(copied!)"} + {t("login.copied")} ) : ( - canType && {"(press C to copy)"} + canType && {t("login.press_c")} )} {":"} @@ -236,19 +237,11 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { pasteValue={paste.pasteValue} pasteError={paste.pasteError} submitting={paste.submitting} - caption={ - - {"After signing in, copy the code shown and paste it here:"} - - } + caption={{t("login.paste_caption")}} /> ) : ( - - { - "This terminal can't accept keyboard input, so the paste-back fallback is unavailable — finish sign-in in the browser." - } - + {t("login.no_keyboard")} )} @@ -258,5 +251,5 @@ export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { } export function loginTitle() { - return {"Login"}; + return {t("login.title")}; } diff --git a/src/components/ManualCredentials.tsx b/src/components/ManualCredentials.tsx index 240b2f2..afb374f 100644 --- a/src/components/ManualCredentials.tsx +++ b/src/components/ManualCredentials.tsx @@ -1,5 +1,6 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; +import { t } from "@/lib/i18n.js"; import { slugifyProviderName } from "@/lib/provider.js"; export interface ManualCredentialsValue { @@ -16,13 +17,27 @@ interface ManualCredentialsProps { // The provider name is optional — an empty value means "use the default // provider identity" and the caller resolves it (lib/provider.ts). The URL and // key stay required. +// +// The `key` and `optional` halves are static, but the labels are looked up per +// render: a module-level constant would freeze the English text at import time, +// before a test could switch locale. const FIELDS = [ - { key: "providerName" as const, label: "Provider Name", optional: true }, - { key: "baseUrl" as const, label: "API URL", optional: false }, - { key: "apiKey" as const, label: "API Key", optional: false }, -]; - -const LABEL_WIDTH = Math.max(...FIELDS.map((f) => f.label.length)); + { + key: "providerName" as const, + labelKey: "manual_creds.field.provider_name", + optional: true, + }, + { + key: "baseUrl" as const, + labelKey: "manual_creds.field.api_url", + optional: false, + }, + { + key: "apiKey" as const, + labelKey: "manual_creds.field.api_key", + optional: false, + }, +] as const; type Values = Record<(typeof FIELDS)[number]["key"], string>; @@ -49,7 +64,7 @@ export function ManualCredentials({ if (key.return) { const value = values[current.key].trim(); if (!value && !current.optional) { - setError(`${current.label} is required`); + setError(t("common.field_required", { field: t(current.labelKey) })); return; } setError(null); @@ -98,6 +113,11 @@ export function ManualCredentials({ ); const providerId = slugifyProviderName(values.providerName); + // Derived from the active locale's labels. Rendered as an Ink + // rather than String.padEnd so the gutter stays correct for scripts whose + // characters are not one cell wide — Yoga measures display width, padEnd + // counts UTF-16 code units. + const labelWidth = Math.max(...FIELDS.map((f) => t(f.labelKey).length)) + 2; return ( @@ -105,22 +125,27 @@ export function ManualCredentials({ const isActive = !readOnly && !submitted && i === index; const isPast = submitted || i < index; const value = values[field.key]; - const label = field.label.padEnd(LABEL_WIDTH, " "); return ( - - {`${label}: `} - + + + {`${t(field.labelKey)}: `} + + {value} {isActive && } - {isPast && !value && (empty)} + {isPast && !value && ( + {t("manual_creds.empty")} + )} {field.key === "providerName" && providerId && ( - {`${" ".repeat(LABEL_WIDTH + 2)}→ id: ${providerId}`} + + {`→ id: ${providerId}`} )} @@ -133,9 +158,7 @@ export function ManualCredentials({ )} {!readOnly && !submitted && ( - - {"Press Enter to confirm each field (Provider Name is optional)."} - + {t("manual_creds.hint")} )} @@ -143,5 +166,5 @@ export function ManualCredentials({ } export function manualCredentialsTitle() { - return {"Enter API credentials"}; + return {t("manual_creds.title")}; } diff --git a/src/components/ModelSelect.tsx b/src/components/ModelSelect.tsx index dc5800d..a9fb35d 100644 --- a/src/components/ModelSelect.tsx +++ b/src/components/ModelSelect.tsx @@ -7,6 +7,7 @@ import { fetchModelWindows, isInvalidKeyError, } from "@/lib/backend.js"; +import { t } from "@/lib/i18n.js"; import { limitsFromWindow, resetModelLimitsCache } from "@/lib/model-limits.js"; interface ModelSelectProps { @@ -141,7 +142,7 @@ export function ModelSelect({ - Fetching available models... + {` ${t("model_select.loading")}`} ); } @@ -149,8 +150,12 @@ export function ModelSelect({ if (phase === "errored") { return ( - {`Failed to fetch models: ${error ?? "unknown error"}`} - {"Press Enter to retry, Ctrl-C to quit"} + + {t("model_select.failed", { + error: error ?? t("tasklist.unknown_error"), + })} + + {t("common.retry_hint")} ); } @@ -196,8 +201,8 @@ export function ModelSelect({ export function modelSelectTitle(readOnly = false) { return ( - {"Choose default model "} - {!readOnly && (↑/↓ to move, Enter to confirm)} + {`${t("model_select.title")} `} + {!readOnly && {t("common.hint.move_confirm")}} ); } diff --git a/src/components/PasteBack.tsx b/src/components/PasteBack.tsx index fae8a0d..41371c0 100644 --- a/src/components/PasteBack.tsx +++ b/src/components/PasteBack.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { useCanType } from "@/components/useCanType.js"; +import { t } from "@/lib/i18n.js"; // Shared no-browser paste-back affordance for the SSO login flow. A remote or // headless user finishes login in a browser on another device and lands on the @@ -118,12 +119,8 @@ export function PasteBackPrompt({ {caption ?? ( <> - - {"After you sign in, the page shows an authorization code."} - - - {'Use its "Copy code" button, then paste the code here:'} - + {t("paste_back.caption_1")} + {t("paste_back.caption_2")} )} @@ -133,7 +130,7 @@ export function PasteBackPrompt({ {pasteError && {pasteError}} - {submitting ? "Completing sign-in..." : "Press Enter to submit."} + {submitting ? t("paste_back.completing") : t("paste_back.submit_hint")} ); diff --git a/src/components/ProxyPrompt.tsx b/src/components/ProxyPrompt.tsx index 3e7ac05..b6ff74c 100644 --- a/src/components/ProxyPrompt.tsx +++ b/src/components/ProxyPrompt.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; import { normalizeProxyInput } from "@/lib/doctor.js"; +import { t } from "@/lib/i18n.js"; interface ProxyPromptProps { /** Called with a normalized proxy URL, or null when the user skips. */ @@ -17,13 +18,15 @@ interface ProxyPromptProps { // Concrete forms, because "host:port" alone leaves real questions unanswered: // does a hostname work, how do I pass a password, do I need http://. Each line // exists to answer one of those. -const EXAMPLES: [string, string][] = [ - ["10.60.129.1:3128", "IP and port"], - ["proxy.corp.vn:8080", "hostname and port"], - ["user:pass@10.60.129.1:3128", "proxy that needs a login"], - ["http://10.60.129.1:3128", "full URL (http:// is assumed if you omit it)"], -]; +const EXAMPLES = [ + ["10.60.129.1:3128", "proxy_prompt.example.ip_port"], + ["proxy.corp.vn:8080", "proxy_prompt.example.host_port"], + ["user:pass@10.60.129.1:3128", "proxy_prompt.example.with_login"], + ["http://10.60.129.1:3128", "proxy_prompt.example.full_url"], +] as const; +// Measures the addresses, which are literals in every locale — only the note +// beside each one is translated — so this can stay a module constant. const EXAMPLE_WIDTH = Math.max(...EXAMPLES.map(([e]) => e.length)); /** @@ -67,8 +70,8 @@ export function ProxyPrompt({ // silent failure, so it gets its own message. setError( /^\d+$/.test(trimmed) - ? `"${trimmed}" looks like just the port. Enter the host too, e.g. 10.0.0.1:${trimmed}` - : "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", + ? t("proxy_prompt.error.port_only", { input: trimmed }) + : t("proxy_prompt.error.invalid"), ); return; } @@ -94,7 +97,9 @@ export function ProxyPrompt({ if (submitted) { return ( - {value.trim() ? `Retrying via ${value.trim()}…` : "Skipped."} + {value.trim() + ? t("proxy_prompt.retrying", { proxy: value.trim() }) + : t("proxy_prompt.skipped")} ); } @@ -104,34 +109,24 @@ export function ProxyPrompt({ {currentProxy ? ( <> - {`The network checks failed even though a proxy is configured (${currentProxy}).`} - - - { - "If that address is wrong, enter the correct one and CoDev will re-run the checks with it." - } + {t("proxy_prompt.failed_with_proxy", { proxy: currentProxy })} + {t("proxy_prompt.wrong_address")} ) : ( - - { - "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied." - } - + {t("proxy_prompt.failed_no_proxy")} )} - - {"Nothing is written to disk — this applies to this run only."} - + {t("proxy_prompt.not_written")} - {"Examples:"} - {EXAMPLES.map(([example, note]) => ( + {t("proxy_prompt.examples")} + {EXAMPLES.map(([example, noteKey]) => ( {" "} {example} - {note} + {t(noteKey)} ))} @@ -139,8 +134,8 @@ export function ProxyPrompt({ {currentProxy - ? "Proxy (host:port), or Enter to keep the current one: " - : "Proxy (host:port), or Enter to skip: "} + ? t("proxy_prompt.field.keep") + : t("proxy_prompt.field.skip")} {value} @@ -151,5 +146,5 @@ export function ProxyPrompt({ } export function proxyPromptTitle() { - return {"Configure a proxy"}; + return {t("proxy_prompt.title")}; } diff --git a/src/components/SetupComplete.tsx b/src/components/SetupComplete.tsx index df0f89b..4393ccb 100644 --- a/src/components/SetupComplete.tsx +++ b/src/components/SetupComplete.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import type { ReactNode } from "react"; import type { Tool } from "@/lib/configure.js"; -import { HAPPY_CODING, HELP_HINT } from "@/lib/const.js"; +import { t } from "@/lib/i18n.js"; interface SetupCompleteProps { tools: Tool[]; @@ -18,10 +18,10 @@ export function SetupComplete({ tools, shimsInstalled }: SetupCompleteProps) { {resumeMessage(tools, shimsInstalled)} - {HELP_HINT} + {t("common.hint.help")} - {HAPPY_CODING} + {t("common.happy_coding")} ); @@ -29,15 +29,15 @@ export function SetupComplete({ tools, shimsInstalled }: SetupCompleteProps) { function resumeMessage(tools: Tool[], shimsInstalled: boolean): ReactNode { if (tools.length === 0) return null; - if (!shimsInstalled) return Done!; + if (!shimsInstalled) return {t("common.done")}; if (process.platform === "win32") { - return Done! Restart your terminal.; + return {t("setup.complete.restart_terminal")}; } return ( - {"Done! Run "} + {t("setup.complete.reload_shell_prefix")} exec $SHELL - {" to reload your shell."} + {t("setup.complete.reload_shell_suffix")} ); } diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx index 79959ed..1c5df36 100644 --- a/src/components/TaskList.tsx +++ b/src/components/TaskList.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import Spinner from "ink-spinner"; import { useEffect, useRef, useState } from "react"; +import { t } from "@/lib/i18n.js"; import { logError, logInfo, logWarn } from "@/lib/log.js"; // Three terminal outcomes for a task: @@ -49,12 +50,13 @@ export interface TaskItem { run: () => Promise; } -export interface TaskVerb { - // e.g. { infinitive: "install", present: "Installing", past: "Installed" } - infinitive: string; - present: string; - past: string; -} +// Which set of row messages this list speaks. It used to be a +// `{ infinitive, present, past }` struct that `rowText` substituted into English +// word order ("Failed to " + infinitive + " " + label) — a shape no other +// language can satisfy, since nothing outside English conjugates by slotting +// three principal parts into a fixed frame. Each state is now a complete +// sentence in the catalog, and this prop only picks which family to read. +export type TaskVerb = "install" | "update"; type Status = "pending" | "running" | "done" | "warned" | "failed"; @@ -78,7 +80,11 @@ interface TaskListProps { export function TaskList({ tasks, verb, onDone }: TaskListProps) { const [rows, setRows] = useState(() => - tasks.map((t) => ({ key: t.key, label: t.label, status: "pending" })), + tasks.map((task) => ({ + key: task.key, + label: task.label, + status: "pending", + })), ); const hasRun = useRef(false); const hasReported = useRef(false); @@ -149,18 +155,27 @@ function TaskRow({ row, verb }: { row: Row; verb: TaskVerb }) { } function rowText(row: Row, verb: TaskVerb): string { + // `row.label` is a package or product name and is interpolated, never + // translated. The template literals resolve to real MessageKey unions, so a + // catalog missing one verb's messages is a compile error rather than a + // runtime fallback. switch (row.status) { case "running": - return `${verb.present} ${row.label}...`; + return t(`tasklist.${verb}.running`, { label: row.label }); case "done": - return `${verb.past} ${row.label}`; + return t(`tasklist.${verb}.done`, { label: row.label }); case "warned": // Don't claim the task completed ("Installed X (warning: …)") when // the install actually didn't run — the row would lie. Just surface // the warning; the message itself names the editor / CLI involved. - return `Warning: ${row.warning ?? "unknown"}`; + return t("tasklist.warning", { + warning: row.warning ?? t("tasklist.unknown"), + }); case "failed": - return `Failed to ${verb.infinitive} ${row.label}: ${row.error ?? "unknown error"}`; + return t(`tasklist.${verb}.failed`, { + label: row.label, + error: row.error ?? t("tasklist.unknown_error"), + }); default: return row.label; } diff --git a/src/components/ToolSelect.tsx b/src/components/ToolSelect.tsx index 3e7ea38..dc79008 100644 --- a/src/components/ToolSelect.tsx +++ b/src/components/ToolSelect.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from "ink"; import { useState } from "react"; import type { Tool } from "@/lib/configure.js"; +import { t } from "@/lib/i18n.js"; // Sentinel values emitted when the user picks an editor-extension row. // InstallApp expands them via the merged EditorSelect sub-step into the @@ -37,13 +38,13 @@ const TOOLS: { // Rows actually rendered and navigable. Hidden tools are dropped from the UI // only; everything downstream (Configure, restore, update) is untouched. -const VISIBLE_TOOLS = TOOLS.filter((t) => !t.hidden); +const VISIBLE_TOOLS = TOOLS.filter((tool) => !tool.hidden); // Locked tools are emitted on every confirm regardless of the mutable // selection, and always lead the emitted list. const LOCKED_VALUES: ToolSelectValue[] = VISIBLE_TOOLS.filter( - (t) => t.locked, -).map((t) => t.value); + (tool) => tool.locked, +).map((tool) => tool.value); interface ToolSelectProps { onConfirm: (tools: ToolSelectValue[]) => void; @@ -87,8 +88,13 @@ export function ToolSelect({ { isActive: !readOnly }, ); - const lockedSuffix = - mode === "config" ? " (always configured)" : " (always installed)"; + // The rows themselves are brand names and stay untranslated in every locale; + // only this suffix and the title below are message keys. + const lockedSuffix = ` ${t( + mode === "config" + ? "tool_select.locked.config" + : "tool_select.locked.install", + )}`; return ( @@ -116,12 +122,18 @@ export function toolSelectTitle( readOnly = false, mode: "install" | "config" = "install", ) { - const verb = mode === "install" ? "install" : "configure"; + // One complete sentence per mode rather than an English verb dropped into a + // shared frame — the frame does not survive translation. + const title = t( + mode === "install" + ? "tool_select.title.install" + : "tool_select.title.config", + ); return ( - {`Select the AI agent(s) to ${verb} `} + {`${title} `} {!readOnly && ( - (↑/↓ to move, Space to select, Enter to confirm) + {t("common.hint.move_select_confirm")} )} ); diff --git a/src/components/Update.tsx b/src/components/Update.tsx index da489e4..b8ef5f7 100644 --- a/src/components/Update.tsx +++ b/src/components/Update.tsx @@ -9,6 +9,7 @@ import { ensureCodegraphInstalled, } from "@/lib/codegraph.js"; import { detectConfiguredTools } from "@/lib/configure.js"; +import { t } from "@/lib/i18n.js"; import { CLAUDE_CODE_INTELLIJ_PLUGIN_ID, CONTINUE_INTELLIJ_PLUGIN_ID, @@ -89,7 +90,7 @@ export function Update({ onDone }: UpdateProps) { jetbrainsAvailable, codegraphInstalled, ] = await Promise.all([ - Promise.all(NPM_TOOLS.map((t) => detectInstalledViaNpm(t))), + Promise.all(NPM_TOOLS.map((tool) => detectInstalledViaNpm(tool))), hasClaudeCodeMarker || hasContinueMarker ? isCodeCliAvailable() : Promise.resolve(false), @@ -140,13 +141,13 @@ export function Update({ onDone }: UpdateProps) { - Checking installed agents... + {t("update.detecting")} ); } if (phase.kind === "nothing") { - return Nothing to update.; + return {t("update.nothing")}; } const tasks: TaskItem[] = [ @@ -195,14 +196,16 @@ export function Update({ onDone }: UpdateProps) { label: CODEGRAPH_PKG, run: async () => { const err = await ensureCodegraphInstalled(); - return err ? { warning: `CodeGraph not updated: ${err}` } : null; + return err + ? { warning: t("update.codegraph_failed", { error: err }) } + : null; }, }); } return ( void; readOnly?: boolean; + /** Defaults to the localized "Continue?" when omitted. */ promptText?: string; } @@ -15,7 +17,7 @@ export function YesNo({ defaultAnswer, onAnswer, readOnly = false, - promptText = "Continue?", + promptText, }: YesNoProps) { const [buffer, setBuffer] = useState(""); const [submitted, setSubmitted] = useState(false); @@ -56,11 +58,14 @@ export function YesNo({ { isActive: !readOnly && !submitted }, ); + // The [Y/n] letters are the keys the user actually types and are matched + // against "y" below, so they stay untranslated in every locale. const label = defaultAnswer === "yes" ? "[Y/n]" : "[y/N]"; + const prompt = promptText ?? t("common.continue_question"); return ( - {`${promptText} ${label} `} + {`${prompt} ${label} `} {buffer} {!readOnly && !submitted && } diff --git a/src/index.tsx b/src/index.tsx index a253b5a..29a3b0c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/const.js"; import { doctorOutcome, rerunDoctorWithProxy } from "@/lib/doctor.js"; import { printHelp, printVersion } from "@/lib/help.js"; +import { t } from "@/lib/i18n.js"; import { initLogging, logWarn } from "@/lib/log.js"; import { runLogs } from "@/lib/logs.js"; import { applyEnvProxy } from "@/lib/proxy.js"; @@ -66,11 +67,15 @@ import { UploadApp } from "@/UploadApp.js"; // comfortably below this and still handled by the --experimental-sqlite // re-exec in lib/reexec.ts.) if (!nodeVersionMeets(process.versions.node)) { + // Safe to translate even here, above the argv parse: lib/i18n.ts resolves + // lazily from the environment, so it needs no initialization step. console.error( - `CoDev requires Node.js >= ${MIN_NODE_STRING} (Node ${RECOMMENDED_NODE} recommended). ` + - `Current version: ${process.version}.\n` + - `Below ${MIN_NODE_STRING}, Node ignores HTTP_PROXY/HTTPS_PROXY entirely, so sign-in ` + - `cannot work behind a corporate proxy.\nDownload: ${NODE_DOWNLOAD_URL}`, + t("cli.node_too_old", { + min: MIN_NODE_STRING, + recommended: RECOMMENDED_NODE, + current: process.version, + url: NODE_DOWNLOAD_URL, + }), ); process.exit(1); } @@ -262,9 +267,7 @@ switch (command) { password !== undefined; // Non-interactive admin login needs both halves; one alone is a usage error. if ((username === undefined) !== (password === undefined)) { - console.error( - "codevhub login: --username and --password must be provided together.", - ); + console.error(t("cli.login.credentials_together")); process.exit(1); } // Only the admin *form* needs the keyboard. SSO sign-in completes through @@ -291,7 +294,9 @@ switch (command) { // Full sign-out: drop the SSO session AND any SkillHub admin cookie. const ssoOut = await logout(); const cookieOut = clearSkillhubCookie(); - console.log(ssoOut || cookieOut ? "Logged out." : "Not logged in."); + console.log( + ssoOut || cookieOut ? t("cli.logged_out") : t("cli.not_logged_in"), + ); process.exit(0); break; } @@ -358,7 +363,10 @@ switch (command) { } if (!(RESTORE_AGENTS as readonly string[]).includes(agent)) { console.error( - `Unknown agent: ${agent}. Valid: ${RESTORE_AGENTS.join(", ")}.`, + t("cli.unknown_agent", { + agent: agent ?? "", + valid: RESTORE_AGENTS.join(", "), + }), ); process.exit(1); } @@ -376,9 +384,9 @@ switch (command) { const targetDir = args.find((a) => !a.startsWith("-")) ?? "."; if (code === 0 && existsSync(join(targetDir, ".codegraph"))) { console.log( - `Created the local ${styleText("cyan", ".codegraph/")} directory. ` + - "You can commit it if you'd like to share the knowledge graph with " + - "your team.", + t("cli.codegraph_dir_created", { + dir: styleText("cyan", ".codegraph/"), + }), ); } process.exit(code); @@ -466,7 +474,7 @@ switch (command) { console.error( sub === undefined ? "Usage: codevhub skill ..." - : `Unknown skill subcommand: ${sub}. Valid: search, pull, push.`, + : t("cli.unknown_skill_subcommand", { sub }), ); process.exit(1); break; @@ -508,10 +516,7 @@ switch (command) { if (args.length === 0) { agents = detectCodevTools(); if (agents.length === 0) { - console.log( - "No CoDev-installed tools found. Run `codevhub install` first, " + - "or specify agents explicitly: `codevhub hook claude|codex|opencode`.", - ); + console.log(t("cli.no_tools_for_hook")); process.exit(0); } } else { @@ -520,16 +525,20 @@ switch (command) { ); if (invalid.length > 0) { console.error( - `Unknown agent(s): ${invalid.join(", ")}. Valid: ${SHIM_AGENTS.join(", ")}.`, + t("cli.unknown_agents", { + agents: invalid.join(", "), + valid: SHIM_AGENTS.join(", "), + }), ); process.exit(1); } agents = args as ShimAgent[]; } const r = installShims(agents); - console.log(`Installed shims in ${r.shimDir}`); - for (const path of r.rcFilesUpdated) console.log(` patched ${path}`); - if (r.windowsUserPathUpdated) console.log(" updated user PATH"); + console.log(t("cli.shims_installed", { dir: r.shimDir })); + for (const path of r.rcFilesUpdated) + console.log(t("cli.shims_patched", { path })); + if (r.windowsUserPathUpdated) console.log(t("cli.shims_path_updated")); console.log(activationHint()); process.exit(0); break; @@ -537,11 +546,17 @@ switch (command) { case "unhook": { const r = uninstallShims(); if (r.shimsRemoved.length === 0 && r.rcFilesUpdated.length === 0) { - console.log("No CoDev shims installed."); + console.log(t("cli.shims_none")); } else { - console.log(`Removed ${r.shimsRemoved.length} shim(s) from ${r.shimDir}`); - for (const path of r.rcFilesUpdated) console.log(` cleaned ${path}`); - if (r.windowsUserPathUpdated) console.log(" updated user PATH"); + console.log( + t("cli.shims_removed", { + count: r.shimsRemoved.length, + dir: r.shimDir, + }), + ); + for (const path of r.rcFilesUpdated) + console.log(t("cli.shims_cleaned", { path })); + if (r.windowsUserPathUpdated) console.log(t("cli.shims_path_updated")); console.log(activationHint()); } process.exit(0); diff --git a/src/lib/const.ts b/src/lib/const.ts index 0d4a71f..81416d8 100644 --- a/src/lib/const.ts +++ b/src/lib/const.ts @@ -51,9 +51,6 @@ export function nodeVersionMeets(version: string): boolean { return patch >= MIN_NODE.patch; } -export const HELP_HINT = "Run `codevhub --help` to see all commands."; -export const HAPPY_CODING = "Happy coding! 🎉"; - interface CodevAuthFile { supabase_url?: string; supabase_anon_key?: string; diff --git a/src/lib/help.ts b/src/lib/help.ts index 848b9c3..7d22f2c 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -1,53 +1,16 @@ import { VERSION } from "@/lib/const.js"; +import { t } from "@/lib/i18n.js"; export function printVersion() { console.log(`${VERSION}`); } +/** + * The whole screen is a single message per locale rather than one key per line. + * Command names, flags and their arguments are fixed tokens the user types, so + * only the descriptions beside them change — and the column alignment has to be + * maintained as a unit, which per-line keys make impossible to see or review. + */ export function printHelp() { - console.log(`CoDev — AI Coding Agent Hub - -Usage: codevhub [command] [options] - -Bare \`codevhub\` opens CoDev Code, the built-in coding agent, in the current -directory (\`codev\` opens it directly). Any command not listed below is -passed through to it as well — \`codevhub run "fix the tests"\`, -\`codevhub serve\`, \`codevhub models\`, and so on. - -Hub commands: - doctor Check your environment and network before installing - (Node version, npm, proxy/TLS, sign-in, LLM access; - --force to test a real sign-in instead of the cached one) - install Install and configure AI coding agents - config Configure existing AI coding agents - update Update installed AI coding agents - init Build the knowledge graph - upload Export and upload logs to the monitor module - (--force, -f re-uploads every conversation) - model Switch the default model - restore [agent] Restore an agent's pre-CoDev config - (no arg processes every agent) - logs Show the last run from the diagnostic log - (--path newest file, --trace one run, --verbose extra detail) - login Sign in to SSO (--force to bypass cached session, - --admin for interactive admin sign-in, or - --username --password

for non-interactive admin sign-in) - logout Sign out (SSO and admin session) - remove Revert this machine to its pre-CoDev state (--yes to skip prompt) - --version, -v Show version - --help, -h Show this help - -Skill hub: - skill search Search the public skill hub - (--json for machine-readable output, - --limit to cap results, default 20) - skill pull Download and install a skill for your agents - (prompts for location and agents; --here or --global to - set the scope, --agent or --all-agents to set the - agents, --dir for an exact path, --force to - overwrite; --json for machine-readable output) - skill push Publish a skill (a directory with SKILL.md, or a .zip) - (previews and confirms before upload; --draft-only to stop - at DRAFT, --auto-approve for admins, --json for output) -`); + console.log(t("help.body")); } diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..b818171 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,166 @@ +import { en, type MessageKey } from "@/lib/locales/en.js"; +import { vi } from "@/lib/locales/vi.js"; + +export type { MessageKey }; + +export type Locale = "en" | "vi"; + +const CATALOGS: Record> = { en, vi }; +const SUPPORTED = Object.keys(CATALOGS) as Locale[]; +const DEFAULT_LOCALE: Locale = "en"; + +/** + * The base of a `_one` / `_other` pair. Only bases that declare BOTH + * halves qualify, so `tCount` can never be handed a key whose singular form was + * never written. + */ +// Written as a generic helper rather than inlined: a conditional type only +// distributes over a *naked type parameter*, so `MessageKey extends ...` would +// test the whole union at once and collapse to `never`. +type PluralBase = K extends `${infer Base}_other` + ? `${Base}_one` extends MessageKey + ? Base + : never + : never; + +export type PluralKey = PluralBase; + +export type Params = Record; + +/** + * Resolved once per process and memoized. Deliberately lazy rather than an + * `initLocale()` the dispatcher calls: ESM imports are evaluated before + * `index.tsx`'s body runs, so module-level strings (and the Node-version gate at + * the top of index.tsx, which fires before argv is even destructured) would + * always read the locale before an explicit init could set it. Every input here + * is an environment variable — fixed before the process starts — so resolving on + * first use has no ordering hazard at all. + * + * It is also why there is no `--lang` flag: argv would put that hazard back. + */ +let cached: Locale | null = null; + +/** + * "vi_VN.UTF-8" / "vi-VN" / "VI" / "vi@quot" all mean Vietnamese. Anything we + * don't ship (including "C" and "POSIX") returns null so the caller falls + * through to the next source rather than pinning an unsupported locale. + */ +function normalize(raw: string | undefined): Locale | null { + if (!raw) return null; + const tag = raw.trim().toLowerCase().split(".")[0]?.split("@")[0] ?? ""; + const primary = tag.split(/[-_]/)[0] ?? ""; + return SUPPORTED.includes(primary as Locale) ? (primary as Locale) : null; +} + +function resolve(): Locale { + const env = process.env; + // Each spelling is normalized independently. A plain `??` chain over the raw + // values would let an exported-but-empty LC_ALL mask a perfectly good LANG — + // the same trap lib/proxy.ts documents for HTTP_PROXY/http_proxy. + const fromEnv = + normalize(env.CODEV_LANG) ?? + normalize(env.LC_ALL) ?? + normalize(env.LC_MESSAGES) ?? + normalize(env.LANG); + if (fromEnv) return fromEnv; + // The Windows path: LANG is normally unset there, but Node's ICU reports the + // OS locale here. Guarded because a broken ICU build must not take the CLI + // down over a display-language lookup. + try { + return ( + normalize(Intl.DateTimeFormat().resolvedOptions().locale) ?? + DEFAULT_LOCALE + ); + } catch { + return DEFAULT_LOCALE; + } +} + +export function currentLocale(): Locale { + if (cached === null) cached = resolve(); + return cached; +} + +/** + * Drops the memoized locale so the next lookup re-reads the environment. + * Tests only — pair it with `vi.stubEnv("CODEV_LANG", …)`. Mirrors the existing + * `resetLogging()` / `resetModelLimitsCache()` / `resetSystemCaCertsCache()` + * hooks. + */ +export function resetLocaleCache(): void { + cached = null; +} + +/** Replaces `{name}` placeholders. An unsupplied name is left verbatim. */ +function interpolate(template: string, params?: Params): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = params[name]; + return value === undefined ? match : String(value); + }); +} + +/** + * Look up a message in the active locale. + * + * Falls back to English, then to the key itself. `Record` on + * each non-English catalog makes a missing key a compile error, so neither + * fallback should ever fire — they exist so that a bad catalog degrades to + * readable output instead of a blank frame or a throw. + */ +export function t(key: MessageKey, params?: Params): string { + const template = CATALOGS[currentLocale()][key] ?? en[key] ?? key; + return interpolate(template, params); +} + +/** + * Count-aware lookup, selecting between `_one` and `_other`. `count` + * is added to the interpolation params automatically. + * + * Deliberately not a plural-rules engine: English is the only locale we ship + * that inflects at all, and Vietnamese has no plural form, so both catalogs are + * fully served by a two-way split. A locale with a genuine `few`/`many` category + * would be the moment to reach for `Intl.PluralRules`. + */ +export function tCount(key: PluralKey, count: number, params?: Params): string { + const suffixed = `${key}${count === 1 ? "_one" : "_other"}` as MessageKey; + return t(suffixed, { count, ...params }); +} + +/** + * Natural-language list join in the active locale: "X", "X and Y", + * "X, Y, and Z" in English; "X, Y và Z" in Vietnamese. + * + * `Intl.ListFormat` is built into Node and its English output is identical to + * the hand-rolled Oxford-comma joiner this replaces. + */ +export function formatList(items: string[]): string { + if (items.length === 0) return ""; + if (items.length === 1) return items[0] ?? ""; + return listFormatter().format(items); +} + +/** + * The same join, split into its items and the separators between them, so a + * caller can style the items independently — `Confirm` renders each restore + * command in cyan but the ", and " between them in plain text. + * + * Without this a component has to hand-code the separators, which is where the + * English comma rules were previously baked in. + */ +export function formatListParts( + items: string[], +): { type: "element" | "literal"; value: string }[] { + if (items.length === 0) return []; + if (items.length === 1) { + return [{ type: "element", value: items[0] ?? "" }]; + } + return listFormatter().formatToParts(items); +} + +function listFormatter(): Intl.ListFormat { + return new Intl.ListFormat(currentLocale(), { + style: "long", + type: "conjunction", + }); +} diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts new file mode 100644 index 0000000..0e6b4ca --- /dev/null +++ b/src/lib/locales/en.ts @@ -0,0 +1,365 @@ +/** + * The source-of-truth message catalog. Every other locale is typed as + * `Record`, so adding a key here without adding it to the + * others is a `pnpm typecheck` failure. + * + * Keys are namespaced by surface: `common.*` for anything shared, then one + * namespace per screen. A `_one` / `_other` pair is a plural, read + * through `tCount` rather than `t`. + * + * What does NOT belong here: brand names ("CoDev Code", "Claude Code"), command + * and flag names, env var names, provider/model ids, URLs, status-union + * literals, and the control-flow `new Error(...)` sentinels the Ink apps throw + * to force a non-zero exit. Those are contract values, not display text. + */ +export const en = { + // Shared affordances. The keyboard hints were duplicated verbatim across + // four different pickers before they landed here. + "common.hint.move_confirm": "(↑/↓ to move, Enter to confirm)", + "common.hint.move_select_confirm": + "(↑/↓ to move, Space to select, Enter to confirm)", + "common.hint.help": "Run `codevhub --help` to see all commands.", + "common.happy_coding": "Happy coding! 🎉", + "common.done": "Done!", + "common.file_one": "{count} file", + "common.file_other": "{count} files", + + // Task-list rows. One complete sentence per verb per state — `{label}` is a + // package or product name and is never translated. + "tasklist.install.running": "Installing {label}...", + "tasklist.install.done": "Installed {label}", + "tasklist.install.failed": "Failed to install {label}: {error}", + "tasklist.update.running": "Updating {label}...", + "tasklist.update.done": "Updated {label}", + "tasklist.update.failed": "Failed to update {label}: {error}", + "tasklist.warning": "Warning: {warning}", + "tasklist.unknown": "unknown", + "tasklist.unknown_error": "unknown error", + + // The wordmark itself is a logo, not text. Only the tagline under it is + // translatable; the version token beside it is not. + "banner.tagline": "AI Coding Agent Hub", + + // Pickers. The product names on the rows ("CoDev Code", "VS Code", …) are + // brand names and stay in every locale, so only the titles and the + // always-on suffix appear here. + "tool_select.title.install": "Select the AI agent(s) to install", + "tool_select.title.config": "Select the AI agent(s) to configure", + "tool_select.locked.install": "(always installed)", + "tool_select.locked.config": "(always configured)", + "editor_select.title": "Select the editor(s) to install extensions in", + "auth_method.title": "Choose configuration method", + "auth_method.new": "Get a new API Key", + "auth_method.manual": "I have my own API Key", + "auth_method.existing": "Reuse existing API Key", + "auth_method.skip": "Skip configuration", + + // Closing frame. The shell command in the reload hint is a literal the user + // types, so it sits between the two halves rather than inside either. + "setup.complete.restart_terminal": "Done! Restart your terminal.", + "setup.complete.reload_shell_prefix": "Done! Run ", + "setup.complete.reload_shell_suffix": " to reload your shell.", + + // Shared across every prompt that offers a retry or validates a field. + "common.continue_question": "Continue?", + "common.retry_hint": "Press Enter to retry, Ctrl-C to quit", + "common.field_required": "{field} is required", + + // Install / Update rows. + "install.hint.vscode_continue": + "You can install the Continue extension yourself later.", + "install.hint.jetbrains_continue": + "You can install the Continue plugin yourself later.", + "install.hint.vscode_claude_code": + "You can install the Claude Code extension yourself later.", + "install.hint.jetbrains_claude_code": + "You can install the Claude Code plugin yourself later.", + "install.codegraph_failed": "CodeGraph not installed: {error}", + "update.codegraph_failed": "CodeGraph not updated: {error}", + "update.detecting": "Checking installed agents...", + "update.nothing": "Nothing to update.", + "update.title": "Updating packages", + + // Configure step. `{tool}` is a product name and is not translated. + "configure.title": "Configure tools", + "configure.configured": "Configured {tool}", + "configure.failed": "Configure failed: {error}", + + // Pre-install confirmation. The restore commands between the two halves are + // literal commands, joined by Intl.ListFormat rather than hard-coded commas. + "confirm.title": "Heads up — CoDev will change your settings.", + "confirm.revert_prefix": "To revert to your pre-CoDev state, run ", + + // Doctor's row renderer. The check labels, details and diagnosis prose come + // from lib/doctor.ts and are deliberately still English — only this + // component's own chrome is translated. + "checklist.env_passed_one": "{count} environment check passed", + "checklist.env_passed_other": "{count} environment checks passed", + "checklist.field.what": "What happened", + "checklist.field.cause": "Likely cause", + "checklist.field.fix": "What to do", + "checklist.field.context": "Context", + "checklist.field.raw": "Raw", + + "activity.commands": "Commands run", + "activity.endpoints": "Endpoints contacted", + "activity.no_response": "no response", + + // Proxy prompt. The example addresses themselves are literals; only the note + // beside each one is translated. + "proxy_prompt.title": "Configure a proxy", + "proxy_prompt.examples": "Examples:", + "proxy_prompt.example.ip_port": "IP and port", + "proxy_prompt.example.host_port": "hostname and port", + "proxy_prompt.example.with_login": "proxy that needs a login", + "proxy_prompt.example.full_url": + "full URL (http:// is assumed if you omit it)", + "proxy_prompt.failed_with_proxy": + "The network checks failed even though a proxy is configured ({proxy}).", + "proxy_prompt.wrong_address": + "If that address is wrong, enter the correct one and CoDev will re-run the checks with it.", + "proxy_prompt.failed_no_proxy": + "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied.", + "proxy_prompt.not_written": + "Nothing is written to disk — this applies to this run only.", + "proxy_prompt.field.keep": + "Proxy (host:port), or Enter to keep the current one: ", + "proxy_prompt.field.skip": "Proxy (host:port), or Enter to skip: ", + "proxy_prompt.retrying": "Retrying via {proxy}…", + "proxy_prompt.skipped": "Skipped.", + "proxy_prompt.error.port_only": + '"{input}" looks like just the port. Enter the host too, e.g. 10.0.0.1:{input}', + "proxy_prompt.error.invalid": + "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", + + // Sign-in. + "login.title": "Login", + "login.failed": "Login failed: {reason}", + "login.signed_in": "✓ Signed in", + "login.signed_in_as": "✓ Signed in as {email}", + "login.starting": "Starting sign-in...", + "login.waiting": "Waiting for sign-in to complete in your browser...", + "login.browser_didnt_open": "Browser didn't open? Sign in here ", + "login.copied": "(copied!)", + "login.press_c": "(press C to copy)", + "login.paste_caption": + "After signing in, copy the code shown and paste it here:", + "login.no_keyboard": + "This terminal can't accept keyboard input, so the paste-back fallback is unavailable — finish sign-in in the browser.", + + "paste_back.caption_1": + "After you sign in, the page shows an authorization code.", + "paste_back.caption_2": + 'Use its "Copy code" button, then paste the code here:', + "paste_back.completing": "Completing sign-in...", + "paste_back.submit_hint": "Press Enter to submit.", + + "fetch_key.title": "Fetching new API Key", + "fetch_key.pending": "Fetching API key from gateway...", + "fetch_key.success": "✓ API key obtained successfully.", + "fetch_key.failed": "Failed to fetch API key: {reason}", + "fetch_key.empty": "Gateway returned an empty API key.", + "fetch_key.empty_again": "Gateway returned an empty API key again.", + "fetch_key.manual_hint": + "Press Enter to enter credentials manually, Ctrl-C to quit", + + "manual_creds.title": "Enter API credentials", + "manual_creds.field.provider_name": "Provider Name", + "manual_creds.field.api_url": "API URL", + "manual_creds.field.api_key": "API Key", + "manual_creds.empty": "(empty)", + "manual_creds.hint": + "Press Enter to confirm each field (Provider Name is optional).", + + // ADMIN/SUPERADMIN are server-side role names and stay verbatim. + "admin_login.title": "Admin login", + "admin_login.field.username": "Username", + "admin_login.field.password": "Password", + "admin_login.signing_in": "Signing in...", + "admin_login.attempt": "(attempt {n} of {max})", + "admin_login.gave_up": "({max} failed attempts — giving up)", + "admin_login.only_admin": + "Only ADMIN/SUPERADMIN accounts can sign in here — regular users use `codevhub login`.", + + "model_select.title": "Choose default model", + "model_select.loading": "Fetching available models...", + "model_select.failed": "Failed to fetch models: {error}", + + // `codevhub login`. The admin form's own strings live under admin_login.*. + "login.admin.logged_in_as": "✓ Logged in as {username} ({role})", + "login.signing_out": "Signing out previous session", + "login.revoking": "Revoking tokens...", + + // `codevhub remove`. The per-step labels and details come from lib/remove.ts + // and are still English; only this screen's own copy is translated. + "remove.confirm": + "Everything will be reverted to the pre-CoDev state. Do you want to proceed?", + "remove.aborted": "Abort.", + "remove.running": "Removing CoDev components...", + "remove.kept_one": + "Kept {count} config file CoDev didn't write — your own settings were left untouched:", + "remove.kept_other": + "Kept {count} config files CoDev didn't write — your own settings were left untouched:", + "remove.some_failed": "✗ Some steps failed:", + "remove.success_prefix": "Removed successfully. You can now run ", + "remove.success_suffix": + " to remove the CoDev package. Restart your terminal to apply.", + + // `codevhub upload`. The live status line comes from lib/upload.ts's + // onStatus callback and is still English; this is only its initial value. + "upload.uploading": "Uploading logs...", + "upload.browser_url": "If the browser didn't open, visit this URL manually:", + "upload.no_keyboard": + "This terminal can't accept keyboard input — finish sign-in in the browser.", + "upload.failed": "✗ Upload failed", + "upload.none_found": "No conversations found for this project.", + "upload.looked_in": "codevhub looked in:", + "upload.launch_hint": + "If you used an AI agent here, make sure you launched it from this directory.", + "upload.uploaded": "✓ Uploaded {uploaded}/{found} conversation logs", + "upload.skipped": "Skipped {count} unchanged logs", + "upload.failed_logs": "Failed {count} logs:", + "upload.more": "(+{count} more)", + "upload.source": "Source: {dir}", + + // `codevhub skill pull`. The skill's own name and the agent labels are + // proper nouns and are interpolated, not translated. + "skill_pull.title": "Install {name} skill", + "skill_pull.title_generic": "Install skill", + "skill_pull.resolving": "Resolving skill...", + "skill_pull.installing": "Installing...", + "skill_pull.install_to": "Install {name} to:", + "skill_pull.location.current": "Current directory (recommended)", + "skill_pull.location.global": "Global", + "skill_pull.which_agents": "For which agents?", + "skill_pull.toggle_hint": "space toggles · enter confirms", + "skill_pull.no_keyboard": + "This terminal cannot supply keystrokes, so the install prompts cannot be shown.\nPass --here, --global, or --dir to choose a location without them.", + + // `codevhub skill push`. DRAFT / PUBLIC are server-side status values and + // stay verbatim inside the translated sentences. + "skill_push.title": "Publish skill to the hub", + "skill_push.step.uploading": "Uploading", + "skill_push.step.saving": "Saving metadata", + "skill_push.step.submitting": "Submitting for review", + "skill_push.step.approving": "Approving (admin)", + "skill_push.mode.draft": "Save as a DRAFT (not submitted).", + "skill_push.mode.auto_approve": + "Upload, submit, and auto-approve to PUBLIC (admin only).", + "skill_push.mode.submit": "Upload and submit for review.", + "skill_push.no_keyboard": + "This terminal cannot supply keystrokes, so the confirmation prompt cannot be shown.\nRe-run with --json to publish without confirming.", + "skill_push.preparing": "Preparing archive...", + "skill_push.archive_one": "{fileName} ({count} file, {size})", + "skill_push.archive_other": "{fileName} ({count} files, {size})", + "skill_push.and_more": " … and {count} more", + "skill_push.excluded": "Excluded: {list}", + "skill_push.confirm": "Publish this skill?", + "skill_push.checking_signin": "Checking sign-in...", + "skill_push.publishing": "Publishing", + "skill_push.cancelled": "Cancelled.", + + // `codevhub model`. The tool names in the summary line are brand names, + // joined by Intl.ListFormat. + "model.loading": "Loading saved credentials...", + "model.no_creds_prefix": "No CoDev credentials found. Run ", + "model.no_tools_prefix": "No CoDev-configured AI tools found. Run ", + "model.run_install_suffix": " first.", + "model.re_auth": + "Saved API key was rejected — refreshing credentials before continuing.", + "model.reauth_failed": + "Re-authentication did not produce a valid key. Run 'codevhub install' to refresh credentials.", + "model.update_configs_title": "Update tool configs", + "model.updating": "Updating tool configs...", + "model.updated_prefix": "Default model updated to ", + "model.updated_middle": " for ", + "model.opencode_prefix": "In ", + "model.opencode_suffix": ", switch models anytime with /models.", + + // `codevhub doctor`. The check labels, details, diagnoses and Next-steps + // lines all come from lib/doctor.ts and are still English — this is the + // screen's own chrome only. + "doctor.group.environment": "Environment", + "doctor.group.network": "Network", + "doctor.group.account": "Account & credentials", + "doctor.group.llm": "LLM access", + "doctor.group.state": "This machine", + "doctor.step.activity": "Activity", + "doctor.step.result": "Result", + "doctor.summary.ok": + "✓ Everything checks out. You're ready to run `codevhub install`.", + "doctor.summary.warned": + "▲ {warned} warning(s). `codevhub install` should work, but read the notes below first.", + "doctor.summary.failed": + "✗ {failed} check(s) failed. Fix these before running `codevhub install`.", + "doctor.summary.failed_with_warnings": + "✗ {failed} check(s) failed, {warned} warning(s). Fix these before running `codevhub install`.", + "doctor.next_steps": "Next steps", + "doctor.report_saved": + "Full report saved to {path} — attach it to a support ticket.", + + // `codevhub install` / `codevhub config`. The pre-flight check rows and the + // gateway's own failure reasons come from lib/doctor.ts and lib/backend.ts + // and are still English. + "setup.abort": "Abort.", + "setup.preflight.title": "Checking your environment", + "setup.preflight.hint": + "Run `codevhub doctor` for the full check — npm, network, sign-in and LLM access — plus setup instructions.", + "setup.installing.packages": "Installing packages", + "setup.installing.codegraph": "Installing CodeGraph", + "setup.refresh.title": "Refresh CoDev config", + "setup.saved_key.title": "Checking saved API key", + "setup.saved_key.verifying": "Verifying with gateway...", + "setup.saved_key.valid": "Saved API key is valid.", + "setup.saved_key.invalid": + "Saved API key is no longer valid; choose another method.", + "setup.saved_key.unverifiable": "Could not verify saved API key: {error}", + "setup.model_list.title": "Model list", + "setup.model_list.fallback": + "Couldn't fetch the model list ({error}); using fallback model {model}.", + "setup.gateway.title": "Verifying gateway access", + "setup.gateway.sending": "Sending a test request to {model}…", + "setup.gateway.the_model": "the model", + "setup.gateway.ok": "Gateway accepted a test request.", + "setup.gateway.warning_hint": + "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch.", + "setup.codegraph.title": "Set up CodeGraph", + "setup.codegraph.running": "Setting up CodeGraph…", + "setup.codegraph.incomplete": "CodeGraph setup did not complete.", + "setup.codegraph.wired": "Wired CodeGraph into {targets}.", + "setup.ripgrep.title": "File search", + "setup.ripgrep.failed": + "Could not stage ripgrep for CoDev Code: {error}. File search may be empty on Windows — install ripgrep (winget install BurntSushi.ripgrep.MSVC) and restart the agent.", + + // The `--help` screen, as one message per locale. Command names, flags and + // their arguments are fixed tokens the user types — only the descriptions + // beside them are translated, and the column alignment is maintained by hand. + "help.body": + 'CoDev \u2014 AI Coding Agent Hub\n\nUsage: codevhub [command] [options]\n\nBare `codevhub` opens CoDev Code, the built-in coding agent, in the current\ndirectory (`codev` opens it directly). Any command not listed below is\npassed through to it as well \u2014 `codevhub run "fix the tests"`,\n`codevhub serve`, `codevhub models`, and so on.\n\nHub commands:\n doctor Check your environment and network before installing\n (Node version, npm, proxy/TLS, sign-in, LLM access;\n --force to test a real sign-in instead of the cached one)\n install Install and configure AI coding agents\n config Configure existing AI coding agents\n update Update installed AI coding agents\n init Build the knowledge graph\n upload Export and upload logs to the monitor module\n (--force, -f re-uploads every conversation)\n model Switch the default model\n restore [agent] Restore an agent\'s pre-CoDev config\n (no arg processes every agent)\n logs Show the last run from the diagnostic log\n (--path newest file, --trace one run, --verbose extra detail)\n login Sign in to SSO (--force to bypass cached session,\n --admin for interactive admin sign-in, or\n --username --password

for non-interactive admin sign-in)\n logout Sign out (SSO and admin session)\n remove Revert this machine to its pre-CoDev state (--yes to skip prompt)\n --version, -v Show version\n --help, -h Show this help\n\nSkill hub:\n skill search Search the public skill hub\n (--json for machine-readable output,\n --limit to cap results, default 20)\n skill pull Download and install a skill for your agents\n (prompts for location and agents; --here or --global to\n set the scope, --agent or --all-agents to set the\n agents, --dir for an exact path, --force to\n overwrite; --json for machine-readable output)\n skill push Publish a skill (a directory with SKILL.md, or a .zip)\n (previews and confirms before upload; --draft-only to stop\n at DRAFT, --auto-approve for admins, --json for output)\n', + + // Dispatcher-level output. Usage lines are deliberately absent: they are + // command signatures, and lib/skill-install.ts's PULL_USAGE (out of this + // round's scope) would still print English beside a translated one. + "cli.node_too_old": + "CoDev requires Node.js >= {min} (Node {recommended} recommended). Current version: {current}.\nBelow {min}, Node ignores HTTP_PROXY/HTTPS_PROXY entirely, so sign-in cannot work behind a corporate proxy.\nDownload: {url}", + "cli.login.credentials_together": + "codevhub login: --username and --password must be provided together.", + "cli.logged_out": "Logged out.", + "cli.not_logged_in": "Not logged in.", + "cli.unknown_agent": "Unknown agent: {agent}. Valid: {valid}.", + "cli.unknown_agents": "Unknown agent(s): {agents}. Valid: {valid}.", + "cli.unknown_skill_subcommand": + "Unknown skill subcommand: {sub}. Valid: search, pull, push.", + "cli.codegraph_dir_created": + "Created the local {dir} directory. You can commit it if you'd like to share the knowledge graph with your team.", + "cli.no_tools_for_hook": + "No CoDev-installed tools found. Run `codevhub install` first, or specify agents explicitly: `codevhub hook claude|codex|opencode`.", + "cli.shims_installed": "Installed shims in {dir}", + "cli.shims_patched": " patched {path}", + "cli.shims_path_updated": " updated user PATH", + "cli.shims_none": "No CoDev shims installed.", + "cli.shims_removed": "Removed {count} shim(s) from {dir}", + "cli.shims_cleaned": " cleaned {path}", +} as const; + +export type MessageKey = keyof typeof en; diff --git a/src/lib/locales/vi.ts b/src/lib/locales/vi.ts new file mode 100644 index 0000000..0ecffff --- /dev/null +++ b/src/lib/locales/vi.ts @@ -0,0 +1,319 @@ +import type { MessageKey } from "@/lib/locales/en.js"; + +/** + * Vietnamese. The `Record` annotation is the completeness + * guarantee: a key added to `en.ts` and not here fails `pnpm typecheck`. + * + * Vietnamese does not inflect for number, so both halves of a `_one`/`_other` + * pair carry the same text. They stay as two entries rather than collapsing so + * the key sets match exactly across catalogs. + */ +export const vi: Record = { + "common.hint.move_confirm": "(↑/↓ để di chuyển, Enter để xác nhận)", + "common.hint.move_select_confirm": + "(↑/↓ để di chuyển, Space để chọn, Enter để xác nhận)", + "common.hint.help": "Chạy `codevhub --help` để xem tất cả các lệnh.", + "common.happy_coding": "Chúc bạn code vui vẻ! 🎉", + "common.done": "Xong!", + "common.file_one": "{count} tệp", + "common.file_other": "{count} tệp", + + "tasklist.install.running": "Đang cài đặt {label}...", + "tasklist.install.done": "Đã cài đặt {label}", + "tasklist.install.failed": "Cài đặt {label} thất bại: {error}", + "tasklist.update.running": "Đang cập nhật {label}...", + "tasklist.update.done": "Đã cập nhật {label}", + "tasklist.update.failed": "Cập nhật {label} thất bại: {error}", + "tasklist.warning": "Cảnh báo: {warning}", + "tasklist.unknown": "không xác định", + "tasklist.unknown_error": "lỗi không xác định", + + "banner.tagline": "Trung tâm AI Coding Agent", + + "tool_select.title.install": "Chọn (các) AI agent để cài đặt", + "tool_select.title.config": "Chọn (các) AI agent để cấu hình", + "tool_select.locked.install": "(luôn được cài đặt)", + "tool_select.locked.config": "(luôn được cấu hình)", + "editor_select.title": + "Chọn (các) trình soạn thảo để cài đặt tiện ích mở rộng", + "auth_method.title": "Chọn phương thức cấu hình", + "auth_method.new": "Lấy API Key mới", + "auth_method.manual": "Tôi đã có API Key riêng", + "auth_method.existing": "Dùng lại API Key hiện có", + "auth_method.skip": "Bỏ qua cấu hình", + + "setup.complete.restart_terminal": "Xong! Hãy khởi động lại terminal.", + "setup.complete.reload_shell_prefix": "Xong! Chạy ", + "setup.complete.reload_shell_suffix": " để tải lại shell.", + + "common.continue_question": "Tiếp tục?", + "common.retry_hint": "Nhấn Enter để thử lại, Ctrl-C để thoát", + "common.field_required": "{field} là bắt buộc", + + "install.hint.vscode_continue": + "Bạn có thể tự cài đặt tiện ích Continue sau.", + "install.hint.jetbrains_continue": + "Bạn có thể tự cài đặt plugin Continue sau.", + "install.hint.vscode_claude_code": + "Bạn có thể tự cài đặt tiện ích Claude Code sau.", + "install.hint.jetbrains_claude_code": + "Bạn có thể tự cài đặt plugin Claude Code sau.", + "install.codegraph_failed": "Chưa cài được CodeGraph: {error}", + "update.codegraph_failed": "Chưa cập nhật được CodeGraph: {error}", + "update.detecting": "Đang kiểm tra các agent đã cài đặt...", + "update.nothing": "Không có gì để cập nhật.", + "update.title": "Đang cập nhật các gói", + + "configure.title": "Cấu hình công cụ", + "configure.configured": "Đã cấu hình {tool}", + "configure.failed": "Cấu hình thất bại: {error}", + + "confirm.title": "Lưu ý — CoDev sẽ thay đổi cài đặt của bạn.", + "confirm.revert_prefix": + "Để quay lại trạng thái trước khi dùng CoDev, hãy chạy ", + + "checklist.env_passed_one": "{count} kiểm tra môi trường đã đạt", + "checklist.env_passed_other": "{count} kiểm tra môi trường đã đạt", + "checklist.field.what": "Điều đã xảy ra", + "checklist.field.cause": "Nguyên nhân", + "checklist.field.fix": "Cách khắc phục", + "checklist.field.context": "Bối cảnh", + "checklist.field.raw": "Chi tiết gốc", + + "activity.commands": "Lệnh đã chạy", + "activity.endpoints": "Điểm cuối đã kết nối", + "activity.no_response": "không có phản hồi", + + "proxy_prompt.title": "Cấu hình proxy", + "proxy_prompt.examples": "Ví dụ:", + "proxy_prompt.example.ip_port": "IP và cổng", + "proxy_prompt.example.host_port": "tên máy chủ và cổng", + "proxy_prompt.example.with_login": "proxy cần đăng nhập", + "proxy_prompt.example.full_url": + "URL đầy đủ (mặc định là http:// nếu bạn bỏ qua)", + "proxy_prompt.failed_with_proxy": + "Kiểm tra mạng thất bại mặc dù đã cấu hình proxy ({proxy}).", + "proxy_prompt.wrong_address": + "Nếu địa chỉ đó sai, hãy nhập địa chỉ đúng và CoDev sẽ chạy lại các kiểm tra với nó.", + "proxy_prompt.failed_no_proxy": + "Kiểm tra mạng thất bại. Nếu máy này ra Internet qua proxy, hãy nhập proxy ở đây và CoDev sẽ chạy lại các kiểm tra với nó.", + "proxy_prompt.not_written": + "Không có gì được ghi xuống đĩa — điều này chỉ áp dụng cho lần chạy này.", + "proxy_prompt.field.keep": + "Proxy (host:port), hoặc Enter để giữ proxy hiện tại: ", + "proxy_prompt.field.skip": "Proxy (host:port), hoặc Enter để bỏ qua: ", + "proxy_prompt.retrying": "Đang thử lại qua {proxy}…", + "proxy_prompt.skipped": "Đã bỏ qua.", + "proxy_prompt.error.port_only": + '"{input}" trông chỉ là số cổng. Hãy nhập cả host, ví dụ 10.0.0.1:{input}', + "proxy_prompt.error.invalid": + "Địa chỉ này không giống proxy. Hãy dùng host:port, ví dụ 10.0.0.1:8080", + + "login.title": "Đăng nhập", + "login.failed": "Đăng nhập thất bại: {reason}", + "login.signed_in": "✓ Đã đăng nhập", + "login.signed_in_as": "✓ Đã đăng nhập với {email}", + "login.starting": "Đang bắt đầu đăng nhập...", + "login.waiting": "Đang chờ hoàn tất đăng nhập trên trình duyệt...", + "login.browser_didnt_open": "Trình duyệt không mở? Đăng nhập tại đây ", + "login.copied": "(đã sao chép!)", + "login.press_c": "(nhấn C để sao chép)", + "login.paste_caption": + "Sau khi đăng nhập, hãy sao chép mã hiển thị và dán vào đây:", + "login.no_keyboard": + "Terminal này không nhận được bàn phím nên không dùng được cách dán mã — hãy hoàn tất đăng nhập trên trình duyệt.", + + "paste_back.caption_1": + "Sau khi bạn đăng nhập, trang web sẽ hiển thị một mã ủy quyền.", + "paste_back.caption_2": + 'Dùng nút "Copy code" trên trang, rồi dán mã vào đây:', + "paste_back.completing": "Đang hoàn tất đăng nhập...", + "paste_back.submit_hint": "Nhấn Enter để gửi.", + + "fetch_key.title": "Đang lấy API Key mới", + "fetch_key.pending": "Đang lấy API key từ gateway...", + "fetch_key.success": "✓ Đã lấy API key thành công.", + "fetch_key.failed": "Lấy API key thất bại: {reason}", + "fetch_key.empty": "Gateway trả về API key rỗng.", + "fetch_key.empty_again": "Gateway lại trả về API key rỗng.", + "fetch_key.manual_hint": + "Nhấn Enter để nhập thông tin thủ công, Ctrl-C để thoát", + + "manual_creds.title": "Nhập thông tin API", + "manual_creds.field.provider_name": "Tên nhà cung cấp", + "manual_creds.field.api_url": "URL API", + "manual_creds.field.api_key": "API Key", + "manual_creds.empty": "(trống)", + "manual_creds.hint": + "Nhấn Enter để xác nhận từng trường (Tên nhà cung cấp là tùy chọn).", + + "admin_login.title": "Đăng nhập quản trị", + "admin_login.field.username": "Tên đăng nhập", + "admin_login.field.password": "Mật khẩu", + "admin_login.signing_in": "Đang đăng nhập...", + "admin_login.attempt": "(lần thử {n} trên {max})", + "admin_login.gave_up": "({max} lần thử thất bại — dừng lại)", + "admin_login.only_admin": + "Chỉ tài khoản ADMIN/SUPERADMIN mới đăng nhập được ở đây — người dùng thường hãy dùng `codevhub login`.", + + "model_select.title": "Chọn model mặc định", + "model_select.loading": "Đang lấy danh sách model...", + "model_select.failed": "Lấy danh sách model thất bại: {error}", + + "login.admin.logged_in_as": "✓ Đã đăng nhập với {username} ({role})", + "login.signing_out": "Đang đăng xuất phiên trước", + "login.revoking": "Đang thu hồi token...", + + "remove.confirm": + "Mọi thứ sẽ được hoàn nguyên về trạng thái trước khi dùng CoDev. Bạn có muốn tiếp tục?", + "remove.aborted": "Đã hủy.", + "remove.running": "Đang gỡ các thành phần CoDev...", + "remove.kept_one": + "Đã giữ lại {count} tệp cấu hình không do CoDev tạo — các thiết lập của bạn được giữ nguyên:", + "remove.kept_other": + "Đã giữ lại {count} tệp cấu hình không do CoDev tạo — các thiết lập của bạn được giữ nguyên:", + "remove.some_failed": "✗ Một số bước thất bại:", + "remove.success_prefix": "Đã gỡ thành công. Bây giờ bạn có thể chạy ", + "remove.success_suffix": + " để gỡ gói CoDev. Hãy khởi động lại terminal để áp dụng.", + + "upload.uploading": "Đang tải log lên...", + "upload.browser_url": + "Nếu trình duyệt không mở, hãy truy cập URL này thủ công:", + "upload.no_keyboard": + "Terminal này không nhận được bàn phím — hãy hoàn tất đăng nhập trên trình duyệt.", + "upload.failed": "✗ Tải lên thất bại", + "upload.none_found": "Không tìm thấy hội thoại nào cho dự án này.", + "upload.looked_in": "codevhub đã tìm trong:", + "upload.launch_hint": + "Nếu bạn đã dùng AI agent ở đây, hãy chắc chắn bạn khởi chạy nó từ thư mục này.", + "upload.uploaded": "✓ Đã tải lên {uploaded}/{found} log hội thoại", + "upload.skipped": "Đã bỏ qua {count} log không thay đổi", + "upload.failed_logs": "Thất bại {count} log:", + "upload.more": "(+{count} nữa)", + "upload.source": "Nguồn: {dir}", + + "skill_pull.title": "Cài đặt skill {name}", + "skill_pull.title_generic": "Cài đặt skill", + "skill_pull.resolving": "Đang phân giải skill...", + "skill_pull.installing": "Đang cài đặt...", + "skill_pull.install_to": "Cài đặt {name} vào:", + "skill_pull.location.current": "Thư mục hiện tại (khuyến nghị)", + "skill_pull.location.global": "Toàn hệ thống", + "skill_pull.which_agents": "Dành cho agent nào?", + "skill_pull.toggle_hint": "space để chọn · enter để xác nhận", + "skill_pull.no_keyboard": + "Terminal này không nhận được bàn phím nên không thể hiển thị các bước chọn khi cài đặt.\nHãy dùng --here, --global hoặc --dir để chỉ định vị trí mà không cần chúng.", + + "skill_push.title": "Đăng skill lên hub", + "skill_push.step.uploading": "Đang tải lên", + "skill_push.step.saving": "Đang lưu thông tin", + "skill_push.step.submitting": "Đang gửi để duyệt", + "skill_push.step.approving": "Đang phê duyệt (quản trị)", + "skill_push.mode.draft": "Lưu thành bản DRAFT (chưa gửi duyệt).", + "skill_push.mode.auto_approve": + "Tải lên, gửi duyệt và tự phê duyệt thành PUBLIC (chỉ quản trị).", + "skill_push.mode.submit": "Tải lên và gửi để duyệt.", + "skill_push.no_keyboard": + "Terminal này không nhận được bàn phím nên không thể hiển thị bước xác nhận.\nHãy chạy lại với --json để đăng mà không cần xác nhận.", + "skill_push.preparing": "Đang chuẩn bị gói...", + "skill_push.archive_one": "{fileName} ({count} tệp, {size})", + "skill_push.archive_other": "{fileName} ({count} tệp, {size})", + "skill_push.and_more": " … và {count} tệp nữa", + "skill_push.excluded": "Đã loại trừ: {list}", + "skill_push.confirm": "Đăng skill này?", + "skill_push.checking_signin": "Đang kiểm tra đăng nhập...", + "skill_push.publishing": "Đang đăng", + "skill_push.cancelled": "Đã hủy.", + + "model.loading": "Đang tải thông tin đăng nhập đã lưu...", + "model.no_creds_prefix": + "Không tìm thấy thông tin đăng nhập CoDev. Hãy chạy ", + "model.no_tools_prefix": + "Không tìm thấy công cụ AI nào được CoDev cấu hình. Hãy chạy ", + "model.run_install_suffix": " trước.", + "model.re_auth": + "API key đã lưu bị từ chối — đang làm mới thông tin đăng nhập trước khi tiếp tục.", + "model.reauth_failed": + "Việc xác thực lại không tạo được key hợp lệ. Hãy chạy 'codevhub install' để làm mới thông tin đăng nhập.", + "model.update_configs_title": "Cập nhật cấu hình công cụ", + "model.updating": "Đang cập nhật cấu hình công cụ...", + "model.updated_prefix": "Model mặc định đã đổi thành ", + "model.updated_middle": " cho ", + "model.opencode_prefix": "Trong ", + "model.opencode_suffix": + ", bạn có thể đổi model bất cứ lúc nào bằng /models.", + + "doctor.group.environment": "Môi trường", + "doctor.group.network": "Mạng", + "doctor.group.account": "Tài khoản & thông tin đăng nhập", + "doctor.group.llm": "Truy cập LLM", + "doctor.group.state": "Máy này", + "doctor.step.activity": "Hoạt động", + "doctor.step.result": "Kết quả", + "doctor.summary.ok": + "✓ Mọi thứ đều ổn. Bạn đã sẵn sàng chạy `codevhub install`.", + "doctor.summary.warned": + "▲ {warned} cảnh báo. `codevhub install` vẫn chạy được, nhưng hãy đọc các ghi chú bên dưới trước.", + "doctor.summary.failed": + "✗ {failed} kiểm tra thất bại. Hãy khắc phục trước khi chạy `codevhub install`.", + "doctor.summary.failed_with_warnings": + "✗ {failed} kiểm tra thất bại, {warned} cảnh báo. Hãy khắc phục trước khi chạy `codevhub install`.", + "doctor.next_steps": "Các bước tiếp theo", + "doctor.report_saved": + "Báo cáo đầy đủ đã lưu tại {path} — hãy đính kèm khi mở ticket hỗ trợ.", + + "setup.abort": "Đã hủy.", + "setup.preflight.title": "Đang kiểm tra môi trường của bạn", + "setup.preflight.hint": + "Hãy chạy `codevhub doctor` để kiểm tra đầy đủ — npm, mạng, đăng nhập và truy cập LLM — kèm hướng dẫn thiết lập.", + "setup.installing.packages": "Đang cài đặt các gói", + "setup.installing.codegraph": "Đang cài đặt CodeGraph", + "setup.refresh.title": "Làm mới cấu hình CoDev", + "setup.saved_key.title": "Đang kiểm tra API key đã lưu", + "setup.saved_key.verifying": "Đang xác minh với gateway...", + "setup.saved_key.valid": "API key đã lưu vẫn hợp lệ.", + "setup.saved_key.invalid": + "API key đã lưu không còn hợp lệ; hãy chọn phương thức khác.", + "setup.saved_key.unverifiable": "Không xác minh được API key đã lưu: {error}", + "setup.model_list.title": "Danh sách model", + "setup.model_list.fallback": + "Không lấy được danh sách model ({error}); đang dùng model dự phòng {model}.", + "setup.gateway.title": "Đang xác minh quyền truy cập gateway", + "setup.gateway.sending": "Đang gửi yêu cầu thử tới {model}…", + "setup.gateway.the_model": "model", + "setup.gateway.ok": "Gateway đã chấp nhận yêu cầu thử.", + "setup.gateway.warning_hint": + "Cấu hình vẫn được ghi, nhưng các agent của bạn sẽ gặp đúng lỗi này — hãy khắc phục quyền truy cập gateway (quyền dùng model, ngân sách, hoặc khu vực/IP) rồi khởi chạy lại.", + "setup.codegraph.title": "Thiết lập CodeGraph", + "setup.codegraph.running": "Đang thiết lập CodeGraph…", + "setup.codegraph.incomplete": "Thiết lập CodeGraph chưa hoàn tất.", + "setup.codegraph.wired": "Đã kết nối CodeGraph vào {targets}.", + "setup.ripgrep.title": "Tìm kiếm tệp", + "setup.ripgrep.failed": + "Không chuẩn bị được ripgrep cho CoDev Code: {error}. Việc tìm kiếm tệp có thể không có kết quả trên Windows — hãy cài ripgrep (winget install BurntSushi.ripgrep.MSVC) rồi khởi động lại agent.", + + "help.body": + 'CoDev \u2014 Trung t\u00e2m AI Coding Agent\n\nC\u00e1ch d\u00f9ng: codevhub [l\u1ec7nh] [t\u00f9y ch\u1ecdn]\n\nCh\u1ea1y `codevhub` kh\u00f4ng k\u00e8m l\u1ec7nh s\u1ebd m\u1edf CoDev Code, agent l\u1eadp tr\u00ecnh t\u00edch h\u1ee3p, t\u1ea1i\nth\u01b0 m\u1ee5c hi\u1ec7n t\u1ea1i (`codev` m\u1edf tr\u1ef1c ti\u1ebfp). M\u1ecdi l\u1ec7nh kh\u00f4ng c\u00f3 trong danh s\u00e1ch d\u01b0\u1edbi\n\u0111\u00e2y c\u0169ng \u0111\u01b0\u1ee3c chuy\u1ec3n ti\u1ebfp t\u1edbi n\u00f3 \u2014 `codevhub run "fix the tests"`,\n`codevhub serve`, `codevhub models`, v.v.\n\nL\u1ec7nh c\u1ee7a hub:\n doctor Ki\u1ec3m tra m\u00f4i tr\u01b0\u1eddng v\u00e0 m\u1ea1ng tr\u01b0\u1edbc khi c\u00e0i \u0111\u1eb7t\n (phi\u00ean b\u1ea3n Node, npm, proxy/TLS, \u0111\u0103ng nh\u1eadp, truy c\u1eadp LLM;\n --force \u0111\u1ec3 th\u1eed \u0111\u0103ng nh\u1eadp th\u1eadt thay v\u00ec d\u00f9ng phi\u00ean \u0111\u00e3 l\u01b0u)\n install C\u00e0i \u0111\u1eb7t v\u00e0 c\u1ea5u h\u00ecnh c\u00e1c AI coding agent\n config C\u1ea5u h\u00ecnh c\u00e1c AI coding agent \u0111\u00e3 c\u00f3\n update C\u1eadp nh\u1eadt c\u00e1c AI coding agent \u0111\u00e3 c\u00e0i\n init X\u00e2y d\u1ef1ng knowledge graph\n upload Xu\u1ea5t v\u00e0 t\u1ea3i log l\u00ean module gi\u00e1m s\u00e1t\n (--force, -f \u0111\u1ec3 t\u1ea3i l\u1ea1i to\u00e0n b\u1ed9 h\u1ed9i tho\u1ea1i)\n model \u0110\u1ed5i model m\u1eb7c \u0111\u1ecbnh\n restore [agent] Kh\u00f4i ph\u1ee5c c\u1ea5u h\u00ecnh tr\u01b0\u1edbc CoDev c\u1ee7a m\u1ed9t agent\n (kh\u00f4ng c\u00f3 tham s\u1ed1 th\u00ec x\u1eed l\u00fd m\u1ecdi agent)\n logs Hi\u1ec3n th\u1ecb l\u1ea7n ch\u1ea1y g\u1ea7n nh\u1ea5t trong log ch\u1ea9n \u0111o\u00e1n\n (--path t\u1ec7p m\u1edbi nh\u1ea5t, --trace m\u1ed9t l\u1ea7n ch\u1ea1y, --verbose chi ti\u1ebft h\u01a1n)\n login \u0110\u0103ng nh\u1eadp SSO (--force \u0111\u1ec3 b\u1ecf qua phi\u00ean \u0111\u00e3 l\u01b0u,\n --admin \u0111\u1ec3 \u0111\u0103ng nh\u1eadp qu\u1ea3n tr\u1ecb t\u01b0\u01a1ng t\u00e1c, ho\u1eb7c\n --username --password

\u0111\u1ec3 \u0111\u0103ng nh\u1eadp qu\u1ea3n tr\u1ecb kh\u00f4ng t\u01b0\u01a1ng t\u00e1c)\n logout \u0110\u0103ng xu\u1ea5t (c\u1ea3 SSO v\u00e0 phi\u00ean qu\u1ea3n tr\u1ecb)\n remove Ho\u00e0n nguy\u00ean m\u00e1y n\u00e0y v\u1ec1 tr\u1ea1ng th\u00e1i tr\u01b0\u1edbc CoDev (--yes \u0111\u1ec3 b\u1ecf qua x\u00e1c nh\u1eadn)\n --version, -v Hi\u1ec3n th\u1ecb phi\u00ean b\u1ea3n\n --help, -h Hi\u1ec3n th\u1ecb tr\u1ee3 gi\u00fap n\u00e0y\n\nSkill hub:\n skill search T\u00ecm ki\u1ebfm tr\u00ean skill hub c\u00f4ng khai\n (--json \u0111\u1ec3 xu\u1ea5t d\u1ea1ng m\u00e1y \u0111\u1ecdc,\n --limit \u0111\u1ec3 gi\u1edbi h\u1ea1n k\u1ebft qu\u1ea3, m\u1eb7c \u0111\u1ecbnh 20)\n skill pull T\u1ea3i v\u00e0 c\u00e0i m\u1ed9t skill cho c\u00e1c agent c\u1ee7a b\u1ea1n\n (s\u1ebd h\u1ecfi v\u1ecb tr\u00ed v\u00e0 agent; --here ho\u1eb7c --global \u0111\u1ec3\n ch\u1ecdn ph\u1ea1m vi, --agent ho\u1eb7c --all-agents \u0111\u1ec3 ch\u1ecdn\n agent, --dir cho \u0111\u01b0\u1eddng d\u1eabn ch\u00ednh x\u00e1c, --force \u0111\u1ec3\n ghi \u0111\u00e8; --json \u0111\u1ec3 xu\u1ea5t d\u1ea1ng m\u00e1y \u0111\u1ecdc)\n skill push \u0110\u0103ng m\u1ed9t skill (th\u01b0 m\u1ee5c c\u00f3 SKILL.md, ho\u1eb7c t\u1ec7p .zip)\n (xem tr\u01b0\u1edbc v\u00e0 x\u00e1c nh\u1eadn tr\u01b0\u1edbc khi t\u1ea3i l\u00ean; --draft-only \u0111\u1ec3 d\u1eebng\n \u1edf DRAFT, --auto-approve cho qu\u1ea3n tr\u1ecb, --json \u0111\u1ec3 xu\u1ea5t d\u1eef li\u1ec7u)\n', + + "cli.node_too_old": + "CoDev yêu cầu Node.js >= {min} (khuyến nghị Node {recommended}). Phiên bản hiện tại: {current}.\nDưới {min}, Node hoàn toàn bỏ qua HTTP_PROXY/HTTPS_PROXY, nên không thể đăng nhập sau proxy của công ty.\nTải về: {url}", + "cli.login.credentials_together": + "codevhub login: phải cung cấp đồng thời --username và --password.", + "cli.logged_out": "Đã đăng xuất.", + "cli.not_logged_in": "Chưa đăng nhập.", + "cli.unknown_agent": "Agent không hợp lệ: {agent}. Hợp lệ: {valid}.", + "cli.unknown_agents": "Agent không hợp lệ: {agents}. Hợp lệ: {valid}.", + "cli.unknown_skill_subcommand": + "Lệnh con skill không hợp lệ: {sub}. Hợp lệ: search, pull, push.", + "cli.codegraph_dir_created": + "Đã tạo thư mục {dir} trong dự án. Bạn có thể commit nó nếu muốn chia sẻ knowledge graph với cả nhóm.", + "cli.no_tools_for_hook": + "Không tìm thấy công cụ nào do CoDev cài. Hãy chạy `codevhub install` trước, hoặc chỉ định agent cụ thể: `codevhub hook claude|codex|opencode`.", + "cli.shims_installed": "Đã cài shim vào {dir}", + "cli.shims_patched": " đã cập nhật {path}", + "cli.shims_path_updated": " đã cập nhật PATH của người dùng", + "cli.shims_none": "Không có shim nào của CoDev được cài.", + "cli.shims_removed": "Đã gỡ {count} shim khỏi {dir}", + "cli.shims_cleaned": " đã dọn {path}", +}; diff --git a/src/lib/text.ts b/src/lib/text.ts index 6205c1c..39344fa 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -1,11 +1,16 @@ -// Natural-English list join: "X", "X and Y", "X, Y, and Z". -// Used by both `codevhub model` (joining configured tool labels in the -// success message) and `codevhub install`'s Confirm step (joining restore -// commands in the heads-up warning). +import { formatList } from "@/lib/i18n.js"; + +// Natural-language list join: "X", "X and Y", "X, Y, and Z" in English; +// "X, Y và Z" in Vietnamese. +// +// Used by `codevhub model` (joining configured tool labels in the success +// message), `codevhub install`'s Confirm step, and `formatCodegraphTargets` — +// whose result is interpolated into a translated sentence, which is why the +// join has to follow the active locale rather than English's comma rules. +// +// The rules themselves now live in lib/i18n.ts, backed by Intl.ListFormat. This +// stays as the name the non-UI callers already use; its English output is +// byte-identical to the hand-rolled version it replaced. export function formatToolList(labels: string[]): string { - if (labels.length === 0) return ""; - if (labels.length === 1) return labels[0] ?? ""; - if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; - const head = labels.slice(0, -1).join(", "); - return `${head}, and ${labels[labels.length - 1]}`; + return formatList(labels); } diff --git a/tests/components/TaskList.test.tsx b/tests/components/TaskList.test.tsx index d36000c..2bcf7c1 100644 --- a/tests/components/TaskList.test.tsx +++ b/tests/components/TaskList.test.tsx @@ -2,11 +2,7 @@ import { cleanup, render } from "ink-testing-library"; import { afterEach, describe, expect, test, vi } from "vitest"; import { TaskList } from "@/components/TaskList.js"; -const VERB = { - infinitive: "install", - present: "Installing", - past: "Installed", -}; +const VERB = "install" as const; afterEach(() => { cleanup(); @@ -60,7 +56,7 @@ describe("TaskList", () => { expect(onDone).toHaveBeenCalledWith(["a"]); }); - test("marks a task as failed and uses the infinitive verb in the error", async () => { + test("marks a task as failed and names the verb in the error", async () => { const onDone = vi.fn(() => {}); const { frames } = render( { ), }, ]} - verb={{ infinitive: "update", present: "Updating", past: "Updated" }} + verb="update" onDone={() => {}} />, ); diff --git a/tests/lib/i18n.test.ts b/tests/lib/i18n.test.ts new file mode 100644 index 0000000..241a23d --- /dev/null +++ b/tests/lib/i18n.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + currentLocale, + formatList, + type Locale, + resetLocaleCache, + t, + tCount, +} from "@/lib/i18n.js"; + +// Every test drives resolution through the environment, so each one has to +// start from a clean slate — the resolved locale is memoized for the life of +// the process exactly like lib/log.ts's logger and model-limits' window cache. +afterEach(() => { + vi.unstubAllEnvs(); + resetLocaleCache(); +}); + +/** Blank every source so a test only sees the vars it sets itself. */ +function clearLocaleEnv(): void { + for (const name of ["CODEV_LANG", "LC_ALL", "LC_MESSAGES", "LANG"]) { + vi.stubEnv(name, ""); + } + resetLocaleCache(); +} + +describe("locale resolution", () => { + test("CODEV_LANG wins over the POSIX locale variables", () => { + clearLocaleEnv(); + vi.stubEnv("LANG", "en_US.UTF-8"); + vi.stubEnv("CODEV_LANG", "vi"); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + }); + + test("the POSIX variables are consulted in LC_ALL, LC_MESSAGES, LANG order", () => { + clearLocaleEnv(); + vi.stubEnv("LANG", "en_US.UTF-8"); + vi.stubEnv("LC_MESSAGES", "en_US.UTF-8"); + vi.stubEnv("LC_ALL", "vi_VN.UTF-8"); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + }); + + // The trap lib/proxy.ts documents for HTTP_PROXY/http_proxy: a plain `??` + // chain over raw values only falls through on undefined, so an exported-but- + // empty variable would mask a perfectly good one further down. + test("an exported-but-empty variable does not mask a later one", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", ""); + vi.stubEnv("LC_ALL", ""); + vi.stubEnv("LANG", "vi_VN.UTF-8"); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + }); + + test.each([ + "vi", + "VI", + "vi_VN", + "vi-VN", + "vi_VN.UTF-8", + "vi@quot", + ])("%s normalizes to vi", (raw) => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", raw); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + }); + + test("an unshipped locale falls through instead of pinning itself", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "klingon"); + vi.stubEnv("LANG", "vi_VN.UTF-8"); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + }); + + test.each([ + "C", + "POSIX", + "klingon", + "de_DE.UTF-8", + ])("%s with no other source resolves to a locale we actually ship", (raw) => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", raw); + resetLocaleCache(); + // Intl reports the developer's own OS locale as the last source, so the + // exact value is machine-dependent — what must hold is that an + // unrecognized name never escapes as the active locale. + const resolved: Locale = currentLocale(); + expect(["en", "vi"]).toContain(resolved); + }); + + test("the resolved locale is memoized until the cache is reset", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "vi"); + resetLocaleCache(); + expect(currentLocale()).toBe("vi"); + + vi.stubEnv("CODEV_LANG", "en"); + expect(currentLocale()).toBe("vi"); + + resetLocaleCache(); + expect(currentLocale()).toBe("en"); + }); +}); + +describe("t", () => { + test("returns the active locale's message", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "vi"); + resetLocaleCache(); + expect(t("common.done")).toBe("Xong!"); + }); + + test("interpolates {name} placeholders", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "en"); + resetLocaleCache(); + expect(t("common.file_other", { count: 3 })).toBe("3 files"); + }); + + test("leaves a placeholder verbatim when no value is supplied", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "en"); + resetLocaleCache(); + expect(t("common.file_other")).toBe("{count} files"); + }); +}); + +describe("tCount", () => { + test.each([ + [1, "1 file"], + [0, "0 files"], + [2, "2 files"], + ])("English selects on count: %i", (count, expected) => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "en"); + resetLocaleCache(); + expect(tCount("common.file", count)).toBe(expected); + }); + + test("Vietnamese reads the same for either count", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "vi"); + resetLocaleCache(); + expect(tCount("common.file", 1)).toBe("1 tệp"); + expect(tCount("common.file", 5)).toBe("5 tệp"); + }); +}); + +describe("formatList", () => { + test("English keeps the Oxford comma the hand-rolled joiner produced", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "en"); + resetLocaleCache(); + expect(formatList([])).toBe(""); + expect(formatList(["X"])).toBe("X"); + expect(formatList(["X", "Y"])).toBe("X and Y"); + expect(formatList(["X", "Y", "Z"])).toBe("X, Y, and Z"); + }); + + test("Vietnamese joins with và", () => { + clearLocaleEnv(); + vi.stubEnv("CODEV_LANG", "vi"); + resetLocaleCache(); + expect(formatList(["X", "Y"])).toBe("X và Y"); + expect(formatList(["X", "Y", "Z"])).toBe("X, Y và Z"); + }); +}); diff --git a/tests/lib/locales.test.ts b/tests/lib/locales.test.ts new file mode 100644 index 0000000..ce04e21 --- /dev/null +++ b/tests/lib/locales.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { en } from "@/lib/locales/en.js"; +import { vi } from "@/lib/locales/vi.js"; + +// `Record` already makes a missing key a compile error, so +// these tests cover what the type system cannot see: values that are present but +// wrong. A blank string renders as a blank frame; a mistyped placeholder renders +// as literal `{cout}` in front of the user. +const CATALOGS: [string, Record][] = [ + ["en", en], + ["vi", vi], +]; + +/** The `{name}` placeholders a template expects, order-independent. */ +function placeholders(template: string): Set { + return new Set( + [...template.matchAll(/\{(\w+)\}/g)].map((m) => m[1] as string), + ); +} + +describe.each(CATALOGS)("%s catalog", (_name, catalog) => { + test("has no blank messages", () => { + const blank = Object.keys(catalog).filter((k) => catalog[k]?.trim() === ""); + expect(blank).toEqual([]); + }); + + test("has no untrimmed keys", () => { + const bad = Object.keys(catalog).filter((k) => k !== k.trim()); + expect(bad).toEqual([]); + }); +}); + +describe("catalog parity", () => { + test("every locale declares exactly the English key set", () => { + const expected = Object.keys(en).sort(); + for (const [name, catalog] of CATALOGS) { + expect({ [name]: Object.keys(catalog).sort() }).toEqual({ + [name]: expected, + }); + } + }); + + test("each key uses the same placeholders in every locale", () => { + const mismatched: string[] = []; + for (const key of Object.keys(en)) { + const expected = placeholders(en[key as keyof typeof en]); + for (const [name, catalog] of CATALOGS) { + const actual = placeholders(catalog[key] ?? ""); + if ( + actual.size !== expected.size || + [...expected].some((p) => !actual.has(p)) + ) { + mismatched.push( + `${key} (${name}): expected {${[...expected].join(", ")}}, got {${[...actual].join(", ")}}`, + ); + } + } + } + expect(mismatched).toEqual([]); + }); + + test("every plural declares both halves", () => { + const keys = new Set(Object.keys(en)); + const lonely = [...keys] + .filter((k) => k.endsWith("_one") || k.endsWith("_other")) + .filter((k) => { + const base = k.replace(/_(one|other)$/, ""); + return !keys.has(`${base}_one`) || !keys.has(`${base}_other`); + }); + expect(lonely).toEqual([]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8f27793..88d3276 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,12 @@ export default defineConfig({ test: { include: ["tests/**/*.test.{ts,tsx}"], environment: "node", + // Pin the UI language. Hundreds of assertions across the suite match + // English literals, and lib/i18n.ts otherwise resolves from the OS locale + // — so without this a developer whose machine is set to vi_VN would get a + // red suite for no reason. Tests that exercise other locales stub + // CODEV_LANG themselves and call resetLocaleCache(). + env: { CODEV_LANG: "en" }, // Windows CI runners are 2-3× slower and load-variable: a render that // takes 30 ms locally can take a couple of seconds under contention. // Vitest's defaults (5 s test, 10 s hook) leave no slack, so Ink tests