Skip to content
Open
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
39 changes: 37 additions & 2 deletions packages/cli/src/__tests__/tui-copy-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { formatUiMessage, UI_LOCALES } from '@maka/core/ui-locale';
import { formatUiMessage, UI_LOCALES, resolveUiMessageCatalog } from '@maka/core/ui-locale';
import { getTuiPickerCopy, onboardingFailureMessage } from '../pi-tui-pickers.js';
import { getTuiPrimaryGuidance } from '../tui-primary-guidance.js';
import { sessionStatusBadge } from '../tui-session-status.js';
import { TUI_COPY_RESOURCES } from '../tui-copy-catalog.js';

const MESSAGE_VALUES = {
Expand Down Expand Up @@ -54,12 +56,45 @@ const MESSAGE_VALUES = {
} as const;

describe('TUI copy resources', () => {
const KO_DIRECT_DOMAINS = ['mcp-status', 'pickers', 'primary-guidance', 'session-status'];

test('registers every domain without a locale-specific getter branch', () => {
for (const [domain, catalog] of Object.entries(TUI_COPY_RESOURCES)) {
for (const locale of UI_LOCALES) assert.ok(catalog[locale], `${domain}/${locale}`);
for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) {
assert.ok(catalog[locale], `${domain}/${locale}`);
}
if (KO_DIRECT_DOMAINS.includes(domain)) {
assert.ok((catalog as Record<string, unknown>).ko, `${domain}/ko direct entry`);
}
assert.ok(resolveUiMessageCatalog(catalog as never).ko, `${domain}/ko`);
}
});

test('ships direct Korean copy for its four domains, not English fallback', () => {
const mcpKo = (TUI_COPY_RESOURCES['mcp-status'] as { ko?: { title?: string } }).ko;
assert.equal(mcpKo?.title, 'MCP 서버');

assert.equal(getTuiPickerCopy('ko').modelPickerTitle, '모델 선택');
assert.equal(getTuiPickerCopy('ko').searchLabel, '검색');

const guidanceKo = getTuiPrimaryGuidance('ko');
assert.equal(guidanceKo.commands.exit, '이 TUI 연결 해제, 연결된 Host는 계속 실행');
assert.ok(
guidanceKo.help.keybindings.some((line) => line.includes('Ctrl+O — 상세 기록 열기')),
'ko keybindings describe the Ctrl+O transcript reader contract',
);

assert.equal(
sessionStatusBadge({ status: 'running', runningTurnIds: ['turn-1'] }, 'ko'),
'실행 중',
);
assert.equal(
sessionStatusBadge({ status: 'blocked', blockedReason: 'NO_REAL_CONNECTION' }, 'ko'),
'연결 필요',
);
assert.equal(sessionStatusBadge({ status: 'aborted' }, 'ko'), '중지됨');
});

test('keeps translated 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));
Expand Down
182 changes: 182 additions & 0 deletions packages/cli/src/tui-copy-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,41 @@ export const TUI_COPY_RESOURCES = {
},
},
},

ko: {
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}개 도구',
},
},
'form-interaction': {
en: {
Expand Down Expand Up @@ -953,6 +988,93 @@ export const TUI_COPY_RESOURCES = {
closeAction: 'Enter 關閉',
thinkingUnsupported: '目前模型不支援思考級別切換。',
},

ko: {
modelPickerTitle: '모델 선택',
modelSwitchCacheWarning:
'⚠ 모델을 전환하면 프롬프트 캐시가 재구축될 수 있어 다음 요청이 더 느리거나 비용이 더 들 수 있습니다.',
modelSearchHint: '모델 / 프로바이더 / 연결 검색 · ↑↓ 선택 · Enter 확인 · Esc 취소',
searchLabel: '검색',
noMatchingModels: '일치하는 모델이 없습니다',
selectPickerHint: '↑↓ 선택 · Enter 확인 · Esc 닫기',
providerConfigured: '구성됨',
addAccount: '계정 추가',
thinkingLevels: {
off: '끔',
minimal: '최소',
low: '낮음',
medium: '중간',
high: '높음',
xhigh: '매우 높음',
max: '최대',
},
defaultThinkingLevel: '기본값',
thinkingPickerTitle: '사고 수준 선택',
currentMarker: '현재',
defaultMarker: '기본값',
setupTitle: '프로바이더 설정',
baseUrlLabel: 'Base URL',
apiKeyLabel: 'API 키',
onboardingUnavailable:
'온보딩을 사용할 수 없습니다. 이 환경에서는 설정 화면을 제공하지 않습니다.',
onboardingRequestFailed: 'Runtime Host에 연결할 수 없습니다. 다시 시도하세요.',
onboardingRejections: {
provider_unsupported: '이 프로바이더는 API 키 온보딩을 지원하지 않습니다.',
connection_not_found: '이 연결을 더 이상 찾을 수 없습니다. /setup을 다시 열어 시도하세요.',
credential_not_configured: 'API 키가 필요합니다.',
base_url_not_configured: '이 프로바이더는 Base URL이 필요합니다.',
catalog_full: '연결 카탈로그가 가득 찼습니다.',
model_unavailable: '선택한 모델을 더 이상 사용할 수 없습니다. 확인 후 다시 선택하세요.',
superseded: '이 연결이 변경되었습니다. /setup을 다시 열어 시도하세요.',
},
onboardingFailures: {
auth: '프로바이더 인증에 실패했습니다. API 키를 확인하고 다시 시도하세요.',
timeout: '프로바이더 확인 시간이 초과되었습니다. 다시 시도하세요.',
provider_unavailable: '프로바이더를 사용할 수 없습니다. 나중에 다시 시도하세요.',
network: '네트워크 오류로 프로바이더 확인이 중단되었습니다. 다시 시도하세요.',
invalid_response: '프로바이더가 잘못된 응답을 반환했습니다. 다시 시도하세요.',
unknown: '프로바이더 확인에 실패했습니다. 다시 시도하세요.',
},
accountSavedRefreshFailed:
'계정은 저장되었지만 모델 목록이 새로고침되지 않았습니다. Maka를 다시 시작하면 다시 불러옵니다.',
listProvidersFailed: '구성된 연결을 읽을 수 없습니다.',
noConfigurableProviders: '구성 가능한 API 키 프로바이더가 없습니다.',
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:
'비워두면 저장된 키를 재사용하거나, 새 키를 입력해 교체하세요 · Esc로 Base URL로 돌아가기',
provider:
'비워두면 저장된 키를 재사용하거나, 새 키를 입력해 교체하세요 · Esc로 프로바이더 선택으로 돌아가기',
},
enter: {
baseUrl: 'API 키 입력 · 이 기기에만 저장됨 · Esc로 Base URL로 돌아가기',
provider: 'API 키 입력 · 이 기기에만 저장됨 · Esc로 프로바이더 선택으로 돌아가기',
},
},
submitAction: 'Enter로 제출',
verifyingKey: '키 확인 중…',
modelSelectionHint: '모델 검색 · ↑↓ 선택 · Space 토글 · Enter 저장 · Esc 뒤로',
selectedModels: '{count}개 선택됨',
selectedModelsAndSave: '{count}개 선택됨 · Enter로 저장',
saving: '저장 중…',
complete: '완료',
enabledModels: '활성화된 모델: {count}개',
closeAction: 'Enter로 닫기',
thinkingUnsupported: '이 모델은 사고 수준 변경을 지원하지 않습니다.',
},
},
'primary-guidance': {
en: {
Expand Down Expand Up @@ -1117,6 +1239,57 @@ export const TUI_COPY_RESOURCES = {
],
},
},

ko: {
welcome: {
tagline: '함께 일을 완수하세요',
start: '메시지를 입력해 시작하세요',
session: '세션을 전환하거나 재개하세요',
model: '모델 전환',
setup: '모델 프로바이더 설정',
},
commands: {
compact: '세션 컨텍스트 압축',
context: '최근 요청의 컨텍스트 사용량 보기',
exit: '이 TUI 연결 해제, 연결된 Host는 계속 실행',
goal: '자율 목표 상태 보기',
graph: 'Graph 모드 보기, 활성화/비활성화 또는 Graph 작업 실행',
help: '명령어와 단축키 보기',
mcp: '클라이언트 MCP 서버 및 도구 게시 상태 관리',
model: '모델 선택',
move: '현재 세션을 다른 디렉터리로 이동',
new: '새 세션 시작',
permissions: '세션 권한 설정',
recap: '지금까지의 세션을 한 문장으로 요약',
rename: '현재 세션 이름 바꾸기',
resume: '안전한 경계에서 최근 중단된 실행 재개',
rewind: '이전 턴으로 되돌리기',
session: '세션 전환 또는 재개',
setup: '모델 프로바이더 설정 (API 키)',
side: '임시 Side Conversation 열기',
skill: '스킬 호출 (또는 /skill:<name> 직접 입력)',
swarm: 'Swarm 모드 보기, 활성화/비활성화 또는 Swarm 작업 실행',
thinking: '사고 수준 설정',
transcript: 'Maka에서 전체 대화 기록 보기',
},
help: {
commandsHeading: '명령어',
userCommand: ' !<command> — 사용자만 볼 수 있는 셸 명령어 한 번 실행',
keybindingsHeading: '단축키',
keybindings: [
' Ctrl+O — 상세 기록 열기 (안에서 Ctrl+E로 상세 전환)',
' Ctrl+T — 보기의 모든 사고 블록 펼치기/접기',
' 터미널이나 트랙패드로 대화 기록 스크롤',
' Enter (턴 실행 중) — 실행 중인 턴에 메시지 주입',
' Alt+Enter (턴 실행 중) — 다음 턴을 위해 메시지 대기열에 추가',
' Alt+↑ — 대기열에 있는 메시지를 편집기로 가져와 다시 편집',
' Esc Esc (턴 실행 중) — 턴 중단',
' Esc Esc (대기 중) — 이전 턴으로 되돌리기',
' Ctrl+C — 턴 중지, 입력 지우기 또는 두 번 눌러 종료',
' Ctrl+D — 입력이 비어 있을 때 종료',
],
},
},
},
rewind: {
en: {
Expand Down Expand Up @@ -1306,5 +1479,14 @@ export const TUI_COPY_RESOURCES = {
signInRequired: '需要重新登入',
stopped: '已中止',
},

ko: {
running: '실행 중',
waitingForUser: '사용자 대기 중',
permissionRequired: '권한 필요',
connectionRequired: '연결 필요',
signInRequired: '로그인 필요',
stopped: '중지됨',
},
},
} as const;
64 changes: 64 additions & 0 deletions packages/core/src/__tests__/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ describe('generalizedErrorMessageForLocale', () => {
const timeout = new Error('request timeout after 30s');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'en'), 'Request timed out');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'zh-CN'), '请求超时');
assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'ko'), '요청 시간 초과');
assert.equal(
generalizedErrorMessageForLocale(new Error('unclassified'), '操作失败', 'zh-CN'),
'操作失败',
Expand All @@ -385,6 +386,10 @@ describe('generalizedErrorMessageForLocale', () => {
generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-TW'),
'網路錯誤',
);
assert.equal(
generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'ko'),
'네트워크 오류',
);
}
});
});
Expand Down Expand Up @@ -480,6 +485,60 @@ describe('generalizedErrorMessageForLocale zh-CN', () => {
});
});

describe('generalizedErrorMessageForLocale ko', () => {
test('maps provider failures to Korean categories without leaking secrets', () => {
for (const [raw, expected] of [
['Request timeout after 30s', '요청 시간 초과'],
['HTTP 429 Too Many Requests', '모델 속도 제한 초과'],
['OpenAI rate limit reached for model gpt-4', '모델 속도 제한 초과'],
['rate exceeded', '모델 속도 제한 초과'],
['401 Unauthorized', '인증 실패'],
['HTTP 403 forbidden', '인증 실패'],
['Authentication failed', '인증 실패'],
['HTTP 500 Internal Server Error', '모델 제공자 오류'],
['Provider returned 503', '모델 제공자 오류'],
['Bad gateway 502', '모델 제공자 오류'],
['fetch failed', '네트워크 오류'],
['ECONNREFUSED', '네트워크 오류'],
['ENOTFOUND api.example.test', '네트워크 오류'],
['network unreachable', '네트워크 오류'],
['something weird happened', '작업에 실패했습니다'],
['401 Authorization: Bearer sk-live-secret-token-value', '인증 실패'],
]) {
const message = generalizedErrorMessageForLocale(new Error(raw), '작업에 실패했습니다', 'ko');
assert.equal(message, expected);
assert.match(message, /[가-힣]/);
assert.doesNotMatch(message, /sk-live-secret-token-value/);
}
assert.equal(
generalizedErrorMessageForLocale('non-Error string input', '작업에 실패했습니다', 'ko'),
'작업에 실패했습니다',
);
});

test('uses a caller-supplied Korean fallback for unknown errors', () => {
assert.equal(
generalizedErrorMessageForLocale(
new Error('something weird happened'),
'세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.',
'ko',
),
'세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.',
);
});

test('does not mistake runtime authority errors for authentication failures', () => {
assert.equal(
generalizedErrorMessageForLocale(
new Error('Conversation copy contains durable runtime authority facts'),
'이 컨텍스트로는 새 세션을 만들 수 없습니다.',
'ko',
),
'이 컨텍스트로는 새 세션을 만들 수 없습니다.',
);
});
});

describe('localized generalized error messages', () => {
test('routes one shared classification through each locale catalog', () => {
const error = new Error('HTTP 503 from provider');
Expand All @@ -489,6 +548,7 @@ describe('localized generalized error messages', () => {
);
assert.equal(generalizedErrorMessageForLocale(error, '後備', 'zh-CN'), '模型服务返回错误');
assert.equal(generalizedErrorMessageForLocale(error, '備援', 'zh-TW'), '模型服務傳回錯誤');
assert.equal(generalizedErrorMessageForLocale(error, '대체 문구', 'ko'), '모델 제공자 오류');
});

test('uses Taiwan terminology for Traditional Chinese categories', () => {
Expand All @@ -508,6 +568,10 @@ describe('localized generalized error messages', () => {
const error = new Error('something weird happened');
assert.equal(generalizedErrorMessageForLocale(error, '简中后备', 'zh-CN'), '简中后备');
assert.equal(generalizedErrorMessageForLocale(error, '繁中備援', 'zh-TW'), '繁中備援');
assert.equal(
generalizedErrorMessageForLocale(error, '한국어 대체 문구', 'ko'),
'한국어 대체 문구',
);
assert.equal(
generalizedErrorMessageForLocale(error, 'English fallback', 'en'),
'English fallback',
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/__tests__/relative-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ describe('relative timestamp labels', () => {

for (const ageMs of [0, 1_000, 30_000, 59_999]) {
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'en'), 'just now');
assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚');
assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전');
}

assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh-CN'), '1分钟前');
assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'ko'), '1분 전');
assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago');
});

Expand All @@ -54,7 +58,7 @@ describe('relative timestamp labels', () => {
});

it('uses scan-friendly units for sidebar timestamps', () => {
for (const locale of ['zh-CN', 'en'] as const) {
for (const locale of ['zh-CN', 'ko', 'en'] as const) {
for (const [ageMs, expected] of [
[60_000, '1min'],
[46 * 60_000, '46min'],
Expand Down
Loading