From 338ce3a365842abe4fe80dc45292ed1109bddc0f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 29 Apr 2026 23:01:04 +0200 Subject: [PATCH] fix(tui): normalize Thai AM terminal output closes #3904 --- packages/tui/src/tui.ts | 4 ++-- packages/tui/src/utils.ts | 20 ++++++++++---------- packages/tui/test/truncate-to-width.test.ts | 15 +++++++++++---- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c5fb8701..fda30282 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -9,7 +9,7 @@ import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js"; -import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; +import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; /** * Component interface - all components must implement this @@ -800,7 +800,7 @@ export class TUI extends Container { for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!isImageLine(line)) { - lines[i] = line + reset; + lines[i] = normalizeTerminalOutput(line) + reset; } } return lines; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index b8be6461..05d1ce50 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -178,16 +178,6 @@ function graphemeWidth(segment: string): number { return 2; } - // Thai SARA AM (U+0E33) and Lao VOWEL SIGN AM (U+0EB3) are encoded as - // spacing letters, but terminals commonly render isolated occurrences as - // mark-like clusters (compat decomposition: nonspacing niggahita + vowel). - // Count isolated clusters conservatively to avoid cursor drift and stale - // cells during differential rendering. When attached to a base consonant, - // the base code point is first and normal per-codepoint width below applies. - if (cp === 0x0e33 || cp === 0x0eb3) { - return 2; - } - let width = eastAsianWidth(cp); // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. @@ -265,6 +255,16 @@ export function visibleWidth(str: string): number { return width; } +/** + * Normalize text for terminal output without changing logical editor content. + * Some terminals render precomposed Thai/Lao AM vowels inconsistently during + * differential repaint. Their compatibility decompositions have the same cell + * width but avoid stale-cell artifacts in terminal renderers. + */ +export function normalizeTerminalOutput(str: string): string { + return str.replace(/\u0e33/g, "\u0e4d\u0e32").replace(/\u0eb3/g, "\u0ecd\u0eb2"); +} + /** * Extract ANSI escape sequences from a string at the given position. */ diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts index 256a8f29..42d93ffa 100644 --- a/packages/tui/test/truncate-to-width.test.ts +++ b/packages/tui/test/truncate-to-width.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { truncateToWidth, visibleWidth } from "../src/utils.js"; +import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.js"; describe("truncateToWidth", () => { it("keeps output within width for very large unicode input", () => { @@ -60,10 +60,17 @@ describe("visibleWidth", () => { assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); }); - it("counts isolated Thai and Lao AM clusters conservatively", () => { - assert.strictEqual(visibleWidth("ำ"), 2); - assert.strictEqual(visibleWidth("ຳ"), 2); + it("keeps Thai and Lao AM clusters at their normal cell width", () => { + assert.strictEqual(visibleWidth("ำ"), 1); + assert.strictEqual(visibleWidth("ຳ"), 1); assert.strictEqual(visibleWidth("กำ"), 2); assert.strictEqual(visibleWidth("ກຳ"), 2); }); + + it("normalizes Thai and Lao AM vowels only for terminal output", () => { + assert.strictEqual(normalizeTerminalOutput("ำ"), "ํา"); + assert.strictEqual(normalizeTerminalOutput("ຳ"), "ໍາ"); + assert.strictEqual(visibleWidth(normalizeTerminalOutput("ำabc")), visibleWidth("ำabc")); + assert.strictEqual(visibleWidth(normalizeTerminalOutput("ຳabc")), visibleWidth("ຳabc")); + }); });