From 604bc3408dd34a96ffa42f6a9c5b50008241fe28 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:15 +0800 Subject: [PATCH 1/6] feat(opencode): add locale-aware i18n core Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/i18n/en.ts | 54 ++++++++++++++++++++ packages/opencode/src/i18n/index.ts | 76 +++++++++++++++++++++++++++++ packages/opencode/src/i18n/zh.ts | 52 ++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 packages/opencode/src/i18n/en.ts create mode 100644 packages/opencode/src/i18n/index.ts create mode 100644 packages/opencode/src/i18n/zh.ts diff --git a/packages/opencode/src/i18n/en.ts b/packages/opencode/src/i18n/en.ts new file mode 100644 index 000000000000..578cf8ef7382 --- /dev/null +++ b/packages/opencode/src/i18n/en.ts @@ -0,0 +1,54 @@ +export const dict = { + "cli.export.progress": "Exporting session: {{session}}", + "cli.export.latest": "latest", + "cli.export.intro": "Export session", + "cli.export.none": "No sessions found", + "cli.export.done": "Done", + "cli.export.select": "Select session to export", + "cli.export.outro": "Exporting session...", + "cli.export.not_found": "Session not found: {{session}}", + "cli.serve.warning_unsecured": "Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.", + "cli.serve.listening": "opencode server listening on http://{{hostname}}:{{port}}", + "cli.web.warning_unsecured": "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.", + "cli.web.local": "Local access:", + "cli.web.network": "Network access:", + "cli.web.interface": "Web interface:", + "cli.web.mdns": "mDNS:", + "cli.session.not_found": "Session not found: {{session}}", + "cli.session.deleted": "Session {{session}} deleted", + "cli.session.header.id": "Session ID", + "cli.session.header.title": "Title", + "cli.session.header.updated": "Updated", + "tui.status.title": "Status", + "tui.status.close": "esc", + "tui.status.none.mcp": "No MCP Servers", + "tui.status.none.formatter": "No Formatters", + "tui.status.none.plugin": "No Plugins", + "tui.status.count.mcp.one": "{{count}} MCP Server", + "tui.status.count.mcp.other": "{{count}} MCP Servers", + "tui.status.count.lsp.one": "{{count}} LSP Server", + "tui.status.count.lsp.other": "{{count}} LSP Servers", + "tui.status.count.formatter.one": "{{count}} Formatter", + "tui.status.count.formatter.other": "{{count}} Formatters", + "tui.status.count.plugin.one": "{{count}} Plugin", + "tui.status.count.plugin.other": "{{count}} Plugins", + "tui.status.connected": "Connected", + "tui.status.disabled": "Disabled in configuration", + "tui.status.needs_auth": "Needs authentication (run: opencode mcp auth {{name}})", + "tui.dialog.help.title": "Help", + "tui.dialog.help.close": "esc/enter", + "tui.dialog.help.body": "Press {{keybind}} to see all available actions and commands in any context.", + "tui.dialog.help.ok": "ok", + "tui.dialog.agent.title": "Select agent", + "tui.dialog.agent.native": "native", + "tui.dialog.select.search": "Search", + "tui.dialog.select.none": "No results found", + "tui.home.placeholder.todo": "Fix a TODO in the codebase", + "tui.home.placeholder.stack": "What is the tech stack of this project?", + "tui.home.placeholder.tests": "Fix broken tests", + "tui.home.placeholder.shell.ls": "ls -la", + "tui.home.placeholder.shell.git": "git status", + "tui.home.placeholder.shell.pwd": "pwd", +} as const + +export type Dict = typeof dict diff --git a/packages/opencode/src/i18n/index.ts b/packages/opencode/src/i18n/index.ts new file mode 100644 index 000000000000..07adc03d559d --- /dev/null +++ b/packages/opencode/src/i18n/index.ts @@ -0,0 +1,76 @@ +import { dict as en } from "./en" +import { dict as zh } from "./zh" + +export const LOCALES = ["en", "zh"] as const + +export type Locale = (typeof LOCALES)[number] +export type Key = keyof typeof en +export type Params = Record + +const INTL = { + en: "en", + zh: "zh-Hans", +} as const satisfies Record + +const dicts = { + en, + zh, +} as const satisfies Record> + +function from(input: string) { + const value = input.trim().toLowerCase().replaceAll("_", "-") + if (!value) return null + if (value.startsWith("zh")) return "zh" satisfies Locale + if (value.startsWith("en")) return "en" satisfies Locale + return null +} + +export function parseLocale(value: unknown): Locale | null { + if (typeof value !== "string") return null + if ((LOCALES as readonly string[]).includes(value)) return value as Locale + return from(value) +} + +export function normalizeLocale(value: unknown): Locale { + return parseLocale(value) ?? "en" +} + +export function resolveLocale(value?: unknown, env: NodeJS.ProcessEnv = process.env): Locale { + const direct = parseLocale(value) + if (direct) return direct + + for (const key of ["OPENCODE_LOCALE", "LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]) { + const hit = parseLocale(env[key]) + if (hit) return hit + } + + return "en" +} + +export function intl(locale: Locale) { + return INTL[locale] +} + +export function plural(locale: Locale, count: number, forms: { one: Key; other: Key }) { + const rule = new Intl.PluralRules(intl(locale)).select(count) + if (rule === "one") return forms.one + return forms.other +} + +function resolve(text: string, params?: Params) { + if (!params) return text + return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, raw) => { + const key = String(raw) + const value = params[key] + return value === undefined ? "" : String(value) + }) +} + +export function t(locale: Locale, key: Key, params?: Params) { + const value = dicts[locale][key] ?? dicts.en[key] ?? String(key) + return resolve(value, params) +} + +export function datetime(locale: Locale, input: number) { + return new Date(input).toLocaleString(intl(locale)) +} diff --git a/packages/opencode/src/i18n/zh.ts b/packages/opencode/src/i18n/zh.ts new file mode 100644 index 000000000000..a5eef8f5e5db --- /dev/null +++ b/packages/opencode/src/i18n/zh.ts @@ -0,0 +1,52 @@ +export const dict = { + "cli.export.progress": "正在导出会话:{{session}}", + "cli.export.latest": "最新", + "cli.export.intro": "导出会话", + "cli.export.none": "未找到会话", + "cli.export.done": "完成", + "cli.export.select": "选择要导出的会话", + "cli.export.outro": "正在导出会话...", + "cli.export.not_found": "找不到会话:{{session}}", + "cli.serve.warning_unsecured": "警告:未设置 OPENCODE_SERVER_PASSWORD;服务器未受保护。", + "cli.serve.listening": "opencode server 正在监听 http://{{hostname}}:{{port}}", + "cli.web.warning_unsecured": "未设置 OPENCODE_SERVER_PASSWORD;服务器未受保护。", + "cli.web.local": "本地访问:", + "cli.web.network": "局域网访问:", + "cli.web.interface": "Web 界面:", + "cli.web.mdns": "mDNS:", + "cli.session.not_found": "找不到会话:{{session}}", + "cli.session.deleted": "会话 {{session}} 已删除", + "cli.session.header.id": "会话 ID", + "cli.session.header.title": "标题", + "cli.session.header.updated": "更新时间", + "tui.status.title": "状态", + "tui.status.close": "esc", + "tui.status.none.mcp": "没有 MCP 服务器", + "tui.status.none.formatter": "没有 Formatter", + "tui.status.none.plugin": "没有插件", + "tui.status.count.mcp.one": "{{count}} 个 MCP 服务器", + "tui.status.count.mcp.other": "{{count}} 个 MCP 服务器", + "tui.status.count.lsp.one": "{{count}} 个 LSP 服务器", + "tui.status.count.lsp.other": "{{count}} 个 LSP 服务器", + "tui.status.count.formatter.one": "{{count}} 个 Formatter", + "tui.status.count.formatter.other": "{{count}} 个 Formatter", + "tui.status.count.plugin.one": "{{count}} 个插件", + "tui.status.count.plugin.other": "{{count}} 个插件", + "tui.status.connected": "已连接", + "tui.status.disabled": "已在配置中禁用", + "tui.status.needs_auth": "需要认证(运行:opencode mcp auth {{name}})", + "tui.dialog.help.title": "帮助", + "tui.dialog.help.close": "esc/enter", + "tui.dialog.help.body": "按下 {{keybind}} 可在任意上下文查看全部可用操作和命令。", + "tui.dialog.help.ok": "确定", + "tui.dialog.agent.title": "选择 agent", + "tui.dialog.agent.native": "内置", + "tui.dialog.select.search": "搜索", + "tui.dialog.select.none": "没有结果", + "tui.home.placeholder.todo": "修复代码库中的 TODO", + "tui.home.placeholder.stack": "这个项目的技术栈是什么?", + "tui.home.placeholder.tests": "修复失败的测试", + "tui.home.placeholder.shell.ls": "ls -la", + "tui.home.placeholder.shell.git": "git status", + "tui.home.placeholder.shell.pwd": "pwd", +} as const From 6ce0f942ae1664d179475671ac2cd6dec129092a Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:15 +0800 Subject: [PATCH 2/6] feat(opencode): add locale config support Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/config/config.ts | 10 ++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b11ae83192ce..d0c42d1eefe4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -41,6 +41,7 @@ import { Duration, Effect, Layer, Option, ServiceMap } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" +import { normalizeLocale } from "@/i18n" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -890,6 +891,15 @@ export namespace Config { .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + locale: z + .preprocess( + (value) => { + if (value === undefined) return undefined + return normalizeLocale(value) === "en" && value !== "en" ? undefined : normalizeLocale(value) + }, + z.enum(["en", "zh"]).optional(), + ) + .describe("Locale for localized CLI and TUI messages. Defaults to environment locale, then English."), small_model: ModelId.describe( "Small model to use for tasks like title generation in the format of provider/model", ).optional(), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72e549e485ab..17be61667ff1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1484,6 +1484,10 @@ export type Config = { * Model to use in the format of provider/model, eg anthropic/claude-2 */ model?: string + /** + * Locale for localized CLI and TUI messages. Defaults to environment locale, then English. + */ + locale?: "en" | "zh" /** * Small model to use for tasks like title generation in the format of provider/model */ From 1f61fe80ea770e0fab5695f4c16815d31a218c84 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:32 +0800 Subject: [PATCH 3/6] feat(opencode): localize core cli commands Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/export.ts | 31 ++++++++++++++++++------ packages/opencode/src/cli/cmd/serve.ts | 21 ++++++++++++---- packages/opencode/src/cli/cmd/session.ts | 30 ++++++++++++++++++----- packages/opencode/src/cli/cmd/web.ts | 22 +++++++++++++---- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 4088b4818d2b..724a9e895d8d 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,12 +1,26 @@ import type { Argv } from "yargs" import { Session } from "../../session" import { SessionID } from "../../session/schema" +import { Config } from "../../config/config" +import { datetime, resolveLocale, t, type Locale } from "../../i18n" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" +function latest(locale: Locale) { + return t(locale, "cli.export.latest") +} + +export function exportProgress(locale: Locale, session: string) { + return t(locale, "cli.export.progress", { session }) +} + +export function exportHint(locale: Locale, updated: number, id: string) { + return `${datetime(locale, updated)} • ${id.slice(-8)}` +} + export const ExportCommand = cmd({ command: "export [sessionID]", describe: "export session data as JSON", @@ -18,12 +32,13 @@ export const ExportCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { + const locale = resolveLocale((await Config.get()).locale) let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined - process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) + process.stderr.write(exportProgress(locale, sessionID ?? latest(locale)) + "\n") if (!sessionID) { UI.empty() - prompts.intro("Export session", { + prompts.intro(t(locale, "cli.export.intro"), { output: process.stderr, }) @@ -33,10 +48,10 @@ export const ExportCommand = cmd({ } if (sessions.length === 0) { - prompts.log.error("No sessions found", { + prompts.log.error(t(locale, "cli.export.none"), { output: process.stderr, }) - prompts.outro("Done", { + prompts.outro(t(locale, "cli.export.done"), { output: process.stderr, }) return @@ -45,12 +60,12 @@ export const ExportCommand = cmd({ sessions.sort((a, b) => b.time.updated - a.time.updated) const selectedSession = await prompts.autocomplete({ - message: "Select session to export", + message: t(locale, "cli.export.select"), maxItems: 10, options: sessions.map((session) => ({ label: session.title, value: session.id, - hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, + hint: exportHint(locale, session.time.updated, session.id), })), output: process.stderr, }) @@ -61,7 +76,7 @@ export const ExportCommand = cmd({ sessionID = selectedSession - prompts.outro("Exporting session...", { + prompts.outro(t(locale, "cli.export.outro"), { output: process.stderr, }) } @@ -81,7 +96,7 @@ export const ExportCommand = cmd({ process.stdout.write(JSON.stringify(exportData, null, 2)) process.stdout.write(EOL) } catch (error) { - UI.error(`Session not found: ${sessionID!}`) + UI.error(t(locale, "cli.export.not_found", { session: sessionID! })) process.exit(1) } }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index ab51fe8c3e3b..0f630bbdd0c4 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,22 +1,33 @@ import { Server } from "../../server/server" +import { Config } from "../../config/config" +import { resolveLocale, t } from "../../i18n" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" -import { Workspace } from "../../control-plane/workspace" -import { Project } from "../../project/project" -import { Installation } from "../../installation" +import { bootstrap } from "../bootstrap" + +export function serveWarning(locale: ReturnType) { + return t(locale, "cli.serve.warning_unsecured") +} + +export function serveListening(locale: ReturnType, hostname: string, port: number) { + return t(locale, "cli.serve.listening", { hostname, port }) +} export const ServeCommand = cmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { + const locale = await bootstrap(process.cwd(), async () => resolveLocale((await Config.get()).locale)) if (!Flag.OPENCODE_SERVER_PASSWORD) { - console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + console.log(serveWarning(locale)) } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + const port = server.port ?? opts.port + if (port === undefined) throw new Error("Failed to resolve server port") + console.log(serveListening(locale, server.hostname ?? opts.hostname, port)) await new Promise(() => {}) await server.stop() diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8acd7480c941..cea417f2867c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -4,6 +4,8 @@ import { Session } from "../../session" import { SessionID } from "../../session/schema" import { bootstrap } from "../bootstrap" import { UI } from "../ui" +import { Config } from "../../config/config" +import { resolveLocale, t, type Locale as Lang } from "../../i18n" import { Locale } from "../../util/locale" import { Flag } from "../../flag/flag" import { Filesystem } from "../../util/filesystem" @@ -58,15 +60,20 @@ export const SessionDeleteCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { + const locale = resolveLocale((await Config.get()).locale) const sessionID = SessionID.make(args.sessionID) try { await Session.get(sessionID) } catch { - UI.error(`Session not found: ${args.sessionID}`) + UI.error(t(locale, "cli.session.not_found", { session: args.sessionID })) process.exit(1) } await Session.remove(sessionID) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) + UI.println( + UI.Style.TEXT_SUCCESS_BOLD + + t(locale, "cli.session.deleted", { session: args.sessionID }) + + UI.Style.TEXT_NORMAL, + ) }) }, }) @@ -90,6 +97,7 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { + const locale = resolveLocale((await Config.get()).locale) const sessions = [...Session.list({ roots: true, limit: args.maxCount })] if (sessions.length === 0) { @@ -100,7 +108,7 @@ export const SessionListCommand = cmd({ if (args.format === "json") { output = formatSessionJSON(sessions) } else { - output = formatSessionTable(sessions) + output = formatSessionTable(sessions, locale) } const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" @@ -127,18 +135,21 @@ export const SessionListCommand = cmd({ }, }) -function formatSessionTable(sessions: Session.Info[]): string { +function formatSessionTable(sessions: Session.Info[], locale: Lang): string { const lines: string[] = [] const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length)) const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length)) - const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` + const id = t(locale, "cli.session.header.id") + const title = t(locale, "cli.session.header.title") + const updated = t(locale, "cli.session.header.updated") + const header = sessionHeader(locale, maxIdWidth, maxTitleWidth) lines.push(header) lines.push("─".repeat(header.length)) for (const session of sessions) { const truncatedTitle = Locale.truncate(session.title, maxTitleWidth) - const timeStr = Locale.todayTimeOrDateTime(session.time.updated) + const timeStr = Locale.todayTimeOrDateTime(session.time.updated, locale) const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` lines.push(line) } @@ -146,6 +157,13 @@ function formatSessionTable(sessions: Session.Info[]): string { return lines.join(EOL) } +export function sessionHeader(locale: Lang, maxIdWidth: number, maxTitleWidth: number) { + const id = t(locale, "cli.session.header.id") + const title = t(locale, "cli.session.header.title") + const updated = t(locale, "cli.session.header.updated") + return `${id}${" ".repeat(Math.max(1, maxIdWidth - id.length))} ${title}${" ".repeat(Math.max(1, maxTitleWidth - title.length))} ${updated}` +} + function formatSessionJSON(sessions: Session.Info[]): string { const jsonData = sessions.map((session) => ({ id: session.id, diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..ade41414a2c0 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,10 +1,13 @@ import { Server } from "../../server/server" import { UI } from "../ui" +import { Config } from "../../config/config" +import { resolveLocale, t } from "../../i18n" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" +import { bootstrap } from "../bootstrap" function getNetworkIPs() { const nets = networkInterfaces() @@ -33,8 +36,9 @@ export const WebCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", handler: async (args) => { + const locale = await bootstrap(process.cwd(), async () => resolveLocale((await Config.get()).locale)) if (!Flag.OPENCODE_SERVER_PASSWORD) { - UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + t(locale, "cli.web.warning_unsecured")) } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) @@ -45,14 +49,18 @@ export const WebCommand = cmd({ if (opts.hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` - UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) + UI.println( + UI.Style.TEXT_INFO_BOLD + ` ${t(locale, "cli.web.local").padEnd(18)} `, + UI.Style.TEXT_NORMAL, + localhostUrl, + ) // Show network IPs for remote access const networkIPs = getNetworkIPs() if (networkIPs.length > 0) { for (const ip of networkIPs) { UI.println( - UI.Style.TEXT_INFO_BOLD + " Network access: ", + UI.Style.TEXT_INFO_BOLD + ` ${t(locale, "cli.web.network").padEnd(18)} `, UI.Style.TEXT_NORMAL, `http://${ip}:${server.port}`, ) @@ -61,7 +69,7 @@ export const WebCommand = cmd({ if (opts.mdns) { UI.println( - UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_INFO_BOLD + ` ${t(locale, "cli.web.mdns").padEnd(18)} `, UI.Style.TEXT_NORMAL, `${opts.mdnsDomain}:${server.port}`, ) @@ -71,7 +79,11 @@ export const WebCommand = cmd({ open(localhostUrl.toString()).catch(() => {}) } else { const displayUrl = server.url.toString() - UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) + UI.println( + UI.Style.TEXT_INFO_BOLD + ` ${t(locale, "cli.web.interface").padEnd(18)} `, + UI.Style.TEXT_NORMAL, + displayUrl, + ) open(displayUrl).catch(() => {}) } From 9e3dc7fc6d0d2cae364495dc2a09b47ef70f4b1e Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:32 +0800 Subject: [PATCH 4/6] feat(tui): add localized command and dialog copy Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/tui/app.tsx | 46 +++++----- .../cli/cmd/tui/component/dialog-agent.tsx | 6 +- .../cli/cmd/tui/component/dialog-status.tsx | 85 +++++++++++++------ .../opencode/src/cli/cmd/tui/context/i18n.tsx | 16 ++++ 4 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/i18n.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6b2633c371f3..96c0a176750c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -27,6 +27,7 @@ import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" +import { I18nProvider, useI18n } from "@tui/context/i18n" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" @@ -214,25 +215,27 @@ export function tui(input: { events={input.events} > - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -262,6 +265,7 @@ function App(props: { onSnapshot?: () => Promise }) { const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() + const i18n = useI18n() const exit = useExit() const promptRef = usePromptRef() const routes: RouteMap = new Map() @@ -691,7 +695,7 @@ function App(props: { onSnapshot?: () => Promise }) { category: "System", }, { - title: "Help", + title: i18n.t("tui.dialog.help.title"), value: "help.show", slash: { name: "help", @@ -829,7 +833,7 @@ function App(props: { onSnapshot?: () => Promise }) { route.navigate({ type: "home" }) toast.show({ variant: "info", - message: "The current session was deleted", + message: i18n.t("cli.session.deleted", { session: evt.properties.info.id }), }) } }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 365a22445b4b..05ba87787059 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -2,24 +2,26 @@ import { createMemo } from "solid-js" import { useLocal } from "@tui/context/local" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" +import { useI18n } from "@tui/context/i18n" export function DialogAgent() { const local = useLocal() const dialog = useDialog() + const i18n = useI18n() const options = createMemo(() => local.agent.list().map((item) => { return { value: item.name, title: item.name, - description: item.native ? "native" : item.description, + description: item.native ? i18n.t("tui.dialog.agent.native") : item.description, } }), ) return ( { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index ebc65a45b7d9..900a9c3c989a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -1,16 +1,58 @@ import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" -import { For, Match, Switch, Show, createMemo } from "solid-js" +import { For, Show, createMemo } from "solid-js" +import { plural, resolveLocale, t, type Locale } from "@/i18n" export type DialogStatusProps = {} +type CountKind = "mcp" | "lsp" | "formatter" | "plugin" + +const COUNT = { + mcp: { + one: "tui.status.count.mcp.one", + other: "tui.status.count.mcp.other", + }, + lsp: { + one: "tui.status.count.lsp.one", + other: "tui.status.count.lsp.other", + }, + formatter: { + one: "tui.status.count.formatter.one", + other: "tui.status.count.formatter.other", + }, + plugin: { + one: "tui.status.count.plugin.one", + other: "tui.status.count.plugin.other", + }, +} as const + +export function countText(locale: Locale, kind: CountKind, count: number) { + const key = plural(locale, count, COUNT[kind]) + return t(locale, key, { count }) +} + +export function mcpStatusText(locale: Locale, name: string, item: { status: string; error?: string }) { + if (item.status === "connected") return t(locale, "tui.status.connected") + if (item.status === "disabled") return t(locale, "tui.status.disabled") + if (item.status === "needs_auth") return t(locale, "tui.status.needs_auth", { name }) + return item.error ?? item.status +} + export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const locale = createMemo(() => resolveLocale(sync.data.config.locale)) + + useKeyboard((evt) => { + if (evt.name === "return" || evt.name === "escape") { + dialog.clear() + } + }) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -44,15 +86,16 @@ export function DialogStatus() { - Status - - dialog.clear()}> - esc + {t(locale(), "tui.status.title")} + {t(locale(), "tui.status.close")} - 0} fallback={No MCP Servers}> + 0} + fallback={{t(locale(), "tui.status.none.mcp")}} + > - {Object.keys(sync.data.mcp).length} MCP Servers + {countText(locale(), "mcp", Object.keys(sync.data.mcp).length)} {([key, item]) => ( @@ -73,20 +116,7 @@ export function DialogStatus() { • - {key}{" "} - - - Connected - {(val) => val().error} - Disabled in configuration - - Needs authentication (run: opencode mcp auth {key}) - - - {(val) => (val() as { error: string }).error} - - - + {key} {mcpStatusText(locale(), key, item)} )} @@ -95,7 +125,7 @@ export function DialogStatus() { {sync.data.lsp.length > 0 && ( - {sync.data.lsp.length} LSP Servers + {countText(locale(), "lsp", sync.data.lsp.length)} {(item) => ( @@ -118,9 +148,12 @@ export function DialogStatus() { )} - 0} fallback={No Formatters}> + 0} + fallback={{t(locale(), "tui.status.none.formatter")}} + > - {enabledFormatters().length} Formatters + {countText(locale(), "formatter", enabledFormatters().length)} {(item) => ( @@ -140,9 +173,9 @@ export function DialogStatus() { - 0} fallback={No Plugins}> + 0} fallback={{t(locale(), "tui.status.none.plugin")}}> - {plugins().length} Plugins + {countText(locale(), "plugin", plugins().length)} {(item) => ( diff --git a/packages/opencode/src/cli/cmd/tui/context/i18n.tsx b/packages/opencode/src/cli/cmd/tui/context/i18n.tsx new file mode 100644 index 000000000000..41e147d7fdcf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/i18n.tsx @@ -0,0 +1,16 @@ +import { createSimpleContext } from "./helper" +import { resolveLocale, t } from "@/i18n" +import { useSync } from "./sync" + +export const { use: useI18n, provider: I18nProvider } = createSimpleContext({ + name: "I18n", + init: () => { + const sync = useSync() + return { + locale: () => resolveLocale(sync.data.config.locale), + t(key: Parameters[1], params?: Parameters[2]) { + return t(resolveLocale(sync.data.config.locale), key, params) + }, + } + }, +}) From 8f5cf59ebe9ec3f8630dd8faf43aa934f88f7356 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:50 +0800 Subject: [PATCH 5/6] feat(tui): localize helper text and placeholders Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../opencode/src/cli/cmd/tui/routes/home.tsx | 23 ++++++++++++++----- .../src/cli/cmd/tui/ui/dialog-help.tsx | 12 +++++----- .../src/cli/cmd/tui/ui/dialog-select.tsx | 6 +++-- packages/opencode/src/util/locale.ts | 18 ++++++++------- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 79b5c4d7ab96..4590ca060862 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,5 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createEffect, createSignal } from "solid-js" +import { createEffect, createMemo, createSignal } from "solid-js" import { Logo } from "../component/logo" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" @@ -7,14 +7,11 @@ import { useArgs } from "../context/args" import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" +import { useI18n } from "../context/i18n" import { TuiPluginRuntime } from "../plugin" // TODO: what is the best way to do this? let once = false -const placeholder = { - normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"], - shell: ["ls -la", "git status", "pwd"], -} export function Home() { const sync = useSync() @@ -23,8 +20,22 @@ export function Home() { const [ref, setRef] = createSignal() const args = useArgs() const local = useLocal() + const i18n = useI18n() let sent = false + const placeholder = createMemo(() => ({ + normal: [ + i18n.t("tui.home.placeholder.todo"), + i18n.t("tui.home.placeholder.stack"), + i18n.t("tui.home.placeholder.tests"), + ], + shell: [ + i18n.t("tui.home.placeholder.shell.ls"), + i18n.t("tui.home.placeholder.shell.git"), + i18n.t("tui.home.placeholder.shell.pwd"), + ], + })) + const bind = (r: PromptRef | undefined) => { setRef(r) promptRef.set(r) @@ -68,7 +79,7 @@ export function Home() { ref={bind} workspaceID={route.workspaceID} right={} - placeholders={placeholder} + placeholders={placeholder()} /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 4e4527930345..6a6e295af50b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -3,11 +3,13 @@ import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" import { useKeyboard } from "@opentui/solid" import { useKeybind } from "@tui/context/keybind" +import { useI18n } from "@tui/context/i18n" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() const keybind = useKeybind() + const i18n = useI18n() useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { @@ -19,20 +21,18 @@ export function DialogHelp() { - Help + {i18n.t("tui.dialog.help.title")} dialog.clear()}> - esc/enter + {i18n.t("tui.dialog.help.close")} - - Press {keybind.print("command_list")} to see all available actions and commands in any context. - + {i18n.t("tui.dialog.help.body", { keybind: keybind.print("command_list") })} dialog.clear()}> - ok + {i18n.t("tui.dialog.help.ok")} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821cccec79..69a73caf0e15 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -8,6 +8,7 @@ import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" +import { useI18n } from "@tui/context/i18n" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" @@ -54,6 +55,7 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const i18n = useI18n() const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -266,7 +268,7 @@ export function DialogSelect(props: DialogSelectProps) { input.focus() }, 1) }} - placeholder={props.placeholder ?? "Search"} + placeholder={props.placeholder ?? i18n.t("tui.dialog.select.search")} placeholderColor={theme.textMuted} /> @@ -275,7 +277,7 @@ export function DialogSelect(props: DialogSelectProps) { when={grouped().length > 0} fallback={ - No results found + {i18n.t("tui.dialog.select.none")} } > diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7d..66a85b2bdfe9 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -1,30 +1,32 @@ +import { intl, type Locale as Lang } from "@/i18n" + export namespace Locale { export function titlecase(str: string) { return str.replace(/\b\w/g, (c) => c.toUpperCase()) } - export function time(input: number): string { + export function time(input: number, locale?: Lang): string { const date = new Date(input) - return date.toLocaleTimeString(undefined, { timeStyle: "short" }) + return date.toLocaleTimeString(locale ? intl(locale) : undefined, { timeStyle: "short" }) } - export function datetime(input: number): string { + export function datetime(input: number, locale?: Lang): string { const date = new Date(input) - const localTime = time(input) - const localDate = date.toLocaleDateString() + const localTime = time(input, locale) + const localDate = date.toLocaleDateString(locale ? intl(locale) : undefined) return `${localTime} · ${localDate}` } - export function todayTimeOrDateTime(input: number): string { + export function todayTimeOrDateTime(input: number, locale?: Lang): string { const date = new Date(input) const now = new Date() const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() if (isToday) { - return time(input) + return time(input, locale) } else { - return datetime(input) + return datetime(input, locale) } } From 2be1124c43ceac8b72ade0cf8a39651e9e69e9bc Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:50 +0800 Subject: [PATCH 6/6] test(opencode): cover locale resolution and mvp copy Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/test/cli/i18n-mvp.test.ts | 37 +++++++++++++++ packages/opencode/test/config/locale.test.ts | 50 ++++++++++++++++++++ packages/opencode/test/i18n/index.test.ts | 44 +++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 packages/opencode/test/cli/i18n-mvp.test.ts create mode 100644 packages/opencode/test/config/locale.test.ts create mode 100644 packages/opencode/test/i18n/index.test.ts diff --git a/packages/opencode/test/cli/i18n-mvp.test.ts b/packages/opencode/test/cli/i18n-mvp.test.ts new file mode 100644 index 000000000000..94ee168d00e0 --- /dev/null +++ b/packages/opencode/test/cli/i18n-mvp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { countText, mcpStatusText } from "../../src/cli/cmd/tui/component/dialog-status" +import { exportHint, exportProgress } from "../../src/cli/cmd/export" +import { serveListening, serveWarning } from "../../src/cli/cmd/serve" +import { sessionHeader } from "../../src/cli/cmd/session" + +describe("i18n MVP surfaces", () => { + test("formats serve strings", () => { + expect(serveWarning("en")).toContain("OPENCODE_SERVER_PASSWORD") + expect(serveListening("zh", "127.0.0.1", 4096)).toContain("4096") + }) + + test("formats export strings", () => { + expect(exportProgress("en", "latest")).toBe("Exporting session: latest") + expect(exportProgress("zh", "ses_1")).toContain("ses_1") + expect(exportHint("en", Date.UTC(2026, 0, 2, 3, 4, 5), "ses_12345678")).toContain("12345678") + }) + + test("formats dialog status counts", () => { + expect(countText("en", "plugin", 1)).toBe("1 Plugin") + expect(countText("en", "plugin", 2)).toBe("2 Plugins") + expect(countText("zh", "mcp", 3)).toBe("3 个 MCP 服务器") + }) + + test("formats session table header", () => { + expect(sessionHeader("en", 20, 25)).toContain("Session ID") + expect(sessionHeader("zh", 20, 25)).toContain("会话 ID") + expect(sessionHeader("zh", 20, 25)).toContain("更新时间") + }) + + test("formats mcp status messages", () => { + expect(mcpStatusText("en", "demo", { status: "connected" })).toBe("Connected") + expect(mcpStatusText("zh", "demo", { status: "disabled" })).toBe("已在配置中禁用") + expect(mcpStatusText("en", "demo", { status: "needs_auth" })).toContain("opencode mcp auth demo") + expect(mcpStatusText("en", "demo", { status: "failed", error: "boom" })).toBe("boom") + }) +}) diff --git a/packages/opencode/test/config/locale.test.ts b/packages/opencode/test/config/locale.test.ts new file mode 100644 index 000000000000..88e81354c9a3 --- /dev/null +++ b/packages/opencode/test/config/locale.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { Config } from "../../src/config/config" +import { resolveLocale } from "../../src/i18n" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +beforeEach(async () => { + await Config.invalidate(true) +}) + +afterEach(async () => { + await Config.invalidate(true) +}) + +test("loads locale from config and normalizes values", async () => { + await using tmp = await tmpdir({ + config: { + locale: "zh-CN" as never, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.locale).toBe("zh") + }, + }) +}) + +test("invalid locale defers to environment fallback instead of forcing english", async () => { + const prev = process.env.LANG + process.env.LANG = "zh_CN.UTF-8" + await using tmp = await tmpdir({ + config: { + locale: "xx-YY" as never, + username: "testuser", + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.locale).toBeUndefined() + expect(resolveLocale(config.locale)).toBe("zh") + expect(config.username).toBe("testuser") + }, + }) + if (prev === undefined) delete process.env.LANG + else process.env.LANG = prev +}) diff --git a/packages/opencode/test/i18n/index.test.ts b/packages/opencode/test/i18n/index.test.ts new file mode 100644 index 000000000000..aaf725fadc67 --- /dev/null +++ b/packages/opencode/test/i18n/index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { datetime, normalizeLocale, plural, resolveLocale, t } from "../../src/i18n" + +describe("i18n", () => { + test("normalizes supported locales", () => { + expect(normalizeLocale("en")).toBe("en") + expect(normalizeLocale("zh-CN")).toBe("zh") + expect(normalizeLocale("zh_TW")).toBe("zh") + expect(normalizeLocale("unknown")).toBe("en") + }) + + test("resolves locale from config value before env", () => { + expect(resolveLocale("zh", { LANG: "en_US.UTF-8" })).toBe("zh") + }) + + test("resolves locale from env fallback", () => { + expect(resolveLocale(undefined, { LANG: "zh_CN.UTF-8" })).toBe("zh") + expect(resolveLocale(undefined, { LC_ALL: "en_US.UTF-8" })).toBe("en") + expect(resolveLocale(undefined, {})).toBe("en") + }) + + test("falls back to english strings", () => { + expect(t("zh", "cli.export.intro")).toBe("导出会话") + expect(t("en", "cli.export.intro")).toBe("Export session") + }) + + test("interpolates params", () => { + expect(t("zh", "cli.export.not_found", { session: "ses_123" })).toContain("ses_123") + }) + + test("picks plural keys", () => { + expect(plural("en", 1, { one: "tui.status.count.plugin.one", other: "tui.status.count.plugin.other" })).toBe( + "tui.status.count.plugin.one", + ) + expect(plural("en", 2, { one: "tui.status.count.plugin.one", other: "tui.status.count.plugin.other" })).toBe( + "tui.status.count.plugin.other", + ) + }) + + test("formats datetime with explicit locale", () => { + const value = datetime("en", Date.UTC(2026, 0, 2, 3, 4, 5)) + expect(value.length).toBeGreaterThan(0) + }) +})