diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index a3b5239894..a02626d151 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -173,7 +173,7 @@ function historicalGraphSnapshot(graphId: string): AgentGraphClientSnapshot { } /** Catalog API-key providers as wizard entries with no existing connection — - * the default `/setup` provider list for tests that don't need 已设置 state. */ + * the default `/setup` provider list for tests that don't need configured state. */ function defaultOnboardingProviders(): OnboardingProviderEntry[] { return listApiKeyOnboardableProviders().map((provider) => ({ ...provider, @@ -501,6 +501,7 @@ describe('Maka Pi TUI runner', () => { model: 'claude-sonnet-4-5', connectionSlug: 'claude-subscription', permissionMode: 'bypass', + locale: 'zh', terminal, onboarding: fakeOnboardingSurface({ verify: async (input) => { @@ -517,7 +518,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), 'Set Up Provider') !== null; + return latestPlainLineContaining(terminal.writes.join(''), '配置模型提供商') !== null; } catch { return false; } @@ -655,7 +656,9 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), 'Onboarding 不可用') !== null; + return ( + latestPlainLineContaining(terminal.writes.join(''), 'Onboarding is unavailable') !== null + ); } catch { return false; } @@ -698,7 +701,7 @@ describe('Maka Pi TUI runner', () => { ); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /没有可配置的 API key 类供应商/, + /No configurable API key providers are available/, ); process.emit('SIGTERM'); @@ -758,7 +761,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => verifyCalls.length === 1); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '验证') !== null; + return latestPlainLineContaining(terminal.writes.join(''), 'Verifying') !== null; } catch { return false; } @@ -855,7 +858,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => saveCalls.length === 1); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '保存') !== null; + return latestPlainLineContaining(terminal.writes.join(''), 'Saving') !== null; } catch { return false; } @@ -876,7 +879,7 @@ describe('Maka Pi TUI runner', () => { // would be in this exact frame. terminal.input('sk-z'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-z')); - assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /已启用/); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /Models enabled/u); process.emit('SIGTERM'); await Promise.race([ @@ -931,7 +934,7 @@ describe('Maka Pi TUI runner', () => { // A malformed endpoint is rejected in place, before any host call. terminal.input('not a url'); terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('不是有效的 URL')); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('not a valid URL')); for (let i = 0; i < 'not a url'.length; i++) terminal.input('\x7f'); // clear the field terminal.input('https://relay.example.test/v1'); terminal.input('\r'); @@ -951,7 +954,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); // save await waitFor(() => saveCalls.length === 1); assert.equal(saveCalls[0]?.baseUrl, 'https://relay.example.test/v1'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('已启用')); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Models enabled')); process.emit('SIGTERM'); await Promise.race([ @@ -2889,6 +2892,7 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5', connectionSlug: 'openai', providerType: 'openai', + locale: 'en', modelChoices: [ { connectionSlug: 'openai', @@ -2921,7 +2925,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.output().includes('GLM 5.2')); assert.match( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高/, + /Switching models may rebuild the prompt cache; the next request may be slower/, ); // The picker opens on the current model (gpt-5.5); move down to the choice on // the other connection and select it. @@ -3003,7 +3007,7 @@ describe('Maka Pi TUI runner', () => { }); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存/, + /Switching models may rebuild the prompt cache/, ); // Each query isolates exactly one of the five match criteria named by #1098 @@ -3083,7 +3087,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.output().includes('gpt-5.6')); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存/, + /Switching models may rebuild the prompt cache/, ); terminal.input('\x1b[B'); diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts new file mode 100644 index 0000000000..945d9d8014 --- /dev/null +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import { formatTuiCopy, getTuiCopyCatalog, type TuiCopyDomain } from '../tui-copy-catalog.js'; + +const LOCALES_ROOT = fileURLToPath(new URL('../locales/', import.meta.url)); +const COPY_RESOURCES = Object.fromEntries( + readdirSync(LOCALES_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => [entry.name, readLocaleResources(entry.name)]), +) as Readonly>>>; + +describe('TUI copy resources', () => { + test('loads every supported locale and domain through the registry', () => { + assert.deepEqual(Object.keys(COPY_RESOURCES).sort(), [...UI_LOCALES].sort()); + const domains = Object.keys(localeResources('en')).sort() as TuiCopyDomain[]; + for (const locale of UI_LOCALES) { + for (const domain of domains) { + assert.deepEqual(getTuiCopyCatalog(domain)[locale], domainResource(locale, domain)); + } + } + }); + + test('keeps every locale structurally aligned by domain', () => { + const reference = localeResources('en'); + const domains = Object.keys(reference).sort(); + for (const [locale, resources] of Object.entries(COPY_RESOURCES)) { + assert.deepEqual(Object.keys(resources).sort(), domains, locale); + for (const domain of domains) { + assert.deepEqual( + resourcePaths(resources[domain]), + resourcePaths(reference[domain]), + `${locale}/${domain}`, + ); + } + } + }); + + test('keeps interpolation placeholders aligned by domain', () => { + const reference = localeResources('en'); + for (const [locale, resources] of Object.entries(COPY_RESOURCES)) { + for (const [domain, resource] of Object.entries(resources)) { + assert.deepEqual( + resourcePlaceholders(resource), + resourcePlaceholders(reference[domain]), + `${locale}/${domain}`, + ); + } + } + assert.deepEqual(resourcePlaceholders(domainResource('en', 'mcp-status'), true), { + toolCount: ['count'], + }); + assert.deepEqual(resourcePlaceholders(domainResource('en', 'pickers'), true), { + enabledModels: ['count'], + listProvidersFailed: ['detail'], + saveFailed: ['detail'], + selectedModels: ['count'], + selectedModelsAndSave: ['count'], + setupFailed: ['detail'], + verifyFailed: ['detail'], + }); + assert.deepEqual(resourcePlaceholders(domainResource('en', 'primary-guidance'), true), {}); + assert.deepEqual(resourcePlaceholders(domainResource('en', 'session-status'), true), {}); + }); + + test('fails closed when a required interpolation value is absent', () => { + assert.equal(formatTuiCopy('{count} tools', { count: 3 }), '3 tools'); + assert.throws(() => formatTuiCopy('{count} tools', {}), /Missing UI copy placeholder count/u); + }); + + test('keeps model picker copy locale-specific', () => { + const enPickers = domainResource('en', 'pickers'); + const zhPickers = domainResource('zh', 'pickers'); + assert.equal(enPickers.modelPickerTitle, 'Select Model'); + assert.equal(enPickers.searchLabel, 'Search'); + assert.equal(zhPickers.modelPickerTitle, '选择模型'); + assert.equal(zhPickers.searchLabel, '搜索'); + }); +}); + +function readLocaleResources(locale: string): Readonly> { + const localeRoot = join(LOCALES_ROOT, locale); + return Object.fromEntries( + readdirSync(localeRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => [ + entry.name.slice(0, -'.json'.length), + JSON.parse(readFileSync(join(localeRoot, entry.name), 'utf8')) as unknown, + ]), + ); +} + +function localeResources(locale: string): Readonly> { + const resources = COPY_RESOURCES[locale]; + assert.ok(resources, `missing ${locale} locale`); + return resources; +} + +function domainResource(locale: string, domain: string): Readonly> { + const resource = localeResources(locale)[domain]; + assert.ok( + resource && typeof resource === 'object' && !Array.isArray(resource), + `${locale}/${domain}`, + ); + return resource as Readonly>; +} + +function resourcePaths(value: unknown, prefix = ''): string[] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => resourcePaths(item, `${prefix}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([key, item]) => resourcePaths(item, prefix ? `${prefix}.${key}` : key)); + } + return [prefix]; +} + +function resourcePlaceholders( + value: unknown, + populatedOnly = false, +): Readonly> { + const entries = leafEntries(value).map( + ([path, text]) => + [ + path, + [...text.matchAll(/\{([A-Za-z][A-Za-z0-9]*)\}/gu)].map((match) => match[1]!).sort(), + ] as const, + ); + return Object.fromEntries( + populatedOnly ? entries.filter(([, names]) => names.length > 0) : entries, + ); +} + +function leafEntries(value: unknown, prefix = ''): [string, string][] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => leafEntries(item, `${prefix}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => + leafEntries(item, prefix ? `${prefix}.${key}` : key), + ); + } + return [[prefix, String(value)]]; +} diff --git a/packages/cli/src/locales/en/mcp-status.json b/packages/cli/src/locales/en/mcp-status.json new file mode 100644 index 0000000000..2a3832c873 --- /dev/null +++ b/packages/cli/src/locales/en/mcp-status.json @@ -0,0 +1,26 @@ +{ + "title": "MCP SERVERS", + "footer": "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close", + "unavailableTitle": "This TUI is not connected to a local MCP control plane.", + "unavailableDetail": "Client MCP tool association for remote Runtime Hosts is planned for a later release.", + "loading": "Loading mcp.json and discovering tools…", + "loadError": "MCP configuration could not be loaded; no tools were published to the Runtime Host.", + "noServers": "No MCP servers are configured.", + "publication": { + "waiting": "waiting to publish", + "host_unavailable": "Runtime Host reconnecting", + "publishing": "publishing", + "published": "published", + "not_published": "not published", + "error": "publication failed" + }, + "serverState": { + "disabled": "disabled", + "disconnected": "disconnected", + "connecting": "connecting", + "connected": "connected", + "needs-auth": "needs-auth", + "error": "error" + }, + "toolCount": "{count} tools" +} diff --git a/packages/cli/src/locales/en/pickers.json b/packages/cli/src/locales/en/pickers.json new file mode 100644 index 0000000000..aba1224d57 --- /dev/null +++ b/packages/cli/src/locales/en/pickers.json @@ -0,0 +1,62 @@ +{ + "modelPickerTitle": "Select Model", + "modelSwitchCacheWarning": "⚠ Switching models may rebuild the prompt cache; the next request may be slower or cost more.", + "modelSearchHint": "Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel", + "searchLabel": "Search", + "noMatchingModels": "No matching models", + "providerConfigured": "configured", + "thinkingLevels": { + "off": "Off", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "Extra high", + "max": "Maximum" + }, + "defaultThinkingLevel": "Default", + "thinkingPickerTitle": "Select Thinking Level", + "currentMarker": "current", + "defaultMarker": "default", + "setupTitle": "Set Up Provider", + "baseUrlLabel": "Base URL", + "apiKeyLabel": "API key", + "onboardingUnavailable": "Onboarding is unavailable: this environment does not provide a setup surface.", + "verifyFailed": "API key verification failed. Check it and try again. {detail}", + "setupFailed": "Setup failed: {detail}", + "saveFailed": "Save failed: {detail}", + "listProvidersFailed": "Could not read configured connections: {detail}", + "noConfigurableProviders": "No configurable API key providers are available.", + "baseUrlRequired": "Enter a Base URL", + "baseUrlInvalid": "Base URL is not a valid URL", + "baseUrlProtocol": "Base URL must use http or https", + "baseUrlCredentials": "Base URL cannot include credentials", + "baseUrlQuery": "Base URL cannot include a query or fragment", + "baseUrlTooLong": "Base URL cannot exceed 2048 bytes", + "selectModelBeforeSaving": "Select at least one model before saving", + "reuseBaseUrlHint": "Leave blank to reuse the saved Base URL, or enter a replacement · Esc returns to provider selection", + "enterBaseUrlHint": "Enter the relay Base URL (http/https) · Esc returns to provider selection", + "continueAction": "Enter to continue", + "providerSearchHint": "Search providers · ↑↓ select · Enter confirm · Esc cancel", + "noMatchingProviders": "No matching providers", + "keyHints": { + "reuse": { + "baseUrl": "Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to Base URL", + "provider": "Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to provider selection" + }, + "enter": { + "baseUrl": "Enter an API key · stored only on this machine · Esc returns to Base URL", + "provider": "Enter an API key · stored only on this machine · Esc returns to provider selection" + } + }, + "submitAction": "Enter to submit", + "verifyingKey": "Verifying key…", + "modelSelectionHint": "Search models · ↑↓ select · Space toggle · Enter save · Esc back", + "selectedModels": "{count} selected", + "selectedModelsAndSave": "{count} selected · Enter to save", + "saving": "Saving…", + "complete": "Complete", + "enabledModels": "Models enabled: {count}", + "closeAction": "Enter to close", + "thinkingUnsupported": "This model does not support changing the thinking level." +} diff --git a/packages/cli/src/locales/en/primary-guidance.json b/packages/cli/src/locales/en/primary-guidance.json new file mode 100644 index 0000000000..1dfd66dadd --- /dev/null +++ b/packages/cli/src/locales/en/primary-guidance.json @@ -0,0 +1,49 @@ +{ + "welcome": { + "tagline": "Get things done together", + "start": "Type a message to start", + "session": "Switch or resume a session", + "model": "Switch models", + "setup": "Set up a model provider" + }, + "commands": { + "compact": "Compact session context", + "context": "Show latest request context usage", + "exit": "Exit Maka", + "goal": "Show autonomous goal status", + "graph": "Show, enable, disable, or run one Graph turn", + "help": "Show commands and keybindings", + "mcp": "Show client-owned MCP servers and tool publication status", + "model": "Select model", + "move": "Move current session to another directory", + "new": "Start a new session", + "permissions": "Set session permissions", + "recap": "One-sentence recap of the session so far", + "rename": "Rename current session", + "resume": "Resume latest interrupted run at a safe boundary", + "rewind": "Rewind to an earlier turn", + "session": "Resume session", + "setup": "Set up a model provider (API key)", + "side": "Open a temporary side conversation", + "skill": "Invoke a skill (or type /skill: inline)", + "swarm": "Show, enable, disable, or run one Swarm turn", + "thinking": "Set thinking level", + "transcript": "Browse the full transcript inside Maka" + }, + "help": { + "commandsHeading": "Commands", + "keybindingsHeading": "Keybindings", + "keybindings": [ + " Ctrl+O — expand or collapse all tool output", + " Ctrl+T — expand or collapse all thinking in view", + " Scroll the transcript with your terminal or trackpad", + " Enter (during a turn) — steer: inject a message into the running turn", + " Alt+Enter (during a turn) — queue a message for the next turn", + " Alt+↑ — take queued messages back into the editor to re-edit", + " Esc Esc (during a turn) — interrupt the turn", + " Esc Esc (when idle) — rewind to an earlier turn", + " Ctrl+C — stop the turn, clear input, or press twice to exit", + " Ctrl+D — exit when input is empty" + ] + } +} diff --git a/packages/cli/src/locales/en/session-status.json b/packages/cli/src/locales/en/session-status.json new file mode 100644 index 0000000000..9c975bf229 --- /dev/null +++ b/packages/cli/src/locales/en/session-status.json @@ -0,0 +1,8 @@ +{ + "running": "running", + "waitingForUser": "waiting for you", + "permissionRequired": "needs permission", + "connectionRequired": "needs connection", + "signInRequired": "needs sign-in", + "stopped": "stopped" +} diff --git a/packages/cli/src/locales/zh/mcp-status.json b/packages/cli/src/locales/zh/mcp-status.json new file mode 100644 index 0000000000..209f2abc50 --- /dev/null +++ b/packages/cli/src/locales/zh/mcp-status.json @@ -0,0 +1,26 @@ +{ + "title": "MCP 服务器", + "footer": "↑/↓ 滚动 · PgUp/PgDn 翻页 · Home/End 跳转 · q/Esc 关闭", + "unavailableTitle": "当前 TUI 未连接本地 MCP 控制面。", + "unavailableDetail": "远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。", + "loading": "正在读取 mcp.json 并发现工具…", + "loadError": "无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。", + "noServers": "尚未配置 MCP 服务器。", + "publication": { + "waiting": "等待发布", + "host_unavailable": "Runtime Host 重连中", + "publishing": "正在发布", + "published": "已发布", + "not_published": "未发布", + "error": "发布失败" + }, + "serverState": { + "disabled": "已停用", + "disconnected": "未连接", + "connecting": "连接中", + "connected": "已连接", + "needs-auth": "需要登录", + "error": "错误" + }, + "toolCount": "{count} 个工具" +} diff --git a/packages/cli/src/locales/zh/pickers.json b/packages/cli/src/locales/zh/pickers.json new file mode 100644 index 0000000000..55464fb57a --- /dev/null +++ b/packages/cli/src/locales/zh/pickers.json @@ -0,0 +1,62 @@ +{ + "modelPickerTitle": "选择模型", + "modelSwitchCacheWarning": "⚠ 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。", + "modelSearchHint": "搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消", + "searchLabel": "搜索", + "noMatchingModels": "没有匹配的模型", + "providerConfigured": "已设置", + "thinkingLevels": { + "off": "关", + "minimal": "最小", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "超高", + "max": "最高" + }, + "defaultThinkingLevel": "默认", + "thinkingPickerTitle": "选择思考级别", + "currentMarker": "当前", + "defaultMarker": "默认连接", + "setupTitle": "配置模型提供商", + "baseUrlLabel": "Base URL", + "apiKeyLabel": "API key", + "onboardingUnavailable": "Onboarding 不可用:当前运行环境未提供配置入口。", + "verifyFailed": "API key 验证失败,请检查后重新输入。{detail}", + "setupFailed": "配置失败:{detail}", + "saveFailed": "保存失败:{detail}", + "listProvidersFailed": "无法读取已配置的连接:{detail}", + "noConfigurableProviders": "没有可配置的 API key 类供应商。", + "baseUrlRequired": "需要填写 Base URL", + "baseUrlInvalid": "Base URL 不是有效的 URL", + "baseUrlProtocol": "Base URL 必须使用 http 或 https", + "baseUrlCredentials": "Base URL 不能包含账号密码", + "baseUrlQuery": "Base URL 不能包含查询串或片段", + "baseUrlTooLong": "Base URL 不能超过 2048 字节", + "selectModelBeforeSaving": "至少选择一个模型再保存", + "reuseBaseUrlHint": "留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商", + "enterBaseUrlHint": "输入中转站的 Base URL(http/https)· Esc 返回选择服务商", + "continueAction": "Enter 继续", + "providerSearchHint": "搜索服务商,↑↓ 选择 · Enter 确认 · Esc 取消", + "noMatchingProviders": "没有匹配的服务商", + "keyHints": { + "reuse": { + "baseUrl": "留空复用已保存的 key,或输入新 key 轮换 · Esc 返回 Base URL", + "provider": "留空复用已保存的 key,或输入新 key 轮换 · Esc 返回选择服务商" + }, + "enter": { + "baseUrl": "输入 API key · 仅本机存储 · Esc 返回 Base URL", + "provider": "输入 API key · 仅本机存储 · Esc 返回选择服务商" + } + }, + "submitAction": "Enter 提交", + "verifyingKey": "正在验证 key…", + "modelSelectionHint": "搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回", + "selectedModels": "已选 {count}", + "selectedModelsAndSave": "已选 {count} · Enter 保存", + "saving": "正在保存…", + "complete": "完成", + "enabledModels": "已启用模型:{count}", + "closeAction": "Enter 关闭", + "thinkingUnsupported": "当前模型不支持思考级别切换。" +} diff --git a/packages/cli/src/locales/zh/primary-guidance.json b/packages/cli/src/locales/zh/primary-guidance.json new file mode 100644 index 0000000000..f1a94ae4a2 --- /dev/null +++ b/packages/cli/src/locales/zh/primary-guidance.json @@ -0,0 +1,49 @@ +{ + "welcome": { + "tagline": "陪你把事做完", + "start": "输入消息开始对话", + "session": "切换或恢复会话", + "model": "切换模型", + "setup": "配置模型提供商" + }, + "commands": { + "compact": "压缩会话上下文", + "context": "查看最近一次请求的上下文用量", + "exit": "退出 Maka", + "goal": "查看自主目标状态", + "graph": "查看、启用、停用 Graph 模式,或执行一次 Graph 任务", + "help": "查看命令和快捷键", + "mcp": "查看客户端 MCP 服务器和工具发布状态", + "model": "选择模型", + "move": "将当前会话移到其他目录", + "new": "新建会话", + "permissions": "设置会话权限", + "recap": "用一句话总结当前会话", + "rename": "重命名当前会话", + "resume": "从安全边界恢复最近一次中断的执行", + "rewind": "回退到较早的对话轮次", + "session": "切换或恢复会话", + "setup": "配置模型提供商(API Key)", + "side": "打开临时 Side Conversation", + "skill": "调用 Skill(也可直接输入 /skill:)", + "swarm": "查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务", + "thinking": "设置思考级别", + "transcript": "在 Maka 内浏览完整对话记录" + }, + "help": { + "commandsHeading": "命令", + "keybindingsHeading": "快捷键", + "keybindings": [ + " Ctrl+O — 展开或折叠所有工具输出", + " Ctrl+T — 展开或折叠视图中的所有思考块", + " 使用终端或触控板滚动对话记录", + " Enter(任务运行中)— 将消息注入当前任务", + " Alt+Enter(任务运行中)— 将消息排入下一轮", + " Alt+↑ — 将已排队消息取回编辑器重新编辑", + " Esc Esc(任务运行中)— 中断当前任务", + " Esc Esc(空闲时)— 回退到较早的轮次", + " Ctrl+C — 停止任务、清空输入,或连续按两次退出", + " Ctrl+D — 输入为空时退出" + ] + } +} diff --git a/packages/cli/src/locales/zh/session-status.json b/packages/cli/src/locales/zh/session-status.json new file mode 100644 index 0000000000..c59970616d --- /dev/null +++ b/packages/cli/src/locales/zh/session-status.json @@ -0,0 +1,8 @@ +{ + "running": "进行中", + "waitingForUser": "等你确认", + "permissionRequired": "需要权限", + "connectionRequired": "需要连接", + "signInRequired": "需要重新登录", + "stopped": "已中止" +} diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 105a8b0a89..d85c215078 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -27,9 +27,27 @@ import { import type { UiLocale } from '@maka/core/ui-locale'; import type { TuiMcpServerSnapshot, TuiMcpSurface } from './tui-mcp-control.js'; import { ansi } from './tui-ansi.js'; +import { defineTuiCopyCatalog, formatTuiCopy, getTuiCopyCatalog } from './tui-copy-catalog.js'; const CHROME_ROWS = 2; +interface TuiMcpStatusCopy { + readonly title: string; + readonly footer: string; + readonly unavailableTitle: string; + readonly unavailableDetail: string; + readonly loading: string; + readonly loadError: string; + readonly noServers: string; + readonly publication: Readonly< + Record['publication'], string> + >; + readonly serverState: Readonly>; + readonly toolCount: string; +} + +const MCP_STATUS_COPY = defineTuiCopyCatalog()(getTuiCopyCatalog('mcp-status')); + export class McpStatusOverlay implements Component { private top = 0; private documentRows = 0; @@ -75,9 +93,9 @@ export class McpStatusOverlay implements Component { const visible = document.slice(this.top, this.top + this.bodyRows); const start = visible.length === 0 ? 0 : this.top + 1; const end = visible.length === 0 ? 0 : this.top + visible.length; - const title = this.input.locale === 'zh' ? 'MCP 服务器' : 'MCP SERVERS'; + const copy = MCP_STATUS_COPY[this.input.locale]; const header = padLine( - `${ansi.bold(title)} ${ansi.dim(`${start}-${end} / ${document.length}`)}`, + `${ansi.bold(copy.title)} ${ansi.dim(`${start}-${end} / ${document.length}`)}`, safeWidth, ); const body = [ @@ -87,49 +105,26 @@ export class McpStatusOverlay implements Component { ), ]; if (!showFooter) return [header, ...body]; - const footer = - this.input.locale === 'zh' - ? '↑/↓ 滚动 · PgUp/PgDn 翻页 · Home/End 跳转 · q/Esc 关闭' - : '↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close'; - return [header, ...body, padLine(ansi.dim(footer), safeWidth)]; + return [header, ...body, padLine(ansi.dim(copy.footer), safeWidth)]; } private document(): string[] { const snapshot = this.input.surface?.snapshot(); + const copy = MCP_STATUS_COPY[this.input.locale]; if (!snapshot) { - return this.input.locale === 'zh' - ? [ - ansi.yellow('当前 TUI 未连接本地 MCP 控制面。'), - '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', - ] - : [ - ansi.yellow('This TUI is not connected to a local MCP control plane.'), - 'Client MCP tool association for remote Runtime Hosts is planned for a later release.', - ]; + return [ansi.yellow(copy.unavailableTitle), copy.unavailableDetail]; } const lines = [publicationLine(snapshot, this.input.locale)]; if (snapshot.initialization === 'loading') { - lines.push( - this.input.locale === 'zh' - ? '正在读取 mcp.json 并发现工具…' - : 'Loading mcp.json and discovering tools…', - ); + lines.push(copy.loading); return lines; } if (snapshot.initialization === 'error') { - lines.push( - this.input.locale === 'zh' - ? ansi.red('无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。') - : ansi.red( - 'MCP configuration could not be loaded; no tools were published to the Runtime Host.', - ), - ); + lines.push(ansi.red(copy.loadError)); return lines; } if (snapshot.servers.length === 0) { - lines.push( - this.input.locale === 'zh' ? '尚未配置 MCP 服务器。' : 'No MCP servers are configured.', - ); + lines.push(copy.noServers); return lines; } lines.push(''); @@ -155,15 +150,9 @@ function publicationLine( snapshot: ReturnType, locale: UiLocale, ): string { - const publication = { - waiting: locale === 'zh' ? '等待发布' : 'waiting to publish', - host_unavailable: locale === 'zh' ? 'Runtime Host 重连中' : 'Runtime Host reconnecting', - publishing: locale === 'zh' ? '正在发布' : 'publishing', - published: locale === 'zh' ? '已发布' : 'published', - not_published: locale === 'zh' ? '未发布' : 'not published', - error: locale === 'zh' ? '发布失败' : 'publication failed', - }[snapshot.publication]; - const tools = locale === 'zh' ? `${snapshot.toolCount} 个工具` : `${snapshot.toolCount} tools`; + const copy = MCP_STATUS_COPY[locale]; + const publication = copy.publication[snapshot.publication]; + const tools = formatTuiCopy(copy.toolCount, { count: snapshot.toolCount }); return `${ansi.bold(publication)} · ${tools}`; } @@ -171,7 +160,8 @@ function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale): string[] { const protocol = server.negotiatedProtocol ? `${server.negotiatedProtocol.era} ${server.negotiatedProtocol.revision}` : undefined; - const tools = locale === 'zh' ? `${server.toolCount} 个工具` : `${server.toolCount} tools`; + const copy = MCP_STATUS_COPY[locale]; + const tools = formatTuiCopy(copy.toolCount, { count: server.toolCount }); const details = [stateLabel(server.state, locale), server.transport, protocol, tools] .filter(Boolean) .join(' · '); @@ -189,15 +179,7 @@ function statusMarker(state: TuiMcpServerSnapshot['state']): string { } function stateLabel(state: TuiMcpServerSnapshot['state'], locale: UiLocale): string { - if (locale === 'en') return state; - return { - disabled: '已停用', - disconnected: '未连接', - connecting: '连接中', - connected: '已连接', - 'needs-auth': '需要登录', - error: '错误', - }[state]; + return MCP_STATUS_COPY[locale].serverState[state]; } function clamp(value: number, min: number, max: number): number { diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 18dae33449..7b770d41bf 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -37,10 +37,62 @@ import { import type { UserQuestionOption } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { UiLocale } from '@maka/core/ui-locale'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import { PROVIDER_DEFAULTS, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; import type { ModelChoice, OnboardingProviderEntry } from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; +import { defineTuiCopyCatalog, formatTuiCopy, getTuiCopyCatalog } from './tui-copy-catalog.js'; + +interface TuiPickerCopy { + readonly modelPickerTitle: string; + readonly modelSwitchCacheWarning: string; + readonly modelSearchHint: string; + readonly searchLabel: string; + readonly noMatchingModels: string; + readonly providerConfigured: string; + readonly thinkingLevels: Readonly>; + readonly defaultThinkingLevel: string; + readonly thinkingPickerTitle: string; + readonly currentMarker: string; + readonly defaultMarker: string; + readonly setupTitle: string; + readonly baseUrlLabel: string; + readonly apiKeyLabel: string; + readonly onboardingUnavailable: string; + readonly verifyFailed: string; + readonly setupFailed: string; + readonly saveFailed: string; + readonly listProvidersFailed: string; + readonly noConfigurableProviders: string; + readonly baseUrlRequired: string; + readonly baseUrlInvalid: string; + readonly baseUrlProtocol: string; + readonly baseUrlCredentials: string; + readonly baseUrlQuery: string; + readonly baseUrlTooLong: string; + readonly selectModelBeforeSaving: string; + readonly reuseBaseUrlHint: string; + readonly enterBaseUrlHint: string; + readonly continueAction: string; + readonly providerSearchHint: string; + readonly noMatchingProviders: string; + readonly keyHints: Readonly< + Record<'reuse' | 'enter', Readonly>> + >; + readonly submitAction: string; + readonly verifyingKey: string; + readonly modelSelectionHint: string; + readonly selectedModels: string; + readonly selectedModelsAndSave: string; + readonly saving: string; + readonly complete: string; + readonly enabledModels: string; + readonly closeAction: string; + readonly thinkingUnsupported: string; +} + +const TUI_PICKER_COPY = defineTuiCopyCatalog()(getTuiCopyCatalog('pickers')); export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider | undefined; @@ -569,7 +621,9 @@ export class UserQuestionOverlay implements Component { export function modelPickerItems( currentModel: string, models: readonly string[] | undefined, + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); const ids: string[] = []; const seen = new Set(); for (const candidate of [currentModel, ...(models ?? [])]) { @@ -581,7 +635,7 @@ export function modelPickerItems( return ids.map((id) => ({ value: id, label: id, - ...(id === currentModel ? { description: 'current' } : {}), + ...(id === currentModel ? { description: copy.currentMarker } : {}), })); } @@ -594,13 +648,14 @@ export function modelPickerItems( function modelChoicePickerItems( choices: readonly ModelChoice[], current: { model: string; connectionSlug: string }, + copy: TuiPickerCopy, ): SelectItem[] { return choices.map((choice, index) => { const isCurrent = choice.model === current.model && choice.connectionSlug === current.connectionSlug; const tags = [choice.connectionName || choice.connectionSlug]; - if (isCurrent) tags.push('current'); - else if (choice.isDefaultConnection) tags.push('default'); + if (isCurrent) tags.push(copy.currentMarker); + else if (choice.isDefaultConnection) tags.push(copy.defaultMarker); return { value: String(index), label: choice.displayName?.trim() || choice.model, @@ -626,6 +681,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { } export interface ModelSearchOverlayInput { + locale: UiLocale; choices: readonly ModelChoice[]; current: { model: string; connectionSlug: string }; showCacheWarning?: boolean; @@ -633,8 +689,9 @@ export interface ModelSearchOverlayInput { onCancel: () => void; } -export const MODEL_SWITCH_CACHE_WARNING = - '⚠ 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。'; +export function getTuiPickerCopy(locale: UiLocale): TuiPickerCopy { + return TUI_PICKER_COPY[locale]; +} /** * One bottom search field + a bounded single-select list, for the cross- @@ -650,11 +707,13 @@ export class ModelSearchOverlay implements Component { private filtered: readonly ModelChoice[]; private list: SelectList; private readonly initialIndex: number; + private readonly copy: TuiPickerCopy; constructor( private readonly tui: TUI, private readonly input: ModelSearchOverlayInput, ) { + this.copy = getTuiPickerCopy(input.locale); this.filtered = [...input.choices]; this.initialIndex = input.choices.findIndex( (choice) => @@ -669,7 +728,7 @@ export class ModelSearchOverlay implements Component { private buildList(): SelectList { const list = new SelectList( - modelChoicePickerItems(this.filtered, this.input.current), + modelChoicePickerItems(this.filtered, this.input.current, this.copy), 10, selectListTheme(), { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48 }, @@ -721,16 +780,19 @@ export class ModelSearchOverlay implements Component { const safeWidth = Math.max(1, width); this.searchEditor.focused = true; return [ - padLine(`Select Model ${ansi.accent(String(this.filtered.length))}`, safeWidth), - padLine(ansi.dim('搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消'), safeWidth), + padLine( + `${this.copy.modelPickerTitle} ${ansi.accent(String(this.filtered.length))}`, + safeWidth, + ), + padLine(ansi.dim(this.copy.modelSearchHint), safeWidth), ...(this.input.showCacheWarning - ? [padLine(ansi.yellow(MODEL_SWITCH_CACHE_WARNING), safeWidth)] + ? [padLine(ansi.yellow(this.copy.modelSwitchCacheWarning), safeWidth)] : []), padLine('', safeWidth), - ...this.renderFieldRow(this.searchEditor, '搜索', safeWidth), + ...this.renderFieldRow(this.searchEditor, this.copy.searchLabel, safeWidth), padLine('', safeWidth), ...(this.filtered.length === 0 - ? [padLine(ansi.dim('没有匹配的模型'), safeWidth)] + ? [padLine(ansi.dim(this.copy.noMatchingModels), safeWidth)] : this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth))), padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), ]; @@ -788,44 +850,37 @@ export function skillPickerItems(skills: readonly InvocableSkillEntry[]): Select })); } -/** Provider search items for `/setup`, marking connections that already exist - * `已设置` so a re-onboard reads as edit/rotate rather than create. */ +/** Provider search items for `/setup`, distinguishing existing connections. */ export function onboardingProviderPickerItems( providers: readonly OnboardingProviderEntry[], + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); return providers.map((provider) => ({ value: provider.providerType, label: provider.label, description: provider.hasConnection - ? `${provider.providerType} · 已设置` + ? `${provider.providerType} · ${copy.providerConfigured}` : provider.providerType, })); } -const THINKING_LEVEL_LABELS: Record = { - off: '关', - minimal: '最小', - low: '低', - medium: '中', - high: '高', - xhigh: '超高', - max: '最高', -}; - export function thinkingLevelPickerItems( levels: readonly ThinkingLevel[], current: ThinkingLevel | undefined, + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); return [ { value: 'default', - label: '默认', - ...(current === undefined ? { description: 'current' } : {}), + label: copy.defaultThinkingLevel, + ...(current === undefined ? { description: copy.currentMarker } : {}), }, ...levels.map((level) => ({ value: level, - label: THINKING_LEVEL_LABELS[level], - ...(level === current ? { description: 'current' } : {}), + label: copy.thinkingLevels[level], + ...(level === current ? { description: copy.currentMarker } : {}), })), ]; } @@ -841,6 +896,16 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } +function keyEntryHint( + copy: TuiPickerCopy, + hasConnection: boolean, + returnsToBaseUrl: boolean, +): string { + const action = hasConnection ? 'reuse' : 'enter'; + const destination = returnsToBaseUrl ? 'baseUrl' : 'provider'; + return copy.keyHints[action][destination]; +} + export type OnboardingWizardPhase = 'search' | 'baseUrl' | 'key' | 'models' | 'success'; export type OnboardingWizardStatus = @@ -850,6 +915,7 @@ export type OnboardingWizardStatus = | { kind: 'saving' }; export interface OnboardingWizardInput { + locale: UiLocale; providers: readonly OnboardingProviderEntry[]; /** search→key: the user picked a provider. The runner records it — and the * existing connection's identity, when the catalog resolved one — for @@ -902,11 +968,13 @@ export class OnboardingWizard implements Component { private modelHighlight = 0; private modelScroll = 0; private successCount = 0; + private readonly copy: TuiPickerCopy; constructor( private readonly tui: TUI, private readonly input: OnboardingWizardInput, ) { + this.copy = getTuiPickerCopy(input.locale); this.filtered = input.providers; this.list = this.buildList(); this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); @@ -934,7 +1002,7 @@ export class OnboardingWizard implements Component { private buildList(): SelectList { const list = new SelectList( - onboardingProviderPickerItems(this.filtered), + onboardingProviderPickerItems(this.filtered, this.input.locale), 10, selectListTheme(), { minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 }, @@ -988,21 +1056,21 @@ export class OnboardingWizard implements Component { */ private validateBaseUrl(trimmed: string): string | null { if (!trimmed) { - return this.picked?.hasConnection ? null : '需要填写 Base URL'; + return this.picked?.hasConnection ? null : this.copy.baseUrlRequired; } let parsed: URL; try { parsed = new URL(trimmed); } catch { - return 'Base URL 不是有效的 URL'; + return this.copy.baseUrlInvalid; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return 'Base URL 必须使用 http 或 https'; + return this.copy.baseUrlProtocol; } - if (parsed.username || parsed.password) return 'Base URL 不能包含账号密码'; - if (trimmed.includes('?') || trimmed.includes('#')) return 'Base URL 不能包含查询串或片段'; + if (parsed.username || parsed.password) return this.copy.baseUrlCredentials; + if (trimmed.includes('?') || trimmed.includes('#')) return this.copy.baseUrlQuery; if (new TextEncoder().encode(parsed.toString()).byteLength > 2_048) { - return 'Base URL 不能超过 2048 字节'; + return this.copy.baseUrlTooLong; } return null; } @@ -1215,7 +1283,7 @@ export class OnboardingWizard implements Component { if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { if (isKeyRepeat(data)) return; if (this.selectedIds.size === 0) { - this.status = { kind: 'error', text: '至少选择一个模型再保存' }; + this.status = { kind: 'error', text: this.copy.selectModelBeforeSaving }; return; } this.input.onSubmitModels([...this.selectedIds]); @@ -1255,7 +1323,6 @@ export class OnboardingWizard implements Component { if (!model) return; if (this.selectedIds.has(model.id)) this.selectedIds.delete(model.id); else this.selectedIds.add(model.id); - // Clear a stale "至少选择一个模型" error once a selection exists. if (this.status.kind === 'error' && this.selectedIds.size > 0) { this.status = { kind: 'prompt' }; } @@ -1284,16 +1351,21 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; const hint = this.picked?.hasConnection - ? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商' - : '输入中转站的 Base URL(http/https)· Esc 返回选择服务商'; + ? this.copy.reuseBaseUrlHint + : this.copy.enterBaseUrlHint; return [ - padLine(`Set Up Provider ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, width), + padLine( + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, + width, + ), padLine(ansi.dim(hint), width), padLine('', width), - ...this.renderFieldRow(this.baseUrlEditor, 'Base URL', width), + ...this.renderFieldRow(this.baseUrlEditor, this.copy.baseUrlLabel, width), padLine('', width), padLine( - this.status.kind === 'error' ? ansi.red(`✗ ${this.status.text}`) : ansi.dim('Enter 继续'), + this.status.kind === 'error' + ? ansi.red(`✗ ${this.status.text}`) + : ansi.dim(this.copy.continueAction), width, ), padLine(ansi.accent('-'.repeat(width)), width), @@ -1307,15 +1379,15 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; return [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(1)}`)} ${ansi.accent(String(this.filtered.length))}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(1)}`)} ${ansi.accent(String(this.filtered.length))}`, width, ), - padLine(ansi.dim('搜索服务商,↑↓ 选择 · Enter 确认 · Esc 取消'), width), + padLine(ansi.dim(this.copy.providerSearchHint), width), padLine('', width), - ...this.renderFieldRow(this.searchEditor, '搜索', width), + ...this.renderFieldRow(this.searchEditor, this.copy.searchLabel, width), padLine('', width), ...(this.filtered.length === 0 - ? [padLine(ansi.dim('没有匹配的服务商'), width)] + ? [padLine(ansi.dim(this.copy.noMatchingProviders), width)] : this.list.render(width).map((line) => formatPickerItemLine(line, width))), padLine(ansi.accent('-'.repeat(width)), width), ]; @@ -1327,18 +1399,19 @@ export class OnboardingWizard implements Component { this.keyEditor.focused = this.status.kind === 'prompt' || this.status.kind === 'error'; this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; - const backTarget = this.picked?.requiresBaseUrl ? 'Esc 返回 Base URL' : 'Esc 返回选择服务商'; - const hint = this.picked?.hasConnection - ? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}` - : `输入 API key · 仅本机存储 · ${backTarget}`; + const hint = keyEntryHint( + this.copy, + this.picked?.hasConnection === true, + this.picked?.requiresBaseUrl === true, + ); return [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`, width, ), padLine(ansi.dim(hint), width), padLine('', width), - ...this.renderFieldRow(this.keyEditor, 'API key', width), + ...this.renderFieldRow(this.keyEditor, this.copy.apiKeyLabel, width), padLine('', width), padLine(this.renderKeyStatusLine(), width), padLine(ansi.accent('-'.repeat(width)), width), @@ -1348,13 +1421,13 @@ export class OnboardingWizard implements Component { private renderKeyStatusLine(): string { switch (this.status.kind) { case 'prompt': - return ansi.dim('Enter 提交'); + return ansi.dim(this.copy.submitAction); case 'verifying': - return `${ansi.yellow('⠋')} 正在验证 key…`; + return `${ansi.yellow('⠋')} ${this.copy.verifyingKey}`; case 'error': return ansi.red(`✗ ${this.status.text}`); case 'saving': - return ansi.dim('Enter 提交'); + return ansi.dim(this.copy.submitAction); } } @@ -1366,16 +1439,16 @@ export class OnboardingWizard implements Component { const label = this.picked?.label ?? ''; const lines = [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 4 : 3)}`)} ${ansi.accent(label)}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 4 : 3)}`)} ${ansi.accent(label)}`, width, ), - padLine(ansi.dim('搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回'), width), + padLine(ansi.dim(this.copy.modelSelectionHint), width), padLine('', width), - ...this.renderFieldRow(this.modelsSearchEditor, '搜索', width), + ...this.renderFieldRow(this.modelsSearchEditor, this.copy.searchLabel, width), padLine('', width), ]; if (this.filteredModels.length === 0) { - lines.push(padLine(ansi.dim('没有匹配的模型'), width)); + lines.push(padLine(ansi.dim(this.copy.noMatchingModels), width)); } else { const end = Math.min( this.modelScroll + ONBOARDING_MODELS_MAX_VISIBLE, @@ -1398,11 +1471,13 @@ export class OnboardingWizard implements Component { private renderModelsStatusLine(): string { switch (this.status.kind) { case 'prompt': - return ansi.dim(`已选 ${this.selectedIds.size} · Enter 保存`); + return ansi.dim( + formatTuiCopy(this.copy.selectedModelsAndSave, { count: this.selectedIds.size }), + ); case 'verifying': - return ansi.dim(`已选 ${this.selectedIds.size}`); + return ansi.dim(formatTuiCopy(this.copy.selectedModels, { count: this.selectedIds.size })); case 'saving': - return `${ansi.yellow('⠋')} 正在保存…`; + return `${ansi.yellow('⠋')} ${this.copy.saving}`; case 'error': return ansi.red(`✗ ${this.status.text}`); } @@ -1415,10 +1490,16 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; return [ - padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width), - padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width), + padLine( + `${this.copy.setupTitle} ${ansi.dim(`· ${this.copy.complete}`)} ${ansi.accent(label)}`, + width, + ), + padLine( + ansi.green(`✓ ${formatTuiCopy(this.copy.enabledModels, { count: this.successCount })}`), + width, + ), padLine('', width), - padLine(ansi.dim('Enter 关闭'), width), + padLine(ansi.dim(this.copy.closeAction), width), padLine(ansi.accent('-'.repeat(width)), width), ]; } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 51f2ff0630..a2ce0f135d 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -131,11 +131,11 @@ import { import { MakaAutocompleteProvider, DirectoryPickerOverlay, - MODEL_SWITCH_CACHE_WARNING, ModelSearchOverlay, OnboardingWizard, PickerOverlay, UserQuestionOverlay, + getTuiPickerCopy, modelPickerItems, permissionModePickerItems, skillPickerItems, @@ -151,6 +151,7 @@ import { isLiveGoalStatus, } from './pi-goal.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; +import { formatTuiCopy } from './tui-copy-catalog.js'; import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; export interface MakaPiTuiInput { @@ -279,6 +280,7 @@ export function resolveTaskbarProgress( export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; + const pickerCopy = getTuiPickerCopy(locale); const primaryGuidance = getTuiPrimaryGuidance(locale); const terminal = input.terminal ?? new ProcessTerminal(); const taskbarProgress = resolveTaskbarProgress(input.taskbarProgress); @@ -1950,7 +1952,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } if (!input.onboarding) { - wizard.setKeyError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setKeyError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -1970,7 +1972,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // A stale snapshot (the targeted connection is gone) is not a key // problem — retyping cannot fix it, so skip that framing. wizard.setKeyError( - result.stale ? result.text : `API key 验证失败:${result.text}。请检查后重新输入。`, + result.stale + ? result.text + : formatTuiCopy(pickerCopy.verifyFailed, { detail: result.text }), ); requestRender(); return; @@ -1981,7 +1985,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }, (error) => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setKeyError(`配置失败:${error instanceof Error ? error.message : String(error)}`); + wizard.setKeyError( + formatTuiCopy(pickerCopy.setupFailed, { + detail: error instanceof Error ? error.message : String(error), + }), + ); requestRender(); }, ); @@ -1995,7 +2003,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const providerType = wizardProviderType; if (!providerType || !wizard) return; if (!input.onboarding) { - wizard.setModelError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setModelError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -2037,7 +2045,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { (error) => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; wizard.setModelError( - `保存失败:${error instanceof Error ? error.message : String(error)}`, + formatTuiCopy(pickerCopy.saveFailed, { + detail: error instanceof Error ? error.message : String(error), + }), ); requestRender(); }, @@ -2053,7 +2063,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: `无法读取已配置的连接:${error instanceof Error ? error.message : String(error)}`, + text: formatTuiCopy(pickerCopy.listProvidersFailed, { + detail: error instanceof Error ? error.message : String(error), + }), }); requestRender(); return; @@ -2071,13 +2083,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: '没有可配置的 API key 类供应商。', + text: pickerCopy.noConfigurableProviders, }); requestRender(); return; } wizardOverlay?.hide(); wizard = new OnboardingWizard(tui, { + locale, providers, onPickProvider: (providerType, existingConnectionId) => { wizardProviderType = providerType; @@ -2546,6 +2559,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (choices && choices.length > 0) { let overlay: OverlayHandle | undefined; const picker = new ModelSearchOverlay(tui, { + locale, choices, current: { model, connectionSlug }, showCacheWarning: hasConversationHistory, @@ -2559,16 +2573,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } showSelectPicker( - 'Select Model', + pickerCopy.modelPickerTitle, connectionSlug, - modelPickerItems(model, input.models), + modelPickerItems(model, input.models, locale), (item) => { void runControl(() => setModel(item.value)); }, { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48, - notice: hasConversationHistory ? MODEL_SWITCH_CACHE_WARNING : undefined, + notice: hasConversationHistory ? pickerCopy.modelSwitchCacheWarning : undefined, }, ); }; @@ -2601,9 +2615,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const showThinkingLevelList = () => { - const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel); + const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel, locale); showSelectPicker( - 'Select Thinking Level', + pickerCopy.thinkingPickerTitle, thinkingLevel ?? 'default', items, (item) => { @@ -3193,7 +3207,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: '当前模型不支持思考级别切换。', + text: pickerCopy.thinkingUnsupported, }); requestRender(); return; @@ -3214,7 +3228,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { level: 'error', text: thinkingLevels.length === 0 - ? '当前模型不支持思考级别切换。' + ? pickerCopy.thinkingUnsupported : `Usage: /thinking ${['default', ...thinkingLevels].join('|')}`, }); requestRender(); diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts new file mode 100644 index 0000000000..701fee0755 --- /dev/null +++ b/packages/cli/src/tui-copy-catalog.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +export { + defineUiCatalog as defineTuiCopyCatalog, + formatUiCopy as formatTuiCopy, +} from '@maka/core/ui-locale'; +import enMcpStatus from './locales/en/mcp-status.json' with { type: 'json' }; +import enPickers from './locales/en/pickers.json' with { type: 'json' }; +import enPrimaryGuidance from './locales/en/primary-guidance.json' with { type: 'json' }; +import enSessionStatus from './locales/en/session-status.json' with { type: 'json' }; +import zhMcpStatus from './locales/zh/mcp-status.json' with { type: 'json' }; +import zhPickers from './locales/zh/pickers.json' with { type: 'json' }; +import zhPrimaryGuidance from './locales/zh/primary-guidance.json' with { type: 'json' }; +import zhSessionStatus from './locales/zh/session-status.json' with { type: 'json' }; + +const EN_TUI_COPY_RESOURCES = { + 'mcp-status': enMcpStatus, + pickers: enPickers, + 'primary-guidance': enPrimaryGuidance, + 'session-status': enSessionStatus, +}; + +export type TuiCopyDomain = keyof typeof EN_TUI_COPY_RESOURCES; + +const TUI_COPY_RESOURCES = { + zh: { + 'mcp-status': zhMcpStatus, + pickers: zhPickers, + 'primary-guidance': zhPrimaryGuidance, + 'session-status': zhSessionStatus, + }, + en: EN_TUI_COPY_RESOURCES, +} satisfies UiCatalog>; + +export type TuiDomainCatalog = { + readonly [Locale in UiLocale]: (typeof TUI_COPY_RESOURCES)[Locale][Domain]; +}; + +export function getTuiCopyCatalog( + domain: Domain, +): TuiDomainCatalog { + return { + zh: TUI_COPY_RESOURCES.zh[domain], + en: TUI_COPY_RESOURCES.en[domain], + }; +} diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 408d1c9235..874ad8fdfd 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -18,8 +18,9 @@ */ import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import type { UiLocale } from '@maka/core/ui-locale'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; +import { defineTuiCopyCatalog, getTuiCopyCatalog } from './tui-copy-catalog.js'; type TuiCommandId = SlashCommandIdForSurface<'tui'>; @@ -39,106 +40,9 @@ export interface TuiPrimaryGuidanceCopy { }; } -const TUI_PRIMARY_GUIDANCE = { - zh: { - welcome: { - tagline: '陪你把事做完', - start: '输入消息开始对话', - session: '切换或恢复会话', - model: '切换模型', - setup: '配置模型提供商', - }, - commands: { - compact: '压缩会话上下文', - context: '查看最近一次请求的上下文用量', - exit: '退出 Maka', - goal: '查看自主目标状态', - graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', - help: '查看命令和快捷键', - mcp: '查看客户端 MCP 服务器和工具发布状态', - model: '选择模型', - move: '将当前会话移到其他目录', - new: '新建会话', - permissions: '设置会话权限', - recap: '用一句话总结当前会话', - rename: '重命名当前会话', - resume: '从安全边界恢复最近一次中断的执行', - rewind: '回退到较早的对话轮次', - session: '切换或恢复会话', - setup: '配置模型提供商(API Key)', - side: '打开临时 Side Conversation', - skill: '调用 Skill(也可直接输入 /skill:)', - swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', - thinking: '设置思考级别', - transcript: '在 Maka 内浏览完整对话记录', - }, - help: { - commandsHeading: '命令', - keybindingsHeading: '快捷键', - keybindings: [ - ' Ctrl+O — 展开或折叠所有工具输出', - ' Ctrl+T — 展开或折叠视图中的所有思考块', - ' 使用终端或触控板滚动对话记录', - ' Enter(任务运行中)— 将消息注入当前任务', - ' Alt+Enter(任务运行中)— 将消息排入下一轮', - ' Alt+↑ — 将已排队消息取回编辑器重新编辑', - ' Esc Esc(任务运行中)— 中断当前任务', - ' Esc Esc(空闲时)— 回退到较早的轮次', - ' Ctrl+C — 停止任务、清空输入,或连续按两次退出', - ' Ctrl+D — 输入为空时退出', - ], - }, - }, - en: { - welcome: { - tagline: 'Get things done together', - start: 'Type a message to start', - session: 'Switch or resume a session', - model: 'Switch models', - setup: 'Set up a model provider', - }, - commands: { - compact: 'Compact session context', - context: 'Show latest request context usage', - exit: 'Exit Maka', - goal: 'Show autonomous goal status', - graph: 'Show, enable, disable, or run one Graph turn', - help: 'Show commands and keybindings', - mcp: 'Show client-owned MCP servers and tool publication status', - model: 'Select model', - move: 'Move current session to another directory', - new: 'Start a new session', - permissions: 'Set session permissions', - recap: 'One-sentence recap of the session so far', - rename: 'Rename current session', - resume: 'Resume latest interrupted run at a safe boundary', - rewind: 'Rewind to an earlier turn', - session: 'Resume session', - setup: 'Set up a model provider (API key)', - side: 'Open a temporary side conversation', - skill: 'Invoke a skill (or type /skill: inline)', - swarm: 'Show, enable, disable, or run one Swarm turn', - thinking: 'Set thinking level', - transcript: 'Browse the full transcript inside Maka', - }, - help: { - commandsHeading: 'Commands', - keybindingsHeading: 'Keybindings', - keybindings: [ - ' Ctrl+O — expand or collapse all tool output', - ' Ctrl+T — expand or collapse all thinking in view', - ' Scroll the transcript with your terminal or trackpad', - ' Enter (during a turn) — steer: inject a message into the running turn', - ' Alt+Enter (during a turn) — queue a message for the next turn', - ' Alt+↑ — take queued messages back into the editor to re-edit', - ' Esc Esc (during a turn) — interrupt the turn', - ' Esc Esc (when idle) — rewind to an earlier turn', - ' Ctrl+C — stop the turn, clear input, or press twice to exit', - ' Ctrl+D — exit when input is empty', - ], - }, - }, -} satisfies UiCatalog; +const TUI_PRIMARY_GUIDANCE = defineTuiCopyCatalog()( + getTuiCopyCatalog('primary-guidance'), +); export function getTuiPrimaryGuidance( locale: UiLocale, diff --git a/packages/cli/src/tui-session-status.ts b/packages/cli/src/tui-session-status.ts index 40747880d9..0b774084e3 100644 --- a/packages/cli/src/tui-session-status.ts +++ b/packages/cli/src/tui-session-status.ts @@ -18,7 +18,8 @@ */ import type { SessionSummary } from '@maka/core/session'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { defineTuiCopyCatalog, getTuiCopyCatalog } from './tui-copy-catalog.js'; interface TuiSessionStatusCopy { readonly running: string; @@ -29,24 +30,9 @@ interface TuiSessionStatusCopy { readonly stopped: string; } -const TUI_SESSION_STATUS_COPY = { - zh: { - running: '进行中', - waitingForUser: '等你确认', - permissionRequired: '需要权限', - connectionRequired: '需要连接', - signInRequired: '需要重新登录', - stopped: '已中止', - }, - en: { - running: 'running', - waitingForUser: 'waiting for you', - permissionRequired: 'needs permission', - connectionRequired: 'needs connection', - signInRequired: 'needs sign-in', - stopped: 'stopped', - }, -} satisfies UiCatalog; +const TUI_SESSION_STATUS_COPY = defineTuiCopyCatalog()( + getTuiCopyCatalog('session-status'), +); /** * Present the runtime Session state as compact picker copy. diff --git a/packages/core/src/__tests__/ui-locale.test.ts b/packages/core/src/__tests__/ui-locale.test.ts new file mode 100644 index 0000000000..5ca5f3d154 --- /dev/null +++ b/packages/core/src/__tests__/ui-locale.test.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { defineUiCatalog, formatUiCopy } from '../ui-locale.js'; + +describe('UI catalog helpers', () => { + test('retains a complete typed catalog', () => { + const catalog = defineUiCatalog<{ label: string }>()({ + zh: { label: '标签' }, + en: { label: 'Label' }, + }); + + assert.deepEqual(catalog, { + zh: { label: '标签' }, + en: { label: 'Label' }, + }); + }); + + test('formats named values and fails closed when one is absent', () => { + assert.equal(formatUiCopy('Update {version}', { version: '1.2.3' }), 'Update 1.2.3'); + assert.throws( + () => formatUiCopy('Update {version}', {}), + /Missing UI copy placeholder version/u, + ); + }); +}); diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 1f58e75036..8354f3a4d7 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -30,6 +30,40 @@ export const UI_LOCALE_PREFERENCES = ['auto', ...UI_LOCALES] as const; /** A catalog must carry copy for every supported resolved locale. */ export type UiCatalog = Record; +type ExactUiCatalogShape = Actual extends readonly unknown[] + ? Expected extends readonly unknown[] + ? Actual + : never + : Actual extends object + ? Exclude extends never + ? { + readonly [Key in keyof Actual]: ExactUiCatalogShape< + Actual[Key], + Expected[Key & keyof Expected] + >; + } + : never + : Actual; + +export function defineUiCatalog() { + return >( + catalog: Catalog & { + readonly [Locale in UiLocale]: ExactUiCatalogShape; + }, + ): UiCatalog => catalog; +} + +export function formatUiCopy( + template: string, + values: Readonly>, +): string { + return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/gu, (_match, name: string) => { + const value = values[name]; + if (value === undefined) throw new Error(`Missing UI copy placeholder ${name}`); + return String(value); + }); +} + export function isUiLocale(value: unknown): value is UiLocale { return value === 'zh' || value === 'en'; } diff --git a/packages/ui/src/__tests__/ui-copy-catalog.test.ts b/packages/ui/src/__tests__/ui-copy-catalog.test.ts new file mode 100644 index 0000000000..5d552381bf --- /dev/null +++ b/packages/ui/src/__tests__/ui-copy-catalog.test.ts @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { UI_LOCALES } from '@maka/core/ui-locale'; +import { getShellControlsCopy } from '../shell-controls-copy.js'; +import { getUiCopyCatalog, type UiCopyDomain } from '../ui-copy-catalog.js'; + +const LOCALES_ROOT = fileURLToPath(new URL('../locales/', import.meta.url)); +const COPY_RESOURCES = Object.fromEntries( + readdirSync(LOCALES_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => [entry.name, readLocaleResources(entry.name)]), +) as Readonly>>>; + +describe('shared UI copy resources', () => { + test('loads every supported locale and domain through the registry', () => { + assert.deepEqual(Object.keys(COPY_RESOURCES).sort(), [...UI_LOCALES].sort()); + const domains = Object.keys(localeResources('en')).sort() as UiCopyDomain[]; + for (const locale of UI_LOCALES) { + for (const domain of domains) { + assert.deepEqual(getUiCopyCatalog(domain)[locale], domainResource(locale, domain)); + } + } + }); + + test('keeps every locale structurally and parametrically aligned', () => { + const reference = localeResources('en'); + for (const [locale, resources] of Object.entries(COPY_RESOURCES)) { + assert.deepEqual(Object.keys(resources).sort(), Object.keys(reference).sort(), locale); + for (const [domain, resource] of Object.entries(resources)) { + assert.deepEqual(resourcePaths(resource), resourcePaths(reference[domain]), `${locale}/${domain}`); + assert.deepEqual( + resourcePlaceholders(resource), + resourcePlaceholders(reference[domain]), + `${locale}/${domain}`, + ); + } + } + }); + + test('preserves shell control copy and plural behavior', () => { + const en = getShellControlsCopy('en'); + const zh = getShellControlsCopy('zh'); + + assert.equal(en.navigation.buildStamp('1.2.3'), 'Current build 1.2.3'); + assert.equal(en.navigation.updateDownloaded('1.2.3'), 'Update 1.2.3 downloaded. Restart to install.'); + assert.equal(en.navigation.pendingTasks(2), 'Scheduled tasks, 2 active'); + assert.equal(en.search.results(1), '1 match'); + assert.equal(en.search.results(2), '2 matches'); + assert.equal(en.search.truncatedResults(20), 'Many results; showing the first 20'); + assert.equal(zh.navigation.buildStamp('1.2.3'), '当前版本 1.2.3'); + assert.equal(zh.navigation.pendingTasks(2), '定时任务,2 条进行中'); + assert.equal(zh.search.results(2), '找到 2 条匹配'); + }); +}); + +function readLocaleResources(locale: string): Readonly> { + const localeRoot = join(LOCALES_ROOT, locale); + return Object.fromEntries( + readdirSync(localeRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => [ + entry.name.slice(0, -'.json'.length), + JSON.parse(readFileSync(join(localeRoot, entry.name), 'utf8')) as unknown, + ]), + ); +} + +function localeResources(locale: string): Readonly> { + const resources = COPY_RESOURCES[locale]; + assert.ok(resources, `missing ${locale} locale`); + return resources; +} + +function domainResource(locale: string, domain: string): unknown { + const resource = localeResources(locale)[domain]; + assert.notEqual(resource, undefined, `${locale}/${domain}`); + return resource; +} + +function resourcePaths(value: unknown, prefix = ''): string[] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => resourcePaths(item, `${prefix}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([key, item]) => resourcePaths(item, prefix ? `${prefix}.${key}` : key)); + } + return [prefix]; +} + +function resourcePlaceholders(value: unknown): Readonly> { + return Object.fromEntries( + leafEntries(value).map(([path, text]) => [ + path, + [...text.matchAll(/\{([A-Za-z][A-Za-z0-9]*)\}/gu)].map((match) => match[1]!).sort(), + ]), + ); +} + +function leafEntries(value: unknown, prefix = ''): [string, string][] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => leafEntries(item, `${prefix}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => + leafEntries(item, prefix ? `${prefix}.${key}` : key), + ); + } + return [[prefix, String(value)]]; +} diff --git a/packages/ui/src/locales/en/shell-controls.json b/packages/ui/src/locales/en/shell-controls.json new file mode 100644 index 0000000000..085899c282 --- /dev/null +++ b/packages/ui/src/locales/en/shell-controls.json @@ -0,0 +1,37 @@ +{ + "shared": { + "close": "Close" + }, + "navigation": { + "mainLabel": "Main navigation", + "newTask": "New task", + "automations": "Scheduled tasks", + "extensions": "Extensions", + "settings": "Settings", + "buildStamp": "Current build {stamp}", + "updateDownloaded": "Update {version} downloaded. Restart to install.", + "updateFailed": "Update {version} failed. Click to retry or download manually.", + "pendingTasks": "Scheduled tasks, {count} active" + }, + "search": { + "title": "Search", + "conversationsLabel": "Search tasks", + "placeholder": "Search task titles and content…", + "clearLabel": "Clear search", + "statusRegionLabel": "Search status and results", + "unavailable": "Search is unavailable in the current environment. Try again later.", + "privacyTitle": "Search is disabled in privacy mode.", + "privacyDetail": "Turn off privacy mode to search previous tasks by keyword.", + "errorTitle": "Search could not be completed.", + "errorFallback": "Search needs to be refreshed. Try again.", + "introduction": "Start typing to search previous tasks by keyword. Results include local task titles and content only and are not sent over the network.", + "searching": "Searching…", + "empty": "No matching task titles or content. Try another keyword.", + "results": { + "one": "{count} match", + "other": "{count} matches" + }, + "truncatedResults": "Many results; showing the first {count}", + "resultsLabel": "Search results" + } +} diff --git a/packages/ui/src/locales/zh/shell-controls.json b/packages/ui/src/locales/zh/shell-controls.json new file mode 100644 index 0000000000..0ce9cde463 --- /dev/null +++ b/packages/ui/src/locales/zh/shell-controls.json @@ -0,0 +1,37 @@ +{ + "shared": { + "close": "关闭" + }, + "navigation": { + "mainLabel": "主导航", + "newTask": "新任务", + "automations": "定时任务", + "extensions": "扩展", + "settings": "设置", + "buildStamp": "当前版本 {stamp}", + "updateDownloaded": "新版本 {version} 已下载,重启后安装", + "updateFailed": "新版本 {version} 更新失败,点击重试或手动下载", + "pendingTasks": "定时任务,{count} 条进行中" + }, + "search": { + "title": "搜索", + "conversationsLabel": "搜索任务", + "placeholder": "搜索任务标题和内容…", + "clearLabel": "清空搜索", + "statusRegionLabel": "搜索状态和结果", + "unavailable": "当前环境无法连接搜索后端,请稍后重试。", + "privacyTitle": "隐私模式已关闭搜索。", + "privacyDetail": "关闭隐私模式后可以继续按关键词查找历史任务。", + "errorTitle": "搜索暂时无法完成。", + "errorFallback": "搜索服务需要刷新,请重试。", + "introduction": "开始输入以按关键词查找历史任务。结果只包含任务标题和内容文本,不进入网络。", + "searching": "正在搜索…", + "empty": "没有匹配的任务标题或内容。换个关键词试试。", + "results": { + "one": "找到 {count} 条匹配", + "other": "找到 {count} 条匹配" + }, + "truncatedResults": "结果较多,已显示前 {count} 条", + "resultsLabel": "搜索结果" + } +} diff --git a/packages/ui/src/shell-controls-copy.ts b/packages/ui/src/shell-controls-copy.ts index 188ab3fe7b..b8bf23148e 100644 --- a/packages/ui/src/shell-controls-copy.ts +++ b/packages/ui/src/shell-controls-copy.ts @@ -17,7 +17,51 @@ * under the License. */ -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { + defineUiCatalog, + formatUiCopy, + type UiCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { getUiCopyCatalog } from './ui-copy-catalog.js'; + +type ShellControlsResource = { + shared: { + close: string; + }; + navigation: { + mainLabel: string; + newTask: string; + automations: string; + extensions: string; + settings: string; + buildStamp: string; + updateDownloaded: string; + updateFailed: string; + pendingTasks: string; + }; + search: { + title: string; + conversationsLabel: string; + placeholder: string; + clearLabel: string; + statusRegionLabel: string; + unavailable: string; + privacyTitle: string; + privacyDetail: string; + errorTitle: string; + errorFallback: string; + introduction: string; + searching: string; + empty: string; + results: { + one: string; + other: string; + }; + truncatedResults: string; + resultsLabel: string; + }; +}; type ShellControlsCopy = { shared: { @@ -55,74 +99,38 @@ type ShellControlsCopy = { }; }; +const SHELL_CONTROLS_RESOURCES = defineUiCatalog()( + getUiCopyCatalog('shell-controls'), +); + const SHELL_CONTROLS_COPY_BY_LOCALE = { - zh: { - shared: { close: '关闭' }, - navigation: { - mainLabel: '主导航', - newTask: '新任务', - automations: '定时任务', - extensions: '扩展', - settings: '设置', - buildStamp: (stamp: string) => `当前版本 ${stamp}`, - updateDownloaded: (version: string) => `新版本 ${version} 已下载,重启后安装`, - updateFailed: (version: string) => `新版本 ${version} 更新失败,点击重试或手动下载`, - pendingTasks: (count: number) => `定时任务,${count} 条进行中`, - }, - search: { - title: '搜索', - conversationsLabel: '搜索任务', - placeholder: '搜索任务标题和内容…', - clearLabel: '清空搜索', - statusRegionLabel: '搜索状态和结果', - unavailable: '当前环境无法连接搜索后端,请稍后重试。', - privacyTitle: '隐私模式已关闭搜索。', - privacyDetail: '关闭隐私模式后可以继续按关键词查找历史任务。', - errorTitle: '搜索暂时无法完成。', - errorFallback: '搜索服务需要刷新,请重试。', - introduction: '开始输入以按关键词查找历史任务。结果只包含任务标题和内容文本,不进入网络。', - searching: '正在搜索…', - empty: '没有匹配的任务标题或内容。换个关键词试试。', - results: (count: number) => `找到 ${count} 条匹配`, - truncatedResults: (count: number) => `结果较多,已显示前 ${count} 条`, - resultsLabel: '搜索结果', - }, - }, - en: { - shared: { close: 'Close' }, - navigation: { - mainLabel: 'Main navigation', - newTask: 'New task', - automations: 'Scheduled tasks', - extensions: 'Extensions', - settings: 'Settings', - buildStamp: (stamp: string) => `Current build ${stamp}`, - updateDownloaded: (version: string) => `Update ${version} downloaded. Restart to install.`, - updateFailed: (version: string) => `Update ${version} failed. Click to retry or download manually.`, - pendingTasks: (count: number) => `Scheduled tasks, ${count} active`, - }, - search: { - title: 'Search', - conversationsLabel: 'Search tasks', - placeholder: 'Search task titles and content…', - clearLabel: 'Clear search', - statusRegionLabel: 'Search status and results', - unavailable: 'Search is unavailable in the current environment. Try again later.', - privacyTitle: 'Search is disabled in privacy mode.', - privacyDetail: 'Turn off privacy mode to search previous tasks by keyword.', - errorTitle: 'Search could not be completed.', - errorFallback: 'Search needs to be refreshed. Try again.', - introduction: - 'Start typing to search previous tasks by keyword. Results include local task titles and content only and are not sent over the network.', - searching: 'Searching…', - empty: 'No matching task titles or content. Try another keyword.', - results: (count: number) => `${count} ${count === 1 ? 'match' : 'matches'}`, - truncatedResults: (count: number) => `Many results; showing the first ${count}`, - resultsLabel: 'Search results', - }, - }, + zh: materializeShellControlsCopy(SHELL_CONTROLS_RESOURCES.zh), + en: materializeShellControlsCopy(SHELL_CONTROLS_RESOURCES.en), } satisfies UiCatalog; export function getShellControlsCopy(locale: UiLocale): ShellControlsCopy { return SHELL_CONTROLS_COPY_BY_LOCALE[locale]; } + +function materializeShellControlsCopy(resource: ShellControlsResource): ShellControlsCopy { + return { + shared: resource.shared, + navigation: { + ...resource.navigation, + buildStamp: (stamp: string) => formatUiCopy(resource.navigation.buildStamp, { stamp }), + updateDownloaded: (version: string) => + formatUiCopy(resource.navigation.updateDownloaded, { version }), + updateFailed: (version: string) => formatUiCopy(resource.navigation.updateFailed, { version }), + pendingTasks: (count: number) => formatUiCopy(resource.navigation.pendingTasks, { count }), + }, + search: { + ...resource.search, + results: (count: number) => + formatUiCopy(count === 1 ? resource.search.results.one : resource.search.results.other, { + count, + }), + truncatedResults: (count: number) => + formatUiCopy(resource.search.truncatedResults, { count }), + }, + }; +} diff --git a/packages/ui/src/ui-copy-catalog.ts b/packages/ui/src/ui-copy-catalog.ts new file mode 100644 index 0000000000..8c6a9032eb --- /dev/null +++ b/packages/ui/src/ui-copy-catalog.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import enShellControls from './locales/en/shell-controls.json' with { type: 'json' }; +import zhShellControls from './locales/zh/shell-controls.json' with { type: 'json' }; + +const EN_UI_COPY_RESOURCES = { + 'shell-controls': enShellControls, +}; + +export type UiCopyDomain = keyof typeof EN_UI_COPY_RESOURCES; + +const UI_COPY_RESOURCES = { + zh: { + 'shell-controls': zhShellControls, + }, + en: EN_UI_COPY_RESOURCES, +} satisfies UiCatalog>; + +export type UiDomainCatalog = { + readonly [Locale in UiLocale]: (typeof UI_COPY_RESOURCES)[Locale][Domain]; +}; + +export function getUiCopyCatalog( + domain: Domain, +): UiDomainCatalog { + return { + zh: UI_COPY_RESOURCES.zh[domain], + en: UI_COPY_RESOURCES.en[domain], + }; +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 114f810951..b74912659a 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -8,7 +8,7 @@ "jsx": "react-jsx", "composite": true }, - "include": ["src"], + "include": ["src", "src/**/*.json"], "exclude": ["src/**/*.stories.ts", "src/**/*.stories.tsx"], "references": [{ "path": "../core" }] } diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index 5f1d58acba..d507b0e495 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -245,13 +245,16 @@ export function isMakaDevelopmentArtifact(relativePath) { ); } -export function isCurrentDevelopmentJavaScript( +export function isCurrentDevelopmentRuntimeFile( workspaceRoot, relativePath, generatedFiles = new Set(), ) { const portablePath = relativePath.split(/[\\/]/u).join('/'); if (generatedFiles.has(portablePath)) return true; + if (portablePath.endsWith('.json')) { + return existsSync(join(workspaceRoot, 'src', portablePath)); + } if (!portablePath.endsWith('.js')) return false; const sourcePath = portablePath.slice(0, -'.js'.length); return ['.ts', '.tsx', '.mts', '.cts'].some((extension) => diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 4f321c86c4..c21e1eff12 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -32,7 +32,7 @@ import { join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import { collectWorkspaceDependencyClosure, - isCurrentDevelopmentJavaScript, + isCurrentDevelopmentRuntimeFile, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, orderWorkspaceBuilds, @@ -142,15 +142,18 @@ describe('CLI release file policy', () => { assert.equal(isMakaDevelopmentArtifact('dist/index.js'), false); }); - test('development packages exclude JavaScript left by deleted sources', () => { + test('development packages keep only current runtime files', () => { const workspace = mkdtempSync(join(tmpdir(), 'maka-development-output-')); try { - mkdirSync(join(workspace, 'src'), { recursive: true }); + mkdirSync(join(workspace, 'src', 'locales', 'en'), { recursive: true }); writeFileSync(join(workspace, 'src', 'current.ts'), 'export {}\n'); - assert.equal(isCurrentDevelopmentJavaScript(workspace, 'current.js'), true); - assert.equal(isCurrentDevelopmentJavaScript(workspace, 'deleted.js'), false); + writeFileSync(join(workspace, 'src', 'locales', 'en', 'copy.json'), '{}\n'); + assert.equal(isCurrentDevelopmentRuntimeFile(workspace, 'current.js'), true); + assert.equal(isCurrentDevelopmentRuntimeFile(workspace, 'deleted.js'), false); + assert.equal(isCurrentDevelopmentRuntimeFile(workspace, 'locales/en/copy.json'), true); + assert.equal(isCurrentDevelopmentRuntimeFile(workspace, 'locales/en/deleted.json'), false); assert.equal( - isCurrentDevelopmentJavaScript( + isCurrentDevelopmentRuntimeFile( workspace, 'workers\\generated.js', new Set(['workers/generated.js']), diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index c32f44a525..90a867d47e 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -39,7 +39,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { npmSpawnOptions } from './npm-spawn.mjs'; import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs'; import { - isCurrentDevelopmentJavaScript, + isCurrentDevelopmentRuntimeFile, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, orderWorkspaceBuilds, @@ -57,6 +57,10 @@ const preparedTree = process.env.MAKA_CLI_RELEASE_PREPARED_TREE === '1'; const releaseRoot = join(cliSource, 'release'); const artifactRoot = developmentBuild ? createDevelopmentArtifactRoot() : releaseRoot; const stageRoot = join(artifactRoot, 'package'); +const cliLocaleResources = walkFiles(join(cliSource, 'src', 'locales')) + .filter((path) => path.endsWith('.json')) + .map((path) => `dist/${relative(join(cliSource, 'src'), path).split(sep).join('/')}`) + .sort(); const peerPrebuildTargets = ['darwin-arm64', 'linux-arm64', 'linux-x64', 'win32-x64']; const unsupportedArguments = process.argv .slice(2) @@ -443,7 +447,7 @@ function copyRuntimeDist(source, destination, packageName = 'maka-agent') { if (isMakaDevelopmentArtifact(join('dist', relativePath))) return false; return ( !developmentBuild || - isCurrentDevelopmentJavaScript( + isCurrentDevelopmentRuntimeFile( source, relativePath, developmentGeneratedFiles.get(packageName), @@ -666,6 +670,7 @@ function developmentPackageVersion(baseVersion, manifest) { function validateStaging(publishable) { const required = [ 'dist/cli.js', + ...cliLocaleResources, 'README.zh-CN.md', 'DISCLAIMER-WIP', 'RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv', @@ -774,6 +779,7 @@ function validatePackedFiles(files, expectedDependencyManifests) { } const requiredPacked = [ 'dist/cli.js', + ...cliLocaleResources, 'DISCLAIMER-WIP', 'node_modules/@maka/runtime/dist/workers/filesystem-worker.js', 'node_modules/@maka/runtime-host/dist/execution-candidate-main.js',