A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:
- every wide character became `_` (中文测试 → ________)
- `✓` / `⏺` / emoji became `_`
- `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
corners vanished and boxes rendered as loose horizontal lines
The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.
withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.
Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
71 lines
2.6 KiB
TypeScript
71 lines
2.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { withUtf8Locale, DEFAULT_UTF8_LOCALE } from '../src/session/locale.js';
|
|
|
|
/**
|
|
* locale.ts — UTF-8 locale guarantee for the spawned PTY.
|
|
*
|
|
* Why this exists: when the server is launched from Finder / launchd (the Mac
|
|
* desktop app) the process env has NO `LANG`/`LC_*`. tmux then runs in its
|
|
* non-UTF-8 mode and rewrites every wide character to `_` and downgrades
|
|
* double-line box drawing (`═║╔╗`) to DEC Special Graphics single lines — so all
|
|
* CJK output in the Claude Code TUI is destroyed before it ever reaches xterm.
|
|
*/
|
|
describe('withUtf8Locale', () => {
|
|
it('adds LANG when no locale variable is set at all', () => {
|
|
const out = withUtf8Locale({ PATH: '/usr/bin' });
|
|
|
|
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
expect(out.PATH).toBe('/usr/bin');
|
|
});
|
|
|
|
it('keeps a UTF-8 LANG the user already has', () => {
|
|
const out = withUtf8Locale({ LANG: 'zh_CN.UTF-8' });
|
|
|
|
expect(out.LANG).toBe('zh_CN.UTF-8');
|
|
});
|
|
|
|
it('accepts lowercase / hyphen-less spellings of utf8', () => {
|
|
expect(withUtf8Locale({ LANG: 'en_US.utf8' }).LANG).toBe('en_US.utf8');
|
|
expect(withUtf8Locale({ LANG: 'C.UTF8' }).LANG).toBe('C.UTF8');
|
|
});
|
|
|
|
it('replaces a non-UTF-8 LANG (C / POSIX / latin1) with the default', () => {
|
|
expect(withUtf8Locale({ LANG: 'C' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
expect(withUtf8Locale({ LANG: 'POSIX' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
expect(withUtf8Locale({ LANG: 'en_US.ISO8859-1' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
});
|
|
|
|
it('respects LC_ALL — it outranks LANG, so a UTF-8 LC_ALL is enough', () => {
|
|
const out = withUtf8Locale({ LC_ALL: 'ja_JP.UTF-8', LANG: 'C' });
|
|
|
|
expect(out.LC_ALL).toBe('ja_JP.UTF-8');
|
|
expect(out.LANG).toBe('C'); // untouched — LC_ALL already decides the charset
|
|
});
|
|
|
|
it('respects a UTF-8 LC_CTYPE (macOS Terminal.app sets only this)', () => {
|
|
const out = withUtf8Locale({ LC_CTYPE: 'UTF-8' });
|
|
|
|
expect(out.LC_CTYPE).toBe('UTF-8');
|
|
expect(out.LANG).toBeUndefined();
|
|
});
|
|
|
|
it('overrides LANG when LC_CTYPE is present but not UTF-8', () => {
|
|
const out = withUtf8Locale({ LC_CTYPE: 'C' });
|
|
|
|
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
});
|
|
|
|
it('does not mutate the input env (immutability)', () => {
|
|
const input = { PATH: '/usr/bin' };
|
|
const out = withUtf8Locale(input);
|
|
|
|
expect(input).toEqual({ PATH: '/usr/bin' });
|
|
expect(out).not.toBe(input);
|
|
});
|
|
|
|
it('ignores an empty-string locale (unset in practice)', () => {
|
|
expect(withUtf8Locale({ LANG: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
expect(withUtf8Locale({ LC_ALL: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
|
|
});
|
|
});
|