From 6723f7bae73c6b005f36a1523a178dfbafd850ec Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 29 Aug 2026 01:58:08 -0700 Subject: [PATCH 1/2] refactor(cli): centralize localized TUI copy English mode rendered Chinese picker controls because locale selection was mixed into presentation code with no shared message contract. Generated-by: OpenAI Codex Generated-by: Claude Code --- package-lock.json | 5 +- packages/cli/THIRD_PARTY_NOTICES.txt | 96 +++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 33 +- .../src/__tests__/tui-copy-catalog.test.ts | 123 +++++++ packages/cli/src/pi-tui-mcp-status.ts | 103 +++--- packages/cli/src/pi-tui-pickers.ts | 240 ++++++++---- packages/cli/src/pi-tui-runner.ts | 55 ++- packages/cli/src/tui-copy-catalog.ts | 345 ++++++++++++++++++ packages/cli/src/tui-primary-guidance.ts | 112 +----- packages/cli/src/tui-session-status.ts | 28 +- packages/core/package.json | 3 + packages/core/src/__tests__/ui-locale.test.ts | 68 ++++ packages/core/src/ui-locale.ts | 91 +++++ 13 files changed, 1029 insertions(+), 273 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-copy-catalog.test.ts create mode 100644 packages/cli/src/tui-copy-catalog.ts create mode 100644 packages/core/src/__tests__/ui-locale.test.ts diff --git a/package-lock.json b/package-lock.json index c3e7d9e443..22be60e22a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14264,7 +14264,10 @@ "packages/core": { "name": "@maka/core", "version": "0.1.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "intl-messageformat": "11.2.12" + } }, "packages/eval": { "name": "@maka/eval", diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index d1b9143775..f1950bb8bd 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -878,6 +878,60 @@ SOFTWARE. ================================================================================ +Package: @formatjs/fast-memoize@3.1.7 +Declared license: MIT +Selected license: MIT +Repository: formatjs/formatjs.git + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +Package: @formatjs/icu-messageformat-parser@3.5.15 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/formatjs/formatjs.git#packages/icu-messageformat-parser + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +Package: @formatjs/icu-skeleton-parser@2.1.11 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/formatjs/formatjs.git#packages/icu-skeleton-parser + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @larksuiteoapi/node-sdk@1.73.0 Declared license: MIT Selected license: MIT @@ -4751,6 +4805,48 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: intl-messageformat@11.2.12 +Declared license: BSD-3-Clause +Selected license: BSD-3-Clause +Repository: git@github.com:formatjs/formatjs.git + +--- LICENSE.md --- +Copyright (c) 2023, Oath Inc. + +Licensed under the terms of the New BSD license. See below for terms. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the following +conditions are met: + +- Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +- Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +- Neither the name of Oath Inc. nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission of Oath Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + Package: ip-address@10.4.0 Declared license: MIT Selected license: MIT diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f6db8e7598..6e6b9dd207 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -181,7 +181,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, @@ -795,6 +795,7 @@ describe('Maka Pi TUI runner', () => { model: 'claude-sonnet-4-5', connectionSlug: 'claude-subscription', permissionMode: 'bypass', + locale: 'zh', terminal, onboarding: fakeOnboardingSurface({ verify: async (input) => { @@ -811,7 +812,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; } @@ -1024,7 +1025,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; } @@ -1067,7 +1070,7 @@ describe('Maka Pi TUI runner', () => { ); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /没有可配置的 API key 类供应商/, + /No configurable API key providers are available/, ); process.emit('SIGTERM'); @@ -1127,7 +1130,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; } @@ -1224,7 +1227,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; } @@ -1247,7 +1250,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); terminal.input('\r'); // verify the now-existing Connection await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); @@ -1373,7 +1376,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'); @@ -1393,8 +1396,11 @@ 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('已启用')); - assert.match(plainTerminalOutput(terminal.screenOutput()), /账号已保存,但模型列表暂未刷新/); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Models enabled')); + assert.match( + plainTerminalOutput(terminal.screenOutput()), + /Account saved, but the model list has not refreshed/, + ); process.emit('SIGTERM'); await Promise.race([ @@ -3657,6 +3663,7 @@ describe('Maka Pi TUI runner', () => { connectionId: 'connection-openai', connectionSlug: 'openai', providerType: 'openai', + locale: 'en', modelChoices: [ { connectionId: 'connection-openai', @@ -3691,7 +3698,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. @@ -3773,7 +3780,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 // (model id, connection name, connection slug, provider type, provider @@ -3855,7 +3862,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'); terminal.input('\r'); 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..f45ba75960 --- /dev/null +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { formatUiMessage } from '@maka/core/ui-locale'; +import { getTuiPickerCopy } from '../pi-tui-pickers.js'; +import { TUI_COPY_RESOURCES } from '../tui-copy-catalog.js'; + +const MESSAGE_VALUES = { + count: 2, + detail: 'HTTP 401', + hasDetail: true, +} as const; + +describe('TUI copy resources', () => { + test('registers every domain without a locale-specific getter branch', () => { + for (const [domain, catalog] of Object.entries(TUI_COPY_RESOURCES)) { + assert.ok(catalog.en, `${domain}/en`); + assert.ok(catalog.zh, `${domain}/zh`); + } + }); + + test('keeps Chinese coverage, variables, and ICU formatting aligned with English', () => { + for (const [domain, catalog] of Object.entries(TUI_COPY_RESOURCES)) { + const enLeaves = new Map(leafEntries(catalog.en)); + const zhLeaves = new Map(leafEntries(catalog.zh)); + + assert.deepEqual([...zhLeaves.keys()].sort(), [...enLeaves.keys()].sort(), `${domain}/zh`); + for (const [path, enTemplate] of enLeaves) { + const zhTemplate = zhLeaves.get(path)!; + const enVariables = messageVariables(enTemplate); + const zhVariables = messageVariables(zhTemplate); + assert.deepEqual(zhVariables, enVariables, `${domain}/zh/${path}`); + if (enVariables.length === 0) continue; + assert.notEqual( + formatUiMessage(enTemplate, MESSAGE_VALUES, 'en'), + enTemplate, + `${domain}/en/${path}`, + ); + assert.notEqual( + formatUiMessage(zhTemplate, MESSAGE_VALUES, 'zh'), + zhTemplate, + `${domain}/zh/${path}`, + ); + } + } + }); + + test('keeps model picker copy locale-specific', () => { + assert.equal(getTuiPickerCopy('en').modelPickerTitle, 'Select Model'); + assert.equal(getTuiPickerCopy('en').searchLabel, 'Search'); + assert.equal(getTuiPickerCopy('zh').modelPickerTitle, '选择模型'); + assert.equal(getTuiPickerCopy('zh').searchLabel, '搜索'); + assert.equal(getTuiPickerCopy('zh').currentMarker, '当前'); + assert.equal(getTuiPickerCopy('zh').defaultMarker, '默认'); + }); + + test('formats the English MCP count with ICU plural rules', () => { + const template = TUI_COPY_RESOURCES['mcp-status'].en.toolCount; + + assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); + assert.equal(formatUiMessage(template, { count: 2 }, 'en'), '2 tools'); + }); + + test('preserves verification detail placement in both locales', () => { + const en = TUI_COPY_RESOURCES.pickers.en.verifyFailed; + const zh = TUI_COPY_RESOURCES.pickers.zh.verifyFailed; + + assert.equal( + formatUiMessage(en, { detail: 'HTTP 401', hasDetail: true }, 'en'), + 'API key verification failed: HTTP 401. Check it and try again.', + ); + assert.equal( + formatUiMessage(zh, { detail: 'HTTP 401', hasDetail: true }, 'zh'), + 'API key 验证失败:HTTP 401。请检查后重新输入。', + ); + assert.equal( + formatUiMessage(en, { detail: '', hasDetail: false }, 'en'), + 'API key verification failed. Check it and try again.', + ); + assert.equal( + formatUiMessage(zh, { detail: '', hasDetail: false }, 'zh'), + 'API key 验证失败,请检查后重新输入。', + ); + }); +}); + +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)]]; +} + +function messageVariables(template: string): string[] { + return [ + ...new Set( + [...template.matchAll(/\{([A-Za-z][A-Za-z0-9]*)(?=[,}])/gu)].map((match) => match[1]!), + ), + ].sort(); +} diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index a4f967eb7a..8f9f03b9b1 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -27,7 +27,12 @@ import { type TUI, } from '@earendil-works/pi-tui'; import type { McpProtocolPreference, McpServerConfig } from '@maka/core/mcp'; -import type { UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; import type { TuiMcpAction, @@ -37,9 +42,35 @@ import type { TuiMcpServerSnapshot, } from './tui-mcp-control.js'; import { ansi, editorTheme } from './tui-ansi.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; const CHROME_ROWS = 2; +interface TuiMcpStatusCopy { + readonly title: string; + readonly footer: { + readonly back: string; + readonly readOnly: string; + readonly manage: 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, string>>; + readonly configuredOnly: string; + readonly configPending: string; + readonly toolCount: string; +} + +const MCP_STATUS_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['mcp-status']), +); + type GuidedDraft = { serverId: string; transport?: 'stdio' | 'remote'; @@ -171,7 +202,7 @@ export class McpManagementOverlay 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 title = MCP_STATUS_COPY[this.input.locale].title; const header = padLine( `${ansi.bold(title)} ${ansi.dim(`${start}-${end} / ${document.length}`)}`, safeWidth, @@ -463,13 +494,10 @@ export class McpManagementOverlay implements Component { } private footer(): string { - if (this.phase.kind !== 'list') return this.input.locale === 'zh' ? 'Esc 返回' : 'Esc back'; - if (!this.management()) { - return this.input.locale === 'zh' ? '↑/↓ 滚动 · q/Esc 关闭' : '↑/↓ scroll · q/Esc close'; - } - return this.input.locale === 'zh' - ? 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭' - : 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close'; + const copy = MCP_STATUS_COPY[this.input.locale].footer; + if (this.phase.kind !== 'list') return copy.back; + if (!this.management()) return copy.readOnly; + return copy.manage; } private backToList(clearNotice = true): void { @@ -581,15 +609,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 = formatUiMessage(copy.toolCount, { count: snapshot.toolCount }, locale); return `${ansi.bold(publication)} · ${tools}`; } @@ -598,12 +620,9 @@ function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale, selected: b ? `${server.negotiatedProtocol.era} ${server.negotiatedProtocol.revision}` : server.configuredProtocol; const transport = server.transport ?? server.configuredTransport; - const tools = locale === 'zh' ? `${server.toolCount} 个工具` : `${server.toolCount} tools`; - const sync = !server.synchronized - ? locale === 'zh' - ? '配置待同步' - : 'config pending' - : undefined; + const copy = MCP_STATUS_COPY[locale]; + const tools = formatUiMessage(copy.toolCount, { count: server.toolCount }, locale); + const sync = server.synchronized ? undefined : copy.configPending; const details = [stateLabel(server.state, locale), transport, protocol, tools, sync] .filter(Boolean) .join(' · '); @@ -754,15 +773,8 @@ function configurationLine(state: 'synchronizing' | 'out_of_sync', locale: UiLoc } function unavailableDocument(locale: UiLocale): string[] { - return 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.', - ]; + const copy = MCP_STATUS_COPY[locale]; + return [ansi.yellow(copy.unavailableTitle), copy.unavailableDetail]; } function heading(locale: UiLocale, en: string, zh: string): string { @@ -774,21 +786,15 @@ function confirmCopy(locale: UiLocale): string { } function loadingCopy(locale: UiLocale): string { - return locale === 'zh' - ? '正在读取 mcp.json 并发现工具…' - : 'Loading mcp.json and discovering tools…'; + return MCP_STATUS_COPY[locale].loading; } function loadErrorCopy(locale: UiLocale): string { - return locale === 'zh' - ? '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。' - : 'MCP configuration could not be loaded; no tools were published to the Runtime Host.'; + return MCP_STATUS_COPY[locale].loadError; } function emptyCopy(locale: UiLocale): string { - return locale === 'zh' - ? '尚未配置 MCP 服务器。按 a 添加。' - : 'No MCP servers are configured. Press a to add one.'; + return MCP_STATUS_COPY[locale].noServers; } function statusMarker(state: TuiMcpServerSnapshot['state']): string { @@ -799,16 +805,9 @@ function statusMarker(state: TuiMcpServerSnapshot['state']): string { } function stateLabel(state: TuiMcpServerSnapshot['state'], locale: UiLocale): string { - if (!state) return locale === 'zh' ? '仅已配置' : 'configured only'; - if (locale === 'en') return state; - return { - disabled: '已停用', - disconnected: '未连接', - connecting: '连接中', - connected: '已连接', - 'needs-auth': '需要登录', - error: '错误', - }[state]; + const copy = MCP_STATUS_COPY[locale]; + if (!state) return copy.configuredOnly; + return copy.serverState[state] ?? 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 4e4852ff55..dded4439f8 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -37,10 +37,69 @@ 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 { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + 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 { TUI_COPY_RESOURCES } 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 = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES.pickers), +); export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider | undefined; @@ -569,7 +628,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 +642,7 @@ export function modelPickerItems( return ids.map((id) => ({ value: id, label: id, - ...(id === currentModel ? { description: 'current' } : {}), + ...(id === currentModel ? { description: copy.currentMarker } : {}), })); } @@ -634,6 +695,7 @@ export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Ma function modelChoicePickerItems( choices: readonly ModelChoice[], current: { model: string; connectionId?: string; connectionSlug: string }, + copy: TuiPickerCopy, ): SelectItem[] { const connectionLabels = modelChoiceConnectionLabels(choices); return choices.map((choice, index) => { @@ -642,8 +704,8 @@ function modelChoicePickerItems( choice.connectionId === current.connectionId && choice.connectionSlug === current.connectionSlug; const tags = [connectionLabels.get(choice.connectionSlug) ?? 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, @@ -669,6 +731,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { } export interface ModelSearchOverlayInput { + locale: UiLocale; choices: readonly ModelChoice[]; current: { model: string; connectionId?: string; connectionSlug: string }; showCacheWarning?: boolean; @@ -676,8 +739,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- @@ -693,11 +757,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) => @@ -713,7 +779,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 }, @@ -765,16 +831,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), ]; @@ -832,18 +901,19 @@ 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: onboardingProviderKey(provider), label: provider.label, description: 'connectionSlug' in provider - ? `${provider.providerType} · ${provider.connectionSlug} · 已设置` - : `${provider.providerType} · 添加账号`, + ? `${provider.providerType} · ${provider.connectionSlug} · ${copy.providerConfigured}` + : `${provider.providerType} · ${copy.addAccount}`, })); } @@ -853,30 +923,22 @@ function onboardingProviderKey(provider: OnboardingProviderEntry): string { : `create:${provider.target.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 } : {}), })), ]; } @@ -892,6 +954,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 = @@ -901,6 +973,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 @@ -954,11 +1027,13 @@ export class OnboardingWizard implements Component { private modelScroll = 0; private successCount = 0; private successWarning: string | undefined; + 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 }); @@ -986,7 +1061,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 }, @@ -1042,21 +1117,21 @@ export class OnboardingWizard implements Component { */ private validateBaseUrl(trimmed: string): string | null { if (!trimmed) { - return this.picked?.target.kind === 'existing' ? null : '需要填写 Base URL'; + return this.picked?.target.kind === 'existing' ? 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; } @@ -1270,7 +1345,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]); @@ -1310,7 +1385,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' }; } @@ -1338,18 +1412,22 @@ export class OnboardingWizard implements Component { this.keyEditor.focused = false; this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; - const hint = - this.picked?.target.kind === 'existing' - ? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商' - : '输入中转站的 Base URL(http/https)· Esc 返回选择服务商'; + const hint = this.picked?.target.kind === 'existing' + ? 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), @@ -1363,15 +1441,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), ]; @@ -1383,19 +1461,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?.target.kind === 'existing' - ? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}` - : `输入 API key · 仅本机存储 · ${backTarget}`; + const hint = keyEntryHint( + this.copy, + this.picked?.target.kind === 'existing', + 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), @@ -1405,13 +1483,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); } } @@ -1423,16 +1501,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, @@ -1455,11 +1533,23 @@ export class OnboardingWizard implements Component { private renderModelsStatusLine(): string { switch (this.status.kind) { case 'prompt': - return ansi.dim(`已选 ${this.selectedIds.size} · Enter 保存`); + return ansi.dim( + formatUiMessage( + this.copy.selectedModelsAndSave, + { count: this.selectedIds.size }, + this.input.locale, + ), + ); case 'verifying': - return ansi.dim(`已选 ${this.selectedIds.size}`); + return ansi.dim( + formatUiMessage( + this.copy.selectedModels, + { count: this.selectedIds.size }, + this.input.locale, + ), + ); case 'saving': - return `${ansi.yellow('⠋')} 正在保存…`; + return `${ansi.yellow('⠋')} ${this.copy.saving}`; case 'error': return ansi.red(`✗ ${this.status.text}`); } @@ -1472,11 +1562,23 @@ 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( + `✓ ${formatUiMessage( + this.copy.enabledModels, + { count: this.successCount }, + this.input.locale, + )}`, + ), + width, + ), ...(this.successWarning ? [padLine(ansi.yellow(this.successWarning), 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 45f0842fd8..59f2b1f53c 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -52,7 +52,7 @@ import { type SessionSummary, type StoredMessage, } from '@maka/core/session'; -import type { UiLocale } from '@maka/core/ui-locale'; +import { formatUiMessage, type UiLocale } from '@maka/core/ui-locale'; import { buildForeignSessionHandoffMessage, foreignSessionHandoffDisplayText, @@ -144,12 +144,12 @@ import { import { MakaAutocompleteProvider, DirectoryPickerOverlay, - MODEL_SWITCH_CACHE_WARNING, ModelSearchOverlay, OnboardingWizard, PickerOverlay, UserQuestionOverlay, modelChoiceConnectionLabels, + getTuiPickerCopy, modelPickerItems, permissionModePickerItems, skillPickerItems, @@ -361,6 +361,7 @@ function sessionConnectionIdentityNotice( 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); @@ -2116,7 +2117,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } if (!input.onboarding) { - wizard.setKeyError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setKeyError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -2134,7 +2135,13 @@ 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 + : formatUiMessage( + pickerCopy.verifyFailed, + { detail: result.text, hasDetail: result.text.trim().length > 0 }, + locale, + ), ); requestRender(); return; @@ -2145,7 +2152,13 @@ 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( + formatUiMessage( + pickerCopy.setupFailed, + { detail: error instanceof Error ? error.message : String(error) }, + locale, + ), + ); requestRender(); }, ); @@ -2159,7 +2172,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const target = wizardTarget; if (!target || !wizard) return; if (!input.onboarding) { - wizard.setModelError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setModelError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -2212,7 +2225,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { (error) => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; wizard.setModelError( - `保存失败:${error instanceof Error ? error.message : String(error)}`, + formatUiMessage( + pickerCopy.saveFailed, + { detail: error instanceof Error ? error.message : String(error) }, + locale, + ), ); requestRender(); }, @@ -2228,7 +2245,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: `无法读取已配置的连接:${error instanceof Error ? error.message : String(error)}`, + text: formatUiMessage( + pickerCopy.listProvidersFailed, + { detail: error instanceof Error ? error.message : String(error) }, + locale, + ), }); requestRender(); return; @@ -2247,13 +2268,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: (provider) => { // Each picker selection is a new logical intent, even when the user @@ -2760,6 +2782,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, connectionId, connectionSlug }, showCacheWarning: hasConversationHistory, @@ -2773,16 +2796,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, }, ); }; @@ -2815,9 +2838,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) => { @@ -3412,7 +3435,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: '当前模型不支持思考级别切换。', + text: pickerCopy.thinkingUnsupported, }); requestRender(); return; @@ -3433,7 +3456,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..56afb5a4aa --- /dev/null +++ b/packages/cli/src/tui-copy-catalog.ts @@ -0,0 +1,345 @@ +/* + * 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. + */ + +export const TUI_COPY_RESOURCES = { + 'mcp-status': { + en: { + title: 'MCP SERVERS', + footer: { + back: 'Esc back', + readOnly: '↑/↓ scroll · q/Esc close', + manage: + 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · 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. Press a to add one.', + 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', + }, + configuredOnly: 'configured only', + configPending: 'config pending', + toolCount: '{count, plural, one {# tool} other {# tools}}', + }, + zh: { + title: 'MCP 服务器', + footer: { + back: 'Esc 返回', + readOnly: '↑/↓ 滚动 · q/Esc 关闭', + manage: 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭', + }, + unavailableTitle: '当前 TUI 未连接本地 MCP 控制面。', + unavailableDetail: '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', + loading: '正在读取 mcp.json 并发现工具…', + loadError: '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。', + noServers: '尚未配置 MCP 服务器。按 a 添加。', + publication: { + waiting: '等待发布', + host_unavailable: 'Runtime Host 重连中', + publishing: '正在发布', + published: '已发布', + not_published: '未发布', + error: '发布失败', + }, + serverState: { + disabled: '已停用', + disconnected: '未连接', + connecting: '连接中', + connected: '已连接', + 'needs-auth': '需要登录', + error: '错误', + }, + configuredOnly: '仅已配置', + configPending: '配置待同步', + toolCount: '{count} 个工具', + }, + }, + pickers: { + en: { + 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: + '{hasDetail, select, true {API key verification failed: {detail}. Check it and try again.} other {API key verification failed. Check it and try again.}}', + 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.', + }, + zh: { + 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: + '{hasDetail, select, true {API key 验证失败:{detail}。请检查后重新输入。} other {API key 验证失败,请检查后重新输入。}}', + 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: '当前模型不支持思考级别切换。', + }, + }, + 'primary-guidance': { + 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: 'Manage 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', + userCommand: ' ! — run one shell command visible only to you', + 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', + ], + }, + }, + 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: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', + keybindingsHeading: '快捷键', + keybindings: [ + ' Ctrl+O — 展开或折叠所有工具输出', + ' Ctrl+T — 展开或折叠视图中的所有思考块', + ' 使用终端或触控板滚动对话记录', + ' Enter(任务运行中)— 将消息注入当前任务', + ' Alt+Enter(任务运行中)— 将消息排入下一轮', + ' Alt+↑ — 将已排队消息取回编辑器重新编辑', + ' Esc Esc(任务运行中)— 中断当前任务', + ' Esc Esc(空闲时)— 回退到较早的轮次', + ' Ctrl+C — 停止任务、清空输入,或连续按两次退出', + ' Ctrl+D — 输入为空时退出', + ], + }, + }, + }, + 'session-status': { + en: { + running: 'running', + waitingForUser: 'waiting for you', + permissionRequired: 'needs permission', + connectionRequired: 'needs connection', + signInRequired: 'needs sign-in', + stopped: 'stopped', + }, + zh: { + running: '进行中', + waitingForUser: '等你确认', + permissionRequired: '需要权限', + connectionRequired: '需要连接', + signInRequired: '需要重新登录', + stopped: '已中止', + }, + }, +} as const; diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 552c41df0f..398ccac2f1 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -18,8 +18,13 @@ */ import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; type TuiCommandId = SlashCommandIdForSurface<'tui'>; @@ -40,108 +45,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: '命令', - userCommand: ' ! — 执行一次仅用户可见的 shell 命令', - 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: 'Manage 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', - userCommand: ' ! — run one shell command visible only to you', - 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 = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['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..1042a29712 100644 --- a/packages/cli/src/tui-session-status.ts +++ b/packages/cli/src/tui-session-status.ts @@ -18,7 +18,12 @@ */ import type { SessionSummary } from '@maka/core/session'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiSessionStatusCopy { readonly running: string; @@ -29,24 +34,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 = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['session-status']), +); /** * Present the runtime Session state as compact picker copy. diff --git a/packages/core/package.json b/packages/core/package.json index bab15ee8f4..7c0dfeec11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -148,5 +148,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && npm run test:dist", "test:dist": "node --test \"dist/**/*.test.js\"" + }, + "dependencies": { + "intl-messageformat": "11.2.12" } } 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..6fcc097723 --- /dev/null +++ b/packages/core/src/__tests__/ui-locale.test.ts @@ -0,0 +1,68 @@ +/* + * 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, it } from 'node:test'; +import { + UI_LOCALES, + defineUiMessageCatalog, + formatUiMessage, + isUiLocale, + resolveSystemUiLocale, + resolveUiMessageCatalog, + uiLocaleToIntlLocale, +} from '../ui-locale.js'; + +describe('UI message catalogs', () => { + it('falls back to complete English copy for missing translations', () => { + const catalog = defineUiMessageCatalog<{ + title: string; + detail: { ready: string; waiting: string }; + }>()({ + en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + zh: { title: '状态', detail: { ready: '就绪' } }, + }); + + assert.deepEqual(resolveUiMessageCatalog(catalog), { + en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + zh: { title: '状态', detail: { ready: '就绪', waiting: 'Waiting' } }, + }); + }); + + it('uses locale-aware ICU plural rules', () => { + const template = '{count, plural, one {# tool} other {# tools}}'; + + assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); + assert.equal(formatUiMessage(template, { count: 3 }, 'en'), '3 tools'); + }); + + it('fails soft for missing or inherited interpolation values', () => { + assert.equal(formatUiMessage('Hello {name}', {}, 'en'), 'Hello {name}'); + assert.equal(formatUiMessage('{constructor}', {}, 'en'), '{constructor}'); + }); + + it('keeps every locale guard in step with UI_LOCALES', () => { + for (const locale of UI_LOCALES) { + assert.ok(isUiLocale(locale), locale); + assert.equal(resolveSystemUiLocale([locale]), locale); + } + const intlLocales = UI_LOCALES.map(uiLocaleToIntlLocale); + assert.equal(new Set(intlLocales).size, UI_LOCALES.length); + }); +}); diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 1f58e75036..3a7d161734 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -17,6 +17,8 @@ * under the License. */ +import { IntlMessageFormat } from 'intl-messageformat'; + /** Resolved locales supported by human-facing Maka clients. */ export const UI_LOCALES = ['zh', 'en'] as const; @@ -30,6 +32,95 @@ 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 DeepPartial = T extends readonly unknown[] + ? T + : T extends object + ? { readonly [Key in keyof T]?: DeepPartial } + : T; + +export type UiMessageCatalog = { + readonly en: T; +} & Partial<{ + readonly [Locale in Exclude]: DeepPartial; +}>; + +type ExactMessageShape = Actual extends readonly unknown[] + ? NonNullable extends readonly unknown[] + ? Actual + : never + : Actual extends object + ? Exclude> extends never + ? { + readonly [Key in keyof Actual]: ExactMessageShape< + Actual[Key], + NonNullable[Key & keyof NonNullable] + >; + } + : never + : Actual; + +const messageFormatters = new Map(); + +export function defineUiMessageCatalog() { + return >( + catalog: Catalog & { + readonly en: ExactMessageShape; + } & { + readonly [Locale in Exclude]?: ExactMessageShape< + NonNullable, + DeepPartial + >; + }, + ): UiMessageCatalog => catalog; +} + +export function resolveUiMessageCatalog(catalog: UiMessageCatalog): UiCatalog { + return Object.fromEntries( + UI_LOCALES.map((locale) => [ + locale, + locale === 'en' + ? catalog.en + : mergeUiMessages(catalog.en, catalog[locale] as DeepPartial | undefined), + ]), + ) as UiCatalog; +} + +export function formatUiMessage( + template: string, + values: Readonly>, + locale: UiLocale, +): string { + try { + const intlLocale = uiLocaleToIntlLocale(locale); + const cacheKey = `${intlLocale}\u0000${template}`; + let formatter = messageFormatters.get(cacheKey); + if (!formatter) { + formatter = new IntlMessageFormat(template, intlLocale); + messageFormatters.set(cacheKey, formatter); + } + const safeValues = Object.assign(Object.create(null) as Record, values); + const formatted = formatter.format(safeValues); + return Array.isArray(formatted) ? formatted.join('') : String(formatted); + } catch { + return template; + } +} + +function mergeUiMessages(fallback: T, translation: DeepPartial | undefined): T { + if (translation === undefined) return fallback; + if (!isMessageRecord(fallback) || !isMessageRecord(translation)) return translation as T; + return Object.fromEntries( + Object.entries(fallback).map(([key, value]) => [ + key, + mergeUiMessages(value, Object.hasOwn(translation, key) ? translation[key] : undefined), + ]), + ) as T; +} + +function isMessageRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + export function isUiLocale(value: unknown): value is UiLocale { return value === 'zh' || value === 'en'; } From 48067a0c9d5f0c73ea9fe6c51e5340583f234ad3 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 29 Aug 2026 10:53:20 -0700 Subject: [PATCH 2/2] fix(cli): localize picker and setup errors --- .../cli/src/__tests__/pi-tui-runner.test.ts | 96 +++++++++++++++++-- .../__tests__/runtime-host-onboarding.test.ts | 54 ++++++++++- .../src/__tests__/tui-copy-catalog.test.ts | 24 +++-- packages/cli/src/pi-tui-contracts.ts | 43 +++++++-- packages/cli/src/pi-tui-pickers.ts | 40 ++++++-- packages/cli/src/pi-tui-runner.ts | 55 +++-------- packages/cli/src/runtime-host-onboarding.ts | 52 ++-------- packages/cli/src/tui-copy-catalog.ts | 55 +++++++++-- 8 files changed, 285 insertions(+), 134 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 6e6b9dd207..bc0524f253 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -186,7 +186,7 @@ function defaultOnboardingProviders(): OnboardingProviderEntry[] { return listApiKeyOnboardableProviders().map((provider) => ({ ...provider, target: { kind: 'create', providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], })); } @@ -238,7 +238,7 @@ function savedOnboardingRefreshFailed(connectionId = 'saved-connection-id'): Onb }, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }; } @@ -801,7 +801,7 @@ describe('Maka Pi TUI runner', () => { verify: async (input) => { verifyCalls.push(input.apiKey ?? ''); return verifyCalls.length === 1 - ? { kind: 'error', text: 'HTTP 401 Unauthorized' } + ? { kind: 'rejected', reason: 'connection_not_found' } : { kind: 'ok', models: [{ id: 'gpt-5.5' }] }; }, }), @@ -830,7 +830,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(''), '该连接已不存在') !== null; } catch { return false; } @@ -851,6 +851,51 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('localizes a save rejection without displaying Host text', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + let saveCalls = 0; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + locale: 'zh', + terminal, + onboarding: fakeOnboardingSurface({ + save: async () => { + saveCalls += 1; + return { kind: 'rejected', reason: 'superseded' }; + }, + }), + }); + + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('配置模型提供商')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + terminal.input('sk-test'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + await waitFor(() => saveCalls === 1); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('该连接已发生变化')); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /This connection changed/); + + process.emit('SIGTERM'); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close after SIGTERM'); + }), + ]); + }); + test('threads the selected same-provider account identity through verify and save', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -1066,8 +1111,11 @@ describe('Maka Pi TUI runner', () => { terminal.input('/setup'); terminal.input('\r'); await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('storage read failed'), + plainTerminalOutput(terminal.screenOutput()).includes( + 'Could not read configured connections', + ), ); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /storage read failed/); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), /No configurable API key providers are available/, @@ -1158,7 +1206,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-b')); // A's verify now resolves with a failure. It must not clobber attempt B: // no failure status line, and the key being typed for B survives. - resolveFirst({ kind: 'error', text: 'HTTP 401 Unauthorized' }); + resolveFirst({ kind: 'failed', errorClass: 'auth' }); // Sentinel render: one more typed key char forces a full repaint that lands // after A's settled continuation, so a wrongly-applied failure line would be // in this exact frame. @@ -3601,6 +3649,42 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('localizes generic model and thinking picker hints in Chinese', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'gpt-5', + models: ['gpt-5', 'gpt-5-mini'], + connectionSlug: 'openai', + providerType: 'openai', + permissionMode: 'ask', + locale: 'zh', + terminal, + }); + + terminal.input('/model'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择模型')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); + terminal.input('\x1b'); + + terminal.input('/thinking'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择思考级别')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); + assert.doesNotMatch( + plainTerminalOutput(terminal.screenOutput()), + /enter select \/ esc close/iu, + ); + + terminal.input('\x1b'); + exitMaka(terminal); + await run; + }); + test('resumes a read-only session as Read only, and never marks Auto as current', async () => { // #1611 in the TUI: the resumed boundary is read-only, so the status line // must name it and the picker must not present Auto as "the option you are diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 105b80ffb4..2da8d3cbed 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -43,6 +43,54 @@ const live = { } as const; describe('createRuntimeHostOnboardingSurface', () => { + test('preserves Host failure codes without projecting backend text', async () => { + const connection = { + request: async (operation: string) => { + if (operation === 'connection.onboarding.verify') { + return { kind: 'rejected', reason: 'connection_not_found' }; + } + if (operation === 'connection.onboarding.save') { + return { kind: 'failed', errorClass: 'network' }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as RuntimeHostConnection; + const surface = createRuntimeHostOnboardingSurface(connection); + + assert.deepEqual( + await surface.verify({ + target: { kind: 'existing', connectionId: 'gone-id' }, + apiKey: 'sk-test', + }), + { kind: 'rejected', reason: 'connection_not_found' }, + ); + assert.deepEqual( + await surface.save({ + target: { kind: 'existing', connectionId: 'live-id' }, + apiKey: 'sk-test', + enabledModelIds: ['gpt-5-mini'], + models: [{ id: 'gpt-5-mini' }], + }), + { kind: 'failed', errorClass: 'network' }, + ); + }); + + test('classifies transport exceptions without exposing their message', async () => { + const connection = { + request: async () => { + throw new Error('Host transport leaked this English detail'); + }, + } as unknown as RuntimeHostConnection; + + assert.deepEqual( + await createRuntimeHostOnboardingSurface(connection).verify({ + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'sk-test', + }), + { kind: 'unavailable' }, + ); + }); + test('keeps the committed Connection when the follow-up catalog refresh fails', async () => { const committed = { connectionId: 'committed-openai-id', @@ -74,7 +122,7 @@ describe('createRuntimeHostOnboardingSurface', () => { connection: committed, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }); }); @@ -147,6 +195,10 @@ describe('projectProviders', () => { kind: 'create', providerType: 'openai-compatible', }); + assert.equal( + entries.find(({ target }) => target.kind === 'create')?.label, + 'Custom relay (OpenAI Chat-compatible)', + ); }); test('several non-canonical connections remain independently editable', () => { diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index f45ba75960..b59509f2a4 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { formatUiMessage } from '@maka/core/ui-locale'; -import { getTuiPickerCopy } from '../pi-tui-pickers.js'; +import { getTuiPickerCopy, onboardingFailureMessage } from '../pi-tui-pickers.js'; import { TUI_COPY_RESOURCES } from '../tui-copy-catalog.js'; const MESSAGE_VALUES = { @@ -68,6 +68,7 @@ describe('TUI copy resources', () => { assert.equal(getTuiPickerCopy('en').searchLabel, 'Search'); assert.equal(getTuiPickerCopy('zh').modelPickerTitle, '选择模型'); assert.equal(getTuiPickerCopy('zh').searchLabel, '搜索'); + assert.equal(getTuiPickerCopy('zh').selectPickerHint, '↑↓ 选择 · Enter 确认 · Esc 关闭'); assert.equal(getTuiPickerCopy('zh').currentMarker, '当前'); assert.equal(getTuiPickerCopy('zh').defaultMarker, '默认'); }); @@ -79,25 +80,22 @@ describe('TUI copy resources', () => { assert.equal(formatUiMessage(template, { count: 2 }, 'en'), '2 tools'); }); - test('preserves verification detail placement in both locales', () => { - const en = TUI_COPY_RESOURCES.pickers.en.verifyFailed; - const zh = TUI_COPY_RESOURCES.pickers.zh.verifyFailed; - + test('localizes stable onboarding failure codes at the TUI boundary', () => { assert.equal( - formatUiMessage(en, { detail: 'HTTP 401', hasDetail: true }, 'en'), - 'API key verification failed: HTTP 401. Check it and try again.', + onboardingFailureMessage({ kind: 'rejected', reason: 'connection_not_found' }, 'en'), + 'This connection no longer exists. Reopen /setup and try again.', ); assert.equal( - formatUiMessage(zh, { detail: 'HTTP 401', hasDetail: true }, 'zh'), - 'API key 验证失败:HTTP 401。请检查后重新输入。', + onboardingFailureMessage({ kind: 'rejected', reason: 'connection_not_found' }, 'zh'), + '该连接已不存在,请重新打开 /setup 后重试。', ); assert.equal( - formatUiMessage(en, { detail: '', hasDetail: false }, 'en'), - 'API key verification failed. Check it and try again.', + onboardingFailureMessage({ kind: 'failed', errorClass: 'auth' }, 'zh'), + '服务商身份验证失败,请检查 API key 后重试。', ); assert.equal( - formatUiMessage(zh, { detail: '', hasDetail: false }, 'zh'), - 'API key 验证失败,请检查后重新输入。', + onboardingFailureMessage({ kind: 'unavailable' }, 'zh'), + '无法连接 Runtime Host,请重试。', ); }); }); diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 7bbf0de444..957c75ae56 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -21,6 +21,11 @@ import type { ForeignSessionDigest, ForeignSessionSummary } from '@maka/core/for import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; +import type { + ConnectionEffectFailureClass, + ConnectionOnboardingSaveResult as RuntimeHostOnboardingSaveResult, + ConnectionOnboardingVerifyResult as RuntimeHostOnboardingVerifyResult, +} from '@maka/runtime-host/protocol'; import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; export interface ModelChoice { @@ -73,16 +78,32 @@ export interface OnboardingVerifyInput { baseUrl?: string; } +export type OnboardingVerifyRejectionReason = Extract< + RuntimeHostOnboardingVerifyResult, + { readonly kind: 'rejected' } +>['reason']; + +export type OnboardingSaveRejectionReason = Extract< + RuntimeHostOnboardingSaveResult, + { readonly kind: 'rejected' } +>['reason']; + +export type OnboardingRejectionReason = + | OnboardingVerifyRejectionReason + | OnboardingSaveRejectionReason; + +export type OnboardingFailureClass = ConnectionEffectFailureClass; + +export type OnboardingFailure = + | { kind: 'rejected'; reason: OnboardingRejectionReason } + | { kind: 'failed'; errorClass: ConnectionEffectFailureClass } + | { kind: 'unavailable' }; + export type OnboardingVerifyResult = | { kind: 'ok'; models: ModelInfo[] } - | { - kind: 'error'; - text: string; - /** The wizard's provider snapshot is outdated (e.g. the targeted - * connection is gone) — retyping the key cannot fix this, so the - * runner shows the text without its retype-the-key framing. */ - stale?: boolean; - }; + | { kind: 'rejected'; reason: OnboardingVerifyRejectionReason } + | { kind: 'failed'; errorClass: ConnectionEffectFailureClass } + | { kind: 'unavailable' }; export interface OnboardingSaveInput { target: ConnectionOnboardingTarget; @@ -104,9 +125,11 @@ export type OnboardingSaveResult = | { kind: 'ok'; connection: OnboardingSavedConnection; - refresh: { kind: 'ok'; modelChoices: ModelChoice[] } | { kind: 'failed'; warning: string }; + refresh: + | { kind: 'ok'; modelChoices: ModelChoice[] } + | { kind: 'failed'; reason: 'catalog_unavailable' }; } - | { kind: 'error'; text: string }; + | OnboardingFailure; export interface MakaOnboardingSurface { listProviders(): Promise; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index dded4439f8..098e4c2133 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -45,7 +45,13 @@ import { } 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 type { + ModelChoice, + OnboardingFailure, + OnboardingFailureClass, + OnboardingProviderEntry, + OnboardingRejectionReason, +} from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; @@ -55,7 +61,9 @@ interface TuiPickerCopy { readonly modelSearchHint: string; readonly searchLabel: string; readonly noMatchingModels: string; + readonly selectPickerHint: string; readonly providerConfigured: string; + readonly addAccount: string; readonly thinkingLevels: Readonly>; readonly defaultThinkingLevel: string; readonly thinkingPickerTitle: string; @@ -65,9 +73,10 @@ interface TuiPickerCopy { readonly baseUrlLabel: string; readonly apiKeyLabel: string; readonly onboardingUnavailable: string; - readonly verifyFailed: string; - readonly setupFailed: string; - readonly saveFailed: string; + readonly onboardingRequestFailed: string; + readonly onboardingRejections: Readonly>; + readonly onboardingFailures: Readonly>; + readonly accountSavedRefreshFailed: string; readonly listProvidersFailed: string; readonly noConfigurableProviders: string; readonly baseUrlRequired: string; @@ -101,6 +110,18 @@ const TUI_PICKER_COPY = resolveUiMessageCatalog( defineUiMessageCatalog()(TUI_COPY_RESOURCES.pickers), ); +export function onboardingFailureMessage(failure: OnboardingFailure, locale: UiLocale): string { + const copy = TUI_PICKER_COPY[locale]; + switch (failure.kind) { + case 'rejected': + return copy.onboardingRejections[failure.reason]; + case 'failed': + return copy.onboardingFailures[failure.errorClass]; + case 'unavailable': + return copy.onboardingRequestFailed; + } +} + export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider | undefined; private readonly slashCommands: readonly MakaSlashCommandMetadata[]; @@ -401,7 +422,7 @@ export class PickerOverlay implements Component { private readonly input: { title: string; rightLabel: string; - hint?: string; + hint: string; notice?: string; onInput?: (data: string) => boolean; }, @@ -420,7 +441,7 @@ export class PickerOverlay implements Component { const safeWidth = Math.max(1, width); return [ padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), - padLine(ansi.dim(this.input.hint ?? 'enter select / esc close'), safeWidth), + padLine(ansi.dim(this.input.hint), safeWidth), ...(this.input.notice ? [padLine(ansi.yellow(this.input.notice), safeWidth)] : []), padLine('', safeWidth), ...this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth)), @@ -1412,9 +1433,10 @@ export class OnboardingWizard implements Component { this.keyEditor.focused = false; this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; - const hint = this.picked?.target.kind === 'existing' - ? this.copy.reuseBaseUrlHint - : this.copy.enterBaseUrlHint; + const hint = + this.picked?.target.kind === 'existing' + ? this.copy.reuseBaseUrlHint + : this.copy.enterBaseUrlHint; return [ padLine( `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 59f2b1f53c..3580b89432 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -151,6 +151,7 @@ import { modelChoiceConnectionLabels, getTuiPickerCopy, modelPickerItems, + onboardingFailureMessage, permissionModePickerItems, skillPickerItems, thinkingLevelPickerItems, @@ -2079,7 +2080,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const picker = new PickerOverlay(list, { title, rightLabel, - hint: options.hint, + hint: options.hint ?? pickerCopy.selectPickerHint, notice: options.notice, }); let overlay: OverlayHandle | undefined; @@ -2129,20 +2130,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void input.onboarding.verify({ target, apiKey, baseUrl: wizardBaseUrl }).then( (result) => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - if (result.kind === 'error') { - // Probe failed: re-arm the key field in place. The host stores nothing - // during verify, so retrying with a corrected key is clean. - // 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 - : formatUiMessage( - pickerCopy.verifyFailed, - { detail: result.text, hasDetail: result.text.trim().length > 0 }, - locale, - ), - ); + if (result.kind !== 'ok') { + wizard.setKeyError(onboardingFailureMessage(result, locale)); requestRender(); return; } @@ -2150,15 +2139,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizard.setModels(result.models); // advance to the models step requestRender(); }, - (error) => { + () => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setKeyError( - formatUiMessage( - pickerCopy.setupFailed, - { detail: error instanceof Error ? error.message : String(error) }, - locale, - ), - ); + wizard.setKeyError(onboardingFailureMessage({ kind: 'unavailable' }, locale)); requestRender(); }, ); @@ -2190,9 +2173,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }) .then( (result) => { - if (result.kind === 'error') { + if (result.kind !== 'ok') { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setModelError(result.text); + wizard.setModelError(onboardingFailureMessage(result, locale)); requestRender(); return; } @@ -2218,19 +2201,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } wizard.setSuccess( enabledModelIds.length, - result.refresh.kind === 'failed' ? result.refresh.warning : undefined, + result.refresh.kind === 'failed' ? pickerCopy.accountSavedRefreshFailed : undefined, ); requestRender(); }, - (error) => { + () => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setModelError( - formatUiMessage( - pickerCopy.saveFailed, - { detail: error instanceof Error ? error.message : String(error) }, - locale, - ), - ); + wizard.setModelError(onboardingFailureMessage({ kind: 'unavailable' }, locale)); requestRender(); }, ); @@ -2241,15 +2218,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (input.onboarding) { try { providers = await input.onboarding.listProviders(); - } catch (error) { + } catch { state.entries.push({ kind: 'notice', level: 'info', - text: formatUiMessage( - pickerCopy.listProvidersFailed, - { detail: error instanceof Error ? error.message : String(error) }, - locale, - ), + text: pickerCopy.listProvidersFailed, }); requestRender(); return; @@ -2260,7 +2233,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providers = listApiKeyOnboardableProviders().map((provider) => ({ ...provider, target: { kind: 'create' as const, providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], })); } diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 0ec2cdd6f1..610d05c766 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -44,15 +44,9 @@ export function createRuntimeHostOnboardingSurface( baseUrl: trimmedOrNull(input.baseUrl), }); if (result.kind === 'verified') return { kind: 'ok', models: [...result.models] }; - return { - kind: 'error', - text: onboardingFailureText(result), - ...(result.kind === 'rejected' && result.reason === 'connection_not_found' - ? { stale: true } - : {}), - }; - } catch (error) { - return { kind: 'error', text: errorText(error) }; + return result; + } catch { + return { kind: 'unavailable' }; } }, save: async (input) => { @@ -64,7 +58,7 @@ export function createRuntimeHostOnboardingSurface( enabledModelIds: [...input.enabledModelIds], }); if (result.kind !== 'saved') { - return { kind: 'error', text: onboardingFailureText(result) }; + return result; } try { return { @@ -86,12 +80,12 @@ export function createRuntimeHostOnboardingSurface( connection: result.connection, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }; } - } catch (error) { - return { kind: 'error', text: errorText(error) }; + } catch { + return { kind: 'unavailable' }; } }, }; @@ -142,7 +136,7 @@ export function projectProviders(catalog: ConnectionCatalogSnapshot): Onboarding entries.push({ ...provider, target: { kind: 'create', providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], }); } @@ -153,33 +147,3 @@ function trimmedOrNull(value: string | undefined): string | null { const secret = value?.trim() ?? ''; return secret.length === 0 ? null : secret; } - -function onboardingFailureText(input: { - readonly kind: 'rejected' | 'failed'; - readonly reason?: string; - readonly errorClass?: string; -}): string { - if (input.kind === 'failed') return `Connection verification failed: ${input.errorClass}`; - switch (input.reason) { - case 'credential_not_configured': - return 'API key is required'; - case 'base_url_not_configured': - return 'A base URL is required for this provider'; - case 'connection_not_found': - return 'The existing connection is gone — reopen /setup and try again'; - case 'superseded': - return 'The connection changed while onboarding — reopen /setup and try again'; - case 'provider_unsupported': - return 'This provider does not support API-key onboarding'; - case 'catalog_full': - return 'The connection catalog is full'; - case 'model_unavailable': - return 'The selected model is no longer available'; - default: - return 'Connection onboarding was rejected'; - } -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 56afb5a4aa..7576f96b38 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -96,7 +96,9 @@ export const TUI_COPY_RESOURCES = { 'Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel', searchLabel: 'Search', noMatchingModels: 'No matching models', + selectPickerHint: '↑↓ select · Enter confirm · Esc close', providerConfigured: 'configured', + addAccount: 'add account', thinkingLevels: { off: 'Off', minimal: 'Minimal', @@ -115,11 +117,27 @@ export const TUI_COPY_RESOURCES = { apiKeyLabel: 'API key', onboardingUnavailable: 'Onboarding is unavailable: this environment does not provide a setup surface.', - verifyFailed: - '{hasDetail, select, true {API key verification failed: {detail}. Check it and try again.} other {API key verification failed. Check it and try again.}}', - setupFailed: 'Setup failed: {detail}', - saveFailed: 'Save failed: {detail}', - listProvidersFailed: 'Could not read configured connections: {detail}', + onboardingRequestFailed: 'Could not reach the Runtime Host. Try again.', + onboardingRejections: { + provider_unsupported: 'This provider does not support API-key onboarding.', + connection_not_found: 'This connection no longer exists. Reopen /setup and try again.', + credential_not_configured: 'An API key is required.', + base_url_not_configured: 'A Base URL is required for this provider.', + catalog_full: 'The connection catalog is full.', + model_unavailable: 'A selected model is no longer available. Verify and select again.', + superseded: 'This connection changed. Reopen /setup and try again.', + }, + onboardingFailures: { + auth: 'Provider authentication failed. Check the API key and try again.', + timeout: 'Provider verification timed out. Try again.', + provider_unavailable: 'The provider is unavailable. Try again later.', + network: 'A network error interrupted provider verification. Try again.', + invalid_response: 'The provider returned an invalid response. Try again.', + unknown: 'Provider verification failed. Try again.', + }, + accountSavedRefreshFailed: + 'Account saved, but the model list has not refreshed. Restart Maka to reload it.', + listProvidersFailed: 'Could not read configured connections.', noConfigurableProviders: 'No configurable API key providers are available.', baseUrlRequired: 'Enter a Base URL', baseUrlInvalid: 'Base URL is not a valid URL', @@ -164,7 +182,9 @@ export const TUI_COPY_RESOURCES = { modelSearchHint: '搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消', searchLabel: '搜索', noMatchingModels: '没有匹配的模型', + selectPickerHint: '↑↓ 选择 · Enter 确认 · Esc 关闭', providerConfigured: '已设置', + addAccount: '添加账号', thinkingLevels: { off: '关', minimal: '最小', @@ -182,11 +202,26 @@ export const TUI_COPY_RESOURCES = { baseUrlLabel: 'Base URL', apiKeyLabel: 'API key', onboardingUnavailable: 'Onboarding 不可用:当前运行环境未提供配置入口。', - verifyFailed: - '{hasDetail, select, true {API key 验证失败:{detail}。请检查后重新输入。} other {API key 验证失败,请检查后重新输入。}}', - setupFailed: '配置失败:{detail}', - saveFailed: '保存失败:{detail}', - listProvidersFailed: '无法读取已配置的连接:{detail}', + onboardingRequestFailed: '无法连接 Runtime Host,请重试。', + onboardingRejections: { + provider_unsupported: '该服务商不支持 API key 配置。', + connection_not_found: '该连接已不存在,请重新打开 /setup 后重试。', + credential_not_configured: '需要填写 API key。', + base_url_not_configured: '该服务商需要填写 Base URL。', + catalog_full: '连接目录已满。', + model_unavailable: '所选模型已不可用,请重新验证并选择。', + superseded: '该连接已发生变化,请重新打开 /setup 后重试。', + }, + onboardingFailures: { + auth: '服务商身份验证失败,请检查 API key 后重试。', + timeout: '服务商验证超时,请重试。', + provider_unavailable: '服务商当前不可用,请稍后重试。', + network: '网络错误中断了服务商验证,请重试。', + invalid_response: '服务商返回了无效响应,请重试。', + unknown: '服务商验证失败,请重试。', + }, + accountSavedRefreshFailed: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + listProvidersFailed: '无法读取已配置的连接。', noConfigurableProviders: '没有可配置的 API key 类供应商。', baseUrlRequired: '需要填写 Base URL', baseUrlInvalid: 'Base URL 不是有效的 URL',