diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf389..b1a0f9521be2 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -162,7 +162,11 @@ export type ParsedAPICallError = metadata?: Record } -export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError { +export function parseAPICallError(input: { + providerID: ProviderV2.ID + retry400RateLimit?: boolean + error: APICallError +}): ParsedAPICallError { const m = message(input.providerID, input.error) const body = json(input.error.responseBody) if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { @@ -174,11 +178,18 @@ export function parseAPICallError(input: { providerID: ProviderV2.ID; error: API } const metadata = input.error.url ? { url: input.error.url } : undefined + const rateLimitExceeded = + input.retry400RateLimit === true && + input.error.statusCode === 400 && + typeof body?.detail === "string" && + body.detail.toLowerCase().includes("rate limit exceeded") return { type: "api_error", message: m, statusCode: input.error.statusCode, - isRetryable: input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable, + isRetryable: + rateLimitExceeded || + (input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable), responseHeaders: input.error.responseHeaders, responseBody: input.error.responseBody, metadata, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 3a9ae0c6d36c..843c4b327da5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -55,6 +55,17 @@ export const ProviderApi = HttpApi.make("provider") description: "Retrieve available authentication methods for all AI providers.", }), ), + HttpApiEndpoint.post("refresh", `${root}/refresh`, { + query: WorkspaceRoutingQuery, + success: described(Provider.ListResult, "Refreshed list of providers"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.refresh", + summary: "Refresh model catalog", + description: + "Force-refresh the model catalog from models.opencode.ai and return the updated provider list.", + }), + ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderV2.ID }, query: WorkspaceRoutingQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index e1377b6f75c5..6e7caf9acd81 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -58,6 +58,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" } }) + const refresh = Effect.fn("ProviderHttpApi.refresh")(function* () { + yield* ModelsDev.Service.use((s) => s.refresh(true)) + return yield* list() + }) + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { return yield* svc.methods() }) @@ -106,6 +111,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" return handlers .handle("list", list) + .handle("refresh", refresh) .handle("auth", auth) .handleRaw("authorize", authorizeRaw) .handle("callback", callback) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..f79cb9356265 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -602,7 +602,7 @@ export function latest(msgs: WithParts[]) { export function fromError( e: unknown, - ctx: { providerID: ProviderV2.ID; aborted?: boolean }, + ctx: { providerID: ProviderV2.ID; aborted?: boolean; retry400RateLimit?: boolean }, ): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": @@ -676,6 +676,7 @@ export function fromError( case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, + retry400RateLimit: ctx.retry400RateLimit, error: e, }) if (parsed.type === "context_overflow") { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..1d26453780d2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -113,11 +113,14 @@ const layer = Layer.effect( reasoningMap: {}, } let aborted = false + const cfg = yield* config.get() + const retry400RateLimit = cfg.provider?.[input.model.providerID]?.options?.retry400RateLimit === true const parse = (e: unknown) => MessageV2.fromError(e, { providerID: input.model.providerID, aborted, + retry400RateLimit, }) const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index cc43cf4e55de..bf52cd54dd84 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -141,6 +141,18 @@ it.instance("loads tui config with the same precedence order as server config pa ), ) +it.instance("loads the TUI locale from tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { locale: "zh-CN" }) + + expect((yield* getTuiConfig(test.directory)).locale).toBe("zh-CN") + }), + ), +) + it.instance("resolves attention config defaults and overrides", () => withCleanState( Effect.gen(function* () { diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 528760543656..567c4a54e1fc 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -70,7 +70,7 @@ const cfg = { }, } -function providerCfg(url: string) { +function providerCfg(url: string, retry400RateLimit = false) { return { ...cfg, provider: { @@ -80,6 +80,7 @@ function providerCfg(url: string) { options: { ...cfg.provider.test.options, baseURL: url, + retry400RateLimit, }, }, }, @@ -557,6 +558,52 @@ it.live("session.processor effect tests do not retry unknown json errors", () => ), ) +it.live("session.processor effect tests retry provider-local 400 rate limits when enabled", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(400, { detail: "Rate limit exceeded" }) + yield* llm.text("after") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "retry configured 400") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "retry configured 400" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) + expect(handle.message.error).toBeUndefined() + }), + { config: (url) => providerCfg(url, true) }, + ), +) + it.live("session.processor effect tests retry recognized structured json errors", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9d..46029351bea2 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -437,4 +437,56 @@ describe("session.message-v2.fromError", () => { message: "An error occurred while processing your request.", }) }) + + test("marks provider 400 rate limit response bodies as retryable when enabled", () => { + const error = new APICallError({ + message: "Bad request", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + detail: + "Rate limit exceeded for end_user: test@example.com. Limit type: tokens. Current limit: 500000, Remaining: 0. Limit resets at: 2026-06-07 22:57:15 UTC", + }), + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID, retry400RateLimit: true }) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.statusCode).toBe(400) + expect(result.data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: result.data.message }) + }) + + test("keeps provider 400 rate limit response bodies non-retryable when disabled", () => { + const error = new APICallError({ + message: "Bad request", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ detail: "Rate limit exceeded" }), + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID }) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(false) + expect(SessionRetry.retryable(result, retryProvider)).toBeUndefined() + }) + + test("keeps generic provider 400 API call errors non-retryable when enabled", () => { + const error = new APICallError({ + message: "Bad request", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ error: "invalid request" }), + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID, retry400RateLimit: true }) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(false) + expect(SessionRetry.retryable(result, retryProvider)).toBeUndefined() + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..b4c632145734 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -151,6 +151,8 @@ import type { ProviderOauthAuthorizeResponses, ProviderOauthCallbackErrors, ProviderOauthCallbackResponses, + ProviderRefreshErrors, + ProviderRefreshResponses, PtyConnectErrors, PtyConnectResponses, PtyConnectTokenErrors, @@ -3353,6 +3355,36 @@ export class Provider extends HeyApiClient { }) } + /** + * Refresh model catalog + * + * Force-refresh the model catalog from models.opencode.ai and return the updated provider list. + */ + public refresh( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/provider/refresh", + ...options, + ...params, + }) + } + private _oauth?: Oauth get oauth(): Oauth { return (this._oauth ??= new Oauth({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158cc..870139e220b6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9362,6 +9362,40 @@ export type ProviderAuthResponses = { export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type ProviderRefreshData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider/refresh" +} + +export type ProviderRefreshErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderRefreshError = ProviderRefreshErrors[keyof ProviderRefreshErrors] + +export type ProviderRefreshResponses = { + /** + * Refreshed list of providers + */ + 200: { + all: Array + default: { + [key: string]: string + } + connected: Array + } +} + +export type ProviderRefreshResponse = ProviderRefreshResponses[keyof ProviderRefreshResponses] + export type ProviderOauthAuthorizeData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index afe14bb604cc..50b82119df60 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,3 +1,4 @@ +$ bun run --conditions=browser ./src/index.ts generate { "openapi": "3.1.0", "info": { @@ -5105,6 +5106,83 @@ ] } }, + "/provider/refresh": { + "post": { + "tags": ["provider"], + "operationId": "provider.refresh", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Refreshed list of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"], + "additionalProperties": false, + "description": "Refreshed list of providers" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Force-refresh the model catalog from models.opencode.ai and return the updated provider list.", + "summary": "Refresh model catalog", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.refresh({\n ...\n})" + } + ] + } + }, "/provider/{providerID}/oauth/authorize": { "post": { "tags": ["provider"], diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..4dc5dc21ef93 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -66,6 +66,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" +import { TuiI18nProvider } from "./i18n" import { createTuiApiAdapters } from "./plugin/adapters" import { createTuiApi } from "./plugin/api" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" @@ -294,46 +295,48 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index 3dd6829c54c2..687eac3a7300 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -9,6 +9,7 @@ import { useOpencodeKeymap, } from "../keymap" import { useTuiConfig } from "../config" +import { useTuiI18n } from "../i18n" type PaletteCommandEntry = ReturnType[number] @@ -25,6 +26,7 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { export function CommandPaletteDialog() { const config = useTuiConfig() + const i18n = useTuiI18n() const keymap = useOpencodeKeymap() const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { const query = { @@ -69,11 +71,11 @@ export function CommandPaletteDialog() { .map((option) => ({ ...option, value: `suggested:${option.value}`, - category: "Suggested", + category: i18n.t("palette.suggested"), })), ...options(), ] } - return (ref = value)} title="Commands" options={list()} /> + return (ref = value)} title={i18n.t("palette.commands")} options={list()} /> } diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 38db569aaa24..3d570edcd764 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -173,6 +173,13 @@ export function DialogModel(props: { providerID?: string }) { local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, + { + command: "model.dialog.refresh", + title: "Refresh models", + onTrigger() { + void sync.refreshProviders() + }, + }, ]} onFilter={setQuery} flat={true} diff --git a/packages/tui/src/component/startup-loading.tsx b/packages/tui/src/component/startup-loading.tsx index 6665c0c2e8c4..c32565335511 100644 --- a/packages/tui/src/component/startup-loading.tsx +++ b/packages/tui/src/component/startup-loading.tsx @@ -1,11 +1,13 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" import { useTheme } from "../context/theme" import { Spinner } from "./spinner" +import { useTuiI18n } from "../i18n" export function StartupLoading(props: { ready: () => boolean }) { const theme = useTheme().theme + const i18n = useTuiI18n() const [show, setShow] = createSignal(false) - const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) + const text = createMemo(() => (props.ready() ? i18n.t("startup.finishing") : i18n.t("startup.loadingPlugins"))) let wait: NodeJS.Timeout | undefined let hold: NodeJS.Timeout | undefined let stamp = 0 diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index df9239763a68..20f78e4c5141 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -4,6 +4,7 @@ import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" import { createContext, type JSX, useContext } from "solid-js" import { TuiKeybind } from "./keybind" +import { resolveLocale } from "../i18n" export const AttentionSoundName = Schema.Literals([ "default", @@ -30,6 +31,8 @@ export const ScrollAcceleration = Schema.Struct({ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", }) +export const Locale = Schema.Literals(["en", "zh-CN"]).annotate({ description: "TUI display language" }) +export type Locale = Schema.Schema.Type export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String)) export type AttentionSoundPaths = Schema.Schema.Type @@ -62,11 +65,12 @@ export const Info = Schema.Struct({ scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), + locale: Schema.optional(Locale), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) export type Info = Schema.Schema.Type -export type Resolved = Omit & { +export type Resolved = Omit & { attention: { enabled: boolean notifications: boolean @@ -77,6 +81,7 @@ export type Resolved = Omit x.data!), + sdk.client.config.providers({ workspace }, { throwOnError: true }).then((x) => x.data!), + ]) + batch(() => { + setStore("provider", reconcile(providers.providers)) + setStore("provider_default", reconcile(providers.default)) + setStore("provider_next", reconcile(providerList)) + }) + }, bootstrap, } return result diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c59046a01722..1773a08e8400 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -3,11 +3,13 @@ import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Show } from "solid-js" import { abbreviateHome } from "../../runtime" import { useTuiPaths } from "../../context/runtime" +import { useTuiI18n } from "../../i18n" const id = "internal:sidebar-footer" function View(props: { api: TuiPluginApi; sessionID: string }) { const paths = useTuiPaths() + const i18n = useTuiI18n() const theme = () => props.api.theme.current const has = createMemo(() => props.api.state.provider.some( @@ -47,18 +49,18 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { - Getting started + {i18n.t("sidebar.gettingStarted")} props.api.kv.set("dismissed_getting_started", true)}> ✕ - OpenCode includes free models so you can start immediately. + {i18n.t("sidebar.freeModels")} - Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc + {i18n.t("sidebar.providers")} - Connect provider + {i18n.t("sidebar.connectProvider")} /connect diff --git a/packages/tui/src/i18n.tsx b/packages/tui/src/i18n.tsx new file mode 100644 index 000000000000..c00fe6182c03 --- /dev/null +++ b/packages/tui/src/i18n.tsx @@ -0,0 +1,67 @@ +import { createContext, type JSX, useContext } from "solid-js" + +const dictionaries = { + en: { + "home.placeholder.fixTodo": "Fix a TODO in the codebase", + "home.placeholder.techStack": "What is the tech stack of this project?", + "home.placeholder.fixTests": "Fix broken tests", + "sidebar.gettingStarted": "Getting started", + "sidebar.freeModels": "OpenCode includes free models so you can start immediately.", + "sidebar.providers": "Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc", + "sidebar.connectProvider": "Connect provider", + "footer.getStarted": "Get started", + "footer.permission": "Permission", + "footer.permissions": "Permissions", + "startup.finishing": "Finishing startup...", + "startup.loadingPlugins": "Loading plugins...", + "dialog.cancel": "Cancel", + "dialog.confirm": "Confirm", + "palette.commands": "Commands", + "palette.suggested": "Suggested", + }, + "zh-CN": { + "home.placeholder.fixTodo": "修复代码库中的待办事项", + "home.placeholder.techStack": "这个项目使用什么技术栈?", + "home.placeholder.fixTests": "修复失败的测试", + "sidebar.gettingStarted": "开始使用", + "sidebar.freeModels": "OpenCode 包含免费模型,您可以立即开始使用。", + "sidebar.providers": "连接超过 75 家提供商以使用其他模型,包括 Claude、GPT、Gemini 等。", + "sidebar.connectProvider": "连接提供商", + "footer.getStarted": "开始使用", + "footer.permission": "项权限", + "footer.permissions": "项权限", + "startup.finishing": "正在完成启动...", + "startup.loadingPlugins": "正在加载插件...", + "dialog.cancel": "取消", + "dialog.confirm": "确认", + "palette.commands": "命令", + "palette.suggested": "推荐", + }, +} as const + +export type Locale = keyof typeof dictionaries +export type TranslationKey = keyof (typeof dictionaries)["en"] + +export function resolveLocale(value: unknown, env = process.env): Locale { + if (value === "en" || value === "zh-CN") return value + const system = env.LC_ALL || env.LC_MESSAGES || env.LANG || "" + return /^zh(?:[_-]CN|$)/i.test(system) ? "zh-CN" : "en" +} + +export function translate(locale: Locale, key: TranslationKey) { + return dictionaries[locale][key] ?? dictionaries.en[key] +} + +const Context = createContext("en") + +export function TuiI18nProvider(props: { locale: Locale; children: JSX.Element }) { + return {props.children} +} + +export function useTuiI18n() { + const locale = useContext(Context) + return { + locale, + t: (key: TranslationKey) => translate(locale, key), + } +} \ No newline at end of file diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87be..706e250b7dff 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -12,13 +12,9 @@ import { useEditorContext } from "../context/editor" import { useTerminalDimensions } from "@opentui/solid" import { useTuiConfig } from "../config" import { HomeSessionDestinationProvider } from "./home/session-destination" +import { useTuiI18n } from "../i18n" 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 pluginRuntime = usePluginRuntime() const sync = useSync() @@ -30,6 +26,11 @@ export function Home() { const editor = useEditorContext() const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() + const i18n = useTuiI18n() + const placeholder = { + normal: [i18n.t("home.placeholder.fixTodo"), i18n.t("home.placeholder.techStack"), i18n.t("home.placeholder.fixTests")], + shell: ["ls -la", "git status", "pwd"], + } const promptMaxWidth = createMemo(() => { const configured = tuiConfig.prompt?.max_width if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index c3a96254e98b..ad6905b53878 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { useTuiI18n } from "../../i18n" export function Footer() { const { theme } = useTheme() @@ -19,6 +20,7 @@ export function Footer() { }) const directory = useDirectory() const connected = useConnected() + const i18n = useTuiI18n() const [store, setStore] = createStore({ welcome: false, @@ -56,14 +58,15 @@ export function Footer() { - Get started /connect + {i18n.t("footer.getStarted")} /connect 0}> - {permissions().length} Permission - {permissions().length > 1 ? "s" : ""} + {permissions().length} {i18n.t( + permissions().length > 1 ? "footer.permissions" : "footer.permission", + )} diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index a09847af2f9b..674cb20c3fdf 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -5,6 +5,7 @@ import { createStore } from "solid-js/store" import { For } from "solid-js" import { Locale } from "../util/locale" import { useBindings } from "../keymap" +import { useTuiI18n } from "../i18n" export type DialogConfirmProps = { title: string @@ -19,6 +20,7 @@ export type DialogConfirmResult = boolean | undefined export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() + const i18n = useTuiI18n() const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) @@ -80,7 +82,7 @@ export function DialogConfirm(props: DialogConfirmProps) { }} > - {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)} + {key === "cancel" && props.label ? Locale.titlecase(props.label) : i18n.t(`dialog.${key}`)} )} diff --git a/packages/tui/test/i18n.test.ts b/packages/tui/test/i18n.test.ts new file mode 100644 index 000000000000..5a99a4df89db --- /dev/null +++ b/packages/tui/test/i18n.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { resolveLocale, translate } from "../src/i18n" + +describe("TUI locale", () => { + test("uses an explicit supported locale", () => { + expect(resolveLocale("zh-CN", { LANG: "en_US.UTF-8" })).toBe("zh-CN") + }) + + test("detects Chinese system locales", () => { + expect(resolveLocale(undefined, { LANG: "zh_CN.UTF-8" })).toBe("zh-CN") + expect(resolveLocale(undefined, { LC_MESSAGES: "zh", LANG: "en_US.UTF-8" })).toBe("zh-CN") + expect(resolveLocale(undefined, { LC_ALL: "en_US.UTF-8", LC_MESSAGES: "zh_CN.UTF-8" })).toBe("en") + }) + + test("falls back to English for unsupported values", () => { + expect(resolveLocale("fr", { LANG: "en_US.UTF-8" })).toBe("en") + expect(translate("zh-CN", "palette.commands")).toBe("命令") + }) +}) \ No newline at end of file diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 8bf875b1f85f..35bbab8dd6c4 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -273,6 +273,7 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. "enabled": true }, "diff_style": "auto", + "locale": "zh-CN", "mouse": true, "attention": { "enabled": true, @@ -285,6 +286,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. +Set `locale` to `"en"` or `"zh-CN"` to choose the TUI display language. When omitted, OpenCode uses Chinese for a Chinese system locale and English otherwise. + Set `attention.enabled` to turn on TUI desktop notifications and sounds. See [TUI attention](/docs/tui#attention). Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.