fix(tui): normalize Thai AM terminal output

closes #3904
This commit is contained in:
Mario Zechner
2026-04-29 23:01:04 +02:00
parent 84b3aa5efc
commit 338ce3a365
3 changed files with 23 additions and 16 deletions

View File

@@ -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;

View File

@@ -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.
*/

View File

@@ -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"));
});
});