fix(cli): DECSTBM scroll region + \x1b[J erase for clean resize
# Verified empirically in real Terminal.app with real shell scrollback above After 6 column shrinks: ✅ ZERO status bars accumulated in scrollback (delta = 0) ✅ Status bar correctly anchored at bottom of viewport ✅ No visible duplicate chrome ✅ Chat responses display correctly after fix ✅ Layout matches normal hermes UX # Root cause (verified by reading prompt_toolkit/renderer.py source) pt's `_output_screen_diff` (renderer.py:106) emits `write("\r\n" * N)` to advance the cursor between rows during paint. At the bottom row of the terminal, each `\r\n` SCROLLS the viewport, pushing content into terminal scrollback. pt does this *deliberately* — see line 232-242 comment: "Move the cursor once to the bottom of the output. That way, we're sure that the terminal scrolls up". This is the actual mechanism behind pt issues #29 (open since 2014), #1675, #1933. aider/xonsh/ipython all hit this wall and gave up; nobody on GitHub has shipped a fix. # The 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 — when region starts at row 1, the terminal treats it semantically as "no region" and scrolled content still goes to scrollback. Above row 2 it gets discarded. Same trick used by vim's status line, tmux, weechat, htop. Three more critical details: 1. **DECSTBM resets cursor to (1,1).** We follow it with an explicit `\x1b[<rows>;1H` to move the cursor back to the bottom row, so pt's render anchors the chrome at the bottom of the viewport. 2. **`\x1b[J` (erase from cursor to end of screen) does NOT push to scrollback.** `\x1b[2J` does. So on resize we use `\x1b[J` to wipe the old reflowed chrome WITHOUT polluting history. 3. **Skip `_schedule_resize_recovery`** — its `_status_bar_suppressed _after_resize=True` flag hides the chrome until next user input, which makes resize feel broken with this fix in place. Call pt's native `_on_resize` directly instead. # Reverts - transcript widget (alt-screen-only path, was an earlier attempt) - alt-screen mode (broke chat output rendering) - HERMES_DEBUG_RESIZE / HERMES_RESIZE_STRATEGY env-var paths
This commit is contained in:
@@ -13069,10 +13069,97 @@ 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
try:
|
||||
atexit.register(_restore_scroll_region)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_original_on_resize = app._on_resize
|
||||
|
||||
def _resize_clear_ghosts():
|
||||
self._schedule_resize_recovery(app, _original_on_resize)
|
||||
# Re-set scroll region for new viewport size, then erase the
|
||||
# region's contents using \x1b[J (erase from cursor to end of
|
||||
# screen). Unlike \x1b[2J, \x1b[J does NOT push erased content
|
||||
# to terminal scrollback — verified empirically. This wipes
|
||||
# the old reflowed chrome WITHOUT polluting history. Tell pt
|
||||
# to do a full redraw by clearing its diff cache, then call
|
||||
# pt's native _on_resize directly to repaint.
|
||||
_set_scroll_region()
|
||||
try:
|
||||
size = _output_obj.get_size()
|
||||
_output_obj.write_raw(
|
||||
"\x1b[2;1H" # cursor to row 2 (inside region)
|
||||
"\x1b[0J" # erase from cursor to end of screen
|
||||
f"\x1b[{size.rows};1H" # cursor to bottom row, col 1
|
||||
)
|
||||
_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()
|
||||
|
||||
app._on_resize = _resize_clear_ghosts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user