fix(tui): render wide (astral-plane) glyphs correctly across incremental re-render (#51)
Two bugs in src/tui/buffer.ts making width-2 emoji (๐ฌ, ๐ญ, ๐ซ โ
astral-plane codepoints U+1F4XX) fossilize under app() incremental
re-render, observed by tui-sup while building agent-viz on top of
@myobie/pty/tui: a `๐ฌ` on a cards grid navigating until it scrolled
would end up displayed as `๐ฌ๐ฌ` (fossil + new) with shifted digits.
Root causes:
1. writeAnsi iterated the input by UTF-16 code unit (`ansi[i]`), so
astral-plane codepoints stored as surrogate pairs got split into
two lone surrogate halves in separate width-1 cells. Downstream
diff() and fullRender() then emitted the halves independently.
Modern host terminals (kitty, iTerm2, Ghostty) recombined them as
one wide glyph, but the CellBuffer's cell-index โ terminal-column
mapping was off by one on every emoji, cascading into misaligned
writes as the row mutated. Fixed by detecting the high-surrogate
/ low-surrogate pattern and storing the full 2-code-unit string
in one Cell.
2. diff()'s `lastCol = c + 1` cursor-position tracker didn't account
for wide chars advancing the terminal cursor by 2. After emitting
a width-2 glyph at column c, the terminal cursor is at c+2, not
c+1. The adjacency check on the next emit then mispredicted,
causing an extra cursor move (efficiency loss) that combined with
the surrogate-split bug above produced positional drift on rows
with mixed content. Fixed by `lastCol = c + charWidth(nc.char)`.
Refactored the per-cell emit block into an `emit()` closure so both
the wide-char path and any future placeholder-clearing path share the
style-reset + SGR emit logic.
Test coverage: tests/buffer-wide-char-diff.test.ts โ 10 cases using
xterm-headless + @xterm/addon-unicode11 as the oracle. The unicode11
addon is required because default xterm.js width tables treat all
astral-plane codepoints as width-1, which hides the bug behind an
environment quirk; with unicode11 the harness matches real modern
terminal semantics.
Fixes the framework bug tui-sup filed via coord; unblocks agent-viz
using `๐ฌ` directly without a width-1 workaround.