From 3c29b1fa6e6689cfe2fbbd8b2c84aeb965f52004 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 24 Feb 2026 16:13:09 +0100 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 15 +++++++- package.json | 3 +- src/session.ts | 10 ++++- src/tmux-manager.ts | 4 ++ src/web/public/app.js | 78 +++++++++++++++++++++++++++++++++------ src/web/public/index.html | 1 + 6 files changed, 94 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9493ab33..197904e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 52cee7d8..cb518489 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/session.ts b/src/session.ts index d27db4ac..015a1a86 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 253e8dde..bcd6470a 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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', diff --git a/src/web/public/app.js b/src/web/public/app.js index 66ef485e..95a5dc01 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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) { diff --git a/src/web/public/index.html b/src/web/public/index.html index a5f6d031..4f5f210d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -18,6 +18,7 @@ +