Skip to content

feat(app): I18n support (only chinese currently) - #9190

Closed
Eric-Guo wants to merge 11 commits into
anomalyco:devfrom
Eric-Guo:i18n_support
Closed

feat(app): I18n support (only chinese currently)#9190
Eric-Guo wants to merge 11 commits into
anomalyco:devfrom
Eric-Guo:i18n_support

Conversation

@Eric-Guo

@Eric-GuoEric-Guo commented Jan 18, 2026

Copy link
Copy Markdown
Contributor

Implement / Closes: #7170, #6326, #2357

app/desktop only, console/terminal suggest don't do i18n support and not include in this PR

Cmd+Shift+l to switch the language or Cmd+P and type 'language', no UI button added yet.

Windows:

Weixin Image_20260118210057_1064_87

MacOS

iShot_2026-01-18_22 19 33iShot_2026-01-18_22 39 00

CopilotAI review requested due to automatic review settings January 18, 2026 04:03
@github-actions

Copy link
Copy Markdown
Contributor

Hey! Your PR title feat(app) I18n support (only chinese currently) doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Potential Duplicate PRs Found:

  1. PR feat(app): add i18n internationalization support #7290 - feat(app): add i18n internationalization support

  2. PR feat(i18n): add internationalization with Korean translations #6776 - feat(i18n): add internationalization with Korean translations

  3. PR feat: implement comprehensive Chinese translation support #1581 - feat: implement comprehensive Chinese translation support

Note: PR #7290 appears to be the most directly related, as it has the same feature (i18n support) and possibly the same implementation intent as the current PR #9190.

@Eric-GuoEric-Guo changed the title feat(app) I18n support (only chinese currently)feat(app): I18n support (only chinese currently)Jan 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements internationalization (i18n) support for the OpenCode app, currently supporting English and Chinese languages. The implementation adds a language context provider, translation files for both languages, and updates all hardcoded UI strings throughout the application to use translation keys.

Changes:

  • Added LanguageProvider context with translation loading and parameter interpolation
  • Created comprehensive translation files (en.json, zh.json) with 300+ translation keys
  • Updated all UI components and pages to use translation keys instead of hardcoded strings
  • Added language switching commands with keyboard shortcut (mod+shift+l)

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.

Show a summary per file
FileDescription
packages/app/src/context/language.tsxNew language context with translation function and localStorage persistence
packages/app/src/locales/en.jsonEnglish translation file with all UI strings
packages/app/src/locales/zh.jsonChinese translation file with all UI strings
packages/app/tsconfig.jsonEnabled JSON module imports for translation files
packages/app/src/app.tsxIntegrated LanguageProvider into app component tree
packages/app/src/pages/*.tsxUpdated pages to use translation keys
packages/app/src/components/**/*.tsxUpdated all components to use translation keys

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/app/src/pages/error.tsx Outdated
Comment threadpackages/app/src/context/language.tsx Outdated
Comment threadpackages/app/src/context/language.tsx Outdated
Comment threadpackages/app/src/context/language.tsx Outdated
Comment threadpackages/app/src/pages/home.tsx Outdated
Comment threadpackages/app/src/components/session/session-new-view.tsx Outdated
Comment threadpackages/app/src/locales/en.json Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/app/src/components/dialog-select-server.tsx

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/app/src/components/dialog-select-provider.tsx
Comment threadpackages/app/src/app.tsx
@@ -78,6 +79,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
return (
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<LanguageProvider>

CopilotAIJan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation for the LanguageProvider opening tag is inconsistent. It should be aligned with the sibling provider elements.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do after review done.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

packages/app/src/pages/session.tsx:727

  • The command.register callback accesses language context inside an arrow function that's called reactively. Since this callback returns command definitions each time, and these commands are likely registered during component initialization, this could cause the commands to be re-registered every time the language changes. Consider whether commands should be re-registered on language change, and if so, ensure this behavior is intentional and properly handled. If commands should update reactively with language changes, this is correct. Otherwise, consider memoizing or using a different pattern.
 command.register(() => [
{
id: "session.new",
title: language.t("session.new"),
category: language.t("command.category.session"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
{
id: "file.open",
title: language.t("command.title.openFile"),
description: language.t("command.description.openFile"),
category: language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
{
id: "terminal.toggle",
title: language.t("terminal.toggle"),
description: "",
category: language.t("command.category.view"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
},
{
id: "review.toggle",
title: language.t("review.toggle"),
description: "",
category: language.t("command.category.view"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
},
{
id: "terminal.new",
title: language.t("terminal.new"),
description: language.t("command.description.newTerminal"),
category: language.t("command.category.terminal"),
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
{
id: "steps.toggle",
title: language.t("command.title.toggleSteps"),
description: language.t("command.description.toggleSteps"),
category: language.t("command.category.view"),
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
{
id: "message.previous",
title: language.t("command.title.previousMessage"),
description: language.t("command.description.previousMessage"),
category: language.t("command.category.session"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
},
{
id: "message.next",
title: language.t("command.title.nextMessage"),
description: language.t("command.description.nextMessage"),
category: language.t("command.category.session"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
{
id: "model.choose",
title: language.t("command.title.chooseModel"),
description: language.t("command.description.chooseModel"),
category: language.t("command.category.model"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
},
{
id: "mcp.toggle",
title: language.t("command.title.toggleMcp"),
description: language.t("command.description.toggleMcp"),
category: language.t("command.category.mcp"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{
id: "agent.cycle",
title: language.t("command.title.cycleAgent"),
description: language.t("command.description.cycleAgent"),
category: language.t("command.category.agent"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
},
{
id: "agent.cycle.reverse",
title: language.t("command.title.cycleAgentBack"),
description: language.t("command.description.cycleAgentBack"),
category: language.t("command.category.agent"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "model.variant.cycle",
title: language.t("command.title.cycleThinking"),
description: language.t("command.description.cycleThinking"),
category: language.t("command.category.model"),
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: language.t("command.toast.thinkingEffortChanged"),
description: language.t("command.toast.thinkingEffortDescription", {
effort: local.model.variant.current() ?? language.t("model.defaultVariant"),
}),
})
},
},
{
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.title.stopAutoAcceptEdits")
: language.t("command.title.autoAcceptEdits"),
category: language.t("command.category.permissions"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("command.toast.autoAcceptOnTitle")
: language.t("command.toast.autoAcceptOffTitle"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("command.toast.autoAcceptOnDescription")
: language.t("command.toast.autoAcceptOffDescription"),
})
},
},
{
id: "session.undo",
title: language.t("command.title.undo"),
description: language.t("command.description.undo"),
category: language.t("command.category.session"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
const message = userMessages().findLast((x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = userMessages().findLast((x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
{
id: "session.redo",
title: language.t("command.title.redo"),
description: language.t("command.description.redo"),
category: language.t("command.category.session"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
// Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
{
id: "session.compact",
title: language.t("command.title.compactSession"),
description: language.t("command.description.compactSession"),
category: language.t("command.category.session"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("command.toast.noModelTitle"),
description: language.t("command.toast.noModelDescription"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
},
{
id: "session.fork",
title: language.t("command.title.forkFromMessage"),
description: language.t("command.description.forkFromMessage"),
category: language.t("command.category.session"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: language.t("session.shareCommandTitle"),
description: language.t("session.shareCommandDescription"),
category: language.t("command.category.session"),
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: language.t("session.shareCopyFailedTitle"),
variant: "error",
}),
)
})
.then(() =>
showToast({
title: language.t("session.shareSuccessTitle"),
description: language.t("session.shareSuccessDescription"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("session.shareFailedTitle"),
description: language.t("session.shareFailedDescription"),
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: language.t("session.unshareCommandTitle"),
description: language.t("session.unshareCommandDescription"),
category: language.t("command.category.session"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("session.unshareSuccessTitle"),
description: language.t("session.unshareSuccessDescription"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("session.unshareFailedTitle"),
description: language.t("session.unshareFailedDescription"),
variant: "error",
}),
)
},
},
]
: []),
])

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadpackages/app/tsconfig.json Outdated
Comment threadpackages/app/src/context/language.tsx
Comment threadpackages/app/src/components/dialog-select-file.tsx
Comment threadpackages/app/src/locales/en.json Outdated
@adamdotdevin

Copy link
Copy Markdown
Member

@Eric-Guo mind adding some screenshots? What is the mechanism for switching langauges? Is there any auto-detection?

@Eric-Guo

Eric-Guo commented Jan 18, 2026

Copy link
Copy Markdown
ContributorAuthor

screenshot add, you can using Cmd+Shift+l to switch the language or Cmd+P and type 'language'.

The translation is relative complete.

No any auto-detection and no other 4 languages😎, but I think it can be add later without too much conflict here.

@Eric-Guo
Eric-Guoforce-pushed the i18n_support branch 3 times, most recently from 741f54f to 0f7384dCompareJanuary 19, 2026 09:28
@github-actionsgithub-actionsBot mentioned this pull request Jan 19, 2026
Eric-Guoand others added 11 commits January 20, 2026 20:17
…y switching the provider to load from the locale JSON files, then replaced many user‑facing strings with language.t keys and added matching entries in the locale files.
…labels to translations so sorting no longer depends on localized strings.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@Eric-Guo

Copy link
Copy Markdown
ContributorAuthor

do in dev already.

@Eric-Guo
Eric-Guo deleted the i18n_support branch July 25, 2026 05:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]:i18n support

3 participants

@Eric-Guo@adamdotdevin