TUI redraw corrupts on lines containing VS16 emoji (➡️): width counted as 1 column, terminals render 2
Summary
Claude Code measures emoji-presentation sequences (base codepoint + variation selector U+FE0F) as 1 column, but terminals render them as 2. When a line containing one is long enough to wrap, the terminal soft-wraps it into a screen row Claude Code doesn't know exists, and every subsequent cursor-up-and-repaint lands one row off. The result is progressive screen corruption: stale ghost rows, and single characters punched into the middle of words.
The clearest trigger is ➡️ (U+27A1 U+FE0F), which the official mattpocock-skills plugin prints on every recommendation line — see skills/productivity/grilling/SKILL.md:15, ➡️ <your recommended answer>. Any narrow-ish terminal turns that skill's output into garbage.
Root cause
U+27A1 is Extended_Pictographic but not Emoji_Presentation. On its own it is a 1-column text glyph; it only becomes a 2-column emoji when U+FE0F follows. Width code that inspects the base codepoint and treats U+FE0F as a zero-width combining mark returns 1. Code that measures the whole grapheme cluster returns 2 — which is what terminals do.
❓ U+2753 is not affected, because it is East_Asian_Width=Wide on its own, so every implementation agrees it is 2. Only the VS16-dependent ones break.
Reproduction (no API call required)
The composer exercises the same width math, so this needs no request:
- Size a terminal to a known width (I used a 53-column pane).
- Run
claude. - Type
➡️followed by 60As. Do not press Enter. - As a control, on a new line type
XXfollowed by 60As.
Reading the terminal grid back:
len=52 | ❯ ➡️AAAA…AAAA ← ❯ + space + ➡️ + 48 A
len=51 | XXAAAA…AAAA ← 2 spaces + XX + 47 A (control)
Both lines use a 2-column prefix, so the control pins Claude Code's wrap target at 51 columns (2 + 2 + 47).
On the arrow line Claude Code fitted ❯(1) + space(1) + ➡️(w) + 48 A into that same 51-column budget, so w = 1. Had it counted 2, it would have placed 47 As, not 48.
The terminal, meanwhile, draws that line as 52 columns. Claude Code believes it emitted 51.
Actual behaviour
Scale it to a real message. With a 183-column pane and assistant text wrapped to 183, a line containing one ➡️ is physically 184 columns, soft-wraps, and consumes an extra row. From that line down, the differential repaint is off by one row and the pane fills with artifacts:
- ghost rows holding a sparse scatter of characters from an earlier frame
- single characters overwriting spaces mid-word —
Correct me.rendered asCorreat me.,plus a fewasplusra few,these andasthesegand
Corruption begins exactly at the first ➡️ line and worsens with each subsequent one. Lines in the same message with no VS16 emoji render perfectly, including ones far longer that wrap twice.
Expected behaviour
Width should be computed per grapheme cluster, so U+27A1 U+FE0F measures 2 columns and matches what the terminal draws.
Notes
- Terminal width is what decides whether this is visible. At 209 columns the same message never wraps those lines and renders cleanly; at 183 it corrupts. That makes it much more likely to hit anyone running Claude Code inside a multiplexer pane (tmux, herdr, zellij) than in a full-width window.
- I verified the two other components in my stack independently — both count
➡️as 2 columns and agree with each other. Claude Code is the outlier.
Environment
- Claude Code 2.1.226
- Ubuntu 24.04 on WSL2, Windows host
- WezTerm, JetBrains Mono with Segoe UI Emoji fallback
- Reproduced both in a full-width terminal and in a 183-column multiplexer pane
3 Comments
Reproduced on 2.1.233 (Linux, tmux, 53-column pane) using the composer method from the report.
XX+ 60×A): wraps after 47As — first-line budget is 51 columns.➡️+ 60×A): fits 48As into that same 51-column budget, so Claude Code countedU+27A1 U+FE0Fas 1 column.That confirms the reported off-by-one per VS16 emoji-presentation sequence, which at pane widths where the line wraps produces the described repaint corruption. Marking as a confirmed bug: width should be measured per grapheme cluster so
<base codepoint> + U+FE0Fcounts as 2 columns, matching terminal behavior. No fix for this is in the changelog as of 2.1.243.🤖 Generated with Claude Code
More data from a different surface, macOS Terminal.app 470.2 → tmux 3.6a (Homebrew, linked against utf8proc 2.11.3,
variation-selector-always-wideon) → Claude Code 2.1.273 (arm64 native build).The transcript markdown-table renderer counts VS16 sequences as 2 columns, so the 1-column count above is specific to the composer/screen-paint path. I had Claude render a table whose rows each hold one width class:
| Class | Sample | Result in my terminal |
|---|---|---|
| Emoji_Presentation=Yes | ✅ ❌ ⏳ 🟢 ❗ | aligned |
| no Emoji property | ✓ ✗ ● → ☐ | aligned |
| text-default base + U+FE0F | ⚠️ ✔️ ☑️ ℹ️ ➡️ | aligned |
| keycap sequence | 0️⃣ 1️⃣ | separators shift 1 column left per keycap |
| bare text-default base | ⚠ ✔ ✖ ☑ | aligned |
Under tmux the row layout follows tmux's grid regardless of what Terminal.app draws, because tmux forces a VS16 cell to width 2 and resets its cached cursor after it. If the table renderer had padded ➡️ as 1 column, the third row would have been 5 columns short of the others. It was not, so the table path measures
<base> U+FE0Fas 2. That matchesBun.stringWidth(the binary's shared width helper passes{ambiguousIsNarrow: true}to it); the composer measurement in this issue must come from a different engine. The binary also references aBun.ant.CellSegmenterused for the Ink cell grid, which would be the natural place for the two to diverge.Measured cell advance in each layer (Terminal.app via a cursor-position report in a bare window outside tmux; tmux via
#{cursor_x}after writing to a scratch pane; Bun fromstringWidth.cppat bun-v1.4.2):| Sequence | Terminal.app | tmux 3.6a | Bun.stringWidth |
|---|---|---|---|
|
U+2705✅ | 2 | 2 | 2 ||
U+26A0 U+FE0F⚠️ | 1 | 2 | 2 ||
U+27A1 U+FE0F➡️ | 1 | 2 | 2 ||
U+0030 U+FE0F U+20E30️⃣ | 2 | 2 | 2 ||
U+0030 U+20E30⃣ | 2 | 1 | 2 ||
U+1F1FA🇺 | 1 | 1 | 1 ||
U+1F44D U+1F3FB👍🏻 | 4 | 2 | 2 ||
U+1F468 U+200D U+1F469 U+200D U+1F467👨👩👧 | 8 | 2 | 2 |The keycap row is the one I cannot attribute to a terminal: Terminal.app, tmux, and Bun.stringWidth all give
0️⃣2 cells, yet the rendered table row is one column short per keycap, and in the screenshot the keycap glyph overdraws the space that follows it. Whatever placed the character after the keycap believed the keycap took 1 cell. Since the transcript path pads with a width of 2, the 1-cell opinion has to come from a later stage of the render, which again points at the screen-paint engine rather than at the table code.Suggested check for the fix: the composer and the transcript renderer should return the same width for
➡️,⚠️, and0️⃣, and a table row holding a keycap should stay aligned under tmux.Follow-up with the raw bytes. I ran
tmux pipe-paneon the pane running Claude Code 2.1.273 while it rendered a five-row table whose first column holds one test sequence followed by the letterx. Same stack as above: Terminal.app 470.2 → tmux 3.6a → Claude Code.What the transcript painter emits (
\e[nGis CHA, "move to column n"):So the painter's width model is: VS16 sequence 2, keycap with VS16 2, keycap without VS16 1, skin-tone sequence 2. Every changed run is positioned with an absolute column move, and unchanged cells are skipped.
What the terminal does with that. Terminal.app advances 1 cell for
⚠️(measured with a cursor-position report in a bare window) and, judging by thexbeing overdrawn in the screenshot, also 1 for the keycap when it arrives the way tmux forwards it. Under tmux the painter's column moves are absorbed into tmux's grid; on frames where tmux then redraws the line from its grid there is no cursor re-homing, so each such cell shifts the rest of the row one column left. That is why the same row containing⚠️ ✔️ ➡️rendered aligned in one frame and, after a later repaint, rendered as⚠️✔️➡️withwith the separators three columns left. Rows made only of Emoji_Presentation=Yes emoji (✅ ❌ ⏳ 🟢) or symbols with no Emoji property never drifted across any frame.How other terminal software handles the same disagreement, in case it helps the fix: there is no static width table that matches every terminal for VS16, keycaps, ZWJ and skin tones (Terminal.app and kitty give different answers for all four), so the robust approaches are (1) probe at startup by writing a handful of sequences and reading the cursor position back, then choosing the width model from the answers, which is what ucs-detect and kitty's text-sizing protocol detection do, or (2) negotiate mode 2027 where the terminal supports it, or (3) treat every sequence the terminal might disagree on as unsafe for column-aligned output, which is what I have done on my side.