diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 59ae91115..7c664e057 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -20,7 +20,7 @@
- Changed the plan-mode active prompt (`prompts/system/plan-mode-active.md`) to make plans decision-complete and cut filler. Added an Objective framing ("another engineer can execute end-to-end without making a single design decision"), a shared "Resolving Unknowns" section (explore discoverable facts before asking; reserve `ask` for non-derivable preferences/tradeoffs with 2–4 options + a recommended default), and a single shared "The Plan" structure (Context / Approach grouped by behavior not file-by-file / ≤5 Critical files / Verification / Assumptions) that replaces the per-branch structure guidance previously duplicated across the iterative and parallel workflows. Added explicit prohibitions on sections that decide nothing (Non-Goals, Out of Scope, Alternatives Considered, Risks/Mitigations boilerplate, Future Work), on enumerating every file/line, and on inventing schema/validation/precedence policy the request never established.
- Changed completion notifications (`completion.notify`) to fire whenever the agent yields its turn, including in the foreground. The `agent_end` notification was previously gated behind background mode (`isBackgrounded`), so an ordinary foreground turn never emitted one; the gate is gone and the desktop toast now fires on every normal turn completion (still skipped for aborted/error turns and when `completion.notify` is `off`).
- Changed the in-progress `task` tool block to keep the shared `context` brief (`# Goal` / `# Constraints` background) visible after the first progress snapshot arrives, instead of dropping it the moment the streaming call view was replaced by the result frame, and to stop animating a spinner/clock next to the `Task` frame header while running — the per-agent body lines already carry their own running spinner, so the header now shows a static state icon (matching the completed/failed header icons). The context is rendered through a shared `buildContextSection` helper that also undoes per-field double-encoding, so the brief reads cleanly in the result frame even though `renderResult` receives the raw (un-repaired) tool args.
-- Changed the label shown when you press Esc to interrupt a streaming turn from the ambiguous `Operation aborted` to `Interrupted by user`, so a deliberate user interrupt no longer reads like an internal failure. The Esc handler threads the reason through `AgentSession.abort({ reason })` → `Agent.abort(reason)` so it rides the `AbortController` onto the aborted assistant message's `errorMessage` and renders verbatim on both the live and replay paths; aborts that carry no reason still fall back to the retry-aware `Operation aborted` generic. The transcript label resolution is centralized in `resolveAbortLabel` (`session/messages.ts`).
+- Changed the messaging shown when you press Esc to interrupt a streaming turn from the ambiguous `Operation aborted` / `Tool execution was aborted: Request was aborted` to `Interrupted by user`, so a deliberate user interrupt no longer reads like an internal failure. Every Esc/flush interrupt path (`onEscape` while streaming, the queued-message restore-and-abort path, and the empty-submit queue flush) threads the reason through `AgentSession.abort({ reason })` → `Agent.abort(reason)` so it rides the `AbortController` onto the aborted assistant message's `errorMessage`; the turn label renders it verbatim on both the live and replay paths, and the synthetic placeholder results paired with in-flight tool calls now read `Tool execution was aborted: Interrupted by user`. Aborts that carry no reason still fall back to the retry-aware `Operation aborted` generic. Transcript label resolution is centralized in `resolveAbortLabel` (`session/messages.ts`).
### Fixed
diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md
index 038704ff8..eb1cbd501 100644
--- a/packages/tui/CHANGELOG.md
+++ b/packages/tui/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+
### Breaking Changes
- Removed Kitty temp-file image transmission, its startup support probe, the `PI_KITTY_IMAGE_TRANSMISSION` override, and the temp-file helper exports. Kitty/Ghostty image payloads now stay on in-band base64 before placeholder/direct placement, avoiding blank first renders from temp-file load races.
@@ -23,6 +24,8 @@
### Fixed
+- Fixed `visibleWidth()` so terminal column measurements for ANSI and OSC text now match the native truncation/wrapping helpers, including OSC 66 text-sizing spans being counted at their scaled payload width
+- Fixed cursor, padding, and line-fit behavior when strings contain tabs or OSC escapes by aligning `visibleWidth()` with the native text-width model
- Fixed the transcript — or a re-appearing prior view such as the welcome screen — duplicating itself on terminals without a scroll-position oracle (Ghostty/kitty/iTerm/WezTerm) when a foreground tool completes by rewriting a partly-committed block, or when the transcript is reset. A non-destructive viewport repaint no longer re-paints rows that are byte-identical to what is already committed to native scrollback into the active grid; the repaint anchor is clamped to the committed-and-unchanged prefix (`min(firstChanged, scrollbackHighWater)`).
## [15.10.0] - 2026-06-06
diff --git a/packages/tui/bench/width.ts b/packages/tui/bench/width.ts
index 9f8820b38..63e45d230 100644
--- a/packages/tui/bench/width.ts
+++ b/packages/tui/bench/width.ts
@@ -5,7 +5,7 @@
*/
import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives";
import { getDefaultTabWidth } from "@oh-my-pi/pi-utils";
-import { visibleWidthRaw as hybridVisibleWidth, replaceTabs } from "../src/utils";
+import { visibleWidth as hybridVisibleWidth, replaceTabs } from "../src/utils";
const ITERATIONS = 10_000;
const WARMUP = 500;
diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts
index 058a0ff62..47a6e484b 100644
--- a/packages/tui/src/utils.ts
+++ b/packages/tui/src/utils.ts
@@ -4,7 +4,6 @@ import {
extractSegments as nativeExtractSegments,
sliceWithWidth as nativeSliceWithWidth,
truncateToWidth as nativeTruncateToWidth,
- visibleWidth as nativeVisibleWidth,
wrapTextWithAnsi as nativeWrapTextWithAnsi,
type SliceResult,
} from "@oh-my-pi/pi-natives";
@@ -145,44 +144,6 @@ export function padding(n: number): string {
// Grapheme segmenter (shared instance)
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
-const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
-
-// Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
-// sequences. Mirrors the standard ansi-regex coverage so visible-span
-// segmentation lines up with the native ANSI scanner.
-const ANSI_ESCAPE_REGEX =
- /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
-
-function pictographicSpanWidth(span: string): number {
- let width = 0;
- for (const { segment } of segmenter.segment(span)) {
- width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
- }
- return width;
-}
-
-// Width fallback for strings that mix ANSI styling with ZWJ pictographic
-// emoji. `Intl.Segmenter` would split an escape sequence into individual
-// graphemes, so the native scanner (which only skips ANSI when handed the
-// complete sequence) double-counts the printable SGR bytes. Excise the ANSI
-// spans first — they contribute zero cells — and apply the pictographic
-// grapheme override only to the visible spans, then sum.
-function visibleWidthByGrapheme(str: string): number {
- let width = 0;
- let lastIndex = 0;
- ANSI_ESCAPE_REGEX.lastIndex = 0;
- for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
- if (match.index > lastIndex) {
- width += pictographicSpanWidth(str.slice(lastIndex, match.index));
- }
- lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
- }
- if (lastIndex < str.length) {
- width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
- }
- return width;
-}
-
/**
* Get the shared grapheme segmenter instance.
*/
@@ -190,32 +151,104 @@ export function getSegmenter(): Intl.Segmenter {
return segmenter;
}
-export function visibleWidthRaw(str: string): number {
- if (!str) {
- return 0;
- }
+// Kitty OSC 66 text-sizing spans: `\x1b]66;;` terminated by BEL
+// or ST. `Bun.stringWidth` strips the whole span (payload included) to zero
+// cells, but the payload is visible and scales by the `s=` factor, so each is
+// added back so width matches the native truncate/slice/wrap helpers.
+const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
+const OSC66_PREFIX = "\x1b]66;";
+const ESC = "\x1b";
+const TAB = "\t";
+const LONG_WIDTH_FAST_PATH_MIN = 128;
- // Fast path: printable ASCII has one cell per code unit. Defer every
- // control/non-ASCII case (tabs, ANSI/OSC, combining marks, CJK) to the
- // native text engine so all width/slice/wrap helpers share one Unicode
- // model instead of mixing Bun.stringWidth quirks with Rust truncation.
- for (let i = 0; i < str.length; i++) {
- const code = str.charCodeAt(i);
- if (code < 0x20 || code > 0x7e) {
- const tabWidth = getDefaultTabWidth();
- if (str.includes("\x1b]66;")) return nativeVisibleWidth(str, tabWidth);
- return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, tabWidth);
- }
- }
- return str.length;
-}
+// Pin Bun.stringWidth semantics to the native width engine and guard against Bun
+// default drift: strip ANSI/OSC (don't count escape bytes) and treat
+// ambiguous-width East Asian chars as narrow (1 cell), matching `unicode-width`'s
+// non-CJK tables that back truncate/slice/wrap. Hoisted so no per-call alloc.
+const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true } as const;
/**
- * Calculate the visible width of a string in terminal columns.
+ * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
+ *
+ * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
+ * stripping); this adds the two corrections it omits — tabs (expanded to
+ * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
*/
export function visibleWidth(str: string): number {
if (!str) return 0;
- return visibleWidthRaw(str);
+
+ // Long non-escape text is faster through Bun's native scanner than through
+ // a JS printable-ASCII prepass. Escape-bearing strings stay on the scanner
+ // below so CSI/OSC-heavy render output can still bail out at the first ESC.
+ if (str.length >= LONG_WIDTH_FAST_PATH_MIN && !str.includes(ESC)) {
+ let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
+ let tabCount = 0;
+ for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
+ tabCount++;
+ }
+ if (tabCount > 0) width += tabCount * getDefaultTabWidth();
+ return width;
+ }
+
+ let tabCount = 0;
+ let i = 0;
+ for (; i < str.length; i++) {
+ const code = str.charCodeAt(i);
+ if (code < 0x20 || code > 0x7e) {
+ if (code === 0x09) {
+ tabCount++;
+ continue;
+ }
+ break;
+ }
+ }
+ if (i === str.length) {
+ return tabCount === 0 ? str.length : str.length + tabCount * (getDefaultTabWidth() - 1);
+ }
+
+ if (tabCount === 0) {
+ let tabIndex = str.indexOf(TAB, i + 1);
+ if (tabIndex !== -1) {
+ tabCount = 1;
+ for (tabIndex = str.indexOf(TAB, tabIndex + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
+ tabCount++;
+ }
+ }
+ } else {
+ for (let tabIndex = str.indexOf(TAB, i + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
+ tabCount++;
+ }
+ }
+
+ // `Bun.stringWidth` is a JSC builtin (no per-call N-API number box, unlike
+ // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
+ // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
+ let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
+ if (tabCount > 0) width += tabCount * getDefaultTabWidth();
+
+ // OSC 66: add back each stripped span as `scale * (explicit w ?? payload
+ // width)`. Matched rather than replaced to avoid reallocating the string.
+ if (str.includes(OSC66_PREFIX, i)) {
+ OSC66_SPAN_REGEX.lastIndex = 0;
+ for (let m = OSC66_SPAN_REGEX.exec(str); m !== null; m = OSC66_SPAN_REGEX.exec(str)) {
+ let scale = 1;
+ let explicit: number | undefined;
+ for (const part of m[1].split(":")) {
+ // metadata keys are single chars, e.g. `s=2`, `w=5`
+ if (part.indexOf("=") !== 1) continue;
+ const value = Number.parseInt(part.slice(2), 10);
+ if (!Number.isFinite(value)) continue;
+ if (part[0] === "s") {
+ if (value >= 1 && value <= 7) scale = value;
+ } else if (part[0] === "w" && value > 0) {
+ explicit = value;
+ }
+ }
+ width += scale * (explicit ?? Bun.stringWidth(m[2], STRING_WIDTH_OPTS));
+ }
+ }
+
+ return width;
}
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
diff --git a/packages/tui/test/ime-jamo-cursor.test.ts b/packages/tui/test/ime-jamo-cursor.test.ts
deleted file mode 100644
index 665fa8aae..000000000
--- a/packages/tui/test/ime-jamo-cursor.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Regression guard for Korean IME cursor positioning in the Input
- * component.
- *
- * Background: macOS Korean IME (2-bul keyboard) emits Hangul Compatibility
- * Jamo (U+3131..U+318E) during composition. Before commit 79e3170c6 the
- * Input.render() computed `cursorCols = visibleWidth(value.slice(0,
- * cursorIndex))` and `Bun.stringWidth` returned 2 for each jamo (per UAX
- * #11 EAW=W), while every macOS terminal renders them as 1 cell.
- *
- * The user-visible bug was a GROWING horizontal gap between the typed
- * jamo and the IME candidate window — every additional jamo doubled the
- * offset because `cursorCols` was N×2 instead of N×1. With 14 jamo typed,
- * the gap was ~14 cells.
- *
- * After the JS-side width correction, `cursorCols` is N×1. The Rust
- * `pi-natives` `sliceWithWidth` still treats jamo as 2 cells (binary
- * package; follow-up), so the cursor marker placement in `Input.render()`
- * has a residual ≤1-cell-per-jamo offset — but the user-visible "growing
- * gap" is gone because the JS-side `cursorCols` no longer doubles.
- *
- * What this test guards:
- * - The cursor column for a value containing N jamo is **bounded above
- * by `PROMPT_WIDTH + N`** (the correct cell count after the fix).
- * Before the fix it would have been ~`PROMPT_WIDTH + 2N`, which is
- * what the test catches.
- * - Pure-ASCII and Hangul-syllable baselines are unchanged.
- *
- * What this test does NOT guard:
- * - The Rust-side `sliceWithWidth` jamo discrepancy. Tracked as a
- * follow-up; will tighten this test once the Rust crate is rebuilt.
- */
-import { describe, expect, it } from "bun:test";
-import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
-import { Input } from "@oh-my-pi/pi-tui/components/input";
-import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
-
-/**
- * Drive `text` through `Input.handleInput()` one Unicode code point at a
- * time (mirrors what the IME does — one code point per emitted sequence),
- * then return the visual column where the hardware cursor marker lands
- * in the rendered output.
- */
-function cursorColAfterTyping(text: string, width = 80): number {
- const input = new Input();
- (input as unknown as { focused: boolean }).focused = true;
- for (const char of text) {
- input.handleInput(char);
- }
- const [line] = input.render(width);
- const markerIdx = line.indexOf(CURSOR_MARKER);
- if (markerIdx < 0) {
- throw new Error(`CURSOR_MARKER not found in rendered line: ${JSON.stringify(line)}`);
- }
- return visibleWidth(line.slice(0, markerIdx));
-}
-
-const PROMPT_WIDTH = 2; // "> "
-
-// The jamo-cursor regression (PR #1410 / origin issue) only applies on
-// macOS, where terminals render Hangul Compatibility Jamo as 1 cell while
-// UAX#11 (and `Bun.stringWidth`) report 2. Off-darwin both the terminal and
-// the width helper agree on 2, so the doubling regression cannot occur.
-const IS_DARWIN = process.platform === "darwin";
-
-describe("Input cursor column does not grow at 2× per jamo", () => {
- it("ASCII baseline: cursor lands exactly after the typed text", () => {
- expect(cursorColAfterTyping("hello")).toBe(PROMPT_WIDTH + 5);
- });
-
- it("Hangul syllables: cursor lands exactly after typed text (2 cells each)", () => {
- expect(cursorColAfterTyping("안녕")).toBe(PROMPT_WIDTH + 4);
- });
-
- it.skipIf(!IS_DARWIN)("single jamo: cursor column is at most `PROMPT_WIDTH + 1`", () => {
- // Before fix: PROMPT_WIDTH + 2 = 4. After fix: ≤ 3.
- expect(cursorColAfterTyping("ㅁ")).toBeLessThanOrEqual(PROMPT_WIDTH + 1);
- });
-
- it.skipIf(!IS_DARWIN)("8 consecutive jamo: cursor column is at most `PROMPT_WIDTH + 8`", () => {
- // Before fix: PROMPT_WIDTH + 16 = 18. After fix: ≤ 10.
- expect(cursorColAfterTyping("ㅁㄴㅁㄴㅇㅂㄴㅂ")).toBeLessThanOrEqual(PROMPT_WIDTH + 8);
- });
-
- it.skipIf(!IS_DARWIN)("20 consecutive jamo: cursor column is at most `PROMPT_WIDTH + 20`", () => {
- // Before fix: PROMPT_WIDTH + 40 = 42 (catastrophic gap). After fix: ≤ 22.
- const jamo = "ㅁㄴㄷㅂㅈㅎㅋㅌㄱㄹ".repeat(2);
- expect(cursorColAfterTyping(jamo)).toBeLessThanOrEqual(PROMPT_WIDTH + 20);
- });
-
- it.skipIf(!IS_DARWIN)("cursor column grows by ≤1 per typed jamo (not 2)", () => {
- // The regression: each typed jamo would advance cursor by 2 columns
- // instead of 1, doubling the offset every keystroke. Assert the
- // per-step delta never exceeds 1.
- const input = new Input();
- (input as unknown as { focused: boolean }).focused = true;
- const jamo = "ㅁㄴㅇㅂㅈㅎㅋㅌㄷㄹ";
- let prevCol = PROMPT_WIDTH;
- for (let i = 0; i < jamo.length; i++) {
- input.handleInput(jamo[i]);
- const [line] = input.render(80);
- const markerIdx = line.indexOf(CURSOR_MARKER);
- const col = visibleWidth(line.slice(0, markerIdx));
- const delta = col - prevCol;
- expect(delta).toBeLessThanOrEqual(1);
- prevCol = col;
- }
- });
-});
diff --git a/packages/tui/test/issue-643-repro.test.ts b/packages/tui/test/issue-643-repro.test.ts
deleted file mode 100644
index 1c125fd86..000000000
--- a/packages/tui/test/issue-643-repro.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { describe, expect, it } from "bun:test";
-import { type Component, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
-import { VirtualTerminal } from "./virtual-terminal";
-
-// Regression test for https://github.com/can1357/oh-my-pi/issues/643
-//
-// Arabic text with tashkeel (nonspacing diacritics, Unicode category Mn) was
-// over-measured: each mark counted as 1 column instead of 0. A line of Quranic
-// text with N marks therefore reported a width N columns larger than what the
-// terminal actually renders, and the renderer crashed with "Rendered line
-// exceeds terminal width" (13.19.0) once enough marks accumulated.
-//
-// Root cause: `Bun.stringWidth` counts Mn marks as 1 column (still true as of
-// Bun 1.3.x). The fix routes every non-ASCII line through the native width
-// engine (Rust `unicode-width`), which measures nonspacing marks as 0 — in
-// agreement with xterm.js (BMP_COMBINING tables) and real terminals.
-//
-// The contract defended here: tashkeel marks contribute zero columns, so a
-// marked-up line whose letter width exactly equals the terminal width renders
-// unclipped on a single row. Over-measurement would either truncate trailing
-// letters (today's fitting behavior) or crash (the original report).
-
-// Tashkeel samples from the issue, with their correct letter-cell widths.
-const TASHKEEL_SAMPLES: ReadonlyArray = [
- ["بِسْمِ", 3], // 3 letters + 3 marks
- ["سَابِقُوا", 6], // 6 letters + 3 marks
- ["فَتَبَيَّنُوا", 7], // 7 letters + 5 marks (incl. shadda)
-];
-
-class RawLinesComponent implements Component {
- #lines: string[];
-
- constructor(lines: string[]) {
- this.#lines = [...lines];
- }
-
- setLines(lines: string[]): void {
- this.#lines = [...lines];
- }
-
- invalidate(): void {}
-
- render(): string[] {
- return [...this.#lines];
- }
-}
-
-async function settle(term: VirtualTerminal): Promise {
- const nextTick = Promise.withResolvers();
- process.nextTick(nextTick.resolve);
- await nextTick.promise;
- await Bun.sleep(20);
- await term.flush();
-}
-
-describe("issue #643: Arabic tashkeel width measurement", () => {
- it("measures nonspacing tashkeel marks as zero columns", () => {
- for (const [text, width] of TASHKEEL_SAMPLES) {
- expect(visibleWidth(text)).toBe(width);
- }
- // Mixed ASCII + tashkeel stays on the non-ASCII measurement path.
- expect(visibleWidth(`ayah: ${TASHKEEL_SAMPLES[1][0]}`)).toBe(6 + TASHKEEL_SAMPLES[1][1]);
- });
-
- it("renders a full-width tashkeel line unclipped on a single terminal row", async () => {
- // "quran: " (7 cells) + 6-cell word = 13 cells — an exact fit at width 13.
- // The original bug measured the word as 9 (6 letters + 3 marks), making the
- // line appear 3 columns too wide, so the renderer's width fitting would
- // truncate real trailing letters before writing.
- const word = TASHKEEL_SAMPLES[1][0];
- const line = `quran: ${word}`;
- const width = 13;
- expect(visibleWidth(line)).toBe(width);
-
- const term = new VirtualTerminal(width, 6);
- const tui = new TUI(term);
- const component = new RawLinesComponent(["header", line, "tail"]);
- tui.addChild(component);
-
- try {
- tui.start();
- await settle(term);
-
- const viewport = term.getViewport().map(row => row.trimEnd());
- // The full marked-up text survives the round trip: no truncation by the
- // renderer, no clipping or wrapping by the terminal.
- expect(viewport[1]).toBe(line);
- expect(viewport[0]).toBe("header");
- expect(viewport[2]).toBe("tail");
- // Exactly one terminal row per logical row — the marks did not push the
- // line across the right margin.
- expect(term.getScrollBuffer().length).toBe(6);
- } finally {
- tui.stop();
- }
- });
-
- it("keeps row accounting exact when tashkeel rows scroll into native history", async () => {
- const word = TASHKEEL_SAMPLES[2][0];
- const width = 24;
- const height = 5;
- const term = new VirtualTerminal(width, height);
- const tui = new TUI(term);
- const tashkeelRows = Array.from({ length: 8 }, (_v, i) => `ayah-${i} ${word}`);
- const component = new RawLinesComponent(tashkeelRows);
- tui.addChild(component);
-
- try {
- tui.start();
- await settle(term);
-
- // 8 content rows at height 5 → 3 rows pushed into scrollback.
- const buffer = term.getScrollBuffer().map(row => row.trimEnd());
- expect(buffer.length).toBe(tashkeelRows.length);
- for (let i = 0; i < tashkeelRows.length; i++) {
- expect(buffer[i]).toBe(tashkeelRows[i]);
- }
- } finally {
- tui.stop();
- }
- });
-});
diff --git a/packages/tui/test/text-utils.test.ts b/packages/tui/test/text-utils.test.ts
index e118afde2..3f7c820e1 100644
--- a/packages/tui/test/text-utils.test.ts
+++ b/packages/tui/test/text-utils.test.ts
@@ -17,9 +17,6 @@ describe("text utils", () => {
expect(visibleWidth("a\tb")).toBe(1 + 3 + 1);
});
- it("treats Arabic combining marks as zero-width", () => {
- expect(visibleWidth("بَسِمَ")).toBe(3);
- });
it("ignores OSC hyperlinks in visible width", () => {
const text = "\x1b]8;;https://example.com\x07link\x1b]8;;\x07";
expect(visibleWidth(text)).toBe(4);
diff --git a/packages/tui/test/visible-width-jamo.test.ts b/packages/tui/test/visible-width-jamo.test.ts
deleted file mode 100644
index 818a544f1..000000000
--- a/packages/tui/test/visible-width-jamo.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * Regression guard for the Hangul Compatibility Jamo width correction in
- * `visibleWidthRaw`.
- *
- * `Bun.stringWidth` (and the underlying UAX#11 EAW tables) classify Hangul
- * Compatibility Jamo (U+3131..U+318E) as Wide (2 cells), but every macOS
- * terminal we ship to (Ghostty, Terminal.app, iTerm2) actually renders them
- * as a single cell. Without the correction, `#extractCursorPosition` doubles
- * the column count for every jamo emitted by a Korean IME during
- * composition, displacing the hardware cursor (and therefore the IME
- * candidate window) `N_jamo` cells past the actual glyph.
- *
- * Hangul Syllables (U+AC00..U+D7A3, e.g. `안`) are correctly 2 cells in both
- * Bun and the terminal — make sure the fix did NOT regress that. The
- * Halfwidth Hangul block (U+FFA0..U+FFDC) is already classified as Narrow
- * by Bun, so it does not appear in the correction and the test below is a
- * regression sanity check.
- */
-import { describe, expect, it } from "bun:test";
-import { Ellipsis, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
-
-// The macOS-only correction (see PR #1410) keeps jamo at 1 cell on darwin;
-// every other platform follows UAX#11 and reports 2 cells per jamo.
-const JAMO_CELLS = process.platform === "darwin" ? 1 : 2;
-
-describe("visibleWidth — Hangul Compatibility Jamo correction", () => {
- it("single compatibility jamo is 1 cell on darwin, 2 elsewhere", () => {
- // U+3141 HANGUL LETTER MIEUM
- expect(visibleWidth("ㅁ")).toBe(JAMO_CELLS);
- // U+3134 HANGUL LETTER NIEUN
- expect(visibleWidth("ㄴ")).toBe(JAMO_CELLS);
- // U+3147 HANGUL LETTER IEUNG
- expect(visibleWidth("ㅇ")).toBe(JAMO_CELLS);
- // U+3142 HANGUL LETTER PIEUP
- expect(visibleWidth("ㅂ")).toBe(JAMO_CELLS);
- // U+3148 HANGUL LETTER JIEUJ
- expect(visibleWidth("ㅈ")).toBe(JAMO_CELLS);
- });
-
- it("range edges U+3131 and U+318E follow platform width", () => {
- // U+3131 HANGUL LETTER KIYEOK — first jamo in the block
- expect(visibleWidth("\u3131")).toBe(JAMO_CELLS);
- // U+318E HANGUL LETTER ARAEAE — last jamo in the block
- expect(visibleWidth("\u318e")).toBe(JAMO_CELLS);
- });
-
- it("U+3164 HANGUL FILLER (inside the corrected range) follows platform width", () => {
- // Often emitted by IME for empty-syllable placeholders. The filler is the
- // one code point in the block that UAX#11 / `unicode-width` classify as
- // zero-width, so off-darwin it measures 0 cells. On darwin the blanket
- // jamo correction (U+3131..U+318E → 1) forces it to a single cell.
- const fillerCells = process.platform === "darwin" ? 1 : 0;
- expect(visibleWidth("\u3164")).toBe(fillerCells);
- });
-
- it("combining marks on compatibility jamo keep the platform base width", () => {
- // Combining marks add no cells. The native scanner must keep
- // UnicodeWidthStr's sequence rules, then apply the same local jamo
- // correction used for standalone code points.
- expect(visibleWidth("\u3141\u0301")).toBe(JAMO_CELLS);
- const fillerCells = process.platform === "darwin" ? 1 : 0;
- expect(visibleWidth("\u3164\u0301")).toBe(fillerCells);
- });
-
- it("string of 8 consecutive jamo is 8 cells on darwin, 16 elsewhere", () => {
- // Matches the user-typed sequence in the v2 screen recording —
- // before the macOS fix this returned 16 and produced an 8-cell gap.
- expect(visibleWidth("ㅁㄴㅁㄴㅇㅂㄴㅂ")).toBe(8 * JAMO_CELLS);
- });
-
- it("Hangul Syllables (U+AC00..U+D7A3) stay at 2 cells", () => {
- // `안` U+C548 — composed syllable, must remain 2 cells
- expect(visibleWidth("안")).toBe(2);
- // `녕` U+B155 — composed syllable
- expect(visibleWidth("녕")).toBe(2);
- // Whole word: 안녕 = 4 cells
- expect(visibleWidth("안녕")).toBe(4);
- // First & last in the block, for boundary coverage
- expect(visibleWidth("\uac00")).toBe(2); // 가
- expect(visibleWidth("\ud7a3")).toBe(2); // 힣
- });
-
- it("mixed ASCII + syllable + jamo strings add correctly", () => {
- // a (1) + 안 (2) + ㅂ (J) + b (1) = 4 + J
- expect(visibleWidth("a안ㅂb")).toBe(4 + JAMO_CELLS);
- // 11 ASCII letters + 1 syllable + 4 jamo = 11 + 2 + 4*J
- expect(visibleWidth("hello world안ㅁㄴㅇㅂ")).toBe(11 + 2 + 4 * JAMO_CELLS);
- });
-
- it("does not regress ASCII fast path or empty input", () => {
- expect(visibleWidth("")).toBe(0);
- expect(visibleWidth("hello")).toBe(5);
- expect(visibleWidth("a")).toBe(1);
- // Tab character (ASCII 0x09) inside the fast path expands to >2
- expect(visibleWidth("a\tb")).toBeGreaterThan(2);
- });
-
- it("does not change width for other CJK characters", () => {
- // Chinese: 漢字 (each 2 cells)
- expect(visibleWidth("漢字")).toBe(4);
- // Japanese hiragana: あい (each 2 cells)
- expect(visibleWidth("あい")).toBe(4);
- // Japanese katakana: アイ (each 2 cells)
- expect(visibleWidth("アイ")).toBe(4);
- });
-
- it("Halfwidth Hangul block is unaffected (already Narrow in Bun)", () => {
- // U+FFA1 HALFWIDTH HANGUL LETTER KIYEOK — Bun reports 1, untouched.
- expect(visibleWidth("\uffa1")).toBe(1);
- // U+FFDC HALFWIDTH HANGUL LETTER I
- expect(visibleWidth("\uffdc")).toBe(1);
- });
-});
-
-describe("native text helpers — Hangul Compatibility Jamo correction", () => {
- // These exercise the Rust-side `char_width_corrected` wrapper in
- // crates/pi-natives/src/text.rs. They will fail until the native
- // binding is rebuilt (`bun run build:native`); CI rebuilds natives so
- // they pass there. Mirrors the TS-side range U+3131..=U+318E.
-
- it("sliceWithWidth treats jamo per platform width", () => {
- // 8 jamo at JAMO_CELLS cells each must fit fully within the
- // platform's natural cell count.
- const input = "ㅁ".repeat(8);
- const { text, width } = sliceWithWidth(input, 0, 8 * JAMO_CELLS, true);
- expect(text).toBe(input);
- expect(width).toBe(8 * JAMO_CELLS);
- });
-
- it("truncateToWidth keeps 8 jamo within their platform budget", () => {
- // On darwin (1 cell/jamo) 8 jamo fit in 8 cells; off-darwin they need 16.
- const result = truncateToWidth("ㅁ".repeat(20), 8 * JAMO_CELLS, Ellipsis.Omit);
- // Strip any trailing pad to count jamo content.
- const jamo = result.replaceAll(/[^\u3131-\u318E]/g, "");
- expect(jamo.length).toBe(8);
- });
-
- it("native and TS visibleWidth agree on a jamo run", () => {
- // Cross-layer parity guard: without the native fix, the TS path
- // (Bun.stringWidth + manual correction) and the native path
- // (unicode_width) disagreed by a factor of 2.
- const input = "ㅁㄴㅇㅂㅈㄷㄱㅅ";
- expect(visibleWidth(input)).toBe(8 * JAMO_CELLS);
- expect(sliceWithWidth(input, 0, 8 * JAMO_CELLS, true).width).toBe(8 * JAMO_CELLS);
- });
-});
diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts
new file mode 100644
index 000000000..d1bfd8faa
--- /dev/null
+++ b/packages/tui/test/visible-width.test.ts
@@ -0,0 +1,77 @@
+/**
+ * `visibleWidth` measures terminal column width via `Bun.stringWidth` (a JSC
+ * builtin) instead of the native scanner, to keep the render loop off the
+ * N-API number-boxing path that traps under Bun 1.3.x GC pressure.
+ *
+ * Correctness contract: the result MUST equal the native engine's width for the
+ * same input, because `truncateToWidth` / `sliceWithWidth` / `wrapTextWithAnsi`
+ * cut text using that native model — any divergence makes padding / cursor math
+ * (`width - visibleWidth(...)`) drift. This guards the two corrections layered
+ * on top of `Bun.stringWidth` (tabs, OSC 66 scaling) and catches silent
+ * `Bun.stringWidth` width-table drift across Bun upgrades.
+ */
+import { describe, expect, it } from "bun:test";
+import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives";
+import { getDefaultTabWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
+
+const ESC = "\x1b";
+const ST = "\x1b\\";
+const BEL = "\x07";
+const TAB = getDefaultTabWidth();
+
+describe("visibleWidth — parity with the native width engine", () => {
+ const corpus: [string, string][] = [
+ ["empty", ""],
+ ["ascii", "Pending run: passed"],
+ ["styled", `${ESC}[31mred${ESC}[0m text`],
+ ["styled-truecolor", `${ESC}[38;2;1;2;3mx${ESC}[0m`],
+ ["nested-sgr", `${ESC}[1m${ESC}[31mbold${ESC}[0m${ESC}[0m`],
+ ["osc8-st", `${ESC}]8;;https://x.com${ST}link${ESC}]8;;${ST}`],
+ ["osc8-bel", `${ESC}]8;;u${BEL}t${ESC}]8;;${BEL}`],
+ ["cjk", "日本語のテキスト"],
+ ["cjk-mixed", "abc中文def"],
+ ["hangul-syllables", "안녕하세요"],
+ ["styled-cjk", `${ESC}[1m漢字${ESC}[0m`],
+ ["emoji", "👍 done"],
+ ["emoji-zwj", "👨👩👧👦"],
+ ["emoji-flag", "🇯🇵"],
+ ["styled-zwj", `${ESC}[31m👨👩👧👦${ESC}[0m`],
+ ["variation-selector", "▶️"],
+ ["combining", "e\u0301"],
+ ["ambiguous", "§±×→①②③"],
+ ["box-drawing", "─│┌┐└┘"],
+ ["fullwidth", "123"],
+ ["halfwidth-kana", "アイウ"],
+ ["rtl-arabic", "مرحبا"],
+ ["thai", "สวัสดี"],
+ ["tabs", "name\tvalue\tstatus"],
+ ["leading-tabs", "\t\tindented"],
+ ["osc66-scale", `${ESC}]66;s=2;big${ST}`],
+ ["osc66-explicit-w", `${ESC}]66;w=5;Hi${BEL}`],
+ ["osc66-scale-and-w", `${ESC}]66;s=3:w=4;X${ST}`],
+ ["osc66-cjk", `${ESC}]66;s=2;日本${ST}`],
+ ["osc66-inline", `pre ${ESC}]66;s=2;AB${ST} post`],
+ ["osc66-multi", `${ESC}]66;s=2;A${ST} ${ESC}]66;s=3;B${ST}`],
+ ["osc66-with-tabs", `\t${ESC}]66;s=2;X${ST}\t`],
+ ];
+ for (const [name, input] of corpus) {
+ it(name, () => {
+ expect(visibleWidth(input)).toBe(nativeVisibleWidth(input, TAB));
+ });
+ }
+
+ it("strips ANSI (styled text measures as its plain content)", () => {
+ expect(visibleWidth(`${ESC}[31mhello${ESC}[0m`)).toBe(5);
+ });
+
+ it("expands each tab to the configured tab width", () => {
+ expect(visibleWidth("a\tb")).toBe(2 + TAB);
+ expect(visibleWidth("\t\t")).toBe(2 * TAB);
+ });
+
+ it("scales OSC 66 text-sizing payloads by `s=`", () => {
+ expect(visibleWidth(`${ESC}]66;s=2;big${ST}`)).toBe(6); // 2 * width("big")
+ expect(visibleWidth(`${ESC}]66;w=5;Hi${BEL}`)).toBe(5); // explicit width, scale 1
+ expect(visibleWidth(`${ESC}]66;s=3:w=4;X${ST}`)).toBe(12); // 3 * 4
+ });
+});