[FEATURE] Mod API can't localize terminal UI: ui.render returns opaque engine nodes, permission dialog unhookable

Status Open
Reported on v2.1.287
Maintainer reply None cached
Activity 0 comments · opened Oct 1, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

Official UI localization is already requested (#90435, #31413, #81913, #82377, #91878). This request is about the mod API (plugins of function hooks): on 2.1.287 a mod can't localize the terminal UI in a usable way, so the community can't fill the gap either.

I built a probe mod that tried to translate the terminal UI into Simplified Chinese and Japanese through the mod API alone, without touching Claude Code's own files. Findings:

  1. Built-in components return an opaque node. A catch-all on('ui.render', hook) with no matcher does reach every component. One hook received UserMessage, AssistantMessage, ToolUse, ToolResult, ToolGroup, TurnDuration, AbovePrompt, PromptHint, SessionMode and Spinner. But await next(e) resolved to {"type":"engine","ref":1} for every one of them, so the engine's Text elements can't be walked or rewritten. A plugin can only rewrite the props a component declares, or replace the whole drawing with its own tree.
  2. Most fixed UI text is in no prop. Reachable through props or describe events: PromptHint.hint, Spinner.word / message, TurnDuration.word, SessionMode.modes, InfoNotice.text, ToolProgress.hint, command.describe, config.describe. Not reachable:
  • the permission dialog: title, options, footer
  • the mode label: ⏵⏵ auto mode on is drawn outside PromptHint.hint
  • the for 4m 46s part of the turn-duration line
  • the ToolGroup count line (Read 3 files, …), unless the plugin redraws it from calls
  • the welcome banner, the /help body, the model picker, the trust and login dialogs, error messages, Interrupted
  1. The permission dialog is excluded on purpose. The types say: "The permission dialog is drawn by the engine alone, since its answer authorises an action". $.ui.mount in claude plugin test can't mount it either. For a user who doesn't read English, this dialog matters most.
  2. When $.ui.notice shows is undocumented. The one workaround for the dialog is a translated line from $.ui.notice(tool_use_id, text):
  • Called at the start of a tool.call hook, as the doc example does, in default mode: the call didn't throw, but no line appeared in the dialog.
  • Called from $.clock.after(150 / 600 / 1500 ms) after tool.check resolved ask: the line appeared inside the dialog, below the options, prefixed with the plugin's name.
  • In auto mode, where no dialog opens, the same call showed the line in the bottom-right corner.

The doc says only "under the dialog open for tool_use_id" and "a call that is not open is refused", and the early call wasn't refused.

What you get is a half-translated UI, for example ⏵⏵ auto mode on · (shift+tab 切换模式)· ← 查看 agents, while the permission dialog stays in English. Because a dictionary has to key on the exact English strings, every copy change in the CLI breaks it too. I dropped the mod for these reasons.

Proposed Solution

Any one of these would make a localization mod workable:

  1. A text-level hook for engine-drawn strings. Expose each fixed UI string with a stable message id (permission.prompt.question, permission.option.yes, …) and its interpolation values, and let a plugin return a replacement. Community dictionaries keyed by id would survive copy changes.
  2. Label-only relabeling for the permission dialog. The engine keeps the options' meaning, order, count and hotkeys, and a plugin supplies only the displayed labels. What gets authorised stays the same.
  3. At minimum, define when $.ui.notice takes effect. Say which event or moment a notice must be sent from to show in the dialog, or attach a notice sent earlier once the dialog opens.

Alternative Solutions

  • Official localization (the issues linked above) would make the mod unnecessary. A mod route still matters for languages that won't ship officially.
  • Patching the CLI bundle breaks on every update and modifies Claude Code itself.
  • Exact-match prop rewriting plus $.ui.notice is what the probe did. It covers a small part of the fixed text and leaves mixed-language lines.

Priority

Medium - Would be very helpful

Feature Category

Developer tools/SDK

Use Case Example

  1. A user who reads Chinese or Japanese more easily than English enables a localization mod.
  2. The permission dialog reads 要继续吗? 1. 是 2. 是,且不再询问… 3. 否. Slash command descriptions and /config labels are translated too.
  3. Claude's replies, user input, tool output, code, file paths and commands stay untouched.
  4. Contributors add locales/<lang>.json files keyed by message id.

Additional Context

Environment: Claude Code 2.1.287, Windows 11, terminal surface (main screen).

Probe that shows the opaque node (same result for every component):

on('ui.render', async ($, e, next) => {
  const result = await next(e) // always {"type":"engine","ref":1}
  return result
})

Prop rewrite that took effect on screen:

on('ui.render', { component: 'PromptHint' }, ($, e, next) =>
  next({ ...e, props: { ...e.props, hint: '(shift+tab 切换模式)· ← 查看 agents' } }))

Notice timing that showed the line inside the permission dialog:

on('tool.check', async ($, e, next) => {
  const verdict = await next(e)
  const id = e.tool_use_id
  if (verdict.decision === 'ask' && id !== undefined) {
    $.clock.after(150, () => $.ui.notice(id, 'Yes = 是 · No = 否'))
  }
  return verdict
})

View original on GitHub ↗