feat(cli): add Traditional Chinese (zh-TW) as a UI language option (#3569)

* feat(cli): add Traditional Chinese (zh-TW) as a UI language option

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: use upstream unused-keys-only-in-locales.json to resolve conflict

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* revert: remove check-i18n.ts changes to avoid pre-existing zh.js issues

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): add Traditional Chinese (zh-TW) as a UI language option

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): add WITTY_LOADING_PHRASES to zh-TW locale

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): sync zh-TW.js with en.js keys, fix double-escape, fix check-i18n.ts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: resolve conflict in unused-keys-only-in-locales.json

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): add missing Performance translation to zh-TW

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): add quotes to Performance key in zh-TW

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): regenerate zh-TW.js with correct multi-line value parsing

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: resolve conflict in unused-keys-only-in-locales.json

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): regenerate zh-TW.js with correct multi-line value parsing

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): standardize zh-TW.js key quoting and sync zh.js keys

- Convert zh-TW.js keys from double-quoted to single-quoted to match en.js style
- Fix zh.js key mismatches: add missing keys (Value:, No server selected, prompts, required, Enum) and remove extra keys (The name of the extension to update, Session (temporary))
- Regenerate unused-keys-only-in-locales.json

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): update loading phrases when UI language changes

Add getCurrentLanguage() to useMemo deps in usePhraseCycler so that
WITTY_LOADING_PHRASES re-evaluates after a /language switch instead of
staying locked to the language active at mount time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(i18n): normalize locale separators and fix case-insensitive language lookup

- detectSystemLanguage(): normalize POSIX locales (e.g. zh_TW.UTF-8 → zh-tw)
  by replacing underscores with hyphens and lowercasing before matching, so
  users with LANG=zh_TW.UTF-8 correctly detect zh-TW instead of falling
  through to zh
- getLanguageNameFromLocale(): compare codes case-insensitively so that
  normalizeOutputLanguage('zh-TW') resolves to 'Traditional Chinese' instead
  of falling back to 'English'
- Add test cases for zh-TW / zh-tw / ZH-TW in normalizeOutputLanguage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): update getLanguageNameFromLocale mock to include zh-TW

Add 'zh-tw' entry to the mock map and normalize locale input with
toLowerCase() so the mock mirrors the real case-insensitive implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikeWang0316tw 2026-04-24 21:34:46 +08:00 committed by GitHub
parent 609b4324f6
commit 12b26ba063
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1779 additions and 36 deletions

View file

@ -57,15 +57,18 @@ const getLocalePath = (
export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang) {
// Normalize POSIX locales (e.g. zh_TW.UTF-8 → zh-tw) before matching
const normalized = envLang.replace(/_/g, '-').toLowerCase();
for (const lang of SUPPORTED_LANGUAGES) {
if (envLang.startsWith(lang.code)) return lang.code;
if (normalized.startsWith(lang.code.toLowerCase())) return lang.code;
}
}
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
const normalized = locale.replace(/_/g, '-').toLowerCase();
for (const lang of SUPPORTED_LANGUAGES) {
if (locale.startsWith(lang.code)) return lang.code;
if (normalized.startsWith(lang.code.toLowerCase())) return lang.code;
}
} catch {
// Fallback to default

View file

@ -7,6 +7,7 @@
export type SupportedLanguage =
| 'en'
| 'zh'
| 'zh-TW'
| 'ru'
| 'de'
| 'ja'
@ -32,6 +33,12 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
fullName: 'English',
nativeName: 'English',
},
{
code: 'zh-TW',
id: 'zh-TW',
fullName: 'Traditional Chinese',
nativeName: '繁體中文',
},
{
code: 'zh',
id: 'zh-CN',
@ -75,7 +82,8 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
* Used for LLM output language instructions.
*/
export function getLanguageNameFromLocale(locale: SupportedLanguage): string {
const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale);
const lower = locale.toLowerCase();
const lang = SUPPORTED_LANGUAGES.find((l) => l.code.toLowerCase() === lower);
return lang?.fullName || 'English';
}

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
*/
import { useState, useEffect, useRef, useMemo } from 'react';
import { t, ta } from '../../i18n/index.js';
import { t, ta, getCurrentLanguage } from '../../i18n/index.js';
export const WITTY_LOADING_PHRASES: string[] = ["I'm Feeling Lucky"];
@ -23,6 +23,7 @@ export const usePhraseCycler = (
customPhrases?: string[],
) => {
// Get phrases from translations if available
const currentLanguage = getCurrentLanguage();
const loadingPhrases = useMemo(() => {
if (customPhrases && customPhrases.length > 0) {
return customPhrases;
@ -31,7 +32,8 @@ export const usePhraseCycler = (
return translatedPhrases.length > 0
? translatedPhrases
: WITTY_LOADING_PHRASES;
}, [customPhrases]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customPhrases, currentLanguage]);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0],

View file

@ -22,6 +22,7 @@ vi.mock('../i18n/index.js', () => ({
getLanguageNameFromLocale: vi.fn((locale: string) => {
const map: Record<string, string> = {
en: 'English',
'zh-tw': 'Traditional Chinese',
zh: 'Chinese',
ru: 'Russian',
de: 'German',
@ -30,7 +31,7 @@ vi.mock('../i18n/index.js', () => ({
fr: 'French',
es: 'Spanish',
};
return map[locale] || 'English';
return map[locale.toLowerCase()] || 'English';
}),
}));
@ -123,6 +124,12 @@ describe('languageUtils', () => {
expect(normalizeOutputLanguage('Ru')).toBe('Russian');
});
it('should convert "zh-TW" (mixed case) to "Traditional Chinese"', () => {
expect(normalizeOutputLanguage('zh-TW')).toBe('Traditional Chinese');
expect(normalizeOutputLanguage('zh-tw')).toBe('Traditional Chinese');
expect(normalizeOutputLanguage('ZH-TW')).toBe('Traditional Chinese');
});
it('should preserve explicit language names as-is', () => {
expect(normalizeOutputLanguage('Japanese')).toBe('Japanese');
expect(normalizeOutputLanguage('French')).toBe('French');