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
- 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.
- 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.
- Switch back. The tab wakes, a new surface is created, and
zmx attach runs.
- 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=46 → rows=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×46 → 34×96 prefix is present in every one.
Before submitting
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
GhosttySurfaceViewis constructed with a hardcoded placeholder frame andcreateSurface()is called immediately, while the view is not yet in a window:createSurface()ends withupdateSurfaceSize()(:1066). At that point:convertToBacking(:946) applies a 1× scale to the 800×600 placeholder frame and hands Ghostty 800×600 pixels, while the surface config was toldscale_factor = 2.0(:1020);ghostty_surface_size(surface)still reportscell_width_px == 0immediately afterghostty_surface_new, soupdateSurfaceSizetakes the early branch at:954-957that callsghostty_surface_set_sizewithout thecolumns >= 5, rows >= 2sanity 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:16×46and34×96are both the 800×600 placeholder — first unscaled, then scaled to 1600×1200 backing px once the view is in a window. Only56×188is 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: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 attachshould 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) recordslastBackingSize = backingSizeon line 950, before theguard 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
terminalHibernationEnabled(defaulttrue) tears the surface down while the zmx session survives.zmx attachruns.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:
Every session begins
rows=16 cols=46→rows=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:
Re-attach: snapshot serialized at 56×188, replayed into 46 columns, corrected 73 ms later:
Observed across all six live session logs on this machine; the
16×46→34×96prefix is present in every one.Before submitting
ready.