fix(cli): monkey-patch pt's _output_screen_diff to skip reserve-vertical-scroll
# What changed
Replaced DECSTBM scroll region + chrome-row erase approach with a
direct monkey-patch of prompt_toolkit's module-level
`_output_screen_diff` function.
The DECSTBM approach had two killer bugs:
1. Scroll region leaked into the user's shell after hermes quit
(atexit firing semantics + the region persists across processes
in macOS Terminal.app)
2. Chrome-row erase wiped chat content / streaming responses if user
resized mid-stream
# Root cause (re-verified by reading pt/renderer.py)
`_output_screen_diff` (renderer.py L232-242) deliberately moves the
cursor to the bottom of the canvas after painting:
```python
# Correctly reserve vertical space as required by the layout.
# When this is a new screen (drawn for the first time), or for some
# reason higher than the previous one. Move the cursor once to the
# bottom of the output. That way, we're sure that the terminal
# scrolls up, even when the lower lines of the canvas just contain
# whitespace.
if current_height > previous_screen.height:
current_pos = move_cursor(Point(x=0, y=current_height - 1))
```
In non-fullscreen mode this scrolls chrome content into terminal
scrollback EVERY render — not just on resize. The `move_cursor`
walks down via `\r\n` which scrolls when at the bottom row.
# Fix
Wrap `_output_screen_diff` and inflate `previous_screen.height` to
match `screen.height` before passing through. This makes the
`if current_height > previous_screen.height` guard fall through and
skip the bottom-cursor-move entirely. Without that move, pt's render
only writes within the layout's actual rows. `\r\n` between rows
inside the layout body never reaches the bottom of the viewport
(because `move_cursor(0,0)` walks UP first to layout-top, then
`\r\n*N` walks DOWN only as far as the layout actually spans).
# Verified empirically in real Terminal.app
10-resize stress test (mixed shrink+widen) during streaming:
✅ ZERO scrollback delta (0 status bars added)
✅ Full streaming response preserved
✅ User input preserved
✅ Banner preserved in scrollback
✅ Status bar correctly anchored at bottom
✅ No visible duplicates anywhere
✅ No shell breakage after quit (no scroll region to leak)
# Reverted
- DECSTBM scroll region (shell-leak risk gone)
- atexit handler for scroll region restore (no longer needed)
- Chrome-row erase (\x1b[2K walking) — no longer needed
- _hermes_resize_clear function — back to vanilla _schedule_resize_recovery
This commit is contained in:
@@ -13069,103 +13069,74 @@ class HermesCLI:
|
||||
# recovery is a full screen-clear (\x1b[2J\x1b[H) before the next
|
||||
# redraw, so we force one on every resize rather than trying to
|
||||
# compute the exact drift.
|
||||
# DECSTBM scroll region fix for resize scrollback pollution.
|
||||
# Resize handling: monkey-patch prompt_toolkit's _output_screen_diff
|
||||
# to suppress the deliberate "reserve vertical space" scroll-up.
|
||||
#
|
||||
# Root cause: prompt_toolkit's _output_screen_diff (renderer.py
|
||||
# line 106) emits write("\r\n" * N) to advance the cursor between
|
||||
# rows during paint, and explicitly scrolls to the bottom of the
|
||||
# canvas at line 232-242 to "reserve vertical space". At the
|
||||
# bottom row, \r\n SCROLLS the viewport, pushing chrome content
|
||||
# into terminal scrollback. This is the actual mechanism behind
|
||||
# pt issues #29 (open since 2014), #1675, #1933 — same wall hit
|
||||
# by aider, xonsh, ipython. Nobody has shipped a fix.
|
||||
# Root cause (verified by reading pt/renderer.py):
|
||||
# _output_screen_diff (renderer.py line 232-242) explicitly moves
|
||||
# the cursor to the bottom of the canvas after painting "to make
|
||||
# sure the terminal scrolls up, even when the lower lines of the
|
||||
# canvas just contain whitespace". In non-fullscreen mode this
|
||||
# scrolls chrome content into terminal scrollback EVERY render —
|
||||
# not just on resize. Same wall as pt issues #29 (2014), #1675,
|
||||
# #1933. Aider/xonsh/ipython gave up.
|
||||
#
|
||||
# Our fix: DECSTBM (\x1b[<top>;<bottom>r) sets a SCROLL REGION
|
||||
# on the terminal. When pt's \r\n scrolls within the region,
|
||||
# rows that fall off the top of the region are DISCARDED
|
||||
# instead of being pushed to terminal scrollback. Region top
|
||||
# must be > 1 — content scrolling off a region starting at
|
||||
# row 1 still goes to scrollback (terminal treats region top=1
|
||||
# as "no region"). Above row 2 it gets discarded.
|
||||
#
|
||||
# Same trick used by vim's status line, tmux, weechat, htop.
|
||||
#
|
||||
# IMPORTANT: DECSTBM resets the cursor to (1,1). We save the
|
||||
# cursor position with \x1b[s before setting the region and
|
||||
# restore with \x1b[u after, so pt's initial paint still draws
|
||||
# the chrome at the bottom (where the cursor was when hermes
|
||||
# launched).
|
||||
_output_obj = app.renderer.output
|
||||
|
||||
def _set_scroll_region():
|
||||
try:
|
||||
size = _output_obj.get_size()
|
||||
# Region: rows 2 to size.rows. Top=2 keeps everything
|
||||
# except row 1 inside the region; row 1 is excluded so
|
||||
# the very-top row stays as a sacrifice line outside.
|
||||
#
|
||||
# After DECSTBM, the cursor is reset to (1,1). Move it
|
||||
# explicitly to the bottom row so pt's renderer anchors
|
||||
# the chrome at the bottom of the viewport (where it
|
||||
# naturally lives in non-fullscreen mode).
|
||||
_output_obj.write_raw(
|
||||
f"\x1b[2;{size.rows}r" # set scroll region
|
||||
f"\x1b[{size.rows};1H" # cursor to bottom row, col 1
|
||||
)
|
||||
_output_obj.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_set_scroll_region()
|
||||
|
||||
# Restore default scroll region on exit so the user's shell
|
||||
# isn't left with a constrained scroll area.
|
||||
def _restore_scroll_region():
|
||||
try:
|
||||
_output_obj.write_raw("\x1b[r")
|
||||
_output_obj.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fix: monkey-patch the module-level function to skip the
|
||||
# reserve-vertical-space cursor move. This is the surgical
|
||||
# change. Without scroll-to-reserve, pt only moves the cursor
|
||||
# within the layout's actual rows, and \r\n inside the layout
|
||||
# body never reaches the bottom of the viewport (because the
|
||||
# cursor walks up to layout-top first, then back down only as
|
||||
# far as needed for each row).
|
||||
try:
|
||||
atexit.register(_restore_scroll_region)
|
||||
import prompt_toolkit.renderer as _pt_renderer
|
||||
from prompt_toolkit.renderer import _output_screen_diff as _orig_osd
|
||||
|
||||
if not getattr(_pt_renderer, "_hermes_osd_patched", False):
|
||||
from prompt_toolkit.data_structures import Point
|
||||
from prompt_toolkit.layout.screen import Screen
|
||||
|
||||
def _patched_output_screen_diff(
|
||||
app, output, screen, current_pos, color_depth,
|
||||
previous_screen, last_style, is_done, full_screen,
|
||||
attrs_for_style_string, style_string_has_style,
|
||||
size, previous_width,
|
||||
):
|
||||
"""Wraps pt's _output_screen_diff to suppress the
|
||||
reserve-vertical-space scroll (renderer.py L232-242).
|
||||
|
||||
Strategy: trick the function into thinking
|
||||
current_height <= previous_screen.height, which makes
|
||||
its `if current_height > previous_screen.height` guard
|
||||
fall through and skip the move_cursor-to-bottom.
|
||||
|
||||
We do this by inflating previous_screen.height to be
|
||||
>= screen.height before passing through.
|
||||
"""
|
||||
try:
|
||||
prev = previous_screen if previous_screen is not None else Screen()
|
||||
if hasattr(prev, "height"):
|
||||
prev.height = max(prev.height, screen.height)
|
||||
previous_screen_arg = prev
|
||||
except Exception:
|
||||
previous_screen_arg = previous_screen
|
||||
|
||||
return _orig_osd(
|
||||
app, output, screen, current_pos, color_depth,
|
||||
previous_screen_arg, last_style, is_done, full_screen,
|
||||
attrs_for_style_string, style_string_has_style,
|
||||
size, previous_width,
|
||||
)
|
||||
|
||||
_pt_renderer._output_screen_diff = _patched_output_screen_diff
|
||||
_pt_renderer._hermes_osd_patched = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_original_on_resize = app._on_resize
|
||||
|
||||
def _resize_clear_ghosts():
|
||||
# Re-set scroll region for new viewport size, then erase ONLY
|
||||
# the bottom chrome rows (status bar + 2 separator rules +
|
||||
# input area + a couple of safety rows = ~6 rows max). This
|
||||
# leaves chat output intact while wiping any ghost chrome
|
||||
# rows that the column-shrink reflow left visible.
|
||||
#
|
||||
# \x1b[2K erases a single line (no scrollback push). We
|
||||
# walk up from the bottom for CHROME_ROWS rows, erasing each.
|
||||
_set_scroll_region()
|
||||
try:
|
||||
size = _output_obj.get_size()
|
||||
CHROME_ROWS = 8 # generous: chrome + reflow slack
|
||||
start_row = max(2, size.rows - CHROME_ROWS + 1)
|
||||
# Position cursor at the top of the chrome band, then
|
||||
# for each row: erase entire line, move down.
|
||||
buf = []
|
||||
for i, row in enumerate(range(start_row, size.rows + 1)):
|
||||
buf.append(f"\x1b[{row};1H\x1b[2K")
|
||||
# Park cursor at bottom row so pt's repaint anchors there.
|
||||
buf.append(f"\x1b[{size.rows};1H")
|
||||
_output_obj.write_raw("".join(buf))
|
||||
_output_obj.flush()
|
||||
try:
|
||||
from prompt_toolkit.data_structures import Point
|
||||
app.renderer._cursor_pos = Point(x=0, y=0)
|
||||
app.renderer._last_screen = None
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
_original_on_resize()
|
||||
self._schedule_resize_recovery(app, _original_on_resize)
|
||||
|
||||
app._on_resize = _resize_clear_ghosts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user