From 44f1769db52efda2a80957665071cf8c778ef5ef Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 10 Oct 2026 00:08:11 -0300 Subject: [PATCH] Add zh-CN and es prompt-language catalogs --- CHANGELOG.md | 5 ++ hooks/prompt-text.js | 90 +++++++++++++++++++- tests/prompt-text.test.mjs | 165 ++++++++++++++++++++++++++++++++++++- 3 files changed, 258 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4df68..6563712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The agent-facing mod text also comes in Simplified Chinese and Spanish.** + `LICH_PROMPT_LANG` values `zh-CN` and `es` select the new catalogs in + `hooks/prompt-text.js`, translated from the English source; literals such as + `[lich]` and tool and command names stay as they are. + - **The text the mods add for the agent follows lich's prompt language.** The side-question preamble, the subagent card notes and denies, the edit guard's note and the self-command notes now come in English or Brazilian Portuguese, diff --git a/hooks/prompt-text.js b/hooks/prompt-text.js index dd33f9f..fdff001 100644 --- a/hooks/prompt-text.js +++ b/hooks/prompt-text.js @@ -99,8 +99,96 @@ export const ptBR = { controlQueued: "{queued} está na fila nesta sessão e roda quando este turno terminar. Encerre o turno agora.", } +/** @type {Record} */ +export const zhCN = { + askPreamble: + "这是在你的回合之外、你工作期间提出的旁路问题。它不会打断你的回合,你的回答也不会加入对话。" + + "工具不可用:不要调用任何工具。请仅根据对话中已有的内容,用纯文本简要回答。问题:", + onBranch: "在分支 {branch} 上", + whereBranch: "在分支 {branch} 上的 {path} 中,不在当前 checkout 里", + whereShared: "在同一个 checkout {path} 中,并且和你一样编辑其中的文件", + backgrounded: + 'agent 作为 lich 会话 "{label}" 运行,{where}。' + + "它的完整报告会作为 [lich] 备注自动出现在此提示中,而不是 task notification,因此无需调用 " + + "wait_for_answer(调用仍然有效)。SendMessage 无法联系该会话;send_to_session 或 lich send 可以。", + completedBranch: + '工作位于分支 {branch} 上的 {path}(lich 会话 "{label}"),不在当前 checkout 里。' + + "请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。", + completedShared: + '工作位于同一个 checkout {path}(lich 会话 "{label}")中。' + + "请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。", + denyNeverReached: '任务从未送达 "{label}"({status}):请打开它的卡片。', + denyAnswered: 'lich 针对 "{label}"{branchPart} 返回了 "{status}":请打开它的卡片。', + denyInterrupted: + "已中断;某个 lich 会话{branchPart}可能已经拿到任务,它发送的报告会作为 [lich] 备注出现在这里。", + denyStopFailed: 'lich 无法停止 "{label}":{reason}', + lichExited: "lich 退出,退出码 {code}", + editNoteJustNow: "不到一分钟前", + editNoteAgo: "{minutes} 分钟前", + editNote: + "{path} 也被 lich 会话 {who} 在 {at}({ago})编辑过,该会话与你共用此 checkout,可能仍在处理它。" + + "在基于该文件继续之前请重新读取,并通过 send_to_session 或 lich send 与该会话协调,不要撤销它的改动。", + skillNote: + "\n\n在此会话中,内置斜杠命令(compact、clear 等)也可以在这里指定,只要用户要求你运行它:" + + "它会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。", + controlNote: + "\n\n在此会话中,action 为 command 时可以把会话自身作为目标:传入 session " + + '"{sessionId}"。该命令会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。', + denyDefaultSaved: + "在会话内部运行 /{name} 会把它保存为每个新 Claude Code 会话的默认值。" + + "请让用户通过 lich 来设置,lich 的 {name} 覆盖只对此会话生效。", + skillQueued: "{queued} 已进入队列,将在本回合结束后运行。现在请结束本回合。", + controlQueued: "{queued} 已在此会话中排队,将在本回合结束后运行。现在请结束本回合。", +} + +/** @type {Record} */ +export const es = { + askPreamble: + "Esta es una pregunta lateral hecha desde fuera de tu turno, mientras trabajas. No " + + "interrumpe tu turno y tu respuesta no se añade a la conversación. Las herramientas no están " + + "disponibles: no llames a ninguna. Responde con lo que la conversación ya contiene, " + + "en texto plano y de forma breve. Pregunta: ", + onBranch: "en la rama {branch}", + whereBranch: "en la rama {branch} en {path}, no en este checkout", + whereShared: "en este mismo checkout, {path}, y edita sus archivos igual que tú", + backgrounded: + 'El agente se ejecuta como la sesión lich "{label}", {where}. ' + + "Su informe completo llega a este prompt por sí solo como una nota [lich], no como task notification, así que no " + + "hace falta llamar a wait_for_answer (sigue funcionando). SendMessage no puede llegar a esa sesión; " + + "send_to_session o lich send sí.", + completedBranch: + 'El trabajo está en la rama {branch} en {path} (sesión lich "{label}"), no en este checkout. ' + + "Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.", + completedShared: + 'El trabajo está en este mismo checkout, {path} (sesión lich "{label}"). ' + + "Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.", + denyNeverReached: 'la tarea nunca llegó a "{label}" ({status}): abre su tarjeta.', + denyAnswered: 'lich respondió "{status}" sobre "{label}"{branchPart}: abre su tarjeta.', + denyInterrupted: + "interrumpido; puede que una sesión lich{branchPart} ya tenga la tarea, y el informe que envíe llega aquí como una nota [lich].", + denyStopFailed: 'lich no pudo detener "{label}": {reason}', + lichExited: "lich terminó con {code}", + editNoteJustNow: "hace menos de un minuto", + editNoteAgo: "hace {minutes} min", + editNote: + "{path} también fue editado por la sesión lich {who} en {at} ({ago}), que comparte este checkout y puede " + + "seguir trabajando en él. Vuelve a leer el archivo antes de continuar a partir de él y coordínate con esa sesión " + + "mediante send_to_session o lich send en lugar de deshacer su cambio.", + skillNote: + "\n\nEn esta sesión, un slash command integrado (compact, clear y similares) también puede nombrarse aquí, " + + "cuando el usuario te pida ejecutarlo: se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.", + controlNote: + "\n\nEn esta sesión, la acción command acepta la propia sesión como destino: pasa session " + + '"{sessionId}". El comando se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.', + denyDefaultSaved: + "/{name} ejecutado desde dentro de la sesión se guardaría como valor predeterminado de toda nueva sesión de Claude Code. " + + "Pide al usuario que lo defina desde lich, cuyo override de {name} vale solo para esta sesión.", + skillQueued: "{queued} está en cola y se ejecuta cuando termine este turno. Termina el turno ahora.", + controlQueued: "{queued} está en cola en esta sesión y se ejecuta cuando termine este turno. Termina el turno ahora.", +} + /** @type {Record>} */ -const catalogs = { en, "pt-BR": ptBR } +const catalogs = { en, "pt-BR": ptBR, "zh-CN": zhCN, es } /** * The text `key` names in `lang`, its slots filled from `vars`. English stands diff --git a/tests/prompt-text.test.mjs b/tests/prompt-text.test.mjs index 795a847..7083cd8 100644 --- a/tests/prompt-text.test.mjs +++ b/tests/prompt-text.test.mjs @@ -8,7 +8,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { en, ptBR, say, promptLang } from '../hooks/prompt-text.js' +import { en, ptBR, zhCN, es, say, promptLang } from '../hooks/prompt-text.js' import { register as registerAgentCards } from '../hooks/agent-cards.js' import { register as registerEditGuard } from '../hooks/edit-guard.js' import { register as registerSelfCommand } from '../hooks/self-command.js' @@ -215,3 +215,166 @@ test('mod-control: the side question is prefaced in pt-BR', async () => { assert.match(forks[0].prompt, /^Esta é uma pergunta lateral feita de fora do seu turno/) assert.ok(forks[0].prompt.endsWith('Pergunta: o que faz?')) }) + +// ------------------------------------------------------- zh-CN and es catalogs -- + +const NEW_LOCALES = { 'zh-CN': zhCN, es } + +for (const [tag, catalog] of Object.entries(NEW_LOCALES)) { + test(`${tag}: same keys, same slots and the literal names as English`, () => { + assert.deepEqual(Object.keys(catalog).sort(), Object.keys(en).sort()) + for (const key of Object.keys(en)) { + assert.deepEqual(slots(catalog[key]), slots(en[key]), key) + for (const literal of LITERALS) { + if (en[key].includes(literal)) assert.ok(catalog[key].includes(literal), `${key} lost ${literal}`) + } + } + }) + + test(`${tag}: LICH_PROMPT_LANG selects the catalog`, () => { + assert.equal(promptLang(tag), tag) + assert.notEqual(say(tag, 'lichExited', { code: 2 }), 'lich exited 2') + }) +} + +// What each hook renders per locale, one text per hook. The hook is driven the +// way the pt-BR tests above drive it, with only the language swapped. +const EXPECTED = { + 'zh-CN': { + background: /^agent 作为 lich 会话 "w" 运行,在同一个 checkout \/wt\/w 中/, + done: /^feito\n\n工作位于同一个 checkout \/wt\/w(lich 会话 "w")中。/, + deny: '任务从未送达 "w"(unread):请打开它的卡片。', + stop: 'lich 无法停止 "w":boom', + edit: /^\/r\/a\.js 也被 lich 会话 lich-a 在 2026-10-05T12:00:00\.000Z(3 分钟前)编辑过/, + skill: /^base\n\n在此会话中,内置斜杠命令/, + control: /传入 session "lich-1"。/, + queued: '/compact 已进入队列,将在本回合结束后运行。现在请结束本回合。', + saved: /^在会话内部运行 \/model/, + ask: /^这是在你的回合之外/, + }, + es: { + background: /^El agente se ejecuta como la sesión lich "w", en este mismo checkout, \/wt\/w/, + done: /^feito\n\nEl trabajo está en este mismo checkout, \/wt\/w \(sesión lich "w"\)\./, + deny: 'la tarea nunca llegó a "w" (unread): abre su tarjeta.', + stop: 'lich no pudo detener "w": boom', + edit: /^\/r\/a\.js también fue editado por la sesión lich lich-a en 2026-10-05T12:00:00\.000Z \(hace 3 min\)/, + skill: /^base\n\nEn esta sesión, un slash command integrado/, + control: /pasa session "lich-1"\./, + queued: '/compact está en cola y se ejecuta cuando termine este turno. Termina el turno ahora.', + saved: /^\/model ejecutado desde dentro de la sesión/, + ask: /^Esta es una pregunta lateral hecha desde fuera de tu turno/, + }, +} + +for (const [tag, want] of Object.entries(EXPECTED)) { + const LANG = { LICH_PROMPT_LANG: tag } + + test(`${tag}: agent-cards renders the background note, the completed addendum and the denies`, async () => { + const OPENED = { name: 'w-1', label: 'w', path: '/wt/w', id: 'x' } + const env = { ...LANG, LICH_BIN: '/lich', LICH_SESSION_ID: 'lich-1' } + const call = async (status, tool = 'Agent', stderr = '') => { + const hooks = hooksOf(registerAgentCards) + const runs = [ + exits(2, JSON.stringify({ ...OPENED, delivery: { ticket: 't', target: 'w', status, answer: 'feito' } })), + exits(1, '', 'boom'), + ] + const $ = { + env: { get: async (n) => env[n] }, + clock: { now: async () => 0, after: () => ({ cancel() {} }) }, + process: { run: async () => runs.shift() }, + ui: { toast() {}, invalidate() {} }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (x) => x) + const next = Object.assign(pass({ result: 'native' }), { origin: { plugin: 'engine' }, signal: new AbortController().signal }) + const e = { tool: 'Agent', tool_use_id: 't1', description: 'd', prompt: 'p', subagent_type: 'general-purpose' } + const first = await hooks.get('tool.call Agent')($, e, next) + if (tool === 'Agent') return first + return hooks.get('tool.call TaskStop')($, { tool: 'TaskStop', task_id: 'w-1' }, next) + } + + assert.match((await call('pending')).context[0], want.background) + assert.match((await call('answered')).result.content[0].text, want.done) + assert.equal((await call('unread')).deny, want.deny) + assert.equal((await call('pending', 'TaskStop')).deny, want.stop) + }) + + test(`${tag}: edit-guard renders the note about another session`, async () => { + const T0 = Date.parse('2026-10-05T12:00:00.000Z') + const disk = new Map() + let now = T0 + const hooks = hooksOf(registerEditGuard) + const session = (id) => { + const env = { ...LANG, LICH_SESSION_ID: id } + const $ = { + env: { get: async (n) => env[n] }, + clock: { now: async () => now, after: () => ({ cancel() {} }) }, + ui: { log() {}, invalidate() {} }, + process: { run: async () => exits(0, '/r/.git\n') }, + fs: { + exists: async (p) => disk.has(p), + read: async (p) => disk.get(p), + write: async (p, t) => void disk.set(p, t), + }, + } + return () => hooks.get('tool.call Edit')($, { tool: 'Edit', file_path: '/r/a.js' }, pass({ result: {}, text: 'ok' })) + } + await session('lich-a')() + now = T0 + 3 * 60_000 + const result = await session('lich-b')() + assert.match(result.context[0], want.edit) + assert.match(result.context[0], /send_to_session o lich send|send_to_session 或 lich send/) + }) + + test(`${tag}: self-command renders the tool notes and the queued answer`, async () => { + const hooks = hooksOf(registerSelfCommand) + const env = { ...LANG, LICH_SESSION_ID: 'lich-1', LICH_BIN: '/lich' } + const $ = { + env: { get: async (n) => env[n] }, + clock: { after() {} }, + command: { list: async () => [{ name: 'compact', source: 'builtin' }, { name: 'model', source: 'builtin' }], run: async () => ({}) }, + ui: { toast() {}, invalidate() {} }, + process: { run: async () => exits(0, JSON.stringify({ label: 'x', name: 'y' })) }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (e) => e) + + const skill = await hooks.get('tool.describe Skill')($, { tool: 'Skill', description: 'base' }, async (e) => e) + assert.match(skill.description, want.skill) + const control = await hooks.get('tool.describe mcp__lich__control_session')( + $, + { tool: 'mcp__lich__control_session', description: 'base' }, + async (e) => e, + ) + assert.match(control.description, want.control) + + const refused = { isError: true, result: 'compact is a built-in CLI command, not a skill' } + const queued = await hooks.get('tool.call Skill')($, { tool: 'Skill', skill: 'compact' }, async () => refused) + assert.equal(queued.context[0], want.queued) + const denied = await hooks.get('tool.call Skill')($, { tool: 'Skill', skill: 'model', args: 'opus' }, async () => refused) + assert.match(denied.deny, want.saved) + }) + + test(`${tag}: mod-control prefaces the side question`, async () => { + const hooks = hooksOf(registerModControl) + const forks = [] + const env = { ...LANG, LICH_PORT: '1', LICH_TOKEN: 't', LICH_SESSION_ID: 's' } + let served = false + const $ = { + env: { get: async (n) => env[n] }, + clock: { after: (ms, fn) => setImmediate(fn) }, + http: { + fetch: async (url) => { + if (url.includes('/mod/acks')) return { status: 204, ok: true, headers: {}, text: '' } + if (served) return new Promise(() => {}) + served = true + return { status: 200, ok: true, headers: {}, text: JSON.stringify([{ id: 'm1', kind: 'ask', question: 'q?' }]) } + }, + }, + model: { fork: async (args) => (forks.push(args), { isAnswered: true, text: 'a', usage: {} }) }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (e) => e) + for (let i = 0; i < 50 && forks.length === 0; i++) await new Promise((r) => setImmediate(r)) + assert.equal(forks.length, 1) + assert.match(forks[0].prompt, want.ask) + assert.ok(forks[0].prompt.endsWith('q?')) + }) +}