fix(desktop): repaint a terminal whose size changed while its socket was down
What & why
A terminal running a TUI that redraws with relative cursor moves (Claude Code) sometimes comes back from being parked or reconnecting with a mangled screen. Lines are overwritten or the footer is duplicated, while the host's copy of the same session is correct.
Cause. While a pane's socket cannot deliver resizes (parked, reconnecting, or before attached), the xterm can briefly shrink below the cursor row and then grow back to the same size:
- On the shrink, xterm drops the rows below the cursor.
- On the grow, it pulls rows back from scrollback, so the content ends up shifted relative to the cursor.
sendResizedropped both resizes silently. On attach the app sends the final size. That size equals the PTY's, so no SIGWINCH fires and nothing repaints.- The exact seq catch-up then lands on a shifted screen. Claude Code moves down with CUD (which clamps at the bottom row), moves up with CUU, and rewrites rows in place. On a shifted screen this overwrites real lines.
Fix.
- Desktop transport: remembers when the requested size changed while it could not send, and the terminal already had content. After
attached, it sends{ type: "repaint" }. - Restored terminals: a pane restored from its saved buffer is fitted before its socket opens.
mount()now records the size the runtime was created at, so that first offline fit also counts as a change and triggers a repaint (found in review). - Host: handles
repaintwith the existing repaint nudge. Older hosts ignore the unknown message, so new desktops still work against them.
This supersedes #7627. Resetting the GPU renderer did not address this cause.
How I tested it
Offline replay of a real broken pane. I took the app's saved snapshot and the host's exact catch-up bytes for that pane:
- Replaying them gives the correct screen.
- A resize to ≤55 rows and back to 59 before the catch-up reproduces the app's real broken buffer exactly. The cursor was on row 55. Shrinking only to 56 or more rows does not reproduce it.
Dev desktop app over CDP (partly synthetic). I forced the shrink with a CSS override for 200 ms during an unpark, and delayed connect() by 600 ms so the shrink happened before attach. The fixture TUI redraws the way Claude Code does.
- Fix disabled: transcript lines were missing in the app while the host still had them.
- Fix enabled: the app sent
resizeand thenrepaint, the TUI did a full redraw, and the app's screen matched the host's.
Tests:
terminal-ws-transport.test.ts: a newresized while parkedblock. A size that changes and comes back triggers a repaint; an unchanged size sends nothing.terminal.seq-catchup.node-test.ts: a new Phase 7. An exact reattach does not repaint until asked, andrepaintthen produces a full redraw.- Each new test fails without its half of the fix.
- The restored-terminal baseline has no test: a real xterm cannot open under happy-dom, so
mount()cannot run in the unit suite. The desktop terminal tests pass with it (3962/0).
Commands:
bun run lintpasses.bun run typecheckpasses (42/42).bun run test:- Desktop: 0 failures.
- Host-service: one 5 s timeout in
pull-requests.integration.test.ts. That file passes alone, and the host-service suite then passes 2707/0.
bun run test:e2e(host-service):- The runner picks the first Electron binary it finds. My checkout had a stale
electron@41beside 44, so better-sqlite3 failed with an ABI mismatch. I ran the same command with the 44 binary. - The
phase 5 … must converge the visible gridcheck fails on my machine, with or without this change (it also fails on unmodifiedmain). - With that one assertion disabled locally, Phases 1–7 pass, including the new Phase 7.
- The runner picks the first Electron binary it finds. My checkout had a stale
No screenshots: this has no UI change. The before/after evidence above is screen text compared between the app and the host.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (CI fails on lint warnings too) - "Allow edits from maintainers" is checked on fork PRs
Summary by cubic
Fixes TUIs that redraw with relative cursor moves (Claude Code) showing a mangled screen after a pane is parked or reconnects.
While a pane cannot deliver resizes (parked, reconnecting, or before attach), the xterm can shrink below the cursor row and grow back, shifting the screen relative to the program's cursor. On attach the final size matches the PTY's, so no SIGWINCH fires and the shifted screen is never repainted.
The desktop transport now remembers a size change it couldn't send and asks the host for a repaint after attaching. A runtime restored from its saved buffer gets a resize baseline from its starting size, so the first offline resize also marks it for repaint. The host handles the new repaint message; older hosts ignore it, so new desktops still work against them.
Summary by CodeRabbit
- Bug Fixes
- Fixed terminal displays not refreshing correctly after resizing while disconnected or parked. When the terminal reconnects, it now redraws if the dimensions changed, while avoiding an unnecessary redraw when they remain the same.
- Improved recovery of terminal output after reconnecting at the same dimensions, allowing the display to refresh when needed.
- Reconnecting terminals now apply the latest requested size before refreshing, helping ensure the display reflects the current dimensions and output.