[FEATURE] Mod API can't localize terminal UI: ui.render returns opaque engine nodes, permission dialog unhookable
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:
- Built-in components return an opaque node. A catch-all
on('ui.render', hook)with no matcher does reach every component. One hook receivedUserMessage,AssistantMessage,ToolUse,ToolResult,ToolGroup,TurnDuration,AbovePrompt,PromptHint,SessionModeandSpinner. Butawait next(e)resolved to{"type":"engine","ref":1}for every one of them, so the engine'sTextelements 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. - 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 onis drawn outsidePromptHint.hint - the
for 4m 46spart of the turn-duration line - the
ToolGroupcount line (Read 3 files, …), unless the plugin redraws it fromcalls - the welcome banner, the
/helpbody, the model picker, the trust and login dialogs, error messages,Interrupted
- 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.mountinclaude plugin testcan't mount it either. For a user who doesn't read English, this dialog matters most. - When
$.ui.noticeshows 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.callhook, 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)aftertool.checkresolvedask: 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:
- 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. - 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.
- At minimum, define when
$.ui.noticetakes 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.noticeis 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
- A user who reads Chinese or Japanese more easily than English enables a localization mod.
- The permission dialog reads
要继续吗? 1. 是 2. 是,且不再询问… 3. 否. Slash command descriptions and/configlabels are translated too. - Claude's replies, user input, tool output, code, file paths and commands stay untouched.
- Contributors add
locales/<lang>.jsonfiles 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
})