From 85d06052fbee06c87533931febcf68a81f2b0c7a Mon Sep 17 00:00:00 2001 From: Zhou Rui Date: Wed, 4 Mar 2026 04:59:56 +0800 Subject: [PATCH] fix(tui): stabilize regional indicator width to prevent streaming render drift (#1783) --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/utils.ts | 7 +++ ...egression-regional-indicator-width.test.ts | 52 +++++++++++++++++++ packages/tui/test/wrap-ansi.test.ts | 5 ++ 4 files changed, 68 insertions(+) create mode 100644 packages/tui/test/regression-regional-indicator-width.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 0c51e1b1..ca37f7e2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed TUI width calculation for regional indicator symbols (e.g. partial flag sequences like `πŸ‡¨` during streaming) to prevent wrap drift and stale character artifacts in differential rendering. + ## [0.55.4] - 2026-03-02 ## [0.55.3] - 2026-02-27 diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index aeaece04..89b436c4 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -60,6 +60,13 @@ function graphemeWidth(segment: string): number { return 0; } + // Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as + // full-width emoji in terminals, even when isolated during streaming. + // Keep width conservative (2) to avoid terminal auto-wrap drift artifacts. + if (cp >= 0x1f1e6 && cp <= 0x1f1ff) { + return 2; + } + let width = eastAsianWidth(cp); // Trailing halfwidth/fullwidth forms diff --git a/packages/tui/test/regression-regional-indicator-width.test.ts b/packages/tui/test/regression-regional-indicator-width.test.ts new file mode 100644 index 00000000..675ce97f --- /dev/null +++ b/packages/tui/test/regression-regional-indicator-width.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; + +describe("regional indicator width regression", () => { + it("treats partial flag grapheme as full-width to avoid streaming render drift", () => { + // Repro context: + // During streaming, "πŸ‡¨πŸ‡³" often appears as an intermediate "πŸ‡¨" first. + // If "πŸ‡¨" is measured as width 1 while terminal renders it as width 2, + // differential rendering can drift and leave stale characters on screen. + const partialFlag = "πŸ‡¨"; + const listLine = " - πŸ‡¨"; + + assert.strictEqual(visibleWidth(partialFlag), 2); + assert.strictEqual(visibleWidth(listLine), 10); + }); + + it("wraps intermediate partial-flag list line before overflow", () => { + // Width 9 cannot fit " - πŸ‡¨" if πŸ‡¨ is width 2 (8 + 2 = 10). + // This must wrap to avoid terminal auto-wrap mismatch. + const wrapped = wrapTextWithAnsi(" - πŸ‡¨", 9); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(visibleWidth(wrapped[0] || ""), 7); + assert.strictEqual(visibleWidth(wrapped[1] || ""), 2); + }); + + it("treats all regional-indicator singleton graphemes as width 2", () => { + for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) { + const regionalIndicator = String.fromCodePoint(cp); + assert.strictEqual( + visibleWidth(regionalIndicator), + 2, + `Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`, + ); + } + }); + + it("keeps full flag pairs at width 2", () => { + const samples = ["πŸ‡―πŸ‡΅", "πŸ‡ΊπŸ‡Έ", "πŸ‡¬πŸ‡§", "πŸ‡¨πŸ‡³", "πŸ‡©πŸ‡ͺ", "πŸ‡«πŸ‡·"]; + for (const flag of samples) { + assert.strictEqual(visibleWidth(flag), 2, `Expected ${flag} to be width 2`); + } + }); + + it("keeps common streaming emoji intermediates at stable width", () => { + const samples = ["πŸ‘", "πŸ‘πŸ»", "βœ…", "⚑", "⚑️", "πŸ‘¨", "πŸ‘¨β€πŸ’»", "πŸ³οΈβ€πŸŒˆ"]; + for (const sample of samples) { + assert.strictEqual(visibleWidth(sample), 2, `Expected ${sample} to be width 2`); + } + }); +}); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index c747106e..d74a7f51 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -111,6 +111,11 @@ describe("wrapTextWithAnsi", () => { } }); + it("should treat isolated regional indicators as width 2", () => { + assert.strictEqual(visibleWidth("πŸ‡¨"), 2); + assert.strictEqual(visibleWidth("πŸ‡¨πŸ‡³"), 2); + }); + it("should truncate trailing whitespace that exceeds width", () => { const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1); assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);