From a193ad8d1f5d00886ec4d182d9fd0b5a445c9c9c Mon Sep 17 00:00:00 2001 From: heeoneie <68272931+heeoneie@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:01:03 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(i18n-ko):=20core=20=E2=80=94=20add=20k?= =?UTF-8?q?o=20to=20UiLocale=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreate the reviewed core layer from #5011 on the official integration stack. Add Korean to the closed UI locale vocabulary, system resolution, and Intl mapping; complete the three @maka/core UI catalogs required by that strict contract. Reviewed source: #5011 at 36a4377e66d3e2b61f722543dea73a43e8a52344. Refs #3975 Generated-by: Claude Code Co-authored-by: heeoneie <68272931+heeoneie@users.noreply.github.com> --- packages/core/src/__tests__/redaction.test.ts | 64 +++++++++++++++++++ .../core/src/__tests__/relative-time.test.ts | 6 +- .../src/__tests__/tool-quiet-preview.test.ts | 15 +++++ packages/core/src/__tests__/ui-locale.test.ts | 51 ++++++++++++++- packages/core/src/redaction.ts | 7 ++ packages/core/src/relative-time.ts | 1 + packages/core/src/tool-quiet-preview.ts | 10 +++ packages/core/src/ui-locale.ts | 19 ++++-- 8 files changed, 164 insertions(+), 9 deletions(-) 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 Date: Mon, 31 Aug 2026 18:51:36 +0800 Subject: [PATCH 2/3] feat(cli): add Korean catalog for TUI Add ko entries for four TUI domains (mcp-status, pickers, primary-guidance, session-status) in tui-copy-catalog.ts, following #2672 UiCatalog contract and #3974 ko split. Dictionaries are added off the enabled UiLocale union (TUI_COPY_RESOURCES as const), so the change lands without widening UI_LOCALES and without touching Desktop/ui catalogs. Core locale enable (UI_LOCALES, resolveSystemUiLocale) will be done atomically in #3975. Fixes #3979 Generated-by: pi-coding-agent --- .../src/__tests__/tui-copy-catalog.test.ts | 7 +- packages/cli/src/tui-copy-catalog.ts | 182 ++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 2f1d90ce984..b84ed7b9da0 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -19,7 +19,7 @@ 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 { TUI_COPY_RESOURCES } from '../tui-copy-catalog.js'; @@ -56,7 +56,10 @@ const MESSAGE_VALUES = { describe('TUI copy resources', () => { 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}`); + } + assert.ok(resolveUiMessageCatalog(catalog as never).ko, `${domain}/ko`); } }); diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 9272b0b0c18..8c73657e8eb 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: 'Maka 종료', + 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+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; From 120af67bc09ca2087770b2f4d5e7590c44d09243 Mon Sep 17 00:00:00 2001 From: Xmon Dai Date: Wed, 16 Sep 2026 18:29:15 +0800 Subject: [PATCH 3/3] fix(cli): correct ko guidance contracts and assert direct ko copy Address review: /exit now states the TUI disconnects while the Host keeps running, and the Ctrl+O binding describes opening the detailed transcript instead of expanding tool output. The catalog test asserts direct ko entries for its four domains plus concrete Korean getter output, so removing the dictionaries fails the test instead of silently falling back to English. Generated-by: pi-coding-agent --- .../src/__tests__/tui-copy-catalog.test.ts | 32 +++++++++++++++++++ packages/cli/src/tui-copy-catalog.ts | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index b84ed7b9da0..18b93847f26 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -21,6 +21,8 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; 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,15 +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 ['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 8c73657e8eb..d43a6e45ec4 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -1251,7 +1251,7 @@ export const TUI_COPY_RESOURCES = { commands: { compact: '세션 컨텍스트 압축', context: '최근 요청의 컨텍스트 사용량 보기', - exit: 'Maka 종료', + exit: '이 TUI 연결 해제, 연결된 Host는 계속 실행', goal: '자율 목표 상태 보기', graph: 'Graph 모드 보기, 활성화/비활성화 또는 Graph 작업 실행', help: '명령어와 단축키 보기', @@ -1277,7 +1277,7 @@ export const TUI_COPY_RESOURCES = { userCommand: ' ! — 사용자만 볼 수 있는 셸 명령어 한 번 실행', keybindingsHeading: '단축키', keybindings: [ - ' Ctrl+O — 모든 도구 출력 펼치기/접기', + ' Ctrl+O — 상세 기록 열기 (안에서 Ctrl+E로 상세 전환)', ' Ctrl+T — 보기의 모든 사고 블록 펼치기/접기', ' 터미널이나 트랙패드로 대화 기록 스크롤', ' Enter (턴 실행 중) — 실행 중인 턴에 메시지 주입',