From 0567962ceb6534c0ac5827bf8d5d57e3d5044fb8 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:15 +0800 Subject: [PATCH 01/14] 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 c1a8ea12abb1c1e889291f3bf11a187bd5f9100e Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:15 +0800 Subject: [PATCH 02/14] 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 f9ca88341434..40498d4bcc1f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -38,6 +38,7 @@ import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { Npm } from "@/npm" import { InstanceRef } from "@/effect/instance-ref" +import { normalizeLocale } from "@/i18n" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -906,6 +907,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 e2a9a88ad356..b05c4b50e2bb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1521,6 +1521,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 b3c0898977a523a92913e5ccf8fadd473e73e424 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:32 +0800 Subject: [PATCH 03/14] 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 | 19 +++++++++++---- packages/opencode/src/cli/cmd/session.ts | 30 ++++++++++++++++++----- packages/opencode/src/cli/cmd/web.ts | 22 +++++++++++++---- 4 files changed, 78 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 73e7a18a7090..38c8361437c6 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,22 +1,31 @@ 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 = await Server.listen(opts) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + console.log(serveListening(locale, server.hostname, server.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 e656c83d9a7c..de11865600ec 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 = await 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 ca20e088fc14b5b2e56afc489ae8690addbefa8e Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:32 +0800 Subject: [PATCH 04/14] 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 acf007197b5b..bf1dfb458445 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -29,6 +29,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" @@ -158,25 +159,27 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -208,6 +211,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() @@ -617,7 +621,7 @@ function App(props: { onSnapshot?: () => Promise }) { category: "System", }, { - title: "Help", + title: i18n.t("tui.dialog.help.title"), value: "help.show", slash: { name: "help", @@ -756,7 +760,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 008f8ccae2f617d246d35d4907beac9b43248adc Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:50 +0800 Subject: [PATCH 05/14] 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 1cce7fb3963d..41ff6454256f 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 { useProject } from "../context/project" import { useSync } from "../context/sync" @@ -8,14 +8,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() @@ -25,8 +22,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) @@ -75,7 +86,7 @@ export function Home() { ref={bind} workspaceID={project.workspace.current()} 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 109b5f2f111b..b0666e6287c7 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" @@ -56,6 +57,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({ @@ -270,7 +272,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} /> @@ -279,7 +281,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 6049f5bbebc98a4ef13061dfe13bcc2fb208cda2 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Sun, 5 Apr 2026 17:19:50 +0800 Subject: [PATCH 06/14] 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) + }) +}) From 1b97b83884a0381cbe12b2a99d4dcf1714315b04 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 22:50:57 +0800 Subject: [PATCH 07/14] fix(opencode): align serve and web locale config access Use the current Config service API in localized server commands so repo typecheck and pre-push validation pass after the rebase. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/serve.ts | 6 +++++- packages/opencode/src/cli/cmd/web.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 38c8361437c6..48753ea29299 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,5 +1,6 @@ import { Server } from "../../server/server" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { resolveLocale, t } from "../../i18n" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" @@ -19,7 +20,10 @@ export const ServeCommand = cmd({ 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)) + const locale = await bootstrap(process.cwd(), async () => { + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + return resolveLocale(config.locale) + }) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log(serveWarning(locale)) } diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index de11865600ec..4a3eea5a6c0b 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ import { Server } from "../../server/server" import { UI } from "../ui" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { resolveLocale, t } from "../../i18n" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" @@ -36,7 +37,10 @@ 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)) + const locale = await bootstrap(process.cwd(), async () => { + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + return resolveLocale(config.locale) + }) if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + t(locale, "cli.web.warning_unsecured")) } From 2d07e474d9f607c646ff9e44577c9d7714eea267 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 22:50:57 +0800 Subject: [PATCH 08/14] fix(opencode): align cli locale readers with config service Switch localized export and session commands to the current Config service API so locale reads stay compatible with dev branch changes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/export.ts | 4 +++- packages/opencode/src/cli/cmd/session.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 724a9e895d8d..fe8990d5e49b 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -2,6 +2,7 @@ import type { Argv } from "yargs" import { Session } from "../../session" import { SessionID } from "../../session/schema" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { datetime, resolveLocale, t, type Locale } from "../../i18n" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" @@ -32,7 +33,8 @@ export const ExportCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const locale = resolveLocale((await Config.get()).locale) + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const locale = resolveLocale(config.locale) let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined process.stderr.write(exportProgress(locale, sessionID ?? latest(locale)) + "\n") diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index cea417f2867c..c0c40e151bd1 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -5,6 +5,7 @@ import { SessionID } from "../../session/schema" import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { resolveLocale, t, type Locale as Lang } from "../../i18n" import { Locale } from "../../util/locale" import { Flag } from "../../flag/flag" @@ -60,7 +61,8 @@ export const SessionDeleteCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const locale = resolveLocale((await Config.get()).locale) + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const locale = resolveLocale(config.locale) const sessionID = SessionID.make(args.sessionID) try { await Session.get(sessionID) @@ -97,7 +99,8 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const locale = resolveLocale((await Config.get()).locale) + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const locale = resolveLocale(config.locale) const sessions = [...Session.list({ roots: true, limit: args.maxCount })] if (sessions.length === 0) { From cb01594748f2f1f810b8cf317d058ddb294e2d0d Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 22:50:57 +0800 Subject: [PATCH 09/14] test(opencode): update locale config service calls Keep locale coverage on the new Config service interface so the push-blocking typecheck remains green. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/test/config/locale.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/config/locale.test.ts b/packages/opencode/test/config/locale.test.ts index 88e81354c9a3..356231211653 100644 --- a/packages/opencode/test/config/locale.test.ts +++ b/packages/opencode/test/config/locale.test.ts @@ -1,15 +1,16 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import { Config } from "../../src/config/config" +import { AppRuntime } from "../../src/effect/app-runtime" import { resolveLocale } from "../../src/i18n" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" beforeEach(async () => { - await Config.invalidate(true) + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(true))) }) afterEach(async () => { - await Config.invalidate(true) + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(true))) }) test("loads locale from config and normalizes values", async () => { @@ -21,7 +22,7 @@ test("loads locale from config and normalizes values", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) expect(config.locale).toBe("zh") }, }) @@ -39,7 +40,7 @@ test("invalid locale defers to environment fallback instead of forcing english", await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) expect(config.locale).toBeUndefined() expect(resolveLocale(config.locale)).toBe("zh") expect(config.username).toBe("testuser") From eb94526837d75da5bec5845cd922b3a87a5557bd Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 23:21:26 +0800 Subject: [PATCH 10/14] feat(opencode): extend cli and tui i18n strings Add the remaining translation keys needed to finish the audited CLI and TUI user-facing copy on this branch. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/i18n/en.ts | 198 +++++++++++++++++++++++++++++++ packages/opencode/src/i18n/zh.ts | 198 +++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) diff --git a/packages/opencode/src/i18n/en.ts b/packages/opencode/src/i18n/en.ts index 578cf8ef7382..be4f8a79d296 100644 --- a/packages/opencode/src/i18n/en.ts +++ b/packages/opencode/src/i18n/en.ts @@ -43,6 +43,204 @@ export const dict = { "tui.dialog.agent.native": "native", "tui.dialog.select.search": "Search", "tui.dialog.select.none": "No results found", + "tui.dialog.variant.title": "Select variant", + "tui.dialog.variant.default": "Default", + "tui.dialog.command.title": "Commands", + "tui.dialog.command.suggested": "Suggested", + "tui.dialog.session.title": "Sessions", + "tui.dialog.session.today": "Today", + "tui.dialog.session.delete": "delete", + "tui.dialog.session.rename": "rename", + "tui.dialog.session.new_workspace": "new workspace", + "tui.dialog.session.delete_confirm": "Press {{keybind}} again to confirm", + "tui.dialog.mcp.title": "MCPs", + "tui.dialog.mcp.toggle": "toggle", + "tui.dialog.mcp.loading": "Loading", + "tui.dialog.mcp.enabled": "Enabled", + "tui.dialog.mcp.disabled": "Disabled", + "tui.dialog.provider.title": "Connect a provider", + "tui.dialog.provider.select_auth_method": "Select auth method", + "tui.dialog.provider.waiting_authorization": "Waiting for authorization...", + "tui.dialog.provider.authorization_code": "Authorization code", + "tui.dialog.provider.invalid_code": "Invalid code", + "tui.dialog.provider.api_key": "API key", + "tui.dialog.skill.title": "Skills", + "tui.dialog.skill.category": "Skills", + "tui.dialog.skill.search": "Search skills...", + "tui.dialog.workspace.title": "New Workspace", + "tui.dialog.workspace.creating_title": "Creating Workspace", + "tui.dialog.workspace.loading": "Loading workspaces...", + "tui.dialog.workspace.loading_description": "Fetching available workspace adaptors", + "tui.dialog.workspace.creating": "Creating {{type}} workspace...", + "tui.dialog.workspace.creating_description": "This can take a while for remote environments", + "tui.dialog.workspace.create_failed": "Failed to create workspace", + "tui.dialog.workspace.session_failed": "Failed to create workspace session", + "tui.dialog.workspace.load_failed": "Failed to load workspace adaptors", + "tui.dialog.console_org.title": "Switch org", + "tui.dialog.console_org.loading": "Loading orgs...", + "tui.dialog.console_org.none": "No orgs found", + "tui.dialog.console_org.switched": "Switched to {{name}}", + "tui.dialog.stash.title": "Stash", + "tui.dialog.stash.delete": "delete", + "tui.dialog.stash.just_now": "just now", + "tui.dialog.stash.minutes_ago": "{{count}}m ago", + "tui.dialog.stash.hours_ago": "{{count}}h ago", + "tui.dialog.stash.days_ago": "{{count}}d ago", + "tui.dialog.stash.lines": "~{{count}} lines", + "tui.dialog.theme.title": "Themes", + "tui.dialog.tag.title": "Autocomplete", + "tui.dialog.model.popular": "Popular providers", + "tui.dialog.model.favorite": "Favorite", + "tui.dialog.model.connect": "Connect provider", + "tui.dialog.model.view_all": "View all providers", + "tui.dialog.model.title": "Select model", + "cli.run.chdir_failed": "Failed to change directory to {{dir}}", + "cli.run.message_required": "You must provide a message or a command", + "cli.run.session_not_found": "Session not found", + "tui.dialog.session_rename.title": "Rename Session", + "tui.dialog.message.title": "Message Actions", + "tui.dialog.message.revert": "Revert", + "tui.dialog.message.revert_description": "undo messages and file changes", + "tui.dialog.message.copy": "Copy", + "tui.dialog.message.copy_description": "message text to clipboard", + "tui.dialog.message.fork": "Fork", + "tui.dialog.message.fork_description": "create a new session", + "tui.dialog.subagent.title": "Subagent Actions", + "tui.dialog.subagent.open": "Open", + "tui.dialog.subagent.open_description": "the subagent's session", + "tui.dialog.timeline_fork.title": "Fork from message", + "tui.startup.loading_plugins": "Loading plugins...", + "tui.startup.finishing": "Finishing startup...", + "tui.prompt.provider_required": "Connect a provider to send prompts", + "tui.prompt.category": "Prompt", + "tui.prompt.clear": "Clear prompt", + "tui.prompt.submit": "Submit prompt", + "tui.prompt.paste": "Paste", + "tui.prompt.skills": "Skills", + "tui.prompt.stash_push": "Stash prompt", + "tui.prompt.stash_pop": "Stash pop", + "tui.prompt.stash_list": "Stash list", + "tui.prompt.placeholder.ask": 'Ask anything... "{{example}}"', + "tui.prompt.placeholder.run": 'Run a command... "{{example}}"', + "tui.session.category": "Session", + "tui.session.interrupt": "Interrupt session", + "tui.session.open_editor": "Open editor", + "tui.session.create_failed": "Creating a session failed. Open console for more details.", + "tui.common.copied_clipboard": "Copied to clipboard", + "tui.common.confirm": "Confirm", + "tui.common.cancel": "Cancel", + "tui.common.submit": "submit", + "tui.common.processing": "processing...", + "tui.common.enter_text": "Enter text", + "tui.common.working": "Working...", + "tui.common.ok": "ok", + "tui.common.unknown_error": "An unknown error has occurred", + "tui.provider.recommended": "(Recommended)", + "tui.provider.api_key_hint": "(API key)", + "tui.provider.chatgpt_hint": "(ChatGPT Plus/Pro or API key)", + "tui.provider.low_cost_hint": "Low cost subscription for everyone", + "tui.provider.copy": "copy", + "tui.prompt.interrupt_hint": "interrupt", + "tui.session.share": "Share session", + "tui.session.copy_share_link": "Copy share link", + "tui.session.share_copied": "Share URL copied to clipboard!", + "tui.session.share_copy_failed": "Failed to copy URL to clipboard", + "tui.session.share_confirm_title": "Share Session", + "tui.session.share_confirm_message": "Are you sure you want to share it?", + "tui.session.share_failed": "Failed to share session", + "tui.session.rename": "Rename session", + "tui.session.timeline": "Jump to message", + "tui.session.fork": "Fork from message", + "tui.session.compact": "Compact session", + "tui.session.compact_provider_required": "Connect a provider to summarize this session", + "tui.session.unshare": "Unshare session", + "tui.session.unshare_success": "Session unshared successfully", + "tui.session.unshare_failed": "Failed to unshare session", + "tui.session.undo": "Undo previous message", + "tui.session.redo": "Redo", + "tui.session.sidebar_show": "Show sidebar", + "tui.session.sidebar_hide": "Hide sidebar", + "tui.session.conceal_enable": "Enable code concealment", + "tui.session.conceal_disable": "Disable code concealment", + "tui.session.timestamps_show": "Show timestamps", + "tui.session.timestamps_hide": "Hide timestamps", + "tui.session.thinking_show": "Show thinking", + "tui.session.thinking_hide": "Hide thinking", + "tui.session.details_show": "Show tool details", + "tui.session.details_hide": "Hide tool details", + "tui.session.scrollbar_toggle": "Toggle session scrollbar", + "tui.session.generic_output_show": "Show generic tool output", + "tui.session.generic_output_hide": "Hide generic tool output", + "tui.session.page_up": "Page up", + "tui.session.page_down": "Page down", + "tui.session.line_up": "Line up", + "tui.session.line_down": "Line down", + "tui.session.half_page_up": "Half page up", + "tui.session.half_page_down": "Half page down", + "tui.session.first_message": "First message", + "tui.session.last_message": "Last message", + "tui.session.last_user_message": "Jump to last user message", + "tui.session.next_message": "Next message", + "tui.session.previous_message": "Previous message", + "tui.session.copy_last_assistant": "Copy last assistant message", + "tui.session.no_assistant_messages": "No assistant messages found", + "tui.session.no_text_parts": "No text parts found in last assistant message", + "tui.session.no_text_content": "No text content found in last assistant message", + "tui.session.message_copied": "Message copied to clipboard!", + "tui.session.copy_failed": "Failed to copy to clipboard", + "tui.session.copy_transcript": "Copy session transcript", + "tui.session.transcript_copied": "Session transcript copied to clipboard!", + "tui.session.transcript_copy_failed": "Failed to copy session transcript", + "tui.session.export_transcript": "Export session transcript", + "tui.session.export_success": "Session exported to {{filename}}", + "tui.session.export_failed": "Failed to export session", + "tui.session.child_first": "Go to child session", + "tui.session.parent": "Go to parent session", + "tui.session.child_next": "Next child session", + "tui.session.child_previous": "Previous child session", + "tui.session.not_found": "Session not found: {{session}}", + "tui.session.reverted_count": "{{count}} message reverted", + "tui.session.redo_restore_hint": "{{keybind}} or /redo to restore", + "tui.session.redo_confirm_title": "Confirm Redo", + "tui.session.redo_confirm_message": "Are you sure you want to restore the reverted messages?", + "tui.permission.title": "Permission required", + "tui.permission.always_title": "Always allow", + "tui.permission.allow_once": "Allow once", + "tui.permission.allow_always": "Allow always", + "tui.permission.reject": "Reject", + "tui.permission.confirm": "Confirm", + "tui.permission.cancel": "Cancel", + "tui.permission.no_diff": "No diff provided", + "tui.permission.reject_title": "Reject permission", + "tui.permission.reject_help": "Tell OpenCode what to do differently", + "tui.permission.select": "select", + "tui.permission.confirm_hint": "confirm", + "tui.permission.cancel_hint": "cancel", + "tui.permission.fullscreen": "fullscreen", + "tui.permission.minimize": "minimize", + "tui.permission.path": "Path: {{path}}", + "tui.permission.pattern": "Pattern: {{pattern}}", + "tui.permission.url": "URL: {{url}}", + "tui.permission.query": "Query: {{query}}", + "tui.permission.patterns": "Patterns", + "tui.permission.tool": "Tool: {{tool}}", + "tui.permission.edit": "Edit {{path}}", + "tui.permission.read": "Read {{path}}", + "tui.permission.glob": 'Glob "{{pattern}}"', + "tui.permission.grep": 'Grep "{{pattern}}"', + "tui.permission.list": "List {{path}}", + "tui.permission.shell_command": "Shell command", + "tui.permission.unknown": "Unknown", + "tui.permission.task": "{{type}} Task", + "tui.permission.webfetch": "WebFetch {{url}}", + "tui.permission.websearch": 'Exa Web Search "{{query}}"', + "tui.permission.codesearch": 'Exa Code Search "{{query}}"', + "tui.permission.external_directory": "Access external directory {{dir}}", + "tui.permission.doom_loop": "Continue after repeated failures", + "tui.permission.doom_loop_description": "This keeps the session running despite repeated failures.", + "tui.permission.call_tool": "Call tool {{permission}}", + "tui.permission.allow_restart": "This will allow {{permission}} until OpenCode is restarted.", + "tui.permission.allow_patterns": "This will allow the following patterns until OpenCode is restarted", "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", diff --git a/packages/opencode/src/i18n/zh.ts b/packages/opencode/src/i18n/zh.ts index a5eef8f5e5db..2ba4367e42bb 100644 --- a/packages/opencode/src/i18n/zh.ts +++ b/packages/opencode/src/i18n/zh.ts @@ -43,6 +43,204 @@ export const dict = { "tui.dialog.agent.native": "内置", "tui.dialog.select.search": "搜索", "tui.dialog.select.none": "没有结果", + "tui.dialog.variant.title": "选择变体", + "tui.dialog.variant.default": "默认", + "tui.dialog.command.title": "命令", + "tui.dialog.command.suggested": "推荐", + "tui.dialog.session.title": "会话", + "tui.dialog.session.today": "今天", + "tui.dialog.session.delete": "删除", + "tui.dialog.session.rename": "重命名", + "tui.dialog.session.new_workspace": "新建工作区", + "tui.dialog.session.delete_confirm": "再按一次 {{keybind}} 以确认", + "tui.dialog.mcp.title": "MCP", + "tui.dialog.mcp.toggle": "切换", + "tui.dialog.mcp.loading": "加载中", + "tui.dialog.mcp.enabled": "已启用", + "tui.dialog.mcp.disabled": "已禁用", + "tui.dialog.provider.title": "连接提供商", + "tui.dialog.provider.select_auth_method": "选择认证方式", + "tui.dialog.provider.waiting_authorization": "等待授权中...", + "tui.dialog.provider.authorization_code": "授权码", + "tui.dialog.provider.invalid_code": "无效的授权码", + "tui.dialog.provider.api_key": "API Key", + "tui.dialog.skill.title": "技能", + "tui.dialog.skill.category": "技能", + "tui.dialog.skill.search": "搜索技能...", + "tui.dialog.workspace.title": "新建工作区", + "tui.dialog.workspace.creating_title": "正在创建工作区", + "tui.dialog.workspace.loading": "正在加载工作区...", + "tui.dialog.workspace.loading_description": "正在获取可用的工作区适配器", + "tui.dialog.workspace.creating": "正在创建 {{type}} 工作区...", + "tui.dialog.workspace.creating_description": "远程环境可能需要一些时间", + "tui.dialog.workspace.create_failed": "创建工作区失败", + "tui.dialog.workspace.session_failed": "创建工作区会话失败", + "tui.dialog.workspace.load_failed": "加载工作区适配器失败", + "tui.dialog.console_org.title": "切换组织", + "tui.dialog.console_org.loading": "正在加载组织...", + "tui.dialog.console_org.none": "未找到组织", + "tui.dialog.console_org.switched": "已切换到 {{name}}", + "tui.dialog.stash.title": "暂存", + "tui.dialog.stash.delete": "删除", + "tui.dialog.stash.just_now": "刚刚", + "tui.dialog.stash.minutes_ago": "{{count}} 分钟前", + "tui.dialog.stash.hours_ago": "{{count}} 小时前", + "tui.dialog.stash.days_ago": "{{count}} 天前", + "tui.dialog.stash.lines": "约 {{count}} 行", + "tui.dialog.theme.title": "主题", + "tui.dialog.tag.title": "自动补全", + "tui.dialog.model.popular": "热门提供商", + "tui.dialog.model.favorite": "收藏", + "tui.dialog.model.connect": "连接提供商", + "tui.dialog.model.view_all": "查看全部提供商", + "tui.dialog.model.title": "选择模型", + "cli.run.chdir_failed": "切换目录到 {{dir}} 失败", + "cli.run.message_required": "你必须提供一条消息或一条命令", + "cli.run.session_not_found": "找不到会话", + "tui.dialog.session_rename.title": "重命名会话", + "tui.dialog.message.title": "消息操作", + "tui.dialog.message.revert": "回退", + "tui.dialog.message.revert_description": "撤销消息和文件改动", + "tui.dialog.message.copy": "复制", + "tui.dialog.message.copy_description": "将消息文本复制到剪贴板", + "tui.dialog.message.fork": "分叉", + "tui.dialog.message.fork_description": "创建一个新会话", + "tui.dialog.subagent.title": "子代理操作", + "tui.dialog.subagent.open": "打开", + "tui.dialog.subagent.open_description": "查看子代理的会话", + "tui.dialog.timeline_fork.title": "从消息分叉", + "tui.startup.loading_plugins": "正在加载插件...", + "tui.startup.finishing": "正在完成启动...", + "tui.prompt.provider_required": "请先连接提供商再发送提示", + "tui.prompt.category": "提示", + "tui.prompt.clear": "清空提示", + "tui.prompt.submit": "提交提示", + "tui.prompt.paste": "粘贴", + "tui.prompt.skills": "技能", + "tui.prompt.stash_push": "暂存提示", + "tui.prompt.stash_pop": "弹出暂存", + "tui.prompt.stash_list": "暂存列表", + "tui.prompt.placeholder.ask": '输入任何问题... "{{example}}"', + "tui.prompt.placeholder.run": '运行命令... "{{example}}"', + "tui.session.category": "会话", + "tui.session.interrupt": "中断会话", + "tui.session.open_editor": "打开编辑器", + "tui.session.create_failed": "创建会话失败。请打开控制台查看更多信息。", + "tui.common.copied_clipboard": "已复制到剪贴板", + "tui.common.confirm": "确认", + "tui.common.cancel": "取消", + "tui.common.submit": "提交", + "tui.common.processing": "处理中...", + "tui.common.enter_text": "输入文本", + "tui.common.working": "处理中...", + "tui.common.ok": "确定", + "tui.common.unknown_error": "发生了未知错误", + "tui.provider.recommended": "(推荐)", + "tui.provider.api_key_hint": "(API Key)", + "tui.provider.chatgpt_hint": "(ChatGPT Plus/Pro 或 API Key)", + "tui.provider.low_cost_hint": "适合所有人的低成本订阅", + "tui.provider.copy": "复制", + "tui.prompt.interrupt_hint": "中断", + "tui.session.share": "分享会话", + "tui.session.copy_share_link": "复制分享链接", + "tui.session.share_copied": "分享链接已复制到剪贴板!", + "tui.session.share_copy_failed": "复制分享链接失败", + "tui.session.share_confirm_title": "分享会话", + "tui.session.share_confirm_message": "确定要分享这个会话吗?", + "tui.session.share_failed": "分享会话失败", + "tui.session.rename": "重命名会话", + "tui.session.timeline": "跳转到消息", + "tui.session.fork": "从消息分叉", + "tui.session.compact": "压缩会话", + "tui.session.compact_provider_required": "请先连接提供商再总结这个会话", + "tui.session.unshare": "取消分享会话", + "tui.session.unshare_success": "已成功取消分享会话", + "tui.session.unshare_failed": "取消分享会话失败", + "tui.session.undo": "撤销上一条消息", + "tui.session.redo": "重做", + "tui.session.sidebar_show": "显示侧边栏", + "tui.session.sidebar_hide": "隐藏侧边栏", + "tui.session.conceal_enable": "启用代码折叠显示", + "tui.session.conceal_disable": "禁用代码折叠显示", + "tui.session.timestamps_show": "显示时间戳", + "tui.session.timestamps_hide": "隐藏时间戳", + "tui.session.thinking_show": "显示思考过程", + "tui.session.thinking_hide": "隐藏思考过程", + "tui.session.details_show": "显示工具详情", + "tui.session.details_hide": "隐藏工具详情", + "tui.session.scrollbar_toggle": "切换会话滚动条", + "tui.session.generic_output_show": "显示通用工具输出", + "tui.session.generic_output_hide": "隐藏通用工具输出", + "tui.session.page_up": "向上翻页", + "tui.session.page_down": "向下翻页", + "tui.session.line_up": "向上滚动一行", + "tui.session.line_down": "向下滚动一行", + "tui.session.half_page_up": "向上滚动半页", + "tui.session.half_page_down": "向下滚动半页", + "tui.session.first_message": "跳到第一条消息", + "tui.session.last_message": "跳到最后一条消息", + "tui.session.last_user_message": "跳到最后一条用户消息", + "tui.session.next_message": "下一条消息", + "tui.session.previous_message": "上一条消息", + "tui.session.copy_last_assistant": "复制最后一条助手消息", + "tui.session.no_assistant_messages": "未找到助手消息", + "tui.session.no_text_parts": "最后一条助手消息里没有文本片段", + "tui.session.no_text_content": "最后一条助手消息里没有文本内容", + "tui.session.message_copied": "消息已复制到剪贴板!", + "tui.session.copy_failed": "复制到剪贴板失败", + "tui.session.copy_transcript": "复制会话转录", + "tui.session.transcript_copied": "会话转录已复制到剪贴板!", + "tui.session.transcript_copy_failed": "复制会话转录失败", + "tui.session.export_transcript": "导出会话转录", + "tui.session.export_success": "会话已导出到 {{filename}}", + "tui.session.export_failed": "导出会话失败", + "tui.session.child_first": "前往子会话", + "tui.session.parent": "前往父会话", + "tui.session.child_next": "下一个子会话", + "tui.session.child_previous": "上一个子会话", + "tui.session.not_found": "找不到会话:{{session}}", + "tui.session.reverted_count": "已回退 {{count}} 条消息", + "tui.session.redo_restore_hint": "按 {{keybind}} 或输入 /redo 以恢复", + "tui.session.redo_confirm_title": "确认重做", + "tui.session.redo_confirm_message": "确定要恢复已回退的消息吗?", + "tui.permission.title": "需要权限", + "tui.permission.always_title": "始终允许", + "tui.permission.allow_once": "允许一次", + "tui.permission.allow_always": "始终允许", + "tui.permission.reject": "拒绝", + "tui.permission.confirm": "确认", + "tui.permission.cancel": "取消", + "tui.permission.no_diff": "未提供 diff", + "tui.permission.reject_title": "拒绝权限", + "tui.permission.reject_help": "告诉 OpenCode 应该如何改做", + "tui.permission.select": "选择", + "tui.permission.confirm_hint": "确认", + "tui.permission.cancel_hint": "取消", + "tui.permission.fullscreen": "全屏", + "tui.permission.minimize": "最小化", + "tui.permission.path": "路径:{{path}}", + "tui.permission.pattern": "模式:{{pattern}}", + "tui.permission.url": "URL:{{url}}", + "tui.permission.query": "查询:{{query}}", + "tui.permission.patterns": "模式", + "tui.permission.tool": "工具:{{tool}}", + "tui.permission.edit": "编辑 {{path}}", + "tui.permission.read": "读取 {{path}}", + "tui.permission.glob": 'Glob "{{pattern}}"', + "tui.permission.grep": 'Grep "{{pattern}}"', + "tui.permission.list": "列出 {{path}}", + "tui.permission.shell_command": "Shell 命令", + "tui.permission.unknown": "未知", + "tui.permission.task": "{{type}} 任务", + "tui.permission.webfetch": "WebFetch {{url}}", + "tui.permission.websearch": 'Exa Web 搜索 "{{query}}"', + "tui.permission.codesearch": 'Exa 代码搜索 "{{query}}"', + "tui.permission.external_directory": "访问外部目录 {{dir}}", + "tui.permission.doom_loop": "在重复失败后继续", + "tui.permission.doom_loop_description": "即使反复失败,也会继续保持会话运行。", + "tui.permission.call_tool": "调用工具 {{permission}}", + "tui.permission.allow_restart": "这会允许 {{permission}},直到 OpenCode 重启。", + "tui.permission.allow_patterns": "这会允许以下模式,直到 OpenCode 重启", "tui.home.placeholder.todo": "修复代码库中的 TODO", "tui.home.placeholder.stack": "这个项目的技术栈是什么?", "tui.home.placeholder.tests": "修复失败的测试", From d1ea25d1811e9ed6c36d8ee99e810957fd805a25 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 23:21:26 +0800 Subject: [PATCH 11/14] feat(tui): localize shared dialog and toast copy Pass shared prompt, alert, toast, and clipboard feedback through the i18n layer so common TUI surfaces follow the selected locale. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 18 +++++------------- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 14 +++++++------- packages/opencode/src/cli/cmd/tui/ui/toast.tsx | 4 +++- .../opencode/src/cli/cmd/tui/util/selection.ts | 4 ++-- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 642c73b48561..bab3f0d119e8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -1,4 +1,5 @@ import { TextAttributes } from "@opentui/core" +import { useI18n } from "../context/i18n" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { useKeyboard } from "@opentui/solid" @@ -12,6 +13,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() + const i18n = useI18n() useKeyboard((evt) => { if (evt.name === "return") { @@ -25,24 +27,14 @@ export function DialogAlert(props: DialogAlertProps) { {props.title} - dialog.clear()}> - esc - + esc {props.message} - { - props.onConfirm?.() - dialog.clear() - }} - > - ok + + {i18n.t("tui.common.ok")} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 6df99c33fd22..24becf7d7660 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,4 +1,5 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useI18n } from "../context/i18n" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, onMount, type JSX } from "solid-js" @@ -19,6 +20,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() + const i18n = useI18n() let textarea: TextareaRenderable useKeyboard((evt) => { @@ -65,9 +67,7 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} - dialog.clear()}> - esc - + esc {props.description} @@ -82,20 +82,20 @@ export function DialogPrompt(props: DialogPromptProps) { textarea = val }} initialValue={props.value} - placeholder={props.placeholder ?? "Enter text"} + placeholder={props.placeholder ?? i18n.t("tui.common.enter_text")} placeholderColor={theme.textMuted} textColor={props.busy ? theme.textMuted : theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text} /> - {props.busyText ?? "Working..."} + {props.busyText ?? i18n.t("tui.common.working")} - processing...}> + {i18n.t("tui.common.processing")}}> - enter submit + enter {i18n.t("tui.common.submit")} diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb08..bbacbd400dbd 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, type ParentProps, Show } from "solid-js" +import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" @@ -48,6 +49,7 @@ export function Toast() { } function init() { + const i18n = useI18n() const [store, setStore] = createStore({ currentToast: null as ToastOptions | null, }) @@ -72,7 +74,7 @@ function init() { }) toast.show({ variant: "error", - message: "An unknown error has occurred", + message: i18n.t("tui.common.unknown_error"), }) }, get currentToast(): ToastOptions | null { diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 1230852dcc07..b51ff90a63a1 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -11,12 +11,12 @@ type Renderer = { } export namespace Selection { - export function copy(renderer: Renderer, toast: Toast): boolean { + export function copy(renderer: Renderer, toast: Toast, message = "Copied to clipboard"): boolean { const text = renderer.getSelection()?.getSelectedText() if (!text) return false Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .then(() => toast.show({ message, variant: "info" })) .catch(toast.error) renderer.clearSelection() From 021afd470b4f886d6c4a8a1b8b65331a0f5483d1 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 23:21:26 +0800 Subject: [PATCH 12/14] feat(tui): localize remaining dialog and prompt copy Finish the audited TUI component labels, placeholders, and toasts so model, provider, stash, workspace, and prompt flows all use translated strings. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../cli/cmd/tui/component/dialog-command.tsx | 7 ++- .../cmd/tui/component/dialog-console-org.tsx | 16 +++++-- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 15 ++++-- .../cli/cmd/tui/component/dialog-model.tsx | 10 ++-- .../cli/cmd/tui/component/dialog-provider.tsx | 30 +++++++----- .../cmd/tui/component/dialog-session-list.tsx | 17 ++++--- .../tui/component/dialog-session-rename.tsx | 4 +- .../cli/cmd/tui/component/dialog-skill.tsx | 12 ++++- .../cli/cmd/tui/component/dialog-stash.tsx | 24 ++++++---- .../src/cli/cmd/tui/component/dialog-tag.tsx | 4 +- .../cmd/tui/component/dialog-theme-list.tsx | 4 +- .../cli/cmd/tui/component/dialog-variant.tsx | 6 ++- .../tui/component/dialog-workspace-create.tsx | 21 +++++---- .../cli/cmd/tui/component/prompt/index.tsx | 46 ++++++++++--------- .../cli/cmd/tui/component/startup-loading.tsx | 6 ++- 15 files changed, 140 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index f42ba15ec065..104930e7c265 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -12,6 +12,7 @@ import { type ParentProps, } from "solid-js" import { useKeyboard } from "@opentui/solid" +import { useI18n } from "@tui/context/i18n" import { useKeybind } from "@tui/context/keybind" type Context = ReturnType @@ -36,6 +37,7 @@ function init() { const [suspendCount, setSuspendCount] = createSignal(0) const dialog = useDialog() const keybind = useKeybind() + const i18n = useI18n() const entries = createMemo(() => { const all = registrations().flatMap((x) => x()) @@ -55,7 +57,7 @@ function init() { .map((option) => ({ ...option, value: `suggested:${option.value}`, - category: "Suggested", + category: i18n.t("tui.dialog.command.suggested"), })), ) const suspended = () => suspendCount() > 0 @@ -163,9 +165,10 @@ export function CommandProvider(props: ParentProps) { function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { let ref: DialogSelectRef + const i18n = useI18n() const list = () => { if (ref?.filter) return props.options return [...props.suggestedOptions, ...props.options] } - return (ref = r)} title="Commands" options={list()} /> + return (ref = r)} title={i18n.t("tui.dialog.command.title")} options={list()} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-console-org.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-console-org.tsx index eaf3450196e7..6b5a9c478e42 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-console-org.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-console-org.tsx @@ -1,5 +1,6 @@ import { createResource, createMemo } from "solid-js" import { DialogSelect } from "@tui/ui/dialog-select" +import { useI18n } from "@tui/context/i18n" import { useSDK } from "@tui/context/sdk" import { useDialog } from "@tui/ui/dialog" import { useToast } from "@tui/ui/toast" @@ -23,6 +24,7 @@ export function DialogConsoleOrg() { const sdk = useSDK() const dialog = useDialog() const toast = useToast() + const i18n = useI18n() const { theme } = useTheme() const [orgs] = createResource(async () => { @@ -37,7 +39,7 @@ export function DialogConsoleOrg() { if (listed === undefined) { return [ { - title: "Loading orgs...", + title: i18n.t("tui.dialog.console_org.loading"), value: "loading", onSelect: () => {}, }, @@ -47,7 +49,7 @@ export function DialogConsoleOrg() { if (listed.length === 0) { return [ { - title: "No orgs found", + title: i18n.t("tui.dialog.console_org.none"), value: "empty", onSelect: () => {}, }, @@ -91,7 +93,7 @@ export function DialogConsoleOrg() { await sdk.client.instance.dispose() toast.show({ - message: `Switched to ${item.orgName}`, + message: i18n.t("tui.dialog.console_org.switched", { name: item.orgName }), variant: "info", }) dialog.clear() @@ -99,5 +101,11 @@ export function DialogConsoleOrg() { })) }) - return title="Switch org" options={options()} current={current()} /> + return ( + + title={i18n.t("tui.dialog.console_org.title")} + options={options()} + current={current()} + /> + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df98..694b1e8e20eb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -7,22 +7,27 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { useI18n } from "../context/i18n" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() + const i18n = useI18n() if (props.loading) { - return ⋯ Loading + return ⋯ {i18n.t("tui.dialog.mcp.loading")} } if (props.enabled) { - return ✓ Enabled + return ( + ✓ {i18n.t("tui.dialog.mcp.enabled")} + ) } - return ○ Disabled + return ○ {i18n.t("tui.dialog.mcp.disabled")} } export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() + const i18n = useI18n() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) @@ -48,7 +53,7 @@ export function DialogMcp() { const keybinds = createMemo(() => [ { keybind: Keybind.parse("space")[0], - title: "toggle", + title: i18n.t("tui.dialog.mcp.toggle"), onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress if (loading() !== null) return @@ -75,7 +80,7 @@ export function DialogMcp() { return ( { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fb6849d72d16..bb2e20cf51d6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -7,6 +7,7 @@ import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import { useKeybind } from "../context/keybind" +import { useI18n } from "../context/i18n" import * as fuzzysort from "fuzzysort" export function useConnected() { @@ -21,6 +22,7 @@ export function DialogModel(props: { providerID?: string }) { const sync = useSync() const dialog = useDialog() const keybind = useKeybind() + const i18n = useI18n() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -112,7 +114,7 @@ export function DialogModel(props: { providerID?: string }) { providers(), map((option) => ({ ...option, - category: "Popular providers", + category: i18n.t("tui.dialog.model.popular"), })), take(6), ) @@ -134,7 +136,7 @@ export function DialogModel(props: { providerID?: string }) { const title = createMemo(() => { const value = provider() - if (!value) return "Select model" + if (!value) return i18n.t("tui.dialog.model.title") return value.name }) @@ -159,14 +161,14 @@ export function DialogModel(props: { providerID?: string }) { keybind={[ { keybind: keybind.all.model_provider_list?.[0], - title: connected() ? "Connect provider" : "View all providers", + title: connected() ? i18n.t("tui.dialog.model.connect") : i18n.t("tui.dialog.model.view_all"), onTrigger() { dialog.replace(() => ) }, }, { keybind: keybind.all.model_favorite_toggle?.[0], - title: "Favorite", + title: i18n.t("tui.dialog.model.favorite"), disabled: !connected(), onTrigger: (option) => { local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index c0e39e0e2100..58f4716e52eb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -14,6 +14,7 @@ import { useKeyboard } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" +import { useI18n } from "../context/i18n" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -29,6 +30,7 @@ export function createDialogProviderOptions() { const dialog = useDialog() const sdk = useSDK() const toast = useToast() + const i18n = useI18n() const { theme } = useTheme() const options = createMemo(() => { return pipe( @@ -42,10 +44,10 @@ export function createDialogProviderOptions() { title: provider.name, value: provider.id, description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", + opencode: i18n.t("tui.provider.recommended"), + anthropic: i18n.t("tui.provider.api_key_hint"), + openai: i18n.t("tui.provider.chatgpt_hint"), + "opencode-go": i18n.t("tui.provider.low_cost_hint"), }[provider.id], footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", @@ -65,7 +67,7 @@ export function createDialogProviderOptions() { dialog.replace( () => ( ({ title: x.label, value: index, @@ -145,7 +147,8 @@ export function createDialogProviderOptions() { export function DialogProvider() { const options = createDialogProviderOptions() - return + const i18n = useI18n() + return } interface AutoMethodProps { @@ -160,12 +163,13 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() + const i18n = useI18n() useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .then(() => toast.show({ message: i18n.t("tui.common.copied_clipboard"), variant: "info" })) .catch(toast.error) } }) @@ -198,9 +202,9 @@ function AutoMethod(props: AutoMethodProps) { {props.authorization.instructions} - Waiting for authorization... + {i18n.t("tui.dialog.provider.waiting_authorization")} - c copy + c {i18n.t("tui.provider.copy")} ) @@ -217,12 +221,13 @@ function CodeMethod(props: CodeMethodProps) { const sdk = useSDK() const sync = useSync() const dialog = useDialog() + const i18n = useI18n() const [error, setError] = createSignal(false) return ( { const { error } = await sdk.client.provider.oauth.callback({ providerID: props.providerID, @@ -242,7 +247,7 @@ function CodeMethod(props: CodeMethodProps) { {props.authorization.instructions} - Invalid code + {i18n.t("tui.dialog.provider.invalid_code")} )} @@ -260,11 +265,12 @@ function ApiMethod(props: ApiMethodProps) { const sdk = useSDK() const sync = useSync() const { theme } = useTheme() + const i18n = useI18n() return ( () const [search, setSearch] = createDebouncedSignal("", 150) @@ -49,6 +51,7 @@ export function DialogSessionList() { sdk, sync, toast, + i18n, workspaceID, }) } @@ -102,13 +105,15 @@ export function DialogSessionList() { const date = new Date(x.time.updated) let category = date.toDateString() if (category === today) { - category = "Today" + category = i18n.t("tui.dialog.session.today") } const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: isDeleting + ? i18n.t("tui.dialog.session.delete_confirm", { keybind: keybind.print("session_delete") }) + : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -124,7 +129,7 @@ export function DialogSessionList() { return ( { if (toDelete() === option.value) { sdk.client.session.delete({ @@ -156,14 +161,14 @@ export function DialogSessionList() { }, { keybind: keybind.all.session_rename?.[0], - title: "rename", + title: i18n.t("tui.dialog.session.rename"), onTrigger: async (option) => { dialog.replace(() => ) }, }, { keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", + title: i18n.t("tui.dialog.session.new_workspace"), side: "right", disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, onTrigger: () => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx index 141340d55625..dea3812f1f3b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx @@ -1,5 +1,6 @@ import { DialogPrompt } from "@tui/ui/dialog-prompt" import { useDialog } from "@tui/ui/dialog" +import { useI18n } from "@tui/context/i18n" import { useSync } from "@tui/context/sync" import { createMemo } from "solid-js" import { useSDK } from "../context/sdk" @@ -12,11 +13,12 @@ export function DialogSessionRename(props: DialogSessionRenameProps) { const dialog = useDialog() const sync = useSync() const sdk = useSDK() + const i18n = useI18n() const session = createMemo(() => sync.session.get(props.session)) return ( { sdk.client.session.update({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index 4bcd3c7bde41..76ea1c610ca1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -1,6 +1,7 @@ import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { createResource, createMemo } from "solid-js" import { useDialog } from "@tui/ui/dialog" +import { useI18n } from "@tui/context/i18n" import { useSDK } from "@tui/context/sdk" export type DialogSkillProps = { @@ -10,6 +11,7 @@ export type DialogSkillProps = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const i18n = useI18n() dialog.setSize("large") const [skills] = createResource(async () => { @@ -24,7 +26,7 @@ export function DialogSkill(props: DialogSkillProps) { title: skill.name.padEnd(maxWidth), description: skill.description?.replace(/\s+/g, " ").trim(), value: skill.name, - category: "Skills", + category: i18n.t("tui.dialog.skill.category"), onSelect: () => { props.onSelect(skill.name) dialog.clear() @@ -32,5 +34,11 @@ export function DialogSkill(props: DialogSkillProps) { })) }) - return + return ( + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index e8664f6289bc..a98cb901a203 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -5,8 +5,9 @@ import { Locale } from "@/util/locale" import { useTheme } from "../context/theme" import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" +import { useI18n } from "../context/i18n" -function getRelativeTime(timestamp: number): string { +function getRelativeTime(timestamp: number, t: ReturnType["t"]): string { const now = Date.now() const diff = now - timestamp const seconds = Math.floor(diff / 1000) @@ -14,10 +15,10 @@ function getRelativeTime(timestamp: number): string { const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) - if (seconds < 60) return "just now" - if (minutes < 60) return `${minutes}m ago` - if (hours < 24) return `${hours}h ago` - if (days < 7) return `${days}d ago` + if (seconds < 60) return t("tui.dialog.stash.just_now") + if (minutes < 60) return t("tui.dialog.stash.minutes_ago", { count: minutes }) + if (hours < 24) return t("tui.dialog.stash.hours_ago", { count: hours }) + if (days < 7) return t("tui.dialog.stash.days_ago", { count: days }) return Locale.datetime(timestamp) } @@ -31,6 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const stash = usePromptStash() const { theme } = useTheme() const keybind = useKeybind() + const i18n = useI18n() const [toDelete, setToDelete] = createSignal() @@ -42,11 +44,13 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), + title: isDeleting + ? i18n.t("tui.dialog.session.delete_confirm", { keybind: keybind.print("stash_delete") }) + : getStashPreview(entry.input), bg: isDeleting ? theme.error : undefined, value: index, - description: getRelativeTime(entry.timestamp), - footer: lineCount > 1 ? `~${lineCount} lines` : undefined, + description: getRelativeTime(entry.timestamp, i18n.t), + footer: lineCount > 1 ? i18n.t("tui.dialog.stash.lines", { count: lineCount }) : undefined, } }) .toReversed() @@ -54,7 +58,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { return ( { setToDelete(undefined) @@ -71,7 +75,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { keybind={[ { keybind: keybind.all.stash_delete?.[0], - title: "delete", + title: i18n.t("tui.dialog.stash.delete"), onTrigger: (option) => { if (toDelete() === option.value) { stash.remove(option.value) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx index 6d6c62450ea3..feef0f7f8671 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx @@ -1,4 +1,5 @@ import { createMemo, createResource } from "solid-js" +import { useI18n } from "@tui/context/i18n" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "@tui/context/sdk" @@ -7,6 +8,7 @@ import { createStore } from "solid-js/store" export function DialogTag(props: { onSelect?: (value: string) => void }) { const sdk = useSDK() const dialog = useDialog() + const i18n = useI18n() const [store] = createStore({ filter: "", @@ -33,7 +35,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { return ( { props.onSelect?.(option.value) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index f4072c978582..277fc56b5807 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,10 +1,12 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" +import { useI18n } from "../context/i18n" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { onCleanup, onMount } from "solid-js" export function DialogThemeList() { const theme = useTheme() + const i18n = useI18n() const options = Object.keys(theme.all()) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) .map((value) => ({ @@ -22,7 +24,7 @@ export function DialogThemeList() { return ( { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx index 28ee1b28250b..264d9fba8340 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx @@ -1,17 +1,19 @@ import { createMemo } from "solid-js" import { useLocal } from "@tui/context/local" +import { useI18n } from "@tui/context/i18n" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" export function DialogVariant() { const local = useLocal() const dialog = useDialog() + const i18n = useI18n() const options = createMemo(() => { return [ { value: "default", - title: "Default", + title: i18n.t("tui.dialog.variant.default"), onSelect: () => { dialog.clear() local.model.variant.set(undefined) @@ -31,7 +33,7 @@ export function DialogVariant() { return ( options={options()} - title={"Select variant"} + title={i18n.t("tui.dialog.variant.title")} current={local.model.variant.selected()} flat={true} /> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 447a1c325804..395cc1f279ac 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -7,6 +7,7 @@ import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" import { setTimeout as sleep } from "node:timers/promises" import { useSDK } from "../context/sdk" +import { useI18n } from "../context/i18n" import { useToast } from "../ui/toast" type Adaptor = { @@ -30,6 +31,7 @@ export async function openWorkspaceSession(input: { sdk: ReturnType sync: ReturnType toast: ReturnType + i18n: ReturnType workspaceID: string }) { const client = scoped(input.sdk, input.sync, input.workspaceID) @@ -37,7 +39,7 @@ export async function openWorkspaceSession(input: { const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) if (!result) { input.toast.show({ - message: "Failed to create workspace session", + message: input.i18n.t("tui.dialog.workspace.session_failed"), variant: "error", }) return @@ -48,7 +50,7 @@ export async function openWorkspaceSession(input: { } if (!result.data) { input.toast.show({ - message: "Failed to create workspace session", + message: input.i18n.t("tui.dialog.workspace.session_failed"), variant: "error", }) return @@ -68,6 +70,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const project = useProject() const sdk = useSDK() const toast = useToast() + const i18n = useI18n() const [creating, setCreating] = createSignal() const [adaptors, setAdaptors] = createSignal() @@ -83,7 +86,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = .catch(() => undefined) if (!res) { toast.show({ - message: "Failed to load workspace adaptors", + message: i18n.t("tui.dialog.workspace.load_failed"), variant: "error", }) return @@ -97,9 +100,9 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = if (type) { return [ { - title: `Creating ${type} workspace...`, + title: i18n.t("tui.dialog.workspace.creating", { type }), value: "creating" as const, - description: "This can take a while for remote environments", + description: i18n.t("tui.dialog.workspace.creating_description"), }, ] } @@ -107,9 +110,9 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = if (!list) { return [ { - title: "Loading workspaces...", + title: i18n.t("tui.dialog.workspace.loading"), value: "loading" as const, - description: "Fetching available workspace adaptors", + description: i18n.t("tui.dialog.workspace.loading_description"), }, ] } @@ -129,7 +132,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = if (!workspace) { setCreating(undefined) toast.show({ - message: "Failed to create workspace", + message: i18n.t("tui.dialog.workspace.create_failed"), variant: "error", }) return @@ -141,7 +144,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = return ( { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5a3e1d451d6d..1a7c91fa18a1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -37,6 +37,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { useI18n } from "../../context/i18n" export type PromptProps = { sessionID?: string @@ -93,6 +94,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const i18n = useI18n() const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const [auto, setAuto] = createSignal() @@ -102,7 +104,7 @@ export function Prompt(props: PromptProps) { function promptModelWarning() { toast.show({ variant: "warning", - message: "Connect a provider to send prompts", + message: i18n.t("tui.prompt.provider_required"), duration: 3000, }) if (sync.data.provider.length === 0) { @@ -214,9 +216,9 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: "Clear prompt", + title: i18n.t("tui.prompt.clear"), value: "prompt.clear", - category: "Prompt", + category: i18n.t("tui.prompt.category"), hidden: true, onSelect: (dialog) => { input.extmarks.clear() @@ -225,10 +227,10 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Submit prompt", + title: i18n.t("tui.prompt.submit"), value: "prompt.submit", keybind: "input_submit", - category: "Prompt", + category: i18n.t("tui.prompt.category"), hidden: true, onSelect: (dialog) => { if (!input.focused) return @@ -237,10 +239,10 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Paste", + title: i18n.t("tui.prompt.paste"), value: "prompt.paste", keybind: "input_paste", - category: "Prompt", + category: i18n.t("tui.prompt.category"), hidden: true, onSelect: async () => { const content = await Clipboard.read() @@ -254,10 +256,10 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Interrupt session", + title: i18n.t("tui.session.interrupt"), value: "session.interrupt", keybind: "session_interrupt", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, enabled: status().type !== "idle", onSelect: (dialog) => { @@ -286,8 +288,8 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Open editor", - category: "Session", + title: i18n.t("tui.session.open_editor"), + category: i18n.t("tui.session.category"), keybind: "editor_open", value: "prompt.editor", slash: { @@ -373,9 +375,9 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Skills", + title: i18n.t("tui.prompt.skills"), value: "prompt.skills", - category: "Prompt", + category: i18n.t("tui.prompt.category"), slash: { name: "skills", }, @@ -535,9 +537,9 @@ export function Prompt(props: PromptProps) { command.register(() => [ { - title: "Stash prompt", + title: i18n.t("tui.prompt.stash_push"), value: "prompt.stash", - category: "Prompt", + category: i18n.t("tui.prompt.category"), enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return @@ -553,9 +555,9 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Stash pop", + title: i18n.t("tui.prompt.stash_pop"), value: "prompt.stash.pop", - category: "Prompt", + category: i18n.t("tui.prompt.category"), enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() @@ -569,9 +571,9 @@ export function Prompt(props: PromptProps) { }, }, { - title: "Stash list", + title: i18n.t("tui.prompt.stash_list"), value: "prompt.stash.list", - category: "Prompt", + category: i18n.t("tui.prompt.category"), enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( @@ -620,7 +622,7 @@ export function Prompt(props: PromptProps) { console.log("Creating a session failed:", res.error) toast.show({ - message: "Creating a session failed. Open console for more details.", + message: i18n.t("tui.session.create_failed"), variant: "error", }) @@ -841,10 +843,10 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] - return `Run a command... "${example}"` + return i18n.t("tui.prompt.placeholder.run", { example }) } if (!list().length) return undefined - return `Ask anything... "${list()[store.placeholder % list().length]}"` + return i18n.t("tui.prompt.placeholder.ask", { example: list()[store.placeholder % list().length] }) }) const spinnerDef = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx index 6665c0c2e8c4..e47882f08093 100644 --- a/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/startup-loading.tsx @@ -1,11 +1,15 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" +import { useI18n } from "../context/i18n" import { useTheme } from "../context/theme" import { Spinner } from "./spinner" export function StartupLoading(props: { ready: () => boolean }) { const theme = useTheme().theme + const i18n = useI18n() const [show, setShow] = createSignal(false) - const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) + const text = createMemo(() => + props.ready() ? i18n.t("tui.startup.finishing") : i18n.t("tui.startup.loading_plugins"), + ) let wait: NodeJS.Timeout | undefined let hold: NodeJS.Timeout | undefined let stamp = 0 From 25e7093b1d3b375d35641452776c46f9602263c9 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 23:21:26 +0800 Subject: [PATCH 13/14] feat(tui): localize session actions and feedback Translate the remaining session action labels, confirmations, and clipboard/export feedback so the main TUI workflow matches the selected locale. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session/dialog-fork-from-timeline.tsx | 10 +- .../cmd/tui/routes/session/dialog-message.tsx | 16 +- .../tui/routes/session/dialog-subagent.tsx | 8 +- .../src/cli/cmd/tui/routes/session/index.tsx | 185 +++++++++--------- 4 files changed, 120 insertions(+), 99 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 742d51be2280..20e71c852206 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -1,5 +1,6 @@ import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" +import { useI18n } from "@tui/context/i18n" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" @@ -14,6 +15,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess const dialog = useDialog() const sdk = useSDK() const route = useRoute() + const i18n = useI18n() onMount(() => { dialog.setSize("large") @@ -61,5 +63,11 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess return result }) - return props.onMove(option.value)} title="Fork from message" options={options()} /> + return ( + props.onMove(option.value)} + title={i18n.t("tui.dialog.timeline_fork.title")} + options={options()} + /> + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index a51a6cfe585f..af0de84b3daf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -1,4 +1,5 @@ import { createMemo } from "solid-js" +import { useI18n } from "@tui/context/i18n" import { useSync } from "@tui/context/sync" import { DialogSelect } from "@tui/ui/dialog-select" import { useSDK } from "@tui/context/sdk" @@ -14,17 +15,18 @@ export function DialogMessage(props: { }) { const sync = useSync() const sdk = useSDK() + const i18n = useI18n() const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) const route = useRoute() return ( { const msg = message() if (!msg) return @@ -53,9 +55,9 @@ export function DialogMessage(props: { }, }, { - title: "Copy", + title: i18n.t("tui.dialog.message.copy"), value: "message.copy", - description: "message text to clipboard", + description: i18n.t("tui.dialog.message.copy_description"), onSelect: async (dialog) => { const msg = message() if (!msg) return @@ -73,9 +75,9 @@ export function DialogMessage(props: { }, }, { - title: "Fork", + title: i18n.t("tui.dialog.message.fork"), value: "session.fork", - description: "create a new session", + description: i18n.t("tui.dialog.message.fork_description"), onSelect: async (dialog) => { const result = await sdk.client.session.fork({ sessionID: props.sessionID, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx index c5ef70ef06f6..64850eec1c88 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx @@ -1,17 +1,19 @@ import { DialogSelect } from "@tui/ui/dialog-select" +import { useI18n } from "@tui/context/i18n" import { useRoute } from "@tui/context/route" export function DialogSubagent(props: { sessionID: string }) { const route = useRoute() + const i18n = useI18n() return ( { route.navigate({ type: "session", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index c6bc231fcad0..a4b04e80c052 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -87,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" +import { useI18n } from "../../context/i18n" addDefaultParsers(parsers.parsers) @@ -192,7 +193,7 @@ export function Session() { .catch((e) => { console.error(e) toast.show({ - message: `Session not found: ${route.sessionID}`, + message: i18n.t("tui.session.not_found", { session: route.sessionID }), variant: "error", }) return navigate({ type: "home" }) @@ -230,6 +231,7 @@ export function Session() { const keybind = useKeybind() const dialog = useDialog() const renderer = useRenderer() + const i18n = useI18n() event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return @@ -369,11 +371,11 @@ export function Session() { const command = useCommandDialog() command.register(() => [ { - title: session()?.share?.url ? "Copy share link" : "Share session", + title: session()?.share?.url ? i18n.t("tui.session.copy_share_link") : i18n.t("tui.session.share"), value: "session.share", suggested: route.type === "session", keybind: "session_share", - category: "Session", + category: i18n.t("tui.session.category"), enabled: sync.data.config.share !== "disabled", slash: { name: "share", @@ -381,8 +383,8 @@ export function Session() { onSelect: async (dialog) => { const copy = (url: string) => Clipboard.copy(url) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) + .then(() => toast.show({ message: i18n.t("tui.session.share_copied"), variant: "success" })) + .catch(() => toast.show({ message: i18n.t("tui.session.share_copy_failed"), variant: "error" })) const url = session()?.share?.url if (url) { await copy(url) @@ -390,7 +392,11 @@ export function Session() { return } if (!kv.get("share_consent", false)) { - const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?") + const ok = await DialogConfirm.show( + dialog, + i18n.t("tui.session.share_confirm_title"), + i18n.t("tui.session.share_confirm_message"), + ) if (ok !== true) return kv.set("share_consent", true) } @@ -401,7 +407,7 @@ export function Session() { .then((res) => copy(res.data!.share!.url)) .catch((error) => { toast.show({ - message: error instanceof Error ? error.message : "Failed to share session", + message: error instanceof Error ? error.message : i18n.t("tui.session.share_failed"), variant: "error", }) }) @@ -409,10 +415,10 @@ export function Session() { }, }, { - title: "Rename session", + title: i18n.t("tui.session.rename"), value: "session.rename", keybind: "session_rename", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "rename", }, @@ -421,10 +427,10 @@ export function Session() { }, }, { - title: "Jump to message", + title: i18n.t("tui.session.timeline"), value: "session.timeline", keybind: "session_timeline", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "timeline", }, @@ -444,10 +450,10 @@ export function Session() { }, }, { - title: "Fork from message", + title: i18n.t("tui.session.fork"), value: "session.fork", keybind: "session_fork", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "fork", }, @@ -466,10 +472,10 @@ export function Session() { }, }, { - title: "Compact session", + title: i18n.t("tui.session.compact"), value: "session.compact", keybind: "session_compact", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "compact", aliases: ["summarize"], @@ -479,7 +485,7 @@ export function Session() { if (!selectedModel) { toast.show({ variant: "warning", - message: "Connect a provider to summarize this session", + message: i18n.t("tui.session.compact_provider_required"), duration: 3000, }) return @@ -493,10 +499,10 @@ export function Session() { }, }, { - title: "Unshare session", + title: i18n.t("tui.session.unshare"), value: "session.unshare", keybind: "session_unshare", - category: "Session", + category: i18n.t("tui.session.category"), enabled: !!session()?.share?.url, slash: { name: "unshare", @@ -506,10 +512,10 @@ export function Session() { .unshare({ sessionID: route.sessionID, }) - .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) + .then(() => toast.show({ message: i18n.t("tui.session.unshare_success"), variant: "success" })) .catch((error) => { toast.show({ - message: error instanceof Error ? error.message : "Failed to unshare session", + message: error instanceof Error ? error.message : i18n.t("tui.session.unshare_failed"), variant: "error", }) }) @@ -517,10 +523,10 @@ export function Session() { }, }, { - title: "Undo previous message", + title: i18n.t("tui.session.undo"), value: "session.undo", keybind: "messages_undo", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "undo", }, @@ -555,10 +561,10 @@ export function Session() { }, }, { - title: "Redo", + title: i18n.t("tui.session.redo"), value: "session.redo", keybind: "messages_redo", - category: "Session", + category: i18n.t("tui.session.category"), enabled: !!session()?.revert?.messageID, slash: { name: "redo", @@ -582,10 +588,10 @@ export function Session() { }, }, { - title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", + title: sidebarVisible() ? i18n.t("tui.session.sidebar_hide") : i18n.t("tui.session.sidebar_show"), value: "session.sidebar.toggle", keybind: "sidebar_toggle", - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { batch(() => { const isVisible = sidebarVisible() @@ -596,19 +602,19 @@ export function Session() { }, }, { - title: conceal() ? "Disable code concealment" : "Enable code concealment", + title: conceal() ? i18n.t("tui.session.conceal_disable") : i18n.t("tui.session.conceal_enable"), value: "session.toggle.conceal", keybind: "messages_toggle_conceal" as any, - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { setConceal((prev) => !prev) dialog.clear() }, }, { - title: showTimestamps() ? "Hide timestamps" : "Show timestamps", + title: showTimestamps() ? i18n.t("tui.session.timestamps_hide") : i18n.t("tui.session.timestamps_show"), value: "session.toggle.timestamps", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "timestamps", aliases: ["toggle-timestamps"], @@ -619,10 +625,10 @@ export function Session() { }, }, { - title: showThinking() ? "Hide thinking" : "Show thinking", + title: showThinking() ? i18n.t("tui.session.thinking_hide") : i18n.t("tui.session.thinking_show"), value: "session.toggle.thinking", keybind: "display_thinking", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "thinking", aliases: ["toggle-thinking"], @@ -633,39 +639,41 @@ export function Session() { }, }, { - title: showDetails() ? "Hide tool details" : "Show tool details", + title: showDetails() ? i18n.t("tui.session.details_hide") : i18n.t("tui.session.details_show"), value: "session.toggle.actions", keybind: "tool_details", - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { setShowDetails((prev) => !prev) dialog.clear() }, }, { - title: "Toggle session scrollbar", + title: i18n.t("tui.session.scrollbar_toggle"), value: "session.toggle.scrollbar", keybind: "scrollbar_toggle", - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { setShowScrollbar((prev) => !prev) dialog.clear() }, }, { - title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", + title: showGenericToolOutput() + ? i18n.t("tui.session.generic_output_hide") + : i18n.t("tui.session.generic_output_show"), value: "session.toggle.generic_tool_output", - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { setShowGenericToolOutput((prev) => !prev) dialog.clear() }, }, { - title: "Page up", + title: i18n.t("tui.session.page_up"), value: "session.page.up", keybind: "messages_page_up", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) @@ -673,10 +681,10 @@ export function Session() { }, }, { - title: "Page down", + title: i18n.t("tui.session.page_down"), value: "session.page.down", keybind: "messages_page_down", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) @@ -684,10 +692,10 @@ export function Session() { }, }, { - title: "Line up", + title: i18n.t("tui.session.line_up"), value: "session.line.up", keybind: "messages_line_up", - category: "Session", + category: i18n.t("tui.session.category"), disabled: true, onSelect: (dialog) => { scroll.scrollBy(-1) @@ -695,10 +703,10 @@ export function Session() { }, }, { - title: "Line down", + title: i18n.t("tui.session.line_down"), value: "session.line.down", keybind: "messages_line_down", - category: "Session", + category: i18n.t("tui.session.category"), disabled: true, onSelect: (dialog) => { scroll.scrollBy(1) @@ -706,10 +714,10 @@ export function Session() { }, }, { - title: "Half page up", + title: i18n.t("tui.session.half_page_up"), value: "session.half.page.up", keybind: "messages_half_page_up", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) @@ -717,10 +725,10 @@ export function Session() { }, }, { - title: "Half page down", + title: i18n.t("tui.session.half_page_down"), value: "session.half.page.down", keybind: "messages_half_page_down", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) @@ -728,10 +736,10 @@ export function Session() { }, }, { - title: "First message", + title: i18n.t("tui.session.first_message"), value: "session.first", keybind: "messages_first", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) @@ -739,10 +747,10 @@ export function Session() { }, }, { - title: "Last message", + title: i18n.t("tui.session.last_message"), value: "session.last", keybind: "messages_last", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) @@ -750,10 +758,10 @@ export function Session() { }, }, { - title: "Jump to last user message", + title: i18n.t("tui.session.last_user_message"), value: "session.messages_last_user", keybind: "messages_last_user", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: () => { const messages = sync.data.message[route.sessionID] @@ -782,33 +790,33 @@ export function Session() { }, }, { - title: "Next message", + title: i18n.t("tui.session.next_message"), value: "session.message.next", keybind: "messages_next", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { - title: "Previous message", + title: i18n.t("tui.session.previous_message"), value: "session.message.previous", keybind: "messages_previous", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => scrollToMessage("prev", dialog), }, { - title: "Copy last assistant message", + title: i18n.t("tui.session.copy_last_assistant"), value: "messages.copy", keybind: "messages_copy", - category: "Session", + category: i18n.t("tui.session.category"), onSelect: (dialog) => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), ) if (!lastAssistantMessage) { - toast.show({ message: "No assistant messages found", variant: "error" }) + toast.show({ message: i18n.t("tui.session.no_assistant_messages"), variant: "error" }) dialog.clear() return } @@ -816,7 +824,7 @@ export function Session() { const parts = sync.data.part[lastAssistantMessage.id] ?? [] const textParts = parts.filter((part) => part.type === "text") if (textParts.length === 0) { - toast.show({ message: "No text parts found in last assistant message", variant: "error" }) + toast.show({ message: i18n.t("tui.session.no_text_parts"), variant: "error" }) dialog.clear() return } @@ -827,7 +835,7 @@ export function Session() { .trim() if (!text) { toast.show({ - message: "No text content found in last assistant message", + message: i18n.t("tui.session.no_text_content"), variant: "error", }) dialog.clear() @@ -835,15 +843,15 @@ export function Session() { } Clipboard.copy(text) - .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) + .then(() => toast.show({ message: i18n.t("tui.session.message_copied"), variant: "success" })) + .catch(() => toast.show({ message: i18n.t("tui.session.copy_failed"), variant: "error" })) dialog.clear() }, }, { - title: "Copy session transcript", + title: i18n.t("tui.session.copy_transcript"), value: "session.copy", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "copy", }, @@ -863,18 +871,18 @@ export function Session() { }, ) await Clipboard.copy(transcript) - toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) + toast.show({ message: i18n.t("tui.session.transcript_copied"), variant: "success" }) } catch (error) { - toast.show({ message: "Failed to copy session transcript", variant: "error" }) + toast.show({ message: i18n.t("tui.session.transcript_copy_failed"), variant: "error" }) } dialog.clear() }, }, { - title: "Export session transcript", + title: i18n.t("tui.session.export_transcript"), value: "session.export", keybind: "session_export", - category: "Session", + category: i18n.t("tui.session.category"), slash: { name: "export", }, @@ -924,19 +932,19 @@ export function Session() { await Filesystem.write(filepath, result) } - toast.show({ message: `Session exported to ${filename}`, variant: "success" }) + toast.show({ message: i18n.t("tui.session.export_success", { filename }), variant: "success" }) } } catch (error) { - toast.show({ message: "Failed to export session", variant: "error" }) + toast.show({ message: i18n.t("tui.session.export_failed"), variant: "error" }) } dialog.clear() }, }, { - title: "Go to child session", + title: i18n.t("tui.session.child_first"), value: "session.child.first", keybind: "session_child_first", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, onSelect: (dialog) => { moveFirstChild() @@ -944,10 +952,10 @@ export function Session() { }, }, { - title: "Go to parent session", + title: i18n.t("tui.session.parent"), value: "session.parent", keybind: "session_parent", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { @@ -962,10 +970,10 @@ export function Session() { }), }, { - title: "Next child session", + title: i18n.t("tui.session.child_next"), value: "session.child.next", keybind: "session_child_cycle", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { @@ -974,10 +982,10 @@ export function Session() { }), }, { - title: "Previous child session", + title: i18n.t("tui.session.child_previous"), value: "session.child.previous", keybind: "session_child_cycle_reverse", - category: "Session", + category: i18n.t("tui.session.category"), hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { @@ -1089,8 +1097,8 @@ export function Session() { const handleUnrevert = async () => { const confirmed = await DialogConfirm.show( dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", + i18n.t("tui.session.redo_confirm_title"), + i18n.t("tui.session.redo_confirm_message"), ) if (confirmed) { command.trigger("session.redo") @@ -1114,10 +1122,11 @@ export function Session() { paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} > - {revert()!.reverted.length} message reverted - {keybind.print("messages_redo")} or /redo to - restore + {i18n.t("tui.session.reverted_count", { count: revert()!.reverted.length })} + + + {i18n.t("tui.session.redo_restore_hint", { keybind: keybind.print("messages_redo") })} From e3a12378f467531db7c7c984758af9be7bbba5b9 Mon Sep 17 00:00:00 2001 From: gqcdm Date: Tue, 14 Apr 2026 23:21:26 +0800 Subject: [PATCH 14/14] feat(opencode): localize run command errors Translate the remaining run command validation and session errors so CLI fallback paths stay aligned with locale-aware behavior. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/cli/cmd/run.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 17fc4bc08799..e9db7f8de123 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -5,6 +5,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" +import { resolveLocale, t } from "../../i18n" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -310,6 +311,7 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { + const locale = resolveLocale(process.env.OPENCODE_LOCALE) let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -321,7 +323,7 @@ export const RunCommand = cmd({ process.chdir(args.dir) return process.cwd() } catch { - UI.error("Failed to change directory to " + args.dir) + UI.error(t(locale, "cli.run.chdir_failed", { dir: args.dir })) process.exit(1) } })() @@ -351,7 +353,7 @@ export const RunCommand = cmd({ if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") + UI.error(t(locale, "cli.run.message_required")) process.exit(1) } @@ -636,7 +638,7 @@ export const RunCommand = cmd({ const sessionID = await session(sdk) if (!sessionID) { - UI.error("Session not found") + UI.error(t(locale, "cli.run.session_not_found")) process.exit(1) } await share(sdk, sessionID)