Skip to content

Conversation

@Eric-Guo
Copy link
Contributor

@Eric-Guo Eric-Guo commented Jan 18, 2026

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 33 iShot_2026-01-18_22 39 00

Copilot AI review requested due to automatic review settings January 18, 2026 04:03
@github-actions
Copy link
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
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-Guo Eric-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
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.

Copy link
Contributor

Copilot AI left a comment

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
File Description
packages/app/src/context/language.tsx New language context with translation function and localStorage persistence
packages/app/src/locales/en.json English translation file with all UI strings
packages/app/src/locales/zh.json Chinese translation file with all UI strings
packages/app/tsconfig.json Enabled JSON module imports for translation files
packages/app/src/app.tsx Integrated LanguageProvider into app component tree
packages/app/src/pages/*.tsx Updated pages to use translation keys
packages/app/src/components/**/*.tsx Updated all components to use translation keys

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

Copy link
Contributor

Copilot AI left a comment

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.

@Eric-Guo Eric-Guo force-pushed the i18n_support branch 2 times, most recently from 2488744 to cea1607 Compare January 18, 2026 10:45
@Eric-Guo Eric-Guo requested a review from Copilot January 18, 2026 11:18
Copy link
Contributor

Copilot AI left a comment

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.

return (
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<LanguageProvider>
Copy link

Copilot AI Jan 18, 2026

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
Contributor Author

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.

Copy link
Contributor

Copilot AI left a comment

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.

@adamdotdevin
Copy link
Contributor

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

@Eric-Guo
Copy link
Contributor Author

Eric-Guo commented Jan 18, 2026

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-Guo force-pushed the i18n_support branch 3 times, most recently from 741f54f to 0f7384d Compare January 19, 2026 09:28
Eric-Guo and others added 11 commits January 20, 2026 15:18
…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>
Sign up for free to 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

2 participants