Why One Emoji Breaks Your Coding Agent's TUI Borders: wcwidth vs Grapheme Clusters
- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
Paste into Claude Code 2.1.207 and the text after it slides one cell to the right. That is the report in anthropics/claude-code#80475, opened July 23, 2026: stray characters bleeding into neighbouring rows, inverse-video blocks between words, and a mess that stays until Ctrl-L or a window resize forces a full repaint.
The emoji is U+1FAEA, DISTORTED FACE, which arrived with Unicode 17.0 in 2025. The terminal drew it two cells wide. Claude Code's width table was older than the emoji and counted fewer. In the maintainer's closing comment, the fix "came from updating the Unicode width tables in the runtime Claude Code ships with," and from 2.1.221 on, both and (U+1FAE9, FACE WITH BAGS UNDER EYES, reported later in the same thread) measure as two cells.
Claude Code and the terminal disagreed on how many columns U+1FAEA takes, and every cell after it inherited the gap.
Two programs, one grid
A terminal is a grid of cells. A TUI like Claude Code, Codex or Gemini CLI never sees that grid. It writes bytes, and to draw a box it has to predict where the cursor will be after each string. The right border of a box sits at "left edge plus the width I computed." The terminal then advances the cursor by the width it computed.
When those two numbers agree, borders line up. When they differ by one, everything the program draws after that character lands one column off, and every partial redraw targets the wrong cells. That is why these bugs show up as smearing and duplicated lines across the whole screen.
kitty's text sizing protocol docs put it bluntly: if the client program and the terminal disagree on cell counts, "the entire user interface can be broken".
!A terminal grid with two rulers, one from the program and one from the terminal, measuring the same emoji tile and ending one cell apart
The program and the terminal each measure the same character. One cell of disagreement moves every border after it.
Three counts, none of them is width
Start with what JavaScript can count. Save this as count.mjs and run it with node count.mjs:
const seg = new Intl.Segmenter("en", {
granularity: "grapheme",
});
const samples = {
ascii: "A",
cjk: "中",
heart: "\u{2764}",
heartVS16: "\u{2764}\u{FE0F}",
thumbTone: "\u{1F44D}\u{1F3FD}",
family: "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
distorted: "\u{1FAEA}",
};
console.log("ICU Unicode:", process.versions.unicode);
for (const [name, s] of Object.entries(samples)) {
const units = s.length;
const points = [...s].length;
const clusters = [...seg.segment(s)].length;
console.log(name.padEnd(10), s, units, points, clusters);
}
.length counts UTF-16 code units. Spreading the string counts code points. Intl.Segmenter counts grapheme clusters, which UAX #29 defines as user-perceived characters. Its rule GB11 says: "Do not break within emoji modifier sequences or emoji zwj sequences."
The family is three emoji glued together by two ZERO WIDTH JOINERs (U+200D), so it is five code points and eight UTF-16 units but one grapheme, while the skin-tone thumbs-up and the heart followed by U+FE0F are two code points and one grapheme each.
A Chinese character is one code point and one grapheme, and it still takes two cells.
The first line prints the Unicode version your Node's ICU knows. The Node docs example shows unicode: '17.0'. Unicode 18.0 is dated September 16, 2026. Every program that measures width carries a snapshot of some version, and they are rarely the same snapshot.
Now draw a box the naive way, one cell per code point:
const rows = [
"plain text",
"中文",
"\u{2764}\u{FE0F} ok",
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} team",
];
const width = (s) => [...s].length;
const inner = 16;
const edge = "+" + "-".repeat(inner) + "+";
console.log(edge);
for (const r of rows) {
console.log("|" + r + " ".repeat(inner - width(r)) + "|");
}
console.log(edge);
The plain-text row lines up. The 中文 row overshoots by two cells in every terminal, because each character is one code point and two cells. The heart and family rows are where terminals disagree: run the script in two emulators and compare where those right borders land.
Where width actually comes from
The width a terminal assigns usually starts from the East_Asian_Width property in UAX #11. Its values are A (ambiguous), F (fullwidth), H (halfwidth), N (neutral), Na (narrow) and W (wide). W and F take two cells, and W also covers "characters that have the UTS51 property Emoji_Presentation." For ambiguous characters the annex says: "If the context cannot be established reliably, they should be treated as narrow characters by default."
That covers single code points. Emoji sequences are where the tables run out. UTS #51 defines U+FE0F, VARIATION SELECTOR-16, as the character "used to request an emoji presentation." The plain heart U+2764 has no Emoji_Presentation, so on its own it is a text-style, one-cell character. Add VS16 and you ask for the two-cell emoji. A per-code-point width function sees one narrow character plus a variation selector that takes no cells of its own, and so the total depends on whether the function knows the sequence.
The Python wcwidth library documents exactly this split. wcwidth('\U00002640'), the plain ♀ (U+2640), is 1. wcswidth('\U00002640\U0000FE0F'), the same sign with VS16, is 2. Ask for VTE's behaviour with wcstwidth(..., term_program='vte') on that same VS16 string and the docs show 1. Its four-person family example measures 2 by default and 8 with term_program='alacritty', a correction the docs put down to Alacritty not supporting emoji ZWJ.
Here is the Python side of the same experiment:
# pip install --upgrade wcwidth
import unicodedata
from wcwidth import wcwidth, wcswidth
print("UCD", unicodedata.unidata_version)
for ch in ["A", "中", "\U00002764", "\U0001FAEA"]:
eaw = unicodedata.east_asian_width(ch)
print(f"U+{ord(ch):04X}", eaw, wcwidth(ch))
print(wcswidth("\U00002640\U0000FE0F")) # wcwidth docs: 2
unicodedata follows the Python release, not the wcwidth package. The Python 3.15 docs say its data comes from UCD 17.0.0. An older interpreter or an older wcwidth will give a different answer for U+1FAEA, which is the same kind of mismatch as the Claude Code bug.
It goes the other way too. In gemini-cli#25369, Ink, the React renderer Gemini CLI draws with, used Intl.Segmenter to merge a Thai consonant and SARA AM (U+0E33) into one cluster of width 1, while tmux and xterm.js drew it across two columns. The symptom was one streamed line repeated 8 to 10 times and an input line that kept jumping down. It was closed as completed on October 23, 2026, via PR #29883. This time the program was the one counting by clusters, and the terminal in front of it counted differently.
!Three code points, a man, a woman and a girl, fusing through two joiner links into one emoji tile on a terminal grid
A ZWJ sequence is several code points and one grapheme. Whether it takes two cells or eight depends on who is counting.
Mode 2027: asking the terminal to count graphemes
Some terminals now let a program opt into one shared rule. Contour's terminal-unicode-core spec defines DEC private mode 2027. CSI ? 2027 h turns it on, CSI ? 2027 l turns it off, and CSI ? 2027 $ p asks whether the terminal supports it. With the mode on, a grapheme cluster lives in one place on the grid, and a VS16 in the cluster promotes it to emoji presentation, which makes it two cells wide.
You can probe your terminal from Node:
process.stdin.setRawMode(true);
const timer = setTimeout(() => done("no reply"), 500);
function done(msg) {
clearTimeout(timer);
process.stdin.setRawMode(false);
console.log(msg);
process.exit(0);
}
process.stdin.once("data", (b) => done(JSON.stringify(String(b))));
process.stdout.write("\x1b[?2027$p");
The answer is a DECRPM report shaped like CSI ? 2027 ; Ps $ y. Ps 0 means the terminal doesn't recognise the mode, 1 means it is set, 2 means reset, 3 and 4 mean permanently set or reset. The timeout matters: a terminal that ignores the query sends nothing at all.
Support is uneven. In the ucs-detect results, which measure terminals by printing test strings and asking for the cursor position afterwards, Ghostty, WezTerm, foot and Windows Terminal report mode 2027. kitty, iTerm2, Alacritty and xterm.js do not. The page gives no test date and says results are provided as-is, so check the version next to each row.
Ghostty exposes the trade-off as a setting. grapheme-width-method defaults to unicode, which the docs call correct but able to desync the cursor with programs that measure the legacy wcswidth way. legacy matches those programs and can get skin-tone emoji wrong. A program that turns on mode 2027 forces unicode regardless. kitty went a different direction with OSC 66, where the program states the width with w= and the terminal stops guessing.
The two-pane test
Put two agent TUIs side by side and the disagreement becomes something you can locate. I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent's own interface in a real terminal and lets you split panes right or down, so Claude Code and Codex can sit next to each other on the same screen.
!Two agent sessions side by side in Hermes IDE: Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar on the left
Hermes IDE 2.3.4: Codex on orders-types and Claude Code on orders-api, side by side, each in its own hermes/ worktree, with the sessions sidebar showing status, context use, cost and branch.
Paste the same string with an emoji into both. Both panes live in the same app, so the terminal side of the comparison stays fixed. If one pane's border breaks and the other's holds, the difference is in the agents. Codex's TUI is built on Ratatui and pulls in the unicode-width and unicode-segmentation crates (its Cargo.toml), and Claude Code ships its own width tables in its runtime, so the two can carry different Unicode snapshots.
Then flip it. Run the same agent in a second terminal emulator. If the border is fine there, the agent agrees with that emulator and the first one is the odd one out. The rest is cheap:
1. Update the agent first. claude update is the whole fix for #80475.
2. Repaint with Ctrl-L or a resize to clear a smeared screen.
3. Keep fresh emoji out of the strings an agent draws in its chrome: session names, branch names, status lines.
4. If you write a TUI, measure grapheme clusters, query mode 2027 and turn it on when the reply shows the mode is recognised (Ps 1 or 2). Where it isn't, test against the terminals your users actually run.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or Google. Download it and the source is on GitHub.
Finding the character
Next time an agent's box comes out with a jagged right edge, skip the font settings and find the character just before the break. Compare the width your agent gives it with the width your terminal draws, then check which Unicode version each was built with. If the versions match, compare how each one segments that cluster; the Gemini CLI Thai bug was that kind.
---
If this was useful
If you're building your own agent in TypeScript, the terminal is only the surface. AI That Acts covers the part underneath it: tool calling, functions, and getting a first working agent to do real work in Node, so the thing drawing those borders has something worth showing.

It is book 3 of AI in TypeScript, a five-book series that runs from your first LLM call to agents in production.

Why Claude Code Flickered: DEC Mode 2026, Cell Diffs and a 16 ms Frame Budget
- Book: System Design Pocket Guide: Fundamentals
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
GitHub issue #769 on the Claude Code repo, "In-progress Call causes Screen Flickering", was opened on April 12, 2025. It is still open. Peter Steinberger called it Claude Code's signature flicker: the Claude Code screen blinking as tool output streamed in.
On December 17, 2025, Anthropic's Thariq Shihipar started a thread with "We've rewritten Claude Code's terminal rendering system to reduce flickering by roughly 85%" (thread list, same text reposted by Boris Cherny). The fix was rolled back. On January 21, 2026 he posted that it was rolled out again to everyone, with a description of the pipeline. One number from it is worth remembering: a ~16 ms frame budget, with roughly 5 ms to get from the React scene graph to ANSI written.
That 85% is the team's own figure, and 85% is not 100%. What remains depends on whether your terminal knows when a frame ends.
How a TUI repaints a terminal
A terminal is a grid of cells. A program like Claude Code doesn't draw pixels. It writes bytes: printable text, plus escape sequences that move the cursor, erase lines and change colours. The terminal parses that stream into its grid and paints the grid to the screen on its own schedule.
The terminal has no idea where your "frame" ends. If an app moves the cursor up, erases ten lines and writes ten new ones in several writes, the terminal is free to paint after any of them. Catch it after the erase and you see a blank block. Catch it halfway and you see half the old frame under half the new one. At a typical 60 Hz paint rate, that is flicker.
Chris Lloyd, writing about "our pipeline" on Hacker News, said most people picture Claude Code as "just a TUI" when it should be closer to "a small game engine". Each frame, the pipeline builds a scene graph with React, lays it out, rasterizes it to a 2D screen, diffs that against the previous screen, and only then turns the diff into ANSI sequences. Diffing at the cell level means fewer bytes per frame and fewer chances to be caught mid-update, but any frame that still takes more than one write can be painted half-done.
Where the conversation lives matters too. The Claude Code docs say the classic renderer keeps the conversation in your terminal's native scrollback, while the newer fullscreen renderer draws on the alternate screen buffer, like vim or htop, and only renders messages that are visible. The docs say the difference is most noticeable where rendering throughput is the bottleneck, naming the VS Code integrated terminal, tmux and iTerm2. The same page describes fullscreen, still a research preview, as the mode that eliminates flicker. To try it, run /tui fullscreen inside a session, or start with CLAUDE_CODE_NO_FLICKER=1 claude; /tui default switches back.
What ?2026h and ?2026l actually do
The fix for "the terminal painted too early" is to tell the terminal when a frame starts and ends. That is DEC private mode 2026, synchronized output, written up by Christian Parpart in the Contour terminal's spec.
- ESC [ ? 2026 h (set the mode) starts a synchronized update. The terminal keeps reading and processing everything you send, but it keeps showing the last frame it painted.
- ESC [ ? 2026 l (reset the mode) ends it. The terminal paints the current state of the grid in one go.
It is double buffering, with the app saying when to swap. The spec admits there is no consensus on how long a terminal should wait for the end marker before giving up, and notes that a timeout that is too short is no worse than not syncing at all.
You can ask a terminal whether it knows the mode with DECRQM. In bash:
printf '\e[?2026$p'
IFS= read -rs -t 1 -d y reply
printf '%q\n' "$reply"
Look at the digit after the semicolon in the reply. Per the spec, 1 or 2 means the mode is supported (currently set or reset), 0 means not recognized, and 4 means permanently reset, which also means no. No reply within the second counts as unsupported. Claude Code does this probe itself: the docs say it checks for synchronized-output support at startup and uses it when the terminal reports it.
Why tmux kept flickering: issue #37283
On March 22, 2026, a user filed #37283: Claude Code 2.1.81 in tmux 3.4 split panes, under WSL2 and Windows Terminal, flickered whenever it streamed output, with the cursor jumping to the top of the render area and back on every update. Idle panes stayed still. The reporter traced it to redraws not wrapped in DECSET 2026 and proposed the obvious fix: emit \e[?2026h before the cursor moves and clears, \e[?2026l after.
The catch is that tmux is a terminal too. It parses your app's output into its own grid for each pane, then redraws that grid to your real terminal. So the app's markers have to be understood by tmux, not forwarded. The sync value in tmux's terminal-features covers the other hop, tmux syncing its own redraws to the outer terminal. A commenter on the issue pointed out on April 14 that no released tmux supported the app side yet.
The tmux side had already been written. Chris Lloyd opened tmux PR #4744 on December 5, 2025: when a pane's app sends ESC[?2026h, tmux holds that pane's output until ESC[?2026l or a 1-second timeout, and drops the mode on resize so tmux can always redraw. The maintainer applied it to the OpenBSD tree, and the tmux CHANGES file lists it under 3.7, along with "Respond to DECRQM 2026".
The same author also wrote PR #5453 for xterm.js, the terminal component inside VS Code. The mode 2026 request, xterm.js issue #3375, was closed on December 20, 2025 by a commit titled "Add synchronized output support (DEC mode 2026)".
Issue #37283 itself was closed as not planned by a bot after going stale on May 28. The Claude Code docs now state the rule plainly: tmux releases through the 3.6 series don't implement synchronized output, so under them you may see more flicker than running Claude Code directly in your terminal. Upgrade to tmux 3.7 or later, or run Claude Code in its own tab. Check yours with tmux -V. The latest release is 3.8.
See it in your own terminal
This script redraws a 20-row box 150 times. Each frame clears the screen, waits 10 ms to stand in for slow layout, then writes one row at a time with a flush after each. Pass sync and it wraps every frame in the mode 2026 pair.
#!/usr/bin/env python3
"""Redraw a box 150 times, with or without mode 2026."""
import sys
import time
sync = sys.argv[1:] == ["sync"]
out = sys.stdout
out.write("\x1b[?25l") # hide the cursor
try:
for n in range(150):
if sync:
out.write("\x1b[?2026h") # begin synchronized update
out.write("\x1b[H\x1b[2J") # cursor home, erase screen
out.flush()
time.sleep(0.01) # pretend layout is slow
for row in range(20):
out.write(f"| row {row:2} frame {n:3} |\n")
out.flush() # one write per row
if sync:
out.write("\x1b[?2026l") # end: paint it all now
out.flush()
time.sleep(0.03)
finally:
out.write("\x1b[?2026l\x1b[?25h\n") # always end, show cursor
Save it as flicker.py and use a window at least 22 rows tall. Run python3 flicker.py, then python3 flicker.py sync. Then open tmux and run both again.
What to look for, from the spec rather than from my screen: without sync, the 10 ms blank sits between two writes, so the terminal can paint it and the box blinks. With sync on a terminal that supports the mode, the terminal keeps showing the previous full frame until the end marker, so only the frame counter should change. On a terminal or tmux that doesn't know the mode, both runs look the same, which is the same situation #37283 described. The finally line sends the end marker even if you press Ctrl+C mid-frame, so nothing stays frozen.
Where Hermes IDE fits
I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent's own interface in a real terminal, Claude Code included, the same way it would run anywhere else. It doesn't re-draw Claude Code's UI. The bytes Claude Code writes, frame markers and all, go to a terminal emulator, and Hermes renders its terminals GPU-accelerated with WebGL where the system supports it. The one exception is opt-in: tick "Agent view for Claude" on a session and Hermes renders the conversation itself instead of Claude's terminal interface.
!Hermes IDE with two agent sessions side by side, Codex on orders-types and Claude Code on orders-api, each in its own terminal and worktree, with the sessions sidebar on the left
Hermes 2.3.4: Codex and Claude Code side by side, each in its own terminal and hermes/ worktree, with status, context use, cost and branch in the sidebar.
That design means everything above applies inside Hermes too. Whether a frame tears depends on the terminal the agent writes to, so run the DECRQM probe and flicker.py in a Hermes shell next to your usual terminal and trust the reply over anyone's claim, mine included. If you start agents in tmux sessions from Hermes's New Session window, the tmux version rule from the Claude Code docs still holds.
Hermes runs on macOS, Windows and Linux and is source-available under BSL 1.1 on GitHub; you can download it here. It works the same way for every vendor, with your own sign-ins.
---
If this was useful
A 16 ms frame budget, and a 1-second timeout that gives up on a missing end marker instead of freezing the pane, are the same latency and timeout calls you make in any backend. My System Design Pocket Guide: Fundamentals covers those building blocks one at a time, without the interview theatre.

Mentions
Why One Emoji Breaks Your Coding Agent's TUI Borders: wcwidth vs Grapheme Clusters - Book: AI That Acts - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Paste into Claude Code 2.1.207 and the text after it slides one cell to the right. That is the report in anthropics/claude-code#80475, opened July 23, 2026: stray characters bleeding into neighbouring rows, inverse-video blocks between words, and a mess that stays until Ctrl-L or a window resize forces a full repaint. The emoji is U+1FAEA, DISTORTED FACE, which arrived with Unicode 17.0 in 2025. The terminal drew it two cells wide. Claude Code's width table was older than the emoji and counted fewer. In the maintainer's closing comment, the fix "came from updating the Unicode width tables in the runtime Claude Code ships with," and from 2.1.221 on, both and (U+1FAE9, FACE WITH BAGS UNDER EYES, reported later in the same thread) measure as two cells. Claude Code and the terminal disagreed on how many columns U+1FAEA takes, and every cell after it inherited the gap. Two programs, one grid A terminal is a grid of cells. A TUI like Claude Code, Codex or Gemini CLI never sees that grid. It writes bytes, and to draw a box it has to predict where the cursor will be after each string. The right border of a box sits at "left edge plus the width I computed." The terminal then advances the cursor by the width it computed. When those two numbers agree, borders line up. When they differ by one, everything the program draws after that character lands one column off, and every partial redraw targets the wrong cells. That is why these bugs show up as smearing and duplicated lines across the whole screen. kitty's text sizing protocol docs put it bluntly: if the client program and the terminal disagree on cell counts, "the entire user interface can be broken". !A terminal grid with two rulers, one from the program and one from the terminal, measuring the same emoji tile and ending one cell apart The program and the terminal each measure the same character. One cell of disagreement moves every border after it. Three counts, none of them is width Start with what JavaScript can count. Save this as count.mjs and run it with node count.mjs: const seg = new Intl.Segmenter("en", { granularity: "grapheme", }); const samples = { ascii: "A", cjk: "中", heart: "\u{2764}", heartVS16: "\u{2764}\u{FE0F}", thumbTone: "\u{1F44D}\u{1F3FD}", family: "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", distorted: "\u{1FAEA}", }; console.log("ICU Unicode:", process.versions.unicode); for (const [name, s] of Object.entries(samples)) { const units = s.length; const points = [...s].length; const clusters = [...seg.segment(s)].length; console.log(name.padEnd(10), s, units, points, clusters); } .length counts UTF-16 code units. Spreading the string counts code points. Intl.Segmenter counts grapheme clusters, which UAX #29 defines as user-perceived characters. Its rule GB11 says: "Do not break within emoji modifier sequences or emoji zwj sequences." The family is three emoji glued together by two ZERO WIDTH JOINERs (U+200D), so it is five code points and eight UTF-16 units but one grapheme, while the skin-tone thumbs-up and the heart followed by U+FE0F are two code points and one grapheme each. A Chinese character is one code point and one grapheme, and it still takes two cells. The first line prints the Unicode version your Node's ICU knows. The Node docs example shows unicode: '17.0'. Unicode 18.0 is dated September 16, 2026. Every program that measures width carries a snapshot of some version, and they are rarely the same snapshot. Now draw a box the naive way, one cell per code point: const rows = [ "plain text", "中文", "\u{2764}\u{FE0F} ok", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} team", ]; const width = (s) => [...s].length; const inner = 16; const edge = "+" + "-".repeat(inner) + "+"; console.log(edge); for (const r of rows) { console.log("|" + r + " ".repeat(inner - width(r)) + "|"); } console.log(edge); The plain-text row lines up. The 中文 row overshoots by two cells in every terminal, because each character is one code point and two cells. The heart and family rows are where terminals disagree: run the script in two emulators and compare where those right borders land. Where width actually comes from The width a terminal assigns usually starts from the East_Asian_Width property in UAX #11. Its values are A (ambiguous), F (fullwidth), H (halfwidth), N (neutral), Na (narrow) and W (wide). W and F take two cells, and W also covers "characters that have the UTS51 property Emoji_Presentation." For ambiguous characters the annex says: "If the context cannot be established reliably, they should be treated as narrow characters by default." That covers single code points. Emoji sequences are where the tables run out. UTS #51 defines U+FE0F, VARIATION SELECTOR-16, as the character "used to request an emoji presentation." The plain heart U+2764 has no Emoji_Presentation, so on its own it is a text-style, one-cell character. Add VS16 and you ask for the two-cell emoji. A per-code-point width function sees one narrow character plus a variation selector that takes no cells of its own, and so the total depends on whether the function knows the sequence. The Python wcwidth library documents exactly this split. wcwidth('\U00002640'), the plain ♀ (U+2640), is 1. wcswidth('\U00002640\U0000FE0F'), the same sign with VS16, is 2. Ask for VTE's behaviour with wcstwidth(..., term_program='vte') on that same VS16 string and the docs show 1. Its four-person family example measures 2 by default and 8 with term_program='alacritty', a correction the docs put down to Alacritty not supporting emoji ZWJ. Here is the Python side of the same experiment: # pip install --upgrade wcwidth import unicodedata from wcwidth import wcwidth, wcswidth print("UCD", unicodedata.unidata_version) for ch in ["A", "中", "\U00002764", "\U0001FAEA"]: eaw = unicodedata.east_asian_width(ch) print(f"U+{ord(ch):04X}", eaw, wcwidth(ch)) print(wcswidth("\U00002640\U0000FE0F")) # wcwidth docs: 2 unicodedata follows the Python release, not the wcwidth package. The Python 3.15 docs say its data comes from UCD 17.0.0. An older interpreter or an older wcwidth will give a different answer for U+1FAEA, which is the same kind of mismatch as the Claude Code bug. It goes the other way too. In gemini-cli#25369, Ink, the React renderer Gemini CLI draws with, used Intl.Segmenter to merge a Thai consonant and SARA AM (U+0E33) into one cluster of width 1, while tmux and xterm.js drew it across two columns. The symptom was one streamed line repeated 8 to 10 times and an input line that kept jumping down. It was closed as completed on October 23, 2026, via PR #29883. This time the program was the one counting by clusters, and the terminal in front of it counted differently. !Three code points, a man, a woman and a girl, fusing through two joiner links into one emoji tile on a terminal grid A ZWJ sequence is several code points and one grapheme. Whether it takes two cells or eight depends on who is counting. Mode 2027: asking the terminal to count graphemes Some terminals now let a program opt into one shared rule. Contour's terminal-unicode-core spec defines DEC private mode 2027. CSI ? 2027 h turns it on, CSI ? 2027 l turns it off, and CSI ? 2027 $ p asks whether the terminal supports it. With the mode on, a grapheme cluster lives in one place on the grid, and a VS16 in the cluster promotes it to emoji presentation, which makes it two cells wide. You can probe your terminal from Node: process.stdin.setRawMode(true); const timer = setTimeout(() => done("no reply"), 500); function done(msg) { clearTimeout(timer); process.stdin.setRawMode(false); console.log(msg); process.exit(0); } process.stdin.once("data", (b) => done(JSON.stringify(String(b)))); process.stdout.write("\x1b[?2027$p"); The answer is a DECRPM report shaped like CSI ? 2027 ; Ps $ y. Ps 0 means the terminal doesn't recognise the mode, 1 means it is set, 2 means reset, 3 and 4 mean permanently set or reset. The timeout matters: a terminal that ignores the query sends nothing at all. Support is uneven. In the ucs-detect results, which measure terminals by printing test strings and asking for the cursor position afterwards, Ghostty, WezTerm, foot and Windows Terminal report mode 2027. kitty, iTerm2, Alacritty and xterm.js do not. The page gives no test date and says results are provided as-is, so check the version next to each row. Ghostty exposes the trade-off as a setting. grapheme-width-method defaults to unicode, which the docs call correct but able to desync the cursor with programs that measure the legacy wcswidth way. legacy matches those programs and can get skin-tone emoji wrong. A program that turns on mode 2027 forces unicode regardless. kitty went a different direction with OSC 66, where the program states the width with w= and the terminal stops guessing. The two-pane test Put two agent TUIs side by side and the disagreement becomes something you can locate. I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent's own interface in a real terminal and lets you split panes right or down, so Claude Code and Codex can sit next to each other on the same screen. !Two agent sessions side by side in Hermes IDE: Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar on the left Hermes IDE 2.3.4: Codex on orders-types and Claude Code on orders-api, side by side, each in its own hermes/ worktree, with the sessions sidebar showing status, context use, cost and branch. Paste the same string with an emoji into both. Both panes live in the same app, so the terminal side of the comparison stays fixed. If one pane's border breaks and the other's holds, the difference is in the agents. Codex's TUI is built on Ratatui and pulls in the unicode-width and unicode-segmentation crates (its Cargo.toml), and Claude Code ships its own width tables in its runtime, so the two can carry different Unicode snapshots. Then flip it. Run the same agent in a second terminal emulator. If the border is fine there, the agent agrees with that emulator and the first one is the odd one out. The rest is cheap: 1. Update the agent first. claude update is the whole fix for #80475. 2. Repaint with Ctrl-L or a resize to clear a smeared screen. 3. Keep fresh emoji out of the strings an agent draws in its chrome: session names, branch names, status lines. 4. If you write a TUI, measure grapheme clusters, query mode 2027 and turn it on when the reply shows the mode is recognised (Ps 1 or 2). Where it isn't, test against the terminals your users actually run. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or Google. Download it and the source is on GitHub. Finding the character Next time an agent's box comes out with a jagged right edge, skip the font settings and find the character just before the break. Compare the width your agent gives it with the width your terminal draws, then check which Unicode version each was built with. If the versions match, compare how each one segments that cluster; the Gemini CLI Thai bug was that kind. --- If this was useful If you're building your own agent in TypeScript, the terminal is only the surface. AI That Acts covers the part underneath it: tool calling, functions, and getting a first working agent to do real work in Node, so the thing drawing those borders has something worth showing.  It is book 3 of AI in TypeScript, a five-book series that runs from your first LLM call to agents in production. 
Why Claude Code Flickered: DEC Mode 2026, Cell Diffs and a 16 ms Frame Budget - Book: System Design Pocket Guide: Fundamentals - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- GitHub issue #769 on the Claude Code repo, "In-progress Call causes Screen Flickering", was opened on April 12, 2025. It is still open. Peter Steinberger called it Claude Code's signature flicker: the Claude Code screen blinking as tool output streamed in. On December 17, 2025, Anthropic's Thariq Shihipar started a thread with "We've rewritten Claude Code's terminal rendering system to reduce flickering by roughly 85%" (thread list, same text reposted by Boris Cherny). The fix was rolled back. On January 21, 2026 he posted that it was rolled out again to everyone, with a description of the pipeline. One number from it is worth remembering: a ~16 ms frame budget, with roughly 5 ms to get from the React scene graph to ANSI written. That 85% is the team's own figure, and 85% is not 100%. What remains depends on whether your terminal knows when a frame ends. How a TUI repaints a terminal A terminal is a grid of cells. A program like Claude Code doesn't draw pixels. It writes bytes: printable text, plus escape sequences that move the cursor, erase lines and change colours. The terminal parses that stream into its grid and paints the grid to the screen on its own schedule. The terminal has no idea where your "frame" ends. If an app moves the cursor up, erases ten lines and writes ten new ones in several writes, the terminal is free to paint after any of them. Catch it after the erase and you see a blank block. Catch it halfway and you see half the old frame under half the new one. At a typical 60 Hz paint rate, that is flicker. Chris Lloyd, writing about "our pipeline" on Hacker News, said most people picture Claude Code as "just a TUI" when it should be closer to "a small game engine". Each frame, the pipeline builds a scene graph with React, lays it out, rasterizes it to a 2D screen, diffs that against the previous screen, and only then turns the diff into ANSI sequences. Diffing at the cell level means fewer bytes per frame and fewer chances to be caught mid-update, but any frame that still takes more than one write can be painted half-done. Where the conversation lives matters too. The Claude Code docs say the classic renderer keeps the conversation in your terminal's native scrollback, while the newer fullscreen renderer draws on the alternate screen buffer, like vim or htop, and only renders messages that are visible. The docs say the difference is most noticeable where rendering throughput is the bottleneck, naming the VS Code integrated terminal, tmux and iTerm2. The same page describes fullscreen, still a research preview, as the mode that eliminates flicker. To try it, run /tui fullscreen inside a session, or start with CLAUDE_CODE_NO_FLICKER=1 claude; /tui default switches back. What ?2026h and ?2026l actually do The fix for "the terminal painted too early" is to tell the terminal when a frame starts and ends. That is DEC private mode 2026, synchronized output, written up by Christian Parpart in the Contour terminal's spec. - ESC [ ? 2026 h (set the mode) starts a synchronized update. The terminal keeps reading and processing everything you send, but it keeps showing the last frame it painted. - ESC [ ? 2026 l (reset the mode) ends it. The terminal paints the current state of the grid in one go. It is double buffering, with the app saying when to swap. The spec admits there is no consensus on how long a terminal should wait for the end marker before giving up, and notes that a timeout that is too short is no worse than not syncing at all. You can ask a terminal whether it knows the mode with DECRQM. In bash: printf '\e[?2026$p' IFS= read -rs -t 1 -d y reply printf '%q\n' "$reply" Look at the digit after the semicolon in the reply. Per the spec, 1 or 2 means the mode is supported (currently set or reset), 0 means not recognized, and 4 means permanently reset, which also means no. No reply within the second counts as unsupported. Claude Code does this probe itself: the docs say it checks for synchronized-output support at startup and uses it when the terminal reports it. Why tmux kept flickering: issue #37283 On March 22, 2026, a user filed #37283: Claude Code 2.1.81 in tmux 3.4 split panes, under WSL2 and Windows Terminal, flickered whenever it streamed output, with the cursor jumping to the top of the render area and back on every update. Idle panes stayed still. The reporter traced it to redraws not wrapped in DECSET 2026 and proposed the obvious fix: emit \e[?2026h before the cursor moves and clears, \e[?2026l after. The catch is that tmux is a terminal too. It parses your app's output into its own grid for each pane, then redraws that grid to your real terminal. So the app's markers have to be understood by tmux, not forwarded. The sync value in tmux's terminal-features covers the other hop, tmux syncing its own redraws to the outer terminal. A commenter on the issue pointed out on April 14 that no released tmux supported the app side yet. The tmux side had already been written. Chris Lloyd opened tmux PR #4744 on December 5, 2025: when a pane's app sends ESC[?2026h, tmux holds that pane's output until ESC[?2026l or a 1-second timeout, and drops the mode on resize so tmux can always redraw. The maintainer applied it to the OpenBSD tree, and the tmux CHANGES file lists it under 3.7, along with "Respond to DECRQM 2026". The same author also wrote PR #5453 for xterm.js, the terminal component inside VS Code. The mode 2026 request, xterm.js issue #3375, was closed on December 20, 2025 by a commit titled "Add synchronized output support (DEC mode 2026)". Issue #37283 itself was closed as not planned by a bot after going stale on May 28. The Claude Code docs now state the rule plainly: tmux releases through the 3.6 series don't implement synchronized output, so under them you may see more flicker than running Claude Code directly in your terminal. Upgrade to tmux 3.7 or later, or run Claude Code in its own tab. Check yours with tmux -V. The latest release is 3.8. See it in your own terminal This script redraws a 20-row box 150 times. Each frame clears the screen, waits 10 ms to stand in for slow layout, then writes one row at a time with a flush after each. Pass sync and it wraps every frame in the mode 2026 pair. #!/usr/bin/env python3 """Redraw a box 150 times, with or without mode 2026.""" import sys import time sync = sys.argv[1:] == ["sync"] out = sys.stdout out.write("\x1b[?25l") # hide the cursor try: for n in range(150): if sync: out.write("\x1b[?2026h") # begin synchronized update out.write("\x1b[H\x1b[2J") # cursor home, erase screen out.flush() time.sleep(0.01) # pretend layout is slow for row in range(20): out.write(f"| row {row:2} frame {n:3} |\n") out.flush() # one write per row if sync: out.write("\x1b[?2026l") # end: paint it all now out.flush() time.sleep(0.03) finally: out.write("\x1b[?2026l\x1b[?25h\n") # always end, show cursor Save it as flicker.py and use a window at least 22 rows tall. Run python3 flicker.py, then python3 flicker.py sync. Then open tmux and run both again. What to look for, from the spec rather than from my screen: without sync, the 10 ms blank sits between two writes, so the terminal can paint it and the box blinks. With sync on a terminal that supports the mode, the terminal keeps showing the previous full frame until the end marker, so only the frame counter should change. On a terminal or tmux that doesn't know the mode, both runs look the same, which is the same situation #37283 described. The finally line sends the end marker even if you press Ctrl+C mid-frame, so nothing stays frozen. Where Hermes IDE fits I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent's own interface in a real terminal, Claude Code included, the same way it would run anywhere else. It doesn't re-draw Claude Code's UI. The bytes Claude Code writes, frame markers and all, go to a terminal emulator, and Hermes renders its terminals GPU-accelerated with WebGL where the system supports it. The one exception is opt-in: tick "Agent view for Claude" on a session and Hermes renders the conversation itself instead of Claude's terminal interface. !Hermes IDE with two agent sessions side by side, Codex on orders-types and Claude Code on orders-api, each in its own terminal and worktree, with the sessions sidebar on the left Hermes 2.3.4: Codex and Claude Code side by side, each in its own terminal and hermes/ worktree, with status, context use, cost and branch in the sidebar. That design means everything above applies inside Hermes too. Whether a frame tears depends on the terminal the agent writes to, so run the DECRQM probe and flicker.py in a Hermes shell next to your usual terminal and trust the reply over anyone's claim, mine included. If you start agents in tmux sessions from Hermes's New Session window, the tmux version rule from the Claude Code docs still holds. Hermes runs on macOS, Windows and Linux and is source-available under BSL 1.1 on GitHub; you can download it here. It works the same way for every vendor, with your own sign-ins. --- If this was useful A 16 ms frame budget, and a 1-second timeout that gives up on a missing end marker instead of freezing the pane, are the same latency and timeout calls you make in any backend. My System Design Pocket Guide: Fundamentals covers those building blocks one at a time, without the interview theatre. 