fix: CJK/Unicode wide character support (#13)

Set UTF-8 locale in all PTY spawn environments, add xterm-addon-unicode11
for proper double-width character measurement, and update LocalEchoOverlay
helpers to respect CJK character visual width in positioning, line wrapping,
and cursor placement.

Based on PR #13 by @TeigenZhang, adapted to current codebase structure.

Co-Authored-By: Tenggan Zhang <TeigenZhang@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-24 16:13:09 +01:00
co-authored by Tenggan Zhang Claude Opus 4.6
parent 5170c188c3
commit 3c29b1fa6e
6 changed files with 94 additions and 17 deletions
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claudeman",
"version": "0.1600",
"version": "0.1618",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claudeman",
"version": "0.1600",
"version": "0.1618",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -20,6 +20,7 @@
"uuid": "^10.0.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6"
},
@@ -4669,6 +4670,16 @@
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-unicode11": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz",
"integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-webgl": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
+2 -1
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"postinstall": "node scripts/postinstall.js",
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k -f \"$f\" && { brotli -9 -k -f \"$f\" 2>/dev/null || true; }; done",
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k -f \"$f\" && { brotli -9 -k -f \"$f\" 2>/dev/null || true; }; done",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"web": "node dist/index.js web",
@@ -47,6 +47,7 @@
"uuid": "^10.0.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6"
},
+8 -2
View File
@@ -949,7 +949,7 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
});
// Set claudeSessionId immediately since we passed --session-id to Claude
@@ -1016,6 +1016,8 @@ export class Session extends EventEmitter {
cwd: this.workingDir,
env: {
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
COLORTERM: undefined,
@@ -1295,7 +1297,7 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
});
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
@@ -1330,6 +1332,8 @@ export class Session extends EventEmitter {
cwd: this.workingDir,
env: {
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
TERM: 'xterm-256color',
CLAUDEMAN_MUX: '1',
CLAUDEMAN_SESSION_ID: this.id,
@@ -1458,6 +1462,8 @@ export class Session extends EventEmitter {
cwd: this.workingDir,
env: {
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
COLORTERM: undefined,
+4
View File
@@ -265,6 +265,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset CLAUDECODE',
'unset COLORTERM',
'export CLAUDEMAN_MUX=1',
@@ -431,6 +433,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const claudeDir = findClaudeDir();
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset CLAUDECODE',
'unset COLORTERM',
'export CLAUDEMAN_MUX=1',
+66 -12
View File
@@ -933,8 +933,39 @@ function _zl_readTextAfterPrompt(terminal, prompt, offset) {
} catch { return ''; }
}
// Get visual cell width of a character (CJK wide chars = 2, others = 1)
function _zl_charCellWidth(ch, terminal) {
if (terminal?.unicode?.getStringCellWidth) {
return terminal.unicode.getStringCellWidth(ch);
}
const code = ch.codePointAt(0);
if (code >= 0x1100 && (
(code <= 0x115F) || // Hangul Jamo
(code >= 0x2E80 && code <= 0x303E) || // CJK Radicals, Kangxi, Ideographic
(code >= 0x3040 && code <= 0x33BF) || // Hiragana, Katakana, Bopomofo, CJK Compat
(code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Ext A
(code >= 0x4E00 && code <= 0xA4CF) || // CJK Unified, Yi
(code >= 0xA960 && code <= 0xA97C) || // Hangul Jamo Extended-A
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul Syllables
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat Ideographs
(code >= 0xFE30 && code <= 0xFE6F) || // CJK Compat Forms
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth Forms
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth Signs
(code >= 0x1F000 && code <= 0x1FBFF) || // Mahjong, Domino, Emoji
(code >= 0x20000 && code <= 0x2FFFF) || // CJK Unified Ext B-F
(code >= 0x30000 && code <= 0x3FFFF) // CJK Unified Ext G+
)) return 2;
return 1;
}
function _zl_stringCellWidth(str, terminal) {
let w = 0;
for (const ch of str) w += _zl_charCellWidth(ch, terminal);
return w;
}
function _zl_renderOverlay(container, params) {
const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor } = params;
const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor, terminal } = params;
container.style.left = '0px';
container.style.top = (promptRow * cellH) + 'px';
container.innerHTML = '';
@@ -943,13 +974,13 @@ function _zl_renderOverlay(container, params) {
const leftPx = i === 0 ? startCol * cellW : 0;
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
const topPx = i * cellH;
const lineEl = _zl_makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font);
const lineEl = _zl_makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font, terminal);
container.appendChild(lineEl);
}
if (showCursor) {
const lastLine = lines[lines.length - 1];
const lastLineLeft = lines.length === 1 ? startCol : 0;
const cursorCol = lastLineLeft + lastLine.length;
const cursorCol = lastLineLeft + _zl_stringCellWidth(lastLine, terminal);
if (cursorCol < totalCols) {
const cursor = document.createElement('span');
cursor.style.cssText = 'position:absolute;display:inline-block';
@@ -964,7 +995,7 @@ function _zl_renderOverlay(container, params) {
container.style.display = '';
}
function _zl_makeLine(text, leftPx, topPx, widthPx, cellH, cellW, font) {
function _zl_makeLine(text, leftPx, topPx, widthPx, cellH, cellW, font, terminal) {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
el.style.backgroundColor = font.backgroundColor;
@@ -973,18 +1004,21 @@ function _zl_makeLine(text, leftPx, topPx, widthPx, cellH, cellW, font) {
el.style.width = widthPx + 'px';
el.style.height = (cellH + 1) + 'px';
el.style.lineHeight = cellH + 'px';
for (let i = 0; i < text.length; i++) {
let colOffset = 0;
for (const ch of text) {
const charWidth = _zl_charCellWidth(ch, terminal);
const span = document.createElement('span');
span.style.cssText = "position:absolute;display:inline-block;text-align:center;pointer-events:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
span.style.left = (i * cellW) + 'px';
span.style.width = cellW + 'px';
span.style.left = (colOffset * cellW) + 'px';
span.style.width = (charWidth * cellW) + 'px';
span.style.fontFamily = font.fontFamily;
span.style.fontSize = font.fontSize;
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
span.textContent = text[i];
span.textContent = ch;
el.appendChild(span);
colOffset += charWidth;
}
return el;
}
@@ -1192,11 +1226,15 @@ class ZerolagInputAddon {
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
const firstLineCols = Math.max(1, totalCols - startCol);
const lines = []; let remaining = displayText;
lines.push(remaining.slice(0, firstLineCols)); remaining = remaining.slice(firstLineCols);
while (remaining.length > 0) { lines.push(remaining.slice(0, totalCols)); remaining = remaining.slice(totalCols); }
const lines = []; const chars = [...displayText]; let ci = 0;
{ let lineStr = '', lineCols = 0;
while (ci < chars.length) { const cw = _zl_charCellWidth(chars[ci], this._terminal); if (lineCols + cw > firstLineCols) break; lineStr += chars[ci]; lineCols += cw; ci++; }
lines.push(lineStr); }
while (ci < chars.length) { let lineStr = '', lineCols = 0;
while (ci < chars.length) { const cw = _zl_charCellWidth(chars[ci], this._terminal); if (lineCols + cw > totalCols) break; lineStr += chars[ci]; lineCols += cw; ci++; }
lines.push(lineStr); }
const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? _ZL_DEFAULT_CURSOR;
_zl_renderOverlay(this._overlay, { lines, startCol, totalCols, cellW, cellH, promptRow: activePrompt.row, font: this._font, showCursor: this._options.showCursor, cursorColor });
_zl_renderOverlay(this._overlay, { lines, startCol, totalCols, cellW, cellH, promptRow: activePrompt.row, font: this._font, showCursor: this._options.showCursor, cursorColor, terminal: this._terminal });
} catch {
if (this._overlay) { this._overlay.innerHTML = ''; this._overlay.style.display = 'none'; }
}
@@ -2010,6 +2048,14 @@ class ClaudemanApp {
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
this.terminal.loadAddon(unicode11Addon);
this.terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed — default Unicode handling used */ }
}
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
@@ -14365,6 +14411,14 @@ class ClaudemanApp {
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed */ }
}
try {
terminal.open(body);
} catch (err) {
+1
View File
@@ -18,6 +18,7 @@
<script defer src="vendor/xterm.min.js"></script>
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->