mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(cli): kill resize scrollback duplication + light-mode visibility
Two long-standing prompt_toolkit bugs in the base hermes CLI: 1. Resize duplication. Column-shrink resize used to push 40+ rows of duplicate chrome (status bar, input rules) into terminal scrollback every resize. Same wall as pt issues #29 (open since 2014), #1675, #1933 — aider/xonsh/ipython all use alt-screen to dodge it. Root cause (verified by reading prompt_toolkit/renderer.py): _output_screen_diff (renderer.py L232-242) deliberately moves the cursor to the bottom of the canvas after every paint 'to make sure the terminal scrolls up'. In non-fullscreen mode this scrolls chrome content into terminal scrollback on every render — not just on resize. Fix: monkey-patch prompt_toolkit.renderer._output_screen_diff to bypass the reserve-vertical-space cursor move. When pt's logic checks 'if current_height > previous_screen.height', we inflate the previous screen height so the branch falls through. ~30-line wrapper, no fork of pt, no alt-screen, no DECSTBM scroll region. Verified empirically in real Terminal.app: 10 resizes (mixed shrinks/widens 1300→500→1400) during streaming produced ZERO scrollback delta, full agent response preserved, status bar pinned at bottom, no visible duplicates. pt is pinned to ==3.0.52 so the private-function patch is safe; future pt bumps will need to re-verify the signature matches. 2. Light-mode terminal visibility. Hardcoded skin colors (#FFF8DC cornsilk, #FFD700 gold, #B8860B dark goldenrod) are tuned for dark Terminal.app — invisible on light/cream backgrounds. Port ui-tui/src/theme.ts detectLightMode() to Python so the base CLI adapts. Detection priority: HERMES_LIGHT/HERMES_TUI_LIGHT env → HERMES_TUI_THEME=light|dark → HERMES_TUI_BACKGROUND=#RRGGBB → COLORFGBG env (xterm/Konsole/urxvt) → OSC 11 query (\x1b]11;?\x1b\\) with 100ms timeout → default dark. OSC 11 is tty-gated so gateway/cron/batch/subagent code paths don't pay the timeout cost. When light mode is detected, dark-mode colors auto-remap to readable equivalents (#FFF8DC → #1A1A1A, #FFD700 → #9A6B00, etc). Hooked at three points: - _hex_to_ansi() — auto-remaps any color emitted via the ANSI helper - _build_tui_style_dict() — rewrites pt style strings (chrome bg/fg) - SkinConfig.get_color() — wrapped at module load so Rich Panel borders/body text get the remap too Status-bar foreground colors (#C0C0C0, #888888, etc.) are explicitly skipped because they're paired with a dark navy bg — remapping them would make them invisible in dark mode. 3. Other visibility fixes: [thinking] reasoning preview now uses ANSI dim+italic (\x1b[2;3m) instead of #B8860B so it inherits terminal default fg color. Input/prompt area defaults to terminal default fg (was #FFF8DC cornsilk → invisible on cream). Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
parent
bcca5ed34d
commit
f8745f59c2
2 changed files with 391 additions and 29 deletions
406
cli.py
406
cli.py
|
|
@ -1242,7 +1242,13 @@ _STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel
|
|||
|
||||
|
||||
def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
|
||||
"""Convert a hex color like '#268bd2' to a true-color ANSI escape."""
|
||||
"""Convert a hex color like '#268bd2' to a true-color ANSI escape.
|
||||
|
||||
Auto-remaps known dark-mode-tuned colors to readable light-mode
|
||||
equivalents when running on a light terminal (see
|
||||
_maybe_remap_for_light_mode + _LIGHT_MODE_REMAP).
|
||||
"""
|
||||
hex_color = _maybe_remap_for_light_mode(hex_color)
|
||||
try:
|
||||
r = int(hex_color[1:3], 16)
|
||||
g = int(hex_color[3:5], 16)
|
||||
|
|
@ -1253,6 +1259,250 @@ def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
|
|||
return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
# Light/dark terminal mode detection.
|
||||
#
|
||||
# Mirrors ui-tui/src/theme.ts detectLightMode(). Used to decide whether
|
||||
# to remap "near-white" skin colors (e.g. #FFF8DC banner_text, #B8860B
|
||||
# banner_dim) to darker equivalents that are readable on a light
|
||||
# Terminal.app / iTerm2 background.
|
||||
#
|
||||
# Detection priority:
|
||||
# 1. HERMES_LIGHT / HERMES_TUI_LIGHT env (true/false) — explicit override
|
||||
# 2. HERMES_TUI_THEME=light|dark — explicit theme
|
||||
# 3. HERMES_TUI_BACKGROUND=#RRGGBB — explicit bg hint
|
||||
# 4. COLORFGBG env (set by xterm/Konsole/urxvt) — bg slot 7/15 = light
|
||||
# 5. OSC 11 query (\x1b]11;?\x1b\\) — ask the terminal directly
|
||||
# 6. Default: assume dark (matches the legacy Hermes assumption)
|
||||
#
|
||||
# Cached after first call so we don't query the terminal repeatedly.
|
||||
_LIGHT_MODE_CACHE: bool | None = None
|
||||
_TRUE_RE = re.compile(r"^(1|true|on|yes|y)$")
|
||||
_FALSE_RE = re.compile(r"^(0|false|off|no|n)$")
|
||||
_LIGHT_DEFAULT_TERM_PROGRAMS = frozenset() # Apple_Terminal doesn't reliably indicate; require explicit
|
||||
|
||||
|
||||
def _luminance_from_hex(hex_str: str) -> float | None:
|
||||
s = (hex_str or "").strip().lstrip("#")
|
||||
if len(s) == 3:
|
||||
s = "".join(c * 2 for c in s)
|
||||
if len(s) != 6 or not all(c in "0123456789abcdefABCDEF" for c in s):
|
||||
return None
|
||||
try:
|
||||
r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
||||
except ValueError:
|
||||
return None
|
||||
# Rec.709 luma
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
|
||||
|
||||
|
||||
def _query_osc11_background() -> str | None:
|
||||
"""Ask the terminal for its background color via OSC 11.
|
||||
|
||||
Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
|
||||
within a few ms. We wait up to 100ms total before giving up.
|
||||
Returns "#RRGGBB" or None on timeout / non-tty.
|
||||
"""
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return None
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
sys.stdout.write("\x1b]11;?\x1b\\")
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
return None
|
||||
# Read up to ~50ms for the response
|
||||
import select
|
||||
deadline = time.monotonic() + 0.1
|
||||
buf = b""
|
||||
while time.monotonic() < deadline:
|
||||
r, _, _ = select.select([fd], [], [], deadline - time.monotonic())
|
||||
if not r:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(fd, 64)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if b"\x1b\\" in buf or b"\x07" in buf:
|
||||
break
|
||||
# Parse: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
|
||||
m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf)
|
||||
if not m:
|
||||
return None
|
||||
# Each component is 1-4 hex digits — normalize to 8-bit
|
||||
def norm(h: bytes) -> int:
|
||||
v = int(h, 16)
|
||||
# Scale to 0-255 based on hex length
|
||||
bits = len(h) * 4
|
||||
return (v * 255) // ((1 << bits) - 1) if bits else 0
|
||||
r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
finally:
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSANOW, old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_light_mode() -> bool:
|
||||
global _LIGHT_MODE_CACHE
|
||||
if _LIGHT_MODE_CACHE is not None:
|
||||
return _LIGHT_MODE_CACHE
|
||||
result = False
|
||||
try:
|
||||
# 1. Explicit env override
|
||||
for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"):
|
||||
v = (os.environ.get(var) or "").strip().lower()
|
||||
if _TRUE_RE.match(v):
|
||||
result = True
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
if _FALSE_RE.match(v):
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
# 2. Theme hint
|
||||
theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower()
|
||||
if theme == "light":
|
||||
result = True
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
if theme == "dark":
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
# 3. Explicit bg hex
|
||||
bg_hint = os.environ.get("HERMES_TUI_BACKGROUND") or ""
|
||||
bg_lum = _luminance_from_hex(bg_hint)
|
||||
if bg_lum is not None:
|
||||
result = bg_lum >= 0.5
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
# 4. COLORFGBG (xterm/Konsole/urxvt)
|
||||
cfgbg = (os.environ.get("COLORFGBG") or "").strip()
|
||||
if cfgbg:
|
||||
last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg
|
||||
if last.isdigit():
|
||||
bg = int(last)
|
||||
if bg in (7, 15):
|
||||
result = True
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
if 0 <= bg < 16:
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
# 5. OSC 11 query (best-effort, only when stdin/stdout are TTY)
|
||||
bg_color = _query_osc11_background()
|
||||
if bg_color:
|
||||
lum = _luminance_from_hex(bg_color)
|
||||
if lum is not None:
|
||||
result = lum >= 0.5
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
# 6. TERM_PROGRAM allow-list (currently empty)
|
||||
tp = (os.environ.get("TERM_PROGRAM") or "").strip()
|
||||
if tp in _LIGHT_DEFAULT_TERM_PROGRAMS:
|
||||
result = True
|
||||
except Exception:
|
||||
result = False
|
||||
_LIGHT_MODE_CACHE = result
|
||||
return result
|
||||
|
||||
|
||||
# Light-mode equivalents of skin colors that are unreadable on cream
|
||||
# Terminal.app backgrounds. Used by _SkinAwareAnsi to remap colors
|
||||
# at resolution time when light mode is detected.
|
||||
#
|
||||
# IMPORTANT: only remap colors that are used as STANDALONE foregrounds
|
||||
# on the terminal's background. Don't remap colors that are paired
|
||||
# with a dark bg (e.g. status bar text on bg:#1a1a2e) — those would
|
||||
# become invisible the OTHER direction (dark gray on dark navy).
|
||||
_LIGHT_MODE_REMAP: dict[str, str] = {
|
||||
# Original (dark-mode) -> Light-mode replacement (darker, readable)
|
||||
"#FFF8DC": "#1A1A1A", # cornsilk -> near-black
|
||||
"#FFD700": "#9A6B00", # gold -> dark goldenrod (readable on cream)
|
||||
"#FFBF00": "#8A5A00", # amber -> dark amber
|
||||
"#B8860B": "#5C4500", # dark goldenrod -> deeper brown (more contrast)
|
||||
"#DAA520": "#6B4F00", # goldenrod -> dark olive
|
||||
"#F1E6CF": "#1A1A1A", # cream -> near-black
|
||||
"#c9d1d9": "#24292F", # github-light fg
|
||||
"#EAF7FF": "#0F1B26", # ice
|
||||
"#F5F5F5": "#1A1A1A",
|
||||
"#FFF0D4": "#1A1A1A",
|
||||
"#CD7F32": "#8A4F1A", # bronze -> darker bronze
|
||||
"#FFEFB5": "#3A2A00",
|
||||
# NOTE: skipping #C0C0C0/#888888/#555555/#8B8682 — those are
|
||||
# status-bar foregrounds paired with dark navy bg, where dark
|
||||
# remap values would become invisible.
|
||||
}
|
||||
|
||||
|
||||
def _maybe_remap_for_light_mode(hex_color: str) -> str:
|
||||
"""If we're in light mode, remap a dark-mode-tuned color to a
|
||||
higher-contrast equivalent. No-op in dark mode."""
|
||||
if not _detect_light_mode():
|
||||
return hex_color
|
||||
if not hex_color or not hex_color.startswith("#"):
|
||||
return hex_color
|
||||
# Case-insensitive lookup
|
||||
upper = hex_color.upper()
|
||||
if upper in _LIGHT_MODE_REMAP_UPPER:
|
||||
return _LIGHT_MODE_REMAP_UPPER[upper]
|
||||
return hex_color
|
||||
|
||||
|
||||
# Pre-uppercased lookup table for case-insensitive remapping
|
||||
_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()}
|
||||
|
||||
|
||||
def _install_skin_light_mode_hook() -> None:
|
||||
"""Wrap SkinConfig.get_color at import time so EVERY skin color read goes
|
||||
through the light-mode remap. Idempotent."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import SkinConfig # type: ignore[import]
|
||||
except Exception:
|
||||
return
|
||||
if getattr(SkinConfig, "_hermes_light_mode_hook_installed", False):
|
||||
return
|
||||
_orig_get_color = SkinConfig.get_color
|
||||
|
||||
def _wrapped_get_color(self, key, fallback=""):
|
||||
value = _orig_get_color(self, key, fallback)
|
||||
try:
|
||||
return _maybe_remap_for_light_mode(value)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
SkinConfig.get_color = _wrapped_get_color # type: ignore[method-assign]
|
||||
SkinConfig._hermes_light_mode_hook_installed = True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
_install_skin_light_mode_hook()
|
||||
|
||||
|
||||
# Prime the light-mode detection cache early (at module load) when
|
||||
# we're running interactively so OSC 11 happens before pt grabs the
|
||||
# tty. Skip for non-tty contexts (subagents, gateway, tests).
|
||||
try:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
_detect_light_mode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class _SkinAwareAnsi:
|
||||
"""Lazy ANSI escape that resolves from the skin engine on first use.
|
||||
|
||||
|
|
@ -1290,7 +1540,12 @@ class _SkinAwareAnsi:
|
|||
|
||||
|
||||
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
|
||||
_DIM = _SkinAwareAnsi("banner_dim", "#B8860B")
|
||||
# Use ANSI dim+italic attributes (\x1b[2;3m) instead of a hardcoded
|
||||
# hex color so dim/thinking text inherits the terminal's default
|
||||
# foreground color and stays readable in both light and dark
|
||||
# Terminal.app modes. Hardcoded skin colors like #B8860B
|
||||
# (dark goldenrod) become invisible against light cream backgrounds.
|
||||
_DIM = "\x1b[2;3m"
|
||||
|
||||
|
||||
def _accent_hex() -> str:
|
||||
|
|
@ -7947,8 +8202,8 @@ class HermesCLI:
|
|||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
|
||||
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
|
||||
except Exception:
|
||||
label = "⚕ Hermes"
|
||||
_resp_color = "#CD7F32"
|
||||
|
|
@ -8549,7 +8804,8 @@ class HermesCLI:
|
|||
|
||||
set_active_skin(new_skin)
|
||||
_ACCENT.reset() # Re-resolve ANSI color for the new skin
|
||||
_DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin
|
||||
# _DIM is now a fixed dim+italic ANSI escape (terminal-default fg)
|
||||
# so it doesn't need re-resolving on skin switch.
|
||||
if save_config_value("display.skin", new_skin):
|
||||
print(f" Skin set to: {new_skin} (saved)")
|
||||
else:
|
||||
|
|
@ -10928,12 +11184,12 @@ class HermesCLI:
|
|||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
||||
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
|
||||
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
|
||||
except Exception:
|
||||
label = "⚕ Hermes"
|
||||
_resp_color = "#CD7F32"
|
||||
_resp_text = "#FFF8DC"
|
||||
_resp_color = _maybe_remap_for_light_mode("#CD7F32")
|
||||
_resp_text = _maybe_remap_for_light_mode("#FFF8DC")
|
||||
|
||||
is_error_response = result and (result.get("failed") or result.get("partial"))
|
||||
already_streamed = self._stream_started and self._stream_box_opened and not is_error_response
|
||||
|
|
@ -11172,13 +11428,48 @@ class HermesCLI:
|
|||
return "".join(text for _, text in self._get_tui_prompt_fragments())
|
||||
|
||||
def _build_tui_style_dict(self) -> dict[str, str]:
|
||||
"""Layer the active skin's prompt_toolkit colors over the base TUI style."""
|
||||
"""Layer the active skin's prompt_toolkit colors over the base TUI style.
|
||||
|
||||
Also rewrites any hex-color tokens in the resulting style strings
|
||||
to their light-mode equivalents (via _LIGHT_MODE_REMAP) when the
|
||||
terminal is detected as light. This makes the chrome readable
|
||||
on cream Terminal.app backgrounds without per-skin overrides.
|
||||
"""
|
||||
style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
|
||||
style_dict.update(get_prompt_toolkit_style_overrides())
|
||||
except Exception:
|
||||
pass
|
||||
# Light-mode remap on the style strings. Each value is a pt
|
||||
# style string like "bg:#1a1a2e #C0C0C0 bold" — split on space,
|
||||
# rewrite any "#XXX" tokens (including "bg:#XXX") through the
|
||||
# light-mode remap, rejoin.
|
||||
#
|
||||
# CRITICAL: skip the remap entirely when a style string already
|
||||
# specifies its own bg (e.g. status-bar / completion-menu styles
|
||||
# with `bg:#1a1a2e ...`). Those colors were tuned for that
|
||||
# specific dark bg and remapping the FG to a dark equivalent
|
||||
# would produce dark-on-dark (invisible). The terminal's BG
|
||||
# mode is irrelevant — what matters is the bg the style itself
|
||||
# paints.
|
||||
try:
|
||||
if _detect_light_mode():
|
||||
def _remap_value(v: str) -> str:
|
||||
if not v:
|
||||
return v
|
||||
tokens = v.split()
|
||||
has_explicit_bg = any(t.startswith("bg:") for t in tokens)
|
||||
if has_explicit_bg:
|
||||
# The style paints its own bg — leave its fg alone.
|
||||
return v
|
||||
return " ".join(
|
||||
_maybe_remap_for_light_mode(t) if t.startswith("#") else t
|
||||
for t in tokens
|
||||
)
|
||||
style_dict = {k: _remap_value(v or "") for k, v in style_dict.items()}
|
||||
except Exception:
|
||||
pass
|
||||
return style_dict
|
||||
|
||||
def _apply_tui_skin_style(self) -> bool:
|
||||
|
|
@ -11264,6 +11555,13 @@ class HermesCLI:
|
|||
|
||||
def run(self):
|
||||
"""Run the interactive CLI loop with persistent input at bottom."""
|
||||
# Detect light/dark terminal mode now (before pt grabs the tty).
|
||||
# Caches the result so subsequent _hex_to_ansi / style calls
|
||||
# don't risk re-querying mid-render.
|
||||
try:
|
||||
_detect_light_mode()
|
||||
except Exception:
|
||||
pass
|
||||
# Push the entire TUI to the bottom of the terminal so the banner,
|
||||
# responses, and prompt all appear pinned to the bottom — empty
|
||||
# space stays above, not below. This prints enough blank lines to
|
||||
|
|
@ -13027,11 +13325,16 @@ class HermesCLI:
|
|||
|
||||
# Style for the application
|
||||
self._tui_style_base = {
|
||||
'input-area': '#FFF8DC',
|
||||
'placeholder': '#555555 italic',
|
||||
'prompt': '#FFF8DC',
|
||||
# Input area / prompt: empty style strings inherit the
|
||||
# terminal's default foreground/background, so the typed
|
||||
# text is readable in both light and dark Terminal.app
|
||||
# color schemes. (Hardcoding a near-white #FFF8DC made
|
||||
# input invisible on light backgrounds.)
|
||||
'input-area': '',
|
||||
'placeholder': '#888888 italic',
|
||||
'prompt': '',
|
||||
'prompt-working': '#888888 italic',
|
||||
'hint': '#555555 italic',
|
||||
'hint': '#888888 italic',
|
||||
'status-bar': 'bg:#1a1a2e #C0C0C0',
|
||||
'status-bar-strong': 'bg:#1a1a2e #FFD700 bold',
|
||||
'status-bar-dim': 'bg:#1a1a2e #8B8682',
|
||||
|
|
@ -13090,19 +13393,70 @@ class HermesCLI:
|
|||
self._app = app # Store reference for clarify_callback
|
||||
|
||||
# ── Fix ghost status-bar lines on terminal resize ──────────────
|
||||
# When the terminal shrinks (e.g. un-maximize), the emulator reflows
|
||||
# the previously-rendered full-width rows (status bar, input rules)
|
||||
# into multiple narrower rows. prompt_toolkit's _on_resize handler
|
||||
# only cursor_up()s by the stored layout height, missing the extra
|
||||
# rows created by reflow — leaving ghost duplicates visible.
|
||||
# Resize handling: monkey-patch prompt_toolkit's _output_screen_diff
|
||||
# to suppress the deliberate "reserve vertical space" scroll-up.
|
||||
#
|
||||
# It's not just column-shrink: widening, row-shrinking, and
|
||||
# multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch)
|
||||
# all produce the same class of drift, where the renderer's tracked
|
||||
# _cursor_pos.y no longer matches terminal reality. The only reliable
|
||||
# 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.
|
||||
# Background: prompt_toolkit's renderer (renderer.py L232-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 (status bar,
|
||||
# input rules) into terminal scrollback on every render. When
|
||||
# the terminal column-shrinks, the emulator reflows the previously
|
||||
# rendered full-width rows into multiple narrower rows that get
|
||||
# pushed up — leaving ghost duplicates AND polluting scrollback.
|
||||
# Same issue as pt #29 (open since 2014), #1675, #1933.
|
||||
#
|
||||
# Surgical fix: wrap _output_screen_diff so that when its internal
|
||||
# `if current_height > previous_screen.height` branch fires (the
|
||||
# one that does the bottom-cursor-move), we make it fall through
|
||||
# by inflating previous_screen.height first.
|
||||
try:
|
||||
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):
|
||||
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: ONLY when previous_screen is non-None and
|
||||
its current height is genuinely smaller than the new
|
||||
screen's height, inflate it to match. This prevents
|
||||
the bottom-cursor-move at L242 without changing any
|
||||
other code path's behavior.
|
||||
|
||||
Critical: do NOT replace a None previous_screen with
|
||||
a fresh Screen() — that would skip the proper
|
||||
reset_attributes()+erase_down() at L178-185 which
|
||||
fires when previous_screen is None (first-paint /
|
||||
width-change). Without that reset, ANSI styles
|
||||
leak between renders.
|
||||
"""
|
||||
try:
|
||||
if previous_screen is not None and hasattr(previous_screen, "height"):
|
||||
if previous_screen.height < screen.height:
|
||||
previous_screen.height = screen.height
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _orig_osd(
|
||||
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,
|
||||
)
|
||||
|
||||
_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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue