fix(desktop): repaint a terminal whose size changed while its socket was down

AA
10d ago
+127-2

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:

  1. On the shrink, xterm drops the rows below the cursor.
  2. On the grow, it pulls rows back from scrollback, so the content ends up shifted relative to the cursor.
  3. sendResize dropped both resizes silently. On attach the app sends the final size. That size equals the PTY's, so no SIGWINCH fires and nothing repaints.
  4. 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 repaint with 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 resize and then repaint, the TUI did a full redraw, and the app's screen matched the host's.

Tests:

  • terminal-ws-transport.test.ts: a new resized while parked block. 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, and repaint then 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 lint passes.
  • bun run typecheck passes (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@41 beside 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 grid check fails on my machine, with or without this change (it also fails on unmodified main).
    • With that one assertion disabled locally, Phases 1–7 pass, including the new Phase 7.

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 lint and bun run typecheck pass (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.