diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 2f1d90ce984..18b93847f26 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -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 = { @@ -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).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)); diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 9272b0b0c18..d43a6e45ec4 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -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: { @@ -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: { @@ -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: 직접 입력)', + swarm: 'Swarm 모드 보기, 활성화/비활성화 또는 Swarm 작업 실행', + thinking: '사고 수준 설정', + transcript: 'Maka에서 전체 대화 기록 보기', + }, + help: { + commandsHeading: '명령어', + userCommand: ' ! — 사용자만 볼 수 있는 셸 명령어 한 번 실행', + keybindingsHeading: '단축키', + keybindings: [ + ' Ctrl+O — 상세 기록 열기 (안에서 Ctrl+E로 상세 전환)', + ' Ctrl+T — 보기의 모든 사고 블록 펼치기/접기', + ' 터미널이나 트랙패드로 대화 기록 스크롤', + ' Enter (턴 실행 중) — 실행 중인 턴에 메시지 주입', + ' Alt+Enter (턴 실행 중) — 다음 턴을 위해 메시지 대기열에 추가', + ' Alt+↑ — 대기열에 있는 메시지를 편집기로 가져와 다시 편집', + ' Esc Esc (턴 실행 중) — 턴 중단', + ' Esc Esc (대기 중) — 이전 턴으로 되돌리기', + ' Ctrl+C — 턴 중지, 입력 지우기 또는 두 번 눌러 종료', + ' Ctrl+D — 입력이 비어 있을 때 종료', + ], + }, + }, }, rewind: { en: { @@ -1306,5 +1479,14 @@ export const TUI_COPY_RESOURCES = { signInRequired: '需要重新登入', stopped: '已中止', }, + + ko: { + running: '실행 중', + waitingForUser: '사용자 대기 중', + permissionRequired: '권한 필요', + connectionRequired: '연결 필요', + signInRequired: '로그인 필요', + stopped: '중지됨', + }, }, } as const; diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index 10eaec614bc..3faf43957b6 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -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'), '操作失败', @@ -385,6 +386,10 @@ describe('generalizedErrorMessageForLocale', () => { generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-TW'), '網路錯誤', ); + assert.equal( + generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'ko'), + '네트워크 오류', + ); } }); }); @@ -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'); @@ -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', () => { @@ -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', diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts index 9cb6cc031de..99a70361e8b 100644 --- a/packages/core/src/__tests__/relative-time.test.ts +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -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'); }); @@ -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'], diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index 1404b67ad2c..9008521fb57 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -79,6 +79,16 @@ describe('formatToolInvocationLine', () => { ), '選哪個方案? 等 2 問', ); + assert.equal( + formatToolInvocationLine( + { + toolName: 'AskUserQuestion', + args: { questions: [{ question: '어느 방안을 고를까요?' }, { question: '계속할까요?' }] }, + }, + 'ko', + ), + '어느 방안을 고를까요? 외 1개', + ); }); it('keeps the ScheduledTask title headline', () => { @@ -184,6 +194,11 @@ describe('projectToolArgsPreview', () => { assert.ok(line !== undefined); assert.match(line, /后台终端交互/); assert.match(line, /80x24/); + + const koLine = formatToolInvocationLine({ toolName: 'WriteStdin', args: preview }, 'ko'); + assert.ok(koLine !== undefined); + assert.match(koLine, /백그라운드 터미널 상호작용/); + assert.match(koLine, /80x24/); }); it('returns undefined when nothing displayable exists', () => { diff --git a/packages/core/src/__tests__/ui-locale.test.ts b/packages/core/src/__tests__/ui-locale.test.ts index 33046579254..cccfa3bf7ea 100644 --- a/packages/core/src/__tests__/ui-locale.test.ts +++ b/packages/core/src/__tests__/ui-locale.test.ts @@ -34,14 +34,18 @@ import { describe('UI locale', () => { it('accepts only the supported resolved locales and preferences', () => { - assert.equal(['zh-CN', 'zh-TW', 'en'].every(isUiLocale), true); + assert.equal(['zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocale), true); assert.equal(isUiLocale('zh'), false); - assert.equal(['auto', 'zh-CN', 'zh-TW', 'en'].every(isUiLocalePreference), true); + assert.equal(isUiLocale('ko-KR'), false); + assert.equal(['auto', 'zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocalePreference), true); }); it('normalizes the legacy persisted preference without widening the locale contract', () => { assert.equal(normalizeUiLocalePreference('zh'), 'zh-CN'); assert.equal(normalizeUiLocalePreference('zh-TW'), 'zh-TW'); + assert.equal(normalizeUiLocalePreference('ko'), 'ko'); + assert.equal(normalizeUiLocalePreference('auto'), 'auto'); + assert.equal(normalizeUiLocalePreference('ko-KR'), 'auto'); assert.equal(normalizeUiLocalePreference('unsupported'), 'auto'); }); @@ -54,6 +58,17 @@ describe('UI locale', () => { [['zh-HK'], 'zh-TW'], [['zh_MO'], 'zh-TW'], [['zh_TW.UTF-8'], 'zh-TW'], + [['ko'], 'ko'], + [['ko-KR'], 'ko'], + [['ko_KR'], 'ko'], + [['KO-Kr'], 'ko'], + [['ko_KR.UTF-8'], 'ko'], + [['ko-Kore-KR'], 'ko'], + [['ko', 'ko-KR', 'en'], 'ko'], + [['en', 'ko'], 'en'], + [['ko', 'en'], 'ko'], + [['fr-FR', 'ko-KR'], 'ko'], + [['kok-IN'], 'en'], [['fr-FR', 'en-US'], 'en'], [[], 'en'], ] as const) { @@ -64,18 +79,41 @@ describe('UI locale', () => { it('resolves explicit preferences and overrides before the system locale', () => { assert.equal(resolveUiLocale('auto', 'zh-TW'), 'zh-TW'); + assert.equal(resolveUiLocale('auto', 'ko'), 'ko'); assert.equal(resolveUiLocale('zh-CN', 'zh-TW'), 'zh-CN'); + assert.equal(resolveUiLocale('ko', 'en'), 'ko'); + assert.equal(resolveUiLocale('en', 'ko'), 'en'); assert.equal(resolveUiLocale('zh-CN', 'zh-CN', 'en'), 'en'); + assert.equal(resolveUiLocale('auto', 'ko', 'zh-TW'), 'zh-TW'); }); it('keeps every locale guard and formatter in step with UI_LOCALES', () => { + // `ko` is the first locale whose Intl tag is not its own name, so this can + // no longer assert identity. Pinning the table keeps a locale added later + // from silently reaching `Intl` without a deliberate tag. + const intlTags: Record<(typeof UI_LOCALES)[number], string> = { + 'zh-CN': 'zh-CN', + 'zh-TW': 'zh-TW', + ko: 'ko-KR', + en: 'en', + }; for (const locale of UI_LOCALES) { assert.ok(isUiLocale(locale), locale); assert.equal(resolveSystemUiLocale([locale]), locale); - assert.equal(uiLocaleToIntlLocale(locale), locale); + assert.equal(uiLocaleToIntlLocale(locale), intlTags[locale]); } const intlLocales = UI_LOCALES.map(uiLocaleToIntlLocale); assert.equal(new Set(intlLocales).size, UI_LOCALES.length); + for (const tag of intlLocales) { + assert.equal(new Intl.Locale(tag).baseName, tag, tag); + } + }); + + it('maps ko onto the region-qualified Intl tag', () => { + assert.equal(uiLocaleToIntlLocale('ko'), 'ko-KR'); + assert.equal(uiLocaleToIntlLocale('en'), 'en'); + assert.equal(uiLocaleToIntlLocale('zh-CN'), 'zh-CN'); + assert.equal(uiLocaleToIntlLocale('zh-TW'), 'zh-TW'); }); }); @@ -87,12 +125,14 @@ describe('UI message catalogs', () => { }>()({ en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, 'zh-CN': { title: '状态', detail: { ready: '就绪' } }, + ko: { title: '상태', detail: { ready: '준비됨' } }, }); assert.deepEqual(resolveUiMessageCatalog(catalog), { en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, 'zh-CN': { title: '状态', detail: { ready: '就绪', waiting: 'Waiting' } }, 'zh-TW': { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + ko: { title: '상태', detail: { ready: '준비됨', waiting: 'Waiting' } }, }); }); @@ -101,6 +141,11 @@ describe('UI message catalogs', () => { assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); assert.equal(formatUiMessage(template, { count: 3 }, 'en'), '3 tools'); + + // Korean has one plural form; both counts take the `other` branch. + const koTemplate = '{count, plural, other {도구 #개}}'; + assert.equal(formatUiMessage(koTemplate, { count: 1 }, 'ko'), '도구 1개'); + assert.equal(formatUiMessage(koTemplate, { count: 3 }, 'ko'), '도구 3개'); }); it('fails soft for missing or inherited interpolation values', () => { diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index b84f55d0953..2de87a6f03f 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -278,6 +278,13 @@ export const GENERALIZED_ERROR_COPY = { provider_error: 'Provider returned an error', network_error: 'Network error', }, + ko: { + timeout: '요청 시간 초과', + rate_limited: '모델 속도 제한 초과', + auth_failed: '인증 실패', + provider_error: '모델 제공자 오류', + network_error: '네트워크 오류', + }, } satisfies UiCatalog>; export function generalizedErrorMessageForLocale( diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index e2d41d4fdd8..4d03e8f613c 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -51,6 +51,7 @@ const JUST_NOW: UiCatalog = { 'zh-CN': '刚刚', 'zh-TW': '剛剛', en: 'just now', + ko: '방금 전', }; /** Future timestamps are treated as age zero and therefore display as just now. */ diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 760d8990d46..46505563c82 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -82,6 +82,16 @@ const STRINGS_BY_LOCALE: Record = { bytes: (n) => `${n} bytes`, moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''), }, + ko: { + backgroundTerminal: '백그라운드 터미널 상호작용', + empty: '(비어 있음)', + done: '완료', + notDone: '미완료', + replacements: (n) => `${n}군데`, + written: '기록됨', + bytes: (n) => `${n}바이트`, + moreQuestions: (total) => (total > 1 ? ` 외 ${total - 1}개` : ''), + }, }; function strings(locale: UiLocale): QuietPreviewStrings { diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 2272506d792..ba3aceaad2b 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -20,7 +20,7 @@ import { IntlMessageFormat } from 'intl-messageformat'; /** Resolved locales supported by human-facing Maka clients. */ -export const UI_LOCALES = ['zh-CN', 'zh-TW', 'en'] as const; +export const UI_LOCALES = ['zh-CN', 'zh-TW', 'ko', 'en'] as const; export type UiLocale = (typeof UI_LOCALES)[number]; @@ -120,7 +120,7 @@ function isMessageRecord(value: unknown): value is Readonly