Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ function historicalGraphSnapshot(graphId: string): AgentGraphClientSnapshot {
}

/** Catalog API-key providers as wizard entries with no existing connection —
* the default `/setup` provider list for tests that don't need 已设置 state. */
* the default `/setup` provider list for tests that don't need configured state. */
function defaultOnboardingProviders(): OnboardingProviderEntry[] {
return listApiKeyOnboardableProviders().map((provider) => ({
...provider,
Expand Down Expand Up @@ -501,6 +501,7 @@ describe('Maka Pi TUI runner', () => {
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'bypass',
locale: 'zh',
terminal,
onboarding: fakeOnboardingSurface({
verify: async (input) => {
Expand All @@ -517,7 +518,7 @@ describe('Maka Pi TUI runner', () => {
terminal.input('\r');
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), 'Set Up Provider') !== null;
return latestPlainLineContaining(terminal.writes.join(''), '配置模型提供商') !== null;
} catch {
return false;
}
Expand Down Expand Up @@ -655,7 +656,9 @@ describe('Maka Pi TUI runner', () => {
terminal.input('\r');
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), 'Onboarding 不可用') !== null;
return (
latestPlainLineContaining(terminal.writes.join(''), 'Onboarding is unavailable') !== null
);
} catch {
return false;
}
Expand Down Expand Up @@ -698,7 +701,7 @@ describe('Maka Pi TUI runner', () => {
);
assert.doesNotMatch(
plainTerminalOutput(terminal.screenOutput()),
/没有可配置的 API key 类供应商/,
/No configurable API key providers are available/,
);

process.emit('SIGTERM');
Expand Down Expand Up @@ -758,7 +761,7 @@ describe('Maka Pi TUI runner', () => {
await waitFor(() => verifyCalls.length === 1);
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), '验证') !== null;
return latestPlainLineContaining(terminal.writes.join(''), 'Verifying') !== null;
} catch {
return false;
}
Expand Down Expand Up @@ -855,7 +858,7 @@ describe('Maka Pi TUI runner', () => {
await waitFor(() => saveCalls.length === 1);
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), '保存') !== null;
return latestPlainLineContaining(terminal.writes.join(''), 'Saving') !== null;
} catch {
return false;
}
Expand All @@ -876,7 +879,7 @@ describe('Maka Pi TUI runner', () => {
// would be in this exact frame.
terminal.input('sk-z');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-z'));
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /已启用/);
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /Models enabled/u);

process.emit('SIGTERM');
await Promise.race([
Expand Down Expand Up @@ -931,7 +934,7 @@ describe('Maka Pi TUI runner', () => {
// A malformed endpoint is rejected in place, before any host call.
terminal.input('not a url');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('不是有效的 URL'));
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('not a valid URL'));
for (let i = 0; i < 'not a url'.length; i++) terminal.input('\x7f'); // clear the field
terminal.input('https://relay.example.test/v1');
terminal.input('\r');
Expand All @@ -951,7 +954,7 @@ describe('Maka Pi TUI runner', () => {
terminal.input('\r'); // save
await waitFor(() => saveCalls.length === 1);
assert.equal(saveCalls[0]?.baseUrl, 'https://relay.example.test/v1');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('已启用'));
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Models enabled'));

process.emit('SIGTERM');
await Promise.race([
Expand Down Expand Up @@ -2889,6 +2892,7 @@ describe('Maka Pi TUI runner', () => {
model: 'gpt-5.5',
connectionSlug: 'openai',
providerType: 'openai',
locale: 'en',
modelChoices: [
{
connectionSlug: 'openai',
Expand Down Expand Up @@ -2921,7 +2925,7 @@ describe('Maka Pi TUI runner', () => {
await waitFor(() => terminal.output().includes('GLM 5.2'));
assert.match(
plainTerminalOutput(terminal.screenOutput()),
/切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高/,
/Switching models may rebuild the prompt cache; the next request may be slower/,
);
// The picker opens on the current model (gpt-5.5); move down to the choice on
// the other connection and select it.
Expand Down Expand Up @@ -3003,7 +3007,7 @@ describe('Maka Pi TUI runner', () => {
});
assert.doesNotMatch(
plainTerminalOutput(terminal.screenOutput()),
/切换模型可能需要重建提示缓存/,
/Switching models may rebuild the prompt cache/,
);

// Each query isolates exactly one of the five match criteria named by #1098
Expand Down Expand Up @@ -3083,7 +3087,7 @@ describe('Maka Pi TUI runner', () => {
await waitFor(() => terminal.output().includes('gpt-5.6'));
assert.doesNotMatch(
plainTerminalOutput(terminal.screenOutput()),
/切换模型可能需要重建提示缓存/,
/Switching models may rebuild the prompt cache/,
);

terminal.input('\x1b[B');
Expand Down
168 changes: 168 additions & 0 deletions packages/cli/src/__tests__/tui-copy-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { UI_LOCALES } from '@maka/core/ui-locale';
import { formatTuiCopy, getTuiCopyCatalog, type TuiCopyDomain } from '../tui-copy-catalog.js';

const LOCALES_ROOT = fileURLToPath(new URL('../locales/', import.meta.url));
const COPY_RESOURCES = Object.fromEntries(
readdirSync(LOCALES_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => [entry.name, readLocaleResources(entry.name)]),
) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;

describe('TUI copy resources', () => {
test('loads every supported locale and domain through the registry', () => {
assert.deepEqual(Object.keys(COPY_RESOURCES).sort(), [...UI_LOCALES].sort());
const domains = Object.keys(localeResources('en')).sort() as TuiCopyDomain[];
for (const locale of UI_LOCALES) {
for (const domain of domains) {
assert.deepEqual(getTuiCopyCatalog(domain)[locale], domainResource(locale, domain));
}
}
});

test('keeps every locale structurally aligned by domain', () => {
const reference = localeResources('en');
const domains = Object.keys(reference).sort();
for (const [locale, resources] of Object.entries(COPY_RESOURCES)) {
assert.deepEqual(Object.keys(resources).sort(), domains, locale);
for (const domain of domains) {
assert.deepEqual(
resourcePaths(resources[domain]),
resourcePaths(reference[domain]),
`${locale}/${domain}`,
);
}
}
});

test('keeps interpolation placeholders aligned by domain', () => {
const reference = localeResources('en');
for (const [locale, resources] of Object.entries(COPY_RESOURCES)) {
for (const [domain, resource] of Object.entries(resources)) {
assert.deepEqual(
resourcePlaceholders(resource),
resourcePlaceholders(reference[domain]),
`${locale}/${domain}`,
);
}
}
assert.deepEqual(resourcePlaceholders(domainResource('en', 'mcp-status'), true), {
toolCount: ['count'],
});
assert.deepEqual(resourcePlaceholders(domainResource('en', 'pickers'), true), {
enabledModels: ['count'],
listProvidersFailed: ['detail'],
saveFailed: ['detail'],
selectedModels: ['count'],
selectedModelsAndSave: ['count'],
setupFailed: ['detail'],
verifyFailed: ['detail'],
});
assert.deepEqual(resourcePlaceholders(domainResource('en', 'primary-guidance'), true), {});
assert.deepEqual(resourcePlaceholders(domainResource('en', 'session-status'), true), {});
});

test('fails closed when a required interpolation value is absent', () => {
assert.equal(formatTuiCopy('{count} tools', { count: 3 }), '3 tools');
assert.throws(() => formatTuiCopy('{count} tools', {}), /Missing UI copy placeholder count/u);
});

test('keeps model picker copy locale-specific', () => {
const enPickers = domainResource('en', 'pickers');
const zhPickers = domainResource('zh', 'pickers');
assert.equal(enPickers.modelPickerTitle, 'Select Model');
assert.equal(enPickers.searchLabel, 'Search');
assert.equal(zhPickers.modelPickerTitle, '选择模型');
assert.equal(zhPickers.searchLabel, '搜索');
});
});

function readLocaleResources(locale: string): Readonly<Record<string, unknown>> {
const localeRoot = join(LOCALES_ROOT, locale);
return Object.fromEntries(
readdirSync(localeRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => [
entry.name.slice(0, -'.json'.length),
JSON.parse(readFileSync(join(localeRoot, entry.name), 'utf8')) as unknown,
]),
);
}

function localeResources(locale: string): Readonly<Record<string, unknown>> {
const resources = COPY_RESOURCES[locale];
assert.ok(resources, `missing ${locale} locale`);
return resources;
}

function domainResource(locale: string, domain: string): Readonly<Record<string, unknown>> {
const resource = localeResources(locale)[domain];
assert.ok(
resource && typeof resource === 'object' && !Array.isArray(resource),
`${locale}/${domain}`,
);
return resource as Readonly<Record<string, unknown>>;
}

function resourcePaths(value: unknown, prefix = ''): string[] {
if (Array.isArray(value)) {
return value.flatMap((item, index) => resourcePaths(item, `${prefix}[${index}]`));
}
if (value && typeof value === 'object') {
return Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.flatMap(([key, item]) => resourcePaths(item, prefix ? `${prefix}.${key}` : key));
}
return [prefix];
}

function resourcePlaceholders(
value: unknown,
populatedOnly = false,
): Readonly<Record<string, readonly string[]>> {
const entries = leafEntries(value).map(
([path, text]) =>
[
path,
[...text.matchAll(/\{([A-Za-z][A-Za-z0-9]*)\}/gu)].map((match) => match[1]!).sort(),
] as const,
);
return Object.fromEntries(
populatedOnly ? entries.filter(([, names]) => names.length > 0) : entries,
);
}

function leafEntries(value: unknown, prefix = ''): [string, string][] {
if (Array.isArray(value)) {
return value.flatMap((item, index) => leafEntries(item, `${prefix}[${index}]`));
}
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, item]) =>
leafEntries(item, prefix ? `${prefix}.${key}` : key),
);
}
return [[prefix, String(value)]];
}
26 changes: 26 additions & 0 deletions packages/cli/src/locales/en/mcp-status.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"title": "MCP SERVERS",
"footer": "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q/Esc close",
"unavailableTitle": "This TUI is not connected to a local MCP control plane.",
"unavailableDetail": "Client MCP tool association for remote Runtime Hosts is planned for a later release.",
"loading": "Loading mcp.json and discovering tools…",
"loadError": "MCP configuration could not be loaded; no tools were published to the Runtime Host.",
"noServers": "No MCP servers are configured.",
"publication": {
"waiting": "waiting to publish",
"host_unavailable": "Runtime Host reconnecting",
"publishing": "publishing",
"published": "published",
"not_published": "not published",
"error": "publication failed"
},
"serverState": {
"disabled": "disabled",
"disconnected": "disconnected",
"connecting": "connecting",
"connected": "connected",
"needs-auth": "needs-auth",
"error": "error"
},
"toolCount": "{count} tools"
}
62 changes: 62 additions & 0 deletions packages/cli/src/locales/en/pickers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"modelPickerTitle": "Select Model",
"modelSwitchCacheWarning": "⚠ Switching models may rebuild the prompt cache; the next request may be slower or cost more.",
"modelSearchHint": "Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel",
"searchLabel": "Search",
"noMatchingModels": "No matching models",
"providerConfigured": "configured",
"thinkingLevels": {
"off": "Off",
"minimal": "Minimal",
"low": "Low",
"medium": "Medium",
"high": "High",
"xhigh": "Extra high",
"max": "Maximum"
},
"defaultThinkingLevel": "Default",
"thinkingPickerTitle": "Select Thinking Level",
"currentMarker": "current",
"defaultMarker": "default",
"setupTitle": "Set Up Provider",
"baseUrlLabel": "Base URL",
"apiKeyLabel": "API key",
"onboardingUnavailable": "Onboarding is unavailable: this environment does not provide a setup surface.",
"verifyFailed": "API key verification failed. Check it and try again. {detail}",
"setupFailed": "Setup failed: {detail}",
"saveFailed": "Save failed: {detail}",
"listProvidersFailed": "Could not read configured connections: {detail}",
"noConfigurableProviders": "No configurable API key providers are available.",
"baseUrlRequired": "Enter a Base URL",
"baseUrlInvalid": "Base URL is not a valid URL",
"baseUrlProtocol": "Base URL must use http or https",
"baseUrlCredentials": "Base URL cannot include credentials",
"baseUrlQuery": "Base URL cannot include a query or fragment",
"baseUrlTooLong": "Base URL cannot exceed 2048 bytes",
"selectModelBeforeSaving": "Select at least one model before saving",
"reuseBaseUrlHint": "Leave blank to reuse the saved Base URL, or enter a replacement · Esc returns to provider selection",
"enterBaseUrlHint": "Enter the relay Base URL (http/https) · Esc returns to provider selection",
"continueAction": "Enter to continue",
"providerSearchHint": "Search providers · ↑↓ select · Enter confirm · Esc cancel",
"noMatchingProviders": "No matching providers",
"keyHints": {
"reuse": {
"baseUrl": "Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to Base URL",
"provider": "Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to provider selection"
},
"enter": {
"baseUrl": "Enter an API key · stored only on this machine · Esc returns to Base URL",
"provider": "Enter an API key · stored only on this machine · Esc returns to provider selection"
}
},
"submitAction": "Enter to submit",
"verifyingKey": "Verifying key…",
"modelSelectionHint": "Search models · ↑↓ select · Space toggle · Enter save · Esc back",
"selectedModels": "{count} selected",
"selectedModelsAndSave": "{count} selected · Enter to save",
"saving": "Saving…",
"complete": "Complete",
"enabledModels": "Models enabled: {count}",
"closeAction": "Enter to close",
"thinkingUnsupported": "This model does not support changing the thinking level."
}
Loading
Loading