From fef97aee5918cdffb0eea0ef31bed6aa321a2720 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 14 May 2026 21:57:39 -0500 Subject: [PATCH] fix(cli): DECSTBM scroll region + \x1b[J erase for clean resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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[;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[;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 --- cli.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 75506adc65..f946f2ebb4 100644 --- a/cli.py +++ b/cli.py @@ -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[;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