From f8745f59c2738025a02ca161307f4dcbfd0eb34a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 14 May 2026 22:39:59 -0700 Subject: [PATCH] fix(cli): kill resize scrollback duplication + light-mode visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli.py | 406 +++++++++++++++++++++++++++++++++++--- hermes_cli/skin_engine.py | 14 +- 2 files changed, 391 insertions(+), 29 deletions(-) diff --git a/cli.py b/cli.py index af179c86c13..dbbcf877006 100644 --- a/cli.py +++ b/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(): diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index f4d894c1e7a..0946eae9168 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -849,10 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: except Exception: return {} - prompt = skin.get_color("prompt", "#FFF8DC") + # Input/prompt: leave unset by default so the typed text inherits + # the terminal's foreground color (readable in both light and dark + # color schemes). Skins can opt into a colored prompt by setting + # `prompt` explicitly in their YAML. + prompt = skin.get_color("prompt", "") input_rule = skin.get_color("input_rule", "#CD7F32") title = skin.get_color("banner_title", "#FFD700") - text = skin.get_color("banner_text", prompt) + text = skin.get_color("banner_text", "#FFF8DC") dim = skin.get_color("banner_dim", "#555555") label = skin.get_color("ui_label", title) warn = skin.get_color("ui_warn", "#FF8C00") @@ -872,7 +876,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg) return { - "input-area": prompt, + # Typed input always uses terminal default fg/bg so it's + # readable in both light and dark Terminal.app modes. The + # skin's `prompt` color (if any) only styles the prompt symbol, + # NOT the user's typed text. + "input-area": "", "placeholder": f"{dim} italic", "prompt": prompt, "prompt-working": f"{dim} italic",