Skip to content

Force redraw past synchronized output when a hidden buffer reappears#460

Merged
dakra merged 1 commit into
mainfrom
fix-456-stale-on-rehide
Jun 23, 2026
Merged

Force redraw past synchronized output when a hidden buffer reappears#460
dakra merged 1 commit into
mainfrom
fix-456-stale-on-rehide

Conversation

@dakra

@dakra dakra commented Jun 23, 2026

Copy link
Copy Markdown
Owner

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-now
skips its render body (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:

(unless (and (not ghostel--force-next-redraw)
             (ghostel--mode-enabled ghostel--term 2026))
  …render…)

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-redraw was
defeated by the same guard, so the natural recovery didn't work either.

Fix

  • ghostel--redraw-now gains 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). Default nil leaves
    opportunistic output redraws via ghostel--invalidate correctly
    skippable mid-frame.
  • ghostel--window-buffer-change (reappear hook) and
    ghostel-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-output helper that leaves a buffer mid-open-2026 with
pending grid changes: the forced-redraw contract, the reappear hook, and
the recovery command. make -j8 all passes clean.

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
dakra force-pushed the fix-456-stale-on-rehide branch from 75487ea to 3431d79 Compare June 23, 2026 20:40
@dakra
dakra merged commit 3431d79 into main Jun 23, 2026
43 of 44 checks passed
@dakra
dakra deleted the fix-456-stale-on-rehide branch June 23, 2026 21:03
@dakra
dakra temporarily deployed to github-pages June 23, 2026 21:04 — with GitHub Actions Inactive
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.
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.

Ghostel window disappears from Doom workspace while output is streaming

1 participant