Why Claude Code garbles when you rotate your phone

Two weeks on column widths, one measurement that ended them, and the thing every "attach to a running session" implementation gets wrong.

I have a Claude Code session running on my Mac, mirrored live on my phone. I rotate the phone. The screen turns into two columns of interleaved garbage. I tap a button in the TUI, the app's text interface. Nothing happens.

Neither of those is a rendering bug. Chasing them took two weeks, one full revert, and one measurement, and on the way the same root cause quietly corrupted session files on disk. Almost none of it is written down anywhere, so here it is.

01 · PRIOR ART

What already exists, honestly

  • ssh + tmux is the right answer for most people. Blink or Secure ShellFish, Tailscale, done. If that covers you, stop reading and go set it up.
  • VS Code Remote / Codespaces / Cursor: they solve editing, not a running Simulator, and a full editor on a phone screen is misery.
  • VNC / RDP: solves everything visually, at desktop-UI-on-a-phone prices.
  • Hosted agents: fine until you need xcodebuild, a Simulator, or a certificate that lives on your machine.
  • reptyr: steals a running process into a new terminal with ptrace, the debugger interface. Prior art for everything with "attach" in the name.
  • ACP (the Agent Client Protocol, JSON-RPC between editor and agent; Zed 2025, JetBrains and Neovim since): for an agent that speaks it, every problem below disappears. I use it, and adding an agent is a config entry, not code. But it gives you the agent, not the machine: no builds, no logs, no debugger, no tool without an adapter. The PTY, the pseudo-terminal every command-line program speaks, is the one interface everything has.

I wanted the machine, so I was stuck with the PTY.

02 · PTY

The thirty seconds of PTY you need

Spawn a process with pipes and you get its output but no terminal: isatty() is false, vim won't draw, and there's no window size, because a pipe doesn't have one. It's the same reason git log gives you a pager in a terminal and a raw dump when piped into a file. forkpty() creates the real thing. The kernel then owns a window size for that tty, and when it changes it sends the program SIGWINCH, "window changed". That signal is the only reason vim knows to redraw. The rest of the TTY story is told better than I'll ever tell it in The TTY demystified.

One debugging trick worth stealing: run stty -a on the PTY's own tty. If it reports the right rows and columns, your wrapper is correct and the bug is above it. Mine was above it.

03 · WIDTH

The scrollback has no columns

A scrollback (the history above the visible screen) is raw bytes, and bytes have no width. Look at the figure: Claude's spinner repaints itself by moving the cursor up two rows from the bottom, over the command line, onto its own row. Replay the same bytes on a narrower grid and the command wraps onto two rows, so up-two now lands inside the wrapped command and the repaint writes a second spinner mid-text. Two texts on one row. That's the garble.

1 · PAINTED AT 40 COLUMNS · CORRECT Building the app ⠙ compiling… ❯ xcodebuild -scheme Xtend done The spinner repaints itself with "cursor up 2" from the bottom row. Two rows up is the spinner. Correct. 2 · SAME BYTES, REPLAYED AT 18 COLUMNS · GARBLED Building the app ⠙ compiling… ⠙ compiling… -sche me Xtend done The command wrapped onto two rows, so "up 2" now lands inside it. The repaint writes a second spinner mid-command. Two texts on one row. The break positions were never in the bytes. They lived in the grid that is gone.
FIG 1 · one stream of drawing instructions, two grids. Only one matches the instructions

This is nobody's edge case. Codex merged a reflow-scrollback-on-resize fix this year. Claude Code's changelog said "Fixed scrollback duplication" in 2.1.116, and #51828 is open because the symptom came back unchanged four releases in a row, with duplicates still being filed weeks later. When fixes keep not sticking, the cause is usually a layer down.

To replay correctly you have to know where the width changed. So I needed to slip notes into the stream that my own app reads and every other terminal ignores. Terminals have a built-in rule for exactly this, an old escape-sequence family called APC: whatever sits between its start and end markers, a terminal that doesn't understand it must silently throw away. This:

ESC _ Xtend;width=90x30 ESC \

travels inside the stream, lands at the right position by construction, invisible everywhere else. Kitty's graphics protocol rides the same rule. One constraint I'd keep if I did it again: the notes are never written into the stored history, they're added at replay time. Storage stays pure bytes, and old history survives any format change.

04 · THE MEASUREMENT

The measurement that killed the project

I built all of that to re-wrap history at any width. Then, in early August, I recorded real Claude Code sessions through tmux at three widths and looked at what the terminal emulator (the component turning bytes into a grid) actually held:

Claude ends every line it prints with a real newline. 252 real rows, zero soft wraps (lines folded by the terminal, not by the text).

There's nothing to re-wrap. Widening the terminal can never make Claude's output reflow, in my app or in VS Code or in iTerm, because the breaks are in the content, not the layout. Two weeks of reflow machinery, aimed at a property the content doesn't have. What survived is much narrower: it fixes repaint artefacts.

The embarrassing part is that one recording in week one would have shown this. The bug was dramatic, so I went straight to fixing it and never looked at what the content was actually made of.

05 · SWIFTTERM

Two SwiftTerm bugs, one Mirror

SwiftTerm is the terminal emulator most Swift apps embed. Two behaviours each cost me days:

  • resize() loses the cursor's line tail. Shrink 90 to 44 columns: every line re-wraps correctly except the one under the cursor. A 60-char line becomes 44 + 16 normally, just 44 with the cursor on it. So never resize an emulator holding content you can't lose. Serialise at the current width and let the destination wrap.
  • The wrap flag survives overwrites. Repaint a row in place, which Claude does constantly, and the old continuation flag stays on the new content. Trust it and you glue unrelated rows together. Worth an upstream report.

Why does that flag matter? When I serialise the buffer I have to answer one question per row: is the next row a continuation of this one (one long line the terminal folded), or a separate line? Glue two separate lines together and you've invented text that never existed. SwiftTerm knows the answer, that's the wrap flag, but it's internal, there's no API for it. Swift has a reflection tool, Mirror, that looks inside a value and reads fields the API doesn't expose:

let flag = Mirror(reflecting: bufferLine)
    .children
    .first { $0.label == "isWrapped" }?
    .value as? Bool

It's a hack and I treat it as one: if the library ever renames the field, my code falls back to gluing nothing (a missing join is ugly, a wrong join is a lie), and a pinned test screams the day that happens. And since the flag itself can be stale, there's a sanity check on top: a fold can only happen on a row that was completely full, so a wrap claimed under a half-empty row is rejected. That took bad glue-ups from 9 out of 9 down to 1 in 9. The last one, a row repainted to exactly full width, can't be told apart from outside.

06 · THE THESIS

The thesis: modes are announced once

Now the dead taps. A terminal is bytes plus a mode state, and the state is announced once, at startup, never repeated. Claude Code switches to the alternate screen (the separate full-screen buffer vim-style apps draw on) and turns on mouse reporting in its first bytes, then assumes the terminal remembers forever. (Codex never touches the alternate screen; it repaints a small region in one batched frame, which is why its scroll feels instant.)

THE SESSION'S BYTE STREAM, START → NOW alt-screen on mouse on …hours of ordinary output… ↑ WHAT A REATTACH REPLAYS. THE DECLARATIONS ARE OUTSIDE IT fresh emulator on the phone mouse: off. nobody told it otherwise tap → dropped the fix: the host remembers the modes and replays them first, on every reattach
FIG 2 · the declarations were emitted once, before the replay window. The fresh emulator never hears them

Open the app on the phone: a fresh emulator replays recent scrollback. The mode declarations happened before that window. The emulator believes the mouse is off, so your tap sends nothing, so the TUI's own buttons are dead. Every naive "attach to a running session" has this bug. It's why reattached tmux sessions and web terminals feel subtly broken in ways nobody can quite articulate.

The fix isn't clever, you just have to know it's needed: the host remembers the session's mode state and replays it first on every reattach. Mouse modes, bracketed paste (how pasted text gets marked), alternate screen. It fixed the taps, and it fixed a paste bug I hadn't connected to it.

mosh reached this diagnosis in 2012: synchronize state, don't replay bytes. It also dodged the width problem, by syncing only the visible screen and throwing scrollback away, which is its most famous complaint.

07 · THE DETOUR

The detour that corrupted transcripts

This is the part that cost the most. Claude has an inline mode, CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1: no alternate screen, everything is plain scrollback, so the phone scrolls natively with real iOS physics. I shipped it. It cost me twice.

Cost one: total resize synchronization. In inline mode the phone re-renders history bytes itself, so the Mac's kernel-side window size and the phone's grid have to agree exactly, at all times, through rotations, pinch zoom and reattaches. Every mismatch is figure 1. In alternate screen mode the TUI repaints itself on every SIGWINCH; the resize problem doesn't vanish, it changes owner.

Cost two, the deal-breaker: corrupted transcripts. A Claude transcript is a tree, every entry points at its parent, and --resume continues from the last line of the file. My app keeps the phone's claude process alive after you walk away. Continue the session on the Mac and the file grows past that process's head. Fine, until someone types /exit in the stale process. In alternate screen mode that writes nothing. In inline mode it writes a short farewell into the transcript, anchored on the stale head. I pinned it down with a controlled tmux experiment (claude CLI 2.1.220, August 2026): variable set, the farewell gets written; unset, nothing. The file now ends in the past, the next --resume follows it, and everything in between is orphaned on a branch nobody will ever resume. "The phone overwrote my work" was actually a goodbye message grafted onto the wrong node.

I reported it upstream rather than patch around it, since a local workaround means detecting divergence inside someone else's transcript format, and that's a treadmill. Then I reverted, and made non-native scroll good instead, mostly by respecting what the TUI already supports: flicks become mouse-wheel events fed through a per-frame pump with inertia, capped at a few rows per frame because uncapped bursts trigger Claude's own scroll acceleration. Taps become clicks, which resurrected Claude's "jump to bottom" button. Links got underlines so they read as tappable.

08 · FORK

Fork, don't mirror

What I ended up believing: a terminal is the right substrate and the wrong interface for a phone. So the primary view is a chat, structured turns, native scrolling, no raw terminal codes, and the terminal is one tap away when you need it.

Which leaves the question of what happens to the session when that terminal opens. My first design was a mirror, chat and terminal as two synced views of one session. 315 lines of sync machinery, all deleted, because of undocumented CLI behaviour I measured after getting it wrong once. --resume means "continue that conversation file"; the numbers are the file's line count before and after sending one message:

--print --resume                    original 15 -> 23 lines, NO new file
--print --resume --fork-session     original stays 23, fork created at 25

Plain --resume appends to the same file. Two live processes on one session then both write into it while ignoring each other: the file corrupts at the moment of writing, and no syncing after the fact can repair that. --fork-session copies the conversation instead. So the Terminal button runs claude --resume <ref> --fork-session: same history up to that point, its own file from then on, chat untouched.

chat session: keeps running, untouched tap Terminal → claude --resume <ref> --fork-session terminal: same history, its own transcript
FIG 3 · branch instead of sharing, and the concurrency problem disappears

Branch instead of sharing, and there's no concurrency left to manage. One gotcha that's worth an afternoon of your life: the session ref is minted at spawn, but the transcript file only exists after the first completed turn, so resuming a chat that never got a reply fails with "No conversation found". Check the file exists, else launch plain.

09 · STILL BROKEN

Still broken

  • Hard newlines never re-join on widening. That's the content, not a bug, and it's equally true in VS Code and iTerm. Only re-running the program re-renders it.
  • /exit typed inside a forked terminal still diverges silently. No handling.
  • One bad glue-up in nine survives the full-row check. Not fixable from outside SwiftTerm.
  • Garbling during a pinch gesture is inherent; it settles with the gesture.
10 · LESSONS

Three things I'd tell someone starting

  1. Measure the content before building machinery for it. One recording in week one would have saved two weeks.
  2. The bug is one layer below where it looks. Garbled text was a storage bug, not rendering. Dead taps were a missing mode declaration, not touch handling.
  3. When you're building sync between two things, try branching instead. Every sync bug I deleted couldn't exist in the forked design.

The reflow effort was 3,220 insertions across 18 files, and I reverted all of it before shipping something much smaller that worked. Reverting was the right call even though it stung.

The input side is its own story: touch, the keyboard, and the hidden text field with five thousand ghost characters that makes backspace auto-repeat work. Next article.