Force redraw past synchronized output when a hidden buffer reappears#460
Merged
Conversation
A ghostel buffer that is not displayed in any window renders nothing (`ghostel--get-render-window' returns nil), so streaming output accumulates in libghostty's grid but not the Emacs buffer. On reappear `ghostel--window-buffer-change' calls `ghostel--redraw-now' unforced, which hits the synchronized-output (DEC mode 2026) guard and skips the entire render if the terminal program holds 2026 open at that instant. The buffer then keeps its stale pre-hide content until the next non-2026 output, which may never arrive. TUIs like Claude Code wrap their partial frame updates in mode 2026, so a reappear landing inside an open frame intermittently leaves the terminal blank/stale. Give `ghostel--redraw-now' an optional FORCE arg that sets `ghostel--force-next-redraw' before the guard (and before the defer check, so it survives a deferred re-run). The reappear hook and the `ghostel-force-redraw' recovery command now force; opportunistic output redraws via `ghostel--invalidate' stay correctly skippable mid-frame. Incremental redraw is sufficient once allowed to run, so no full repaint is needed. Other must-repaint callers already set the flag. Add native regression tests for all three: the forced-redraw contract, the reappear hook, and the recovery command. Fixes #456
dakra
force-pushed
the
fix-456-stale-on-rehide
branch
from
June 23, 2026 20:40
75487ea to
3431d79
Compare
kohnish
pushed a commit
to kohnish/ghostel
that referenced
this pull request
Jun 24, 2026
dakra#460 gave `ghostel--redraw-now' an optional FORCE argument, but the debug advices that wrap it still declared the old single-argument arity. When debug logging or latency profiling is active, the forced redraw paths (`ghostel-force-redraw', the reappear hook) then signal `wrong-number-of-arguments': - `ghostel-debug--log-redraw' (:around) received three args into a two-parameter lambda, and dropped FORCE when forwarding to the original. - `ghostel-debug--latency-on-render' (:after) received two args into a one-parameter lambda. Accept and forward FORCE in the :around advice (folding it into the skip prediction and the force=... log column so the log stays truthful), and let the :after advice ignore it via `&rest'. `ghostel-debug--log-resize' (:around on `ghostel--adjust-size') had the same latent bug: `ghostel--adjust-size' already took an optional FORCE (a text-scale change passes it), so the advice would crash there too. Fix it the same way. Refresh the stale `declare-function' arity in ghostel-ime-test, and add a regression test asserting both redraw-now advices tolerate and forward FORCE.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #456.
Symptom
A ghostel terminal goes blank/stale when its window is hidden and then
reappears while output is streaming — the buffer and process stay alive,
only the displayed content is wrong. Intermittent, and observed with
Claude Code under plain Emacs + bash (no persp-mode / Doom / fish — those
turned out to be red herrings; persp-mode's layout restore is atomic and
cannot race the redraw).
Root cause
While a ghostel buffer is displayed in no window,
ghostel--redraw-nowskips its render body (
ghostel--get-render-windowreturns nil), sostreaming output accumulates in libghostty's grid but not the Emacs
buffer. On reappear,
ghostel--window-buffer-changecallsghostel--redraw-nowunforced, which hits the synchronized-output(DEC mode 2026) guard:
TUIs like Claude Code wrap their partial frame updates in mode 2026. If
the reappear lands inside an open 2026 block, the entire render is
skipped and the buffer keeps its stale pre-hide content until the next
non-2026 output — which may never come.
ghostel-force-redrawwasdefeated by the same guard, so the natural recovery didn't work either.
Fix
ghostel--redraw-nowgains an optionalFORCEarg that setsghostel--force-next-redrawbefore the guard (and before the defercheck, so it survives a deferred re-run). Default nil leaves
opportunistic output redraws via
ghostel--invalidatecorrectlyskippable mid-frame.
ghostel--window-buffer-change(reappear hook) andghostel-force-redraw(recovery command) now force.Incremental redraw is sufficient once it's allowed to run — it
reconstructs the viewport and backfills hidden scrollback — so no full
repaint is needed. When mode 2026 isn't open at reappear (the common
case), forcing is a no-op versus current behavior; only the buggy path
changes.
Tests
Three native regression tests (one per fixed site) via a shared
with-open-sync-outputhelper that leaves a buffer mid-open-2026 withpending grid changes: the forced-redraw contract, the reappear hook, and
the recovery command.
make -j8 allpasses clean.