TUI redraw corrupts on lines containing VS16 emoji (➡️): width counted as 1 column, terminals render 2

Status Open
Reported on v2.1.226
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 8, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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:

  1. Size a terminal to a known width (I used a 53-column pane).
  2. Run claude.
  3. Type ➡️ followed by 60 As. Do not press Enter.
  4. As a control, on a new line type XX followed by 60 As.

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 as Correat me., plus a few as plusra few, these and as thesegand

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

View original on GitHub ↗

3 Comments

bcherny collaborator · 1 month ago

Reproduced on 2.1.233 (Linux, tmux, 53-column pane) using the composer method from the report.

  • Control line (XX + 60×A): wraps after 47 As — first-line budget is 51 columns.
  • Arrow line (➡️ + 60×A): fits 48 As into that same 51-column budget, so Claude Code counted U+27A1 U+FE0F as 1 column.
  • Reading the terminal grid back shows the emoji occupying 2 cells, so the physically drawn line is one column wider than Claude Code believes it emitted.

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+FE0F counts as 2 columns, matching terminal behavior. No fix for this is in the changelog as of 2.1.243.

🤖 Generated with Claude Code

AriSweedler-at · 29 days ago

More data from a different surface, macOS Terminal.app 470.2 → tmux 3.6a (Homebrew, linked against utf8proc 2.11.3, variation-selector-always-wide on) → 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+FE0F as 2. That matches Bun.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 a Bun.ant.CellSegmenter used 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 from stringWidth.cpp at 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+20E3 0️⃣ | 2 | 2 | 2 |
| U+0030 U+20E3 0⃣ | 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 ➡️, ⚠️, and 0️⃣, and a table row holding a keycap should stay aligned under tmux.

AriSweedler-at · 29 days ago

Follow-up with the raw bytes. I ran tmux pipe-pane on 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 letter x. Same stack as above: Terminal.app 470.2 → tmux 3.6a → Claude Code.

What the transcript painter emits (\e[nG is CHA, "move to column n"):

│\e[6G \e[5G0️⃣\e[7Gx          U+0030 U+FE0F U+20E3 : glyph placed in columns 5-6, x at 7
│\e[6G \e[5G⚠️\e[7Gx          U+26A0 U+FE0F        : same, treated as 2 cells
│\e[5G0⃣x\e[12G│              U+0030 U+20E3        : written contiguously, treated as 1 cell
│ 👍🏻x    │ skin tone         U+1F44D U+1F3FB      : contiguous, treated as 2 cells
\e[14Gk\e[16Gycap              differential repaint skipping column 15 as unchanged

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 the x being 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 ⚠️✔️➡️with with 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.