Skip to content

Window scrolls to point-min after minibuffer-resize in TUI buffers #127

Description

@emil-e

Summary

After vertico (or any minibuffer-resize) closes, a TUI ghostel buffer
can scroll to point-min or to a spot well above the viewport,
leaving the live viewport hidden. A follow-up scroll-conservatively
auto-scroll often salvages it halfway — the prompt ends up pinned
to the bottom of the window with the rows below the cursor (common
in TUIs) clipped off.

Repro

  1. Open a ghostel buffer running a TUI whose cursor sits above the
    viewport bottom (Claude Code, opencode, codex). Scrollback
    should be populated (tested with ~20–230 KB).
  2. Let it sit so the window is anchored on the viewport.
  3. M-x and let vertico open and close (any minibuffer that
    triggers a ghostel window resize — I saw a width change here, but
    height-only minibuffer resizes should behave the same).
  4. Observe: the window-start jumps above (or below) the viewport
    instead of following the live TUI.

Diagnosis

ghostel-debug-start log across the buggy redraw:

REDRAW: 2.0ms force=t→nil … vs=17054→17062 anchor=17054→17062
       wins-before: ws=16314 we=19068 wp=16385 body=57
       wins-after:  ws=1 we=2923 wp=16393 body=57
RESIZE: 57x57 → 80x57 (3.3ms)

Sequence:

  1. Prior redraw ends with the window anchored: ws = vs = anchor = 17054. The window is not in ghostel--scroll-positions.
  2. Between redraws Emacs redisplay drifts window-start below the
    anchor. In my case wp (the live cursor) is at 16385 — above the
    viewport, as happens for TUIs whose cursor isn't on the last
    rendered row. When the minibuffer-triggered resize shrinks the
    body keep-point-visible moves ws forward so wp stays on
    screen, landing at 16314 (below the 17054 anchor).
  3. Resize fires → force redraw.
  4. ghostel--window-anchored-p checks ws (16314) ≥ anchor (17054)
    → nil → window classified as non-anchored.
  5. ghostel--capture-window-state saves a ws-key for the blank line
    at 16314.
  6. Redraw runs.
  7. ghostel--restore-scrollback-window calls ghostel--find-line-pos
    on that key — the blank lines match at point-min, so ws is
    set to 1.
  8. Emacs's scroll-conservatively=101 then auto-scrolls forward to
    bring wp back on screen, overshooting the anchor and producing
    the "prompt on bottom, nothing visible below" symptom.

ghostel--correct-mangled-scroll-positions already handles the same
drift for windows that were previously scrolled — it walks
ghostel--scroll-positions, which doesn't contain anchored windows,
so the bug only surfaces for windows that were on the viewport.

Proposed fix

During a force-triggered redraw (resize, clear-scrollback,
copy-mode-exit, snap-to-input), a window that wasn't recorded as
scrolled at the prior redraw should stay anchored even if Emacs
drifted window-start below the anchor in between:

(defvar ghostel--redraw-force-active nil
  "Dynamically bound to t inside a force-triggered `ghostel--delayed-redraw'.")

(defsubst ghostel--window-anchored-p (win)
  (let ((anchor ghostel--last-anchor-position))
    (or ghostel--snap-requested
        (null anchor)
        (>= (window-start win) anchor)
        (and ghostel--redraw-force-active
             (not (assq win ghostel--scroll-positions))))))

ghostel--delayed-redraw wraps its body in (let ((ghostel--redraw-force-active ghostel--force-next-redraw)) …)
before clearing ghostel--force-next-redraw.

Edge case: if the user genuinely scrolls into scrollback and a
force redraw (resize) fires before any output-driven redraw records
the window in ghostel--scroll-positions, the new rule snaps them
back. In practice there's usually intervening output, so this should
be rare. An alternative is to push windows into
ghostel--scroll-positions from the mouse-wheel intercept path.

Workaround (no upstream change)

For anyone hitting this before a fix lands, advising the resize hook
to restore the anchor works without touching internals:

(defun my/ghostel--restore-anchor-before-resize (process _windows)
  (let ((buffer (process-buffer process)))
    (when (buffer-live-p buffer)
      (with-current-buffer buffer
        (when (and (bound-and-true-p ghostel--term)
                   ghostel--last-anchor-position)
          (let ((anchor ghostel--last-anchor-position)
                (scrolled ghostel--scroll-positions))
            (dolist (win (get-buffer-window-list buffer nil t))
              (when (and (< (window-start win) anchor)
                         (not (assq win scrolled)))
                (set-window-start win anchor t)))))))))

(advice-add #'ghostel--window-adjust-process-window-size
            :before #'my/ghostel--restore-anchor-before-resize)

Environment

  • ghostel 0.15.0 (commit 9846c64)
  • Emacs 30.x, macOS (Darwin 25.3.0)
  • scroll-conservatively 101 (ghostel default)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions