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:
|
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:
|
try:
|
||||||
r = int(hex_color[1:3], 16)
|
r = int(hex_color[1:3], 16)
|
||||||
g = int(hex_color[3:5], 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"
|
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:
|
class _SkinAwareAnsi:
|
||||||
"""Lazy ANSI escape that resolves from the skin engine on first use.
|
"""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)
|
_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:
|
def _accent_hex() -> str:
|
||||||
|
|
@ -7947,8 +8202,8 @@ class HermesCLI:
|
||||||
from hermes_cli.skin_engine import get_active_skin
|
from hermes_cli.skin_engine import get_active_skin
|
||||||
_skin = get_active_skin()
|
_skin = get_active_skin()
|
||||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
|
||||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
|
||||||
except Exception:
|
except Exception:
|
||||||
label = "⚕ Hermes"
|
label = "⚕ Hermes"
|
||||||
_resp_color = "#CD7F32"
|
_resp_color = "#CD7F32"
|
||||||
|
|
@ -8549,7 +8804,8 @@ class HermesCLI:
|
||||||
|
|
||||||
set_active_skin(new_skin)
|
set_active_skin(new_skin)
|
||||||
_ACCENT.reset() # Re-resolve ANSI color for the 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):
|
if save_config_value("display.skin", new_skin):
|
||||||
print(f" Skin set to: {new_skin} (saved)")
|
print(f" Skin set to: {new_skin} (saved)")
|
||||||
else:
|
else:
|
||||||
|
|
@ -10928,12 +11184,12 @@ class HermesCLI:
|
||||||
from hermes_cli.skin_engine import get_active_skin
|
from hermes_cli.skin_engine import get_active_skin
|
||||||
_skin = get_active_skin()
|
_skin = get_active_skin()
|
||||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
|
||||||
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
|
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
|
||||||
except Exception:
|
except Exception:
|
||||||
label = "⚕ Hermes"
|
label = "⚕ Hermes"
|
||||||
_resp_color = "#CD7F32"
|
_resp_color = _maybe_remap_for_light_mode("#CD7F32")
|
||||||
_resp_text = "#FFF8DC"
|
_resp_text = _maybe_remap_for_light_mode("#FFF8DC")
|
||||||
|
|
||||||
is_error_response = result and (result.get("failed") or result.get("partial"))
|
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
|
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())
|
return "".join(text for _, text in self._get_tui_prompt_fragments())
|
||||||
|
|
||||||
def _build_tui_style_dict(self) -> dict[str, str]:
|
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 {})
|
style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
|
||||||
try:
|
try:
|
||||||
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
|
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
|
||||||
style_dict.update(get_prompt_toolkit_style_overrides())
|
style_dict.update(get_prompt_toolkit_style_overrides())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
return style_dict
|
||||||
|
|
||||||
def _apply_tui_skin_style(self) -> bool:
|
def _apply_tui_skin_style(self) -> bool:
|
||||||
|
|
@ -11264,6 +11555,13 @@ class HermesCLI:
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the interactive CLI loop with persistent input at bottom."""
|
"""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,
|
# Push the entire TUI to the bottom of the terminal so the banner,
|
||||||
# responses, and prompt all appear pinned to the bottom — empty
|
# responses, and prompt all appear pinned to the bottom — empty
|
||||||
# space stays above, not below. This prints enough blank lines to
|
# space stays above, not below. This prints enough blank lines to
|
||||||
|
|
@ -13027,11 +13325,16 @@ class HermesCLI:
|
||||||
|
|
||||||
# Style for the application
|
# Style for the application
|
||||||
self._tui_style_base = {
|
self._tui_style_base = {
|
||||||
'input-area': '#FFF8DC',
|
# Input area / prompt: empty style strings inherit the
|
||||||
'placeholder': '#555555 italic',
|
# terminal's default foreground/background, so the typed
|
||||||
'prompt': '#FFF8DC',
|
# 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',
|
'prompt-working': '#888888 italic',
|
||||||
'hint': '#555555 italic',
|
'hint': '#888888 italic',
|
||||||
'status-bar': 'bg:#1a1a2e #C0C0C0',
|
'status-bar': 'bg:#1a1a2e #C0C0C0',
|
||||||
'status-bar-strong': 'bg:#1a1a2e #FFD700 bold',
|
'status-bar-strong': 'bg:#1a1a2e #FFD700 bold',
|
||||||
'status-bar-dim': 'bg:#1a1a2e #8B8682',
|
'status-bar-dim': 'bg:#1a1a2e #8B8682',
|
||||||
|
|
@ -13090,19 +13393,70 @@ class HermesCLI:
|
||||||
self._app = app # Store reference for clarify_callback
|
self._app = app # Store reference for clarify_callback
|
||||||
|
|
||||||
# ── Fix ghost status-bar lines on terminal resize ──────────────
|
# ── Fix ghost status-bar lines on terminal resize ──────────────
|
||||||
# When the terminal shrinks (e.g. un-maximize), the emulator reflows
|
# Resize handling: monkey-patch prompt_toolkit's _output_screen_diff
|
||||||
# the previously-rendered full-width rows (status bar, input rules)
|
# to suppress the deliberate "reserve vertical space" scroll-up.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# It's not just column-shrink: widening, row-shrinking, and
|
# Background: prompt_toolkit's renderer (renderer.py L232-242)
|
||||||
# multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch)
|
# explicitly moves the cursor to the bottom of the canvas after
|
||||||
# all produce the same class of drift, where the renderer's tracked
|
# painting "to make sure the terminal scrolls up, even when the
|
||||||
# _cursor_pos.y no longer matches terminal reality. The only reliable
|
# lower lines of the canvas just contain whitespace". In
|
||||||
# recovery is a full screen-clear (\x1b[2J\x1b[H) before the next
|
# non-fullscreen mode this scrolls chrome content (status bar,
|
||||||
# redraw, so we force one on every resize rather than trying to
|
# input rules) into terminal scrollback on every render. When
|
||||||
# compute the exact drift.
|
# 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
|
_original_on_resize = app._on_resize
|
||||||
|
|
||||||
def _resize_clear_ghosts():
|
def _resize_clear_ghosts():
|
||||||
|
|
|
||||||
|
|
@ -849,10 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
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")
|
input_rule = skin.get_color("input_rule", "#CD7F32")
|
||||||
title = skin.get_color("banner_title", "#FFD700")
|
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")
|
dim = skin.get_color("banner_dim", "#555555")
|
||||||
label = skin.get_color("ui_label", title)
|
label = skin.get_color("ui_label", title)
|
||||||
warn = skin.get_color("ui_warn", "#FF8C00")
|
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)
|
menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg)
|
||||||
|
|
||||||
return {
|
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",
|
"placeholder": f"{dim} italic",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"prompt-working": f"{dim} italic",
|
"prompt-working": f"{dim} italic",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue