Skip to content

Surface is sized before it enters the view hierarchy, so zmx attach runs at a placeholder geometry and the re-attach replay corrupts the screen #780

Description

@dvomar

What happened?

Terminal output is occasionally left visibly corrupted — the previous frame stays on screen underneath the new one, wrapped at a much narrower width. It is most obvious with full-screen TUI agents (Claude Code), where a diff rendered at ~46 columns remains painted under the same diff rendered at 188 columns.

The cause is a geometry mismatch at surface creation. A GhosttySurfaceView is constructed with a hardcoded placeholder frame and createSurface() is called immediately, while the view is not yet in a window:

// supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift:251-254
super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
wantsLayer = true
bridge.surfaceView = self
createSurface()

createSurface() ends with updateSurfaceSize() (:1066). At that point:

  • the view has no window, so convertToBacking (:946) applies a 1× scale to the 800×600 placeholder frame and hands Ghostty 800×600 pixels, while the surface config was told scale_factor = 2.0 (:1020);
  • ghostty_surface_size(surface) still reports cell_width_px == 0 immediately after ghostty_surface_new, so updateSurfaceSize takes the early branch at :954-957 that calls ghostty_surface_set_size without the columns >= 5, rows >= 2 sanity guard that applies on line 960.

Ghostty spawns the surface command — zmx attach <id> — against that PTY. The result is that every session is attached at a placeholder geometry and then corrected twice within ~75 ms. From the zmx daemon log ($TMPDIR/zmx-<uid>/logs/supa-<id>.log), first attach of a fresh session:

[1786091084616] [debug] (default): init resize rows=16 cols=46
[1786091084616] [debug] (default): resize rows=16 cols=46
[1786091084626] [debug] (default): resize rows=34 cols=96
[1786091084762] [debug] (default): resize rows=56 cols=188

16×46 and 34×96 are both the 800×600 placeholder — first unscaled, then scaled to 1600×1200 backing px once the view is in a window. Only 56×188 is the real pane.

On a re-attach this turns into visible corruption, because the daemon serializes its screen snapshot before applying the new client's size (zmx/src/main.zig:942-999, deliberate, to keep the cursor position correct). The snapshot is captured at the old geometry and delivered to a client that is at that moment 46 columns wide:

[1786094461866] [debug] (default): cursor before serialize: x=2 y=50 pending_wrap=false
[1786094461867] [debug] (default): serialize terminal state       <- snapshot taken at 56x188
[1786094461868] [debug] (default): init resize rows=16 cols=46    <- replayed into a 46-column grid
[1786094461868] [debug] (default): resize rows=16 cols=46
[1786094461889] [debug] (default): resize rows=34 cols=96
[1786094461941] [debug] (default): resize rows=56 cols=188

Every 188-column line in the replay wraps roughly four times, the restored screen scrolls, and then the grid widens twice in quick succession. A TUI that repaints by erasing the number of physical lines it believes its last frame occupied (Ink, and therefore Claude Code) then erases the wrong count, and the narrow rendering is left on screen under the wide one.

Because zmx's own re-attach repaint is not size-stable either (neurosnap/zmx#230, "repaint at same size"; neurosnap/zmx#142), the surface never fully recovers on its own until the user forces a redraw.

Expected

A surface should attach at its real geometry. zmx attach should not be spawned against an 800×600 placeholder, and the PTY should not be resized twice within 75 ms of every surface creation.

Secondary observation

updateSurfaceSize (:944-962) records lastBackingSize = backingSize on line 950, before the guard columns >= 5, rows >= 2 else { return } on line 960. A size that is rejected as too small is therefore remembered as if it had been applied, so the surface's cached size and Ghostty's actual grid can disagree until a different size arrives.

Steps to reproduce

  1. Open a worktree tab and start a full-screen TUI agent in it (Claude Code), with enough output that it renders a tall frame — a long diff works well.
  2. Switch to another worktree and leave the tab hidden for more than 5 minutes, so terminalHibernationEnabled (default true) tears the surface down while the zmx session survives.
  3. Switch back. The tab wakes, a new surface is created, and zmx attach runs.
  4. The restored screen shows the pre-hibernation frame wrapped at a much narrower width with the new frame painted over it.

The geometry sequence itself reproduces on every surface creation and is visible without any of the above — start any new tab and check the daemon log:

grep -E "(init )?resize rows=" "$TMPDIR"zmx-$(id -u)/logs/supa-*.log | head

Every session begins rows=16 cols=46rows=34 cols=96 → real size.

Supacode version and build

0.10.8 (1785775286)

macOS version

macOS 26.5.1

System locale

en_CZ

Mac hardware

M3 Pro

Relevant logs or screenshots

Fresh session, three sizes in 146 ms:

[1786091084616] [debug] (default): init resize rows=16 cols=46
[1786091084616] [debug] (default): resize rows=16 cols=46
[1786091084626] [debug] (default): resize rows=34 cols=96
[1786091084762] [debug] (default): resize rows=56 cols=188

Re-attach: snapshot serialized at 56×188, replayed into 46 columns, corrected 73 ms later:

[1786094461866] [debug] (default): cursor before serialize: x=2 y=50 pending_wrap=false
[1786094461867] [debug] (default): serialize terminal state
[1786094461868] [debug] (default): init resize rows=16 cols=46
[1786094461868] [debug] (default): resize rows=16 cols=46
[1786094461889] [debug] (default): resize rows=34 cols=96
[1786094461941] [debug] (default): resize rows=56 cols=188

Observed across all six live session logs on this machine; the 16×4634×96 prefix is present in every one.

Before submitting

  • I searched existing issues and this is not a duplicate.
  • I am on the latest available version of Supacode.
  • I understand pull requests are closed until the issue is marked ready.
  • I agree to follow this project's Code of Conduct.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions