Skip to content

Full-screen agents like Grok Build come back painted after a relaunch, instead of a blank pane until the window is resized - #171

Open
michael-dg wants to merge 1 commit into
AgentSystemLabs:mainfrom
michael-dg:reattach-repaint
Open

michael-dg wants to merge 1 commit into
AgentSystemLabs:mainfrom
michael-dg:reattach-repaint

Conversation

@michael-dg

Copy link
Copy Markdown
Contributor

Full-screen apps that read their size a moment after SIGWINCH came back as a blank pane after a relaunch of nebula, until the window was resized — Grok Build is the agent where it showed. They now come back painted.

Contents: 🐛 Symptom · 🔍 Cause and fix · 📸 Before / After · 🔁 Flow · ⚠️ Risk · 🔧 Technical overview · 📝 Notes

🐛 Symptom

Quit the TUI and relaunch it while a Grok Build session has been running for a while: its pane comes back blank but for its logo, with the tail of a cut escape sequence at the top left (?2026l, d=i,i=1,q=2). It stays blank until the window is really resized. Claude Code sessions do not show it.

🔍 Cause and fix

When an Attach replays a ring that has wrapped, the top of the app's screen is lost, so the DAEMON jiggles the PTY (one row less, then back) to make the app repaint. An app that reads its size a moment after SIGWINCH never saw a change, for two reasons:

  • The TUI attached before its first frame, at its fallback grid (80×24), then sent the drawn pane's size: the PTY bounced from its size to 80×24 and back — every session, on every relaunch. Fix: the main loop holds an attach asked for before the pane has a size and sends it after the next frame, at the drawn size (or the fallback grid if no pane was drawn, so nothing waits longer than a frame).
  • The jiggle's two resizes went out back to back. Fix: the shrunken size holds for 100 ms, in a task of its own so the client's keystrokes never wait; the jiggle reads the session's size at both ends, so a real resize during the hold wins.

An exact replay (a ring that never wrapped) still resizes once, as before. No protocol change.

📸 Before / After

An isolated nebula running Grok Build through NEBULA_AGENT_CMD (hence the card reading claude), its ring shrunk to 6 KiB so it wraps in seconds, the TUI killed and relaunched, captured 10 s later.

Before — main at a00b760 After
Grok's pane after a relaunch on main: blank but for a cut escape sequence, ?2026l, at the top left Grok's pane after a relaunch with this change: its logo, menu, tip and prompt all painted

🔁 Flow

sequenceDiagram
  participant T as TUI, relaunched
  participant D as DAEMON
  participant G as App in its PTY
  Note over T: the tree lands before the first frame
  T->>T: hold the attach for the next frame
  T->>D: Attach at the drawn size, not the 80x24 fallback
  D-->>T: Scrollback from a wrapped ring, top of screen lost
  D->>G: one row less, SIGWINCH
  Note over D,G: JIGGLE_HOLD 100 ms, the app reads the smaller size
  D->>G: back to the session's size, SIGWINCH
  G-->>T: full repaint
Loading

⚠️ Risk

Verdict: 🟢 Low risk — no new surface, no protocol change; a relaunch sizes each PTY once instead of bouncing it, and only a wrapped-ring attach pays a 100 ms jiggle, off the request loop.

Level Why
🔒 Security & production Low no new surface: no ClientRequest, no exec, no file; the held attach is local TUI state and never waits longer than a frame
⚡ Performance Low at most one frame of delay on the first attach; the jiggle is a spawned task, only for a wrapped-ring attach
🧩 Fit with the codebase Low extends the existing dtach-style jiggle; the hold sits beside the pending_attach debounce in PaneState and fires from the draw step that runs sync_pty_size

Rollback: git revert <merge>; nothing persisted.

🔧 Technical overview

  • TUI. event_loop/selection.rs — send_attach holds the attach while PaneState::size_before_attach is set and the pane has no size; send_held_attach sends it after the frame, through attach_sized (the former body). event_loop/loop_run.rs sets the flag (unit tests never draw, so they keep attaching at once) and calls send_held_attach after sync_pty_size.

  • DAEMON. pty/mod.rs — resize_with_jiggle becomes resize_forcing_repaint: a new size is a plain resize, the same size spawns jiggle. attach.rs — a one-line change to call it.

  • Not Grok-specific. Bytes each app writes in the 1.5 s after a jiggle, 100×30 PTY:

    App Idle Jiggle, no hold (before) Held 100 ms
    Grok Build 990 1,045 — no repaint 2,779
    yazi 0 0 — no repaint 8,166
    lazygit 0 5,139 13,684
    less, nvim, top 0 repaint repaint
  • Not done. A server-side screen model (tmux's way) would need no repaint from the app at all, but it is an architecture change.

  • Tests. a_jiggle_lets_the_child_see_the_shrunken_size (fails with no hold) and a_resize_during_the_jiggle_is_kept (fails if the jiggle restores the size it started from) in pty/mod.rs; an_attach_before_the_pane_has_a_size_waits_for_the_frame and a_held_attach_goes_out_after_a_frame_with_no_pane in event_loop/tests.

  • Gate. cargo fmt --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo test --workspace: 1,812 passed on Linux. issues::tests::the_edit_sends_the_title_on_argv_and_the_body_down_stdin is flaky on main with or without this change.

📝 Notes

🤖 Generated with Claude Code

…, instead of a blank pane until the window is resized

When an Attach replays a ring that wrapped, the DAEMON owes the app a
repaint and jiggles its PTY (rows-1, then back). An app that reads its
size a moment after SIGWINCH (Grok Build, yazi) never saw a change, so
relaunching nebula left its pane blank but for the tail of an escape
sequence the replay had cut in half (`?2026l`), until a real resize.

- The TUI attached before its first frame, at the fallback grid (80×24),
  and sent the drawn pane's size an instant later: the PTY bounced from
  its size to 80×24 and back, every session on every relaunch. The main
  loop now holds an attach asked for before the pane has a size
  (`PaneState::size_before_attach`) and sends it right after the next
  frame, sized to the pane that frame drew, or to the fallback grid
  when it drew none, so nothing waits longer than a frame. Views built
  without a terminal (every unit test) attach at once, as before.
- The jiggle's two resizes went out back to back.
  `PtySession::resize_forcing_repaint` (formerly `resize_with_jiggle`)
  decides when the attach lands: a new size is a plain resize, the same
  size spawns a jiggle that holds the shrunken size for `JIGGLE_HOLD`
  (100 ms), in a task of its own so the client's requests never wait.
  The jiggle reads the session's size at both ends, so a real resize
  during the hold wins. An exact replay still resizes once.

Tests: a_jiggle_lets_the_child_see_the_shrunken_size (fails with no
hold), a_resize_during_the_jiggle_is_kept (fails if the jiggle restores
the size it started from), an_attach_before_the_pane_has_a_size_waits_for_the_frame,
a_held_attach_goes_out_after_a_frame_with_no_pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant