- Gate every paint and chrome render on the live width: a paint or render that would land after the width changed but before its recovery now waits for it (checked when it actually runs on the loop, not when it was scheduled). - Recovery that sees the width move again keeps holding; the next recovery runs right after the next width change once the 0.85s hold cap passed. - Track the narrowest width of a drag: narrowing and widening back still refills. - Chrome floor: after a refill the chrome is drawn down to the bottom row (what CPR would tell prompt_toolkit), so the next count is exact when it shrank. - Rows output scrolled while the width changed under it are repainted by the next refill (twice rather than truncated for good). - A resize whose viewport holds the whole history erases from its oldest row, keeping the unrecorded startup banner. - _terminal_reflows: TERM decides before inherited env (st/urxvt from tmux, kitty or vscode); rxvt added; vte-256color no longer matches 'vt'.
950 lines
37 KiB
Python
950 lines
37 KiB
Python
"""Classic-CLI rendering helpers: reasoning-tag stripping, ANSI/skin colours, light-mode detection, markdown/final-content rendering, output-history replay, ``_cprint`` and the panel box/wrap helpers.
|
|
|
|
Split out of ``cli.py``; ``cli`` re-exports every public name and moved bodies late-bind
|
|
cli-level names through ``from cli import ...`` at call time so facade monkeypatch seams hold.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import itertools
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import textwrap
|
|
import threading
|
|
import time
|
|
from contextlib import contextmanager, suppress
|
|
from hermes_cli.banner import format_banner_version_label
|
|
from rich.console import Console
|
|
from rich.text import Text as _RichText
|
|
from typing import Any
|
|
|
|
def _cli():
|
|
"""Late import of the ``cli`` facade: mutable CLI module state (and its test seams) lives there."""
|
|
import cli
|
|
|
|
return cli
|
|
|
|
|
|
_REASONING_TAGS = ("REASONING_SCRATCHPAD", "think", "thinking", "reasoning", "thought")
|
|
|
|
|
|
_TOOL_CALL_TAGS = ("tool_call", "tool_calls", "tool_result", "function_call", "function_calls")
|
|
|
|
|
|
def _strip_reasoning_tags(text: str) -> str:
|
|
"""Strip reasoning blocks (closed, unterminated, orphan-close) and leaked tool-call XML from display text.
|
|
|
|
Keep in sync with ``agent.agent_runtime_helpers.strip_think_blocks`` and the stream consumer's think-tag sets.
|
|
|
|
Also strips tool-call XML blocks some open models leak into visible content (``<tool_call>``,
|
|
``<function_calls>``, Gemma-style ``<function name="…">…</function>``). Ported from
|
|
openclaw/openclaw#67318.
|
|
"""
|
|
from cli import _REASONING_TAGS, _TOOL_CALL_TAGS
|
|
cleaned = text
|
|
for tag in _REASONING_TAGS:
|
|
cleaned = re.sub(rf"<{tag}>.*?</{tag}>\s*", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
|
|
cleaned = re.sub(rf"<{tag}>.*$", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
|
|
cleaned = re.sub(rf"</{tag}>\s*", "", cleaned, flags=re.IGNORECASE)
|
|
for tc_tag in _TOOL_CALL_TAGS:
|
|
cleaned = re.sub(
|
|
rf"<(?:[\w.-]+:)?{tc_tag}\b[^>]*>.*?</(?:[\w.-]+:)?{tc_tag}>\s*",
|
|
"", cleaned, flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
# <function name="..."> — boundary + attribute gated to avoid prose false positives.
|
|
cleaned = re.sub(
|
|
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*<function\b[^>]*\bname\s*=[^>]*>(?:(?:(?!</function>).)*)</function>\s*',
|
|
'', cleaned, flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
cleaned = re.sub(
|
|
r'</(?:(?:[\w.-]+:)?(?:tool_call|tool_calls|tool_result|function_call|function_calls|function))>\s*', '', cleaned,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
# Unterminated opener / stray <arg_key>/<arg_value> markup = stream cut
|
|
# mid tool-call serialization (#101899); strip to end of text.
|
|
cleaned = re.sub(
|
|
r'(?:^|\n)[ \t]*<(?:[\w.-]+:)?(?:tool_call|tool_calls|tool_result|function_call|function_calls)\b[^>]*>.*$'
|
|
r'|(?:^|\n)[^\n<]*</?arg_(?:key|value)\b.*$',
|
|
'',
|
|
cleaned,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
return cleaned.strip()
|
|
|
|
|
|
def _assistant_content_as_text(content: Any) -> str:
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = [str(part.get("text", "")) for part in content if isinstance(part, dict) and part.get("type") == "text"]
|
|
return "\n".join(p for p in parts if p)
|
|
return str(content)
|
|
|
|
|
|
def _assistant_copy_text(content: Any) -> str:
|
|
from cli import _assistant_content_as_text, _strip_reasoning_tags
|
|
return _strip_reasoning_tags(_assistant_content_as_text(content))
|
|
|
|
|
|
_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # #FFD700 bold fallback
|
|
|
|
|
|
_BOLD = "\033[1m"
|
|
|
|
|
|
_RST = "\033[0m"
|
|
|
|
|
|
_STREAM_PAD = "" # no indent: leading whitespace pollutes copy/paste
|
|
|
|
|
|
_STREAM_PARTIAL_PREVIEW_LEN = 60 # tail of an unfinished line mirrored into the spinner
|
|
|
|
|
|
def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
|
|
"""Convert '#RRGGBB' to a true-color ANSI escape, remapping dark-tuned colors in light mode."""
|
|
from cli import _ACCENT_ANSI_DEFAULT, _maybe_remap_for_light_mode
|
|
hex_color = _maybe_remap_for_light_mode(hex_color)
|
|
try:
|
|
r, g, b = (int(hex_color[i:i + 2], 16) for i in (1, 3, 5))
|
|
return f"\033[{'1;' if bold else ''}38;2;{r};{g};{b}m"
|
|
except (ValueError, IndexError):
|
|
return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
|
|
|
|
|
|
_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 isn't reliable; require explicit config
|
|
|
|
|
|
def _luminance_from_hex(hex_str: str) -> float | None:
|
|
"""Rec.709 luma in [0, 1] for '#RGB'/'#RRGGBB', or None when malformed."""
|
|
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
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
|
|
|
|
|
|
_DA1_REPLY_RE = re.compile(rb"\x1b\[\?[0-9;]*c")
|
|
|
|
|
|
def _query_osc11_background() -> str | None:
|
|
"""Terminal background via OSC 11 as "#RRGGBB", or None.
|
|
|
|
Fenced with a DA1 sentinel (``ESC[c``): terminals answer in order and virtually all
|
|
answer DA1, so its reply proves our OSC 11 was processed — otherwise a late reply
|
|
leaks into prompt_toolkit's stdin as typed text. Skipped over SSH (round-trip too
|
|
slow; a late BEL reads as Ctrl+G). A 50 ms drain after TCSAFLUSH catches stragglers.
|
|
|
|
After the main read + TCSAFLUSH, a short drain window (50 ms) catches late-arriving bytes that slipped
|
|
past the flush — a race observed on VPS and container terminals under load (#40250).
|
|
"""
|
|
from cli import _DA1_REPLY_RE
|
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
return None
|
|
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
|
|
return None
|
|
try:
|
|
import select
|
|
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:
|
|
# One write so the OSC 11 query and DA1 fence cannot reorder.
|
|
sys.stdout.write("\x1b]11;?\x1b\\\x1b[c")
|
|
sys.stdout.flush()
|
|
except Exception:
|
|
return None
|
|
# Read until the DA1 fence closes; the 1s deadline only covers terminals ignoring DA1.
|
|
deadline = time.monotonic() + 1.0
|
|
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 _DA1_REPLY_RE.search(buf):
|
|
break
|
|
# Reply: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ — components are 1-4 hex digits.
|
|
m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf)
|
|
if not m:
|
|
return None
|
|
|
|
def norm(h: bytes) -> int:
|
|
v = int(h, 16)
|
|
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:
|
|
# TCSAFLUSH discards unread input, scrubbing a partial reply before prompt_toolkit reads it.
|
|
with suppress(Exception):
|
|
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
|
|
try:
|
|
drain_deadline = time.monotonic() + 0.05
|
|
while time.monotonic() < drain_deadline:
|
|
r, _, _ = select.select([fd], [], [], drain_deadline - time.monotonic())
|
|
if not r or not os.read(fd, 64):
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _heal_cooked_mode_drift(fd: int) -> bool:
|
|
"""Re-apply raw mode on *fd* when termios drifted back to cooked (POSIX only).
|
|
|
|
A lost ``run_in_terminal`` cooked_mode() restore makes the kernel line-buffer every
|
|
keystroke and the CLI looks dead. Mirrors prompt_toolkit's raw_mode flag surgery in
|
|
place. Returns True when healed; False when already raw or not inspectable.
|
|
"""
|
|
try:
|
|
import termios
|
|
attrs = termios.tcgetattr(fd)
|
|
except Exception:
|
|
return False
|
|
lflag = attrs[3]
|
|
if not (lflag & (termios.ICANON | termios.ECHO)):
|
|
return False # still raw — nothing to do
|
|
attrs[3] = lflag & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
|
|
attrs[0] = attrs[0] & ~(termios.IXON | termios.IXOFF | termios.ICRNL | termios.INLCR | termios.IGNCR)
|
|
attrs[6][termios.VMIN] = 1
|
|
try:
|
|
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _detect_light_mode_uncached() -> bool:
|
|
"""The detection ladder documented above; may raise (caller maps errors to dark)."""
|
|
from cli import _FALSE_RE, _LIGHT_DEFAULT_TERM_PROGRAMS, _TRUE_RE, _luminance_from_hex, _query_osc11_background
|
|
for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"):
|
|
v = (os.environ.get(var) or "").strip().lower()
|
|
if _TRUE_RE.match(v):
|
|
return True
|
|
if _FALSE_RE.match(v):
|
|
return False
|
|
theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower()
|
|
if theme == "light":
|
|
return True
|
|
if theme == "dark":
|
|
return False
|
|
bg_lum = _luminance_from_hex(os.environ.get("HERMES_TUI_BACKGROUND") or "")
|
|
if bg_lum is not None:
|
|
return bg_lum >= 0.5
|
|
last = (os.environ.get("COLORFGBG") or "").strip().split(";")[-1]
|
|
if last.isdigit() and 0 <= int(last) < 16:
|
|
return int(last) in {7, 15}
|
|
bg_color = _query_osc11_background()
|
|
if bg_color:
|
|
lum = _luminance_from_hex(bg_color)
|
|
if lum is not None:
|
|
return lum >= 0.5
|
|
return (os.environ.get("TERM_PROGRAM") or "").strip() in _LIGHT_DEFAULT_TERM_PROGRAMS
|
|
|
|
|
|
# Light-mode equivalents of skin colors unreadable on cream backgrounds. Only colors used
|
|
# as STANDALONE foregrounds: ones paired with a dark bg (status bar text on #1a1a2e) would
|
|
# become invisible the other direction, hence #C0C0C0/#888888/#555555/#8B8682 are skipped.
|
|
_LIGHT_MODE_REMAP: dict[str, str] = {
|
|
"#FFF8DC": "#1A1A1A", "#FFD700": "#9A6B00", "#FFBF00": "#8A5A00", "#B8860B": "#5C4500",
|
|
"#DAA520": "#6B4F00", "#F1E6CF": "#1A1A1A", "#c9d1d9": "#24292F", "#EAF7FF": "#0F1B26",
|
|
"#F5F5F5": "#1A1A1A", "#FFF0D4": "#1A1A1A", "#CD7F32": "#8A4F1A", "#FFEFB5": "#3A2A00",
|
|
}
|
|
|
|
|
|
_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()}
|
|
|
|
|
|
def _maybe_remap_for_light_mode(hex_color: str) -> str:
|
|
"""In light mode, remap a dark-tuned color to its higher-contrast equivalent."""
|
|
from cli import _LIGHT_MODE_REMAP_UPPER, _detect_light_mode
|
|
if not _detect_light_mode():
|
|
return hex_color
|
|
if not hex_color or not hex_color.startswith("#"):
|
|
return hex_color
|
|
return _LIGHT_MODE_REMAP_UPPER.get(hex_color.upper(), hex_color)
|
|
|
|
|
|
def _install_skin_light_mode_hook() -> None:
|
|
"""Wrap SkinConfig.get_color so EVERY skin color read goes through the light-mode remap. Idempotent."""
|
|
from cli import _maybe_remap_for_light_mode
|
|
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]
|
|
|
|
|
|
class _SkinAwareAnsi:
|
|
"""Lazy ANSI escape resolved from the skin on first use; ``.reset()`` after a ``/skin`` switch."""
|
|
|
|
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False):
|
|
self._skin_key = skin_key
|
|
self._fallback_hex = fallback_hex
|
|
self._bold = bold
|
|
self._cached: str | None = None
|
|
|
|
def __str__(self) -> str:
|
|
from cli import _hex_to_ansi
|
|
if self._cached is None:
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
self._cached = _hex_to_ansi(
|
|
get_active_skin().get_color(self._skin_key, self._fallback_hex),
|
|
bold=self._bold,
|
|
)
|
|
except Exception:
|
|
self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold)
|
|
return self._cached
|
|
|
|
def __add__(self, other: str) -> str:
|
|
return str(self) + other
|
|
|
|
def __radd__(self, other: str) -> str:
|
|
return other + str(self)
|
|
|
|
def reset(self) -> None:
|
|
"""Clear cache so the next access re-reads the skin."""
|
|
self._cached = None
|
|
|
|
|
|
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
|
|
|
|
|
|
# dim+italic attributes (not a hex) so dim text inherits the terminal foreground in both modes.
|
|
_DIM = "\x1b[2;3m"
|
|
|
|
|
|
def _tty_wrap(s: str, sgr: str) -> str:
|
|
"""Wrap *s* in an SGR attribute when stdout is a real TTY; plain text otherwise."""
|
|
try:
|
|
return f"{sgr}{s}\x1b[0m" if sys.stdout.isatty() else str(s)
|
|
except Exception:
|
|
return str(s)
|
|
|
|
|
|
_b = functools.partial(_tty_wrap, sgr="\x1b[1m") # bold when stdout is a real TTY
|
|
|
|
|
|
_d = functools.partial(_tty_wrap, sgr="\x1b[2;3m") # dim-italic when stdout is a real TTY
|
|
|
|
|
|
def _accent_hex() -> str:
|
|
"""Return the active skin accent color for legacy CLI output lines."""
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
return get_active_skin().get_color("ui_accent", "#FFBF00")
|
|
except Exception:
|
|
return "#FFBF00"
|
|
|
|
|
|
def _rich_text_from_ansi(text: str) -> _RichText:
|
|
"""Rich Text from ANSI output; literal ``[brackets]`` are not treated as markup."""
|
|
return _RichText.from_ansi(text or "")
|
|
|
|
|
|
def _strip_markdown_syntax(text: str) -> str:
|
|
"""Best-effort markdown marker removal for plain-text display."""
|
|
from cli import _rich_text_from_ansi
|
|
plain = _rich_text_from_ansi(text or "").plain
|
|
# HR markers: "-"/"_" runs of 3+, but "*" only when exactly 3 (cron schedules "* * * * *").
|
|
plain = re.sub(r"^\s{0,3}(?:[-_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
|
plain = re.sub(r"^\s{0,3}(?:\*\s*){3}\s*$", "", plain, flags=re.MULTILINE)
|
|
plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE)
|
|
# Blockquotes, lists, and checkboxes are preserved because they carry structure.
|
|
plain = re.sub(r"(```+|~~~+)", "", plain)
|
|
plain = re.sub(r"`([^`]*)`", r"\1", plain)
|
|
plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain)
|
|
plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain)
|
|
plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain)
|
|
plain = re.sub(r"(?<!\w)___([^_]+)___(?!\w)", r"\1", plain)
|
|
plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain)
|
|
plain = re.sub(r"(?<!\w)__([^_]+)__(?!\w)", r"\1", plain)
|
|
# `*emphasis*` only when the inner text is non-whitespace (cron expressions again).
|
|
plain = re.sub(r"\*([^\s*][^*]*?[^\s*])\*", r"\1", plain)
|
|
plain = re.sub(r"(?<!\w)_([^_]+)_(?!\w)", r"\1", plain)
|
|
plain = re.sub(r"~~([^~]+)~~", r"\1", plain)
|
|
plain = re.sub(r"\n{3,}", "\n\n", plain)
|
|
return plain.strip("\n")
|
|
|
|
|
|
_WINDOWS_PATH_WITH_DOT_SEGMENT_RE = re.compile(r"(?i)(?:\b[a-z]:\\|\\\\)[^\s`]*\\\.[^\s`]*")
|
|
|
|
|
|
def _preserve_windows_dot_segments_for_markdown(text: str) -> str:
|
|
r"""Double the ``\`` before hidden dirs in Windows paths: CommonMark reads ``\.`` as an escaped dot."""
|
|
from cli import _WINDOWS_PATH_WITH_DOT_SEGMENT_RE
|
|
if "\\." not in text:
|
|
return text
|
|
|
|
def _protect(match: re.Match[str]) -> str:
|
|
return re.sub(r"(?<!\\)\\(?=\.)", r"\\\\", match.group(0))
|
|
|
|
return _WINDOWS_PATH_WITH_DOT_SEGMENT_RE.sub(_protect, text)
|
|
|
|
|
|
def _terminal_columns() -> int:
|
|
try:
|
|
return shutil.get_terminal_size((80, 24)).columns
|
|
except Exception:
|
|
return 80
|
|
|
|
|
|
def _terminal_width_for_streaming() -> int:
|
|
"""Display cells inside the streamed response box (small margin for resize races)."""
|
|
from cli import _STREAM_PAD, _terminal_columns
|
|
return max(20, _terminal_columns() - len(_STREAM_PAD) - 2)
|
|
|
|
|
|
def _render_final_assistant_content(text: str, mode: str = "render"):
|
|
"""Render final assistant content as markdown, stripped text, or raw text."""
|
|
from cli import _preserve_windows_dot_segments_for_markdown, _rich_text_from_ansi, _strip_markdown_syntax, _terminal_columns, realign_markdown_tables
|
|
from rich.markdown import Markdown
|
|
|
|
# 1 border cell each side + margin so resize races don't push a borderline table into soft-wrap.
|
|
panel_width = max(20, _terminal_columns() - 4)
|
|
|
|
normalized_mode = str(mode or "render").strip().lower()
|
|
if normalized_mode == "strip":
|
|
# Strip first (inline markdown changes cell width), then re-align padding.
|
|
return _RichText(realign_markdown_tables(_strip_markdown_syntax(text), panel_width))
|
|
if normalized_mode == "raw":
|
|
return _rich_text_from_ansi(text or "")
|
|
|
|
# Normalising under-padded tables up front gives narrow-panel fallbacks consistent input.
|
|
plain = _rich_text_from_ansi(text or "").plain
|
|
plain = _preserve_windows_dot_segments_for_markdown(plain)
|
|
plain = realign_markdown_tables(plain, panel_width)
|
|
return Markdown(plain)
|
|
|
|
|
|
def _post_stream_transform_output(response: str, result: dict | None) -> str:
|
|
"""Text still to display after a streamed response transform: the suffix, or the whole response when replaced."""
|
|
if not result or not result.get("response_transformed"):
|
|
return ""
|
|
|
|
original = result.get("pre_transform_response") or ""
|
|
if original and response.startswith(original):
|
|
return response[len(original):]
|
|
|
|
return f"\n[Response transformed after streaming]\n{response}"
|
|
|
|
|
|
def _coerce_output_history_limit(value) -> int:
|
|
try:
|
|
return max(10, int(value))
|
|
except (TypeError, ValueError):
|
|
return 200
|
|
|
|
|
|
def _clear_output_history() -> None:
|
|
_cli()._OUTPUT_HISTORY.clear()
|
|
_set_chrome_floor(None) # the screen is cleared with it
|
|
|
|
|
|
def _output_history_recording() -> bool:
|
|
return _cli()._OUTPUT_HISTORY_ENABLED and not _cli()._OUTPUT_HISTORY_REPLAYING and not _cli()._OUTPUT_HISTORY_SUPPRESSED
|
|
|
|
|
|
def _record_output_history_entry(entry) -> None:
|
|
from cli import _output_history_recording
|
|
if _output_history_recording():
|
|
_cli()._OUTPUT_HISTORY.append(entry)
|
|
|
|
|
|
class _PaintedLine(str):
|
|
"""A recorded output line tagged with the terminal ``width`` it was painted at: a terminal
|
|
that does not reflow keeps the rows it wrapped into then, whatever the width is now."""
|
|
width = None
|
|
|
|
|
|
def _painted_columns():
|
|
"""The width the terminal soft-wraps a print at right now, or ``None``."""
|
|
from prompt_toolkit.application import get_app_or_none
|
|
app = get_app_or_none()
|
|
try:
|
|
if app is not None:
|
|
return app.output.get_size().columns
|
|
return os.get_terminal_size(sys.__stdout__.fileno()).columns
|
|
except (AttributeError, OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _record_output_history(text: str, *, force: bool = False) -> None:
|
|
"""Record ``text`` as painted now. ``force`` skips the recording check when the caller
|
|
made it at print-request time (the print itself was deferred to the app loop)."""
|
|
from cli import _output_history_recording
|
|
if force or _output_history_recording():
|
|
width = _painted_columns()
|
|
lines = []
|
|
# One entry per printed line: ``_pt_print`` ends every text with a newline, so "" and a
|
|
# trailing "\n" are blank rows on screen and must count in the replay's row budget.
|
|
for line in str(text).replace("\r", "").split("\n"):
|
|
line = _PaintedLine(line)
|
|
line.width = width
|
|
lines.append(line)
|
|
_cli()._OUTPUT_HISTORY.extend(lines)
|
|
|
|
|
|
_ANSI_SEQUENCE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
|
|
|
|
|
|
def _ansi_drop_cells(line: str, cells: int) -> str:
|
|
"""``line`` without its first ``cells`` visible cells; escape sequences are kept for their styling."""
|
|
from prompt_toolkit.utils import get_cwidth
|
|
out, pos = [], 0
|
|
for match in [*_ANSI_SEQUENCE_RE.finditer(line), None]:
|
|
end = match.start() if match else len(line)
|
|
for ch in line[pos:end]:
|
|
if cells > 0:
|
|
cells -= get_cwidth(ch)
|
|
else:
|
|
out.append(ch)
|
|
if match:
|
|
out.append(match.group())
|
|
pos = match.end()
|
|
return "".join(out)
|
|
|
|
|
|
# TERM names the terminal the CLI talks to, a multiplexer included (tmux, GNU screen), and is set
|
|
# by that terminal for its children — decisive whenever it names one. Everything else is inherited
|
|
# from whatever started the shell (TMUX, KITTY_WINDOW_ID or TERM_PROGRAM=vscode leak into an st
|
|
# or urxvt launched from there), so it only speaks for a generic TERM such as xterm-256color;
|
|
# XTERM_VERSION first there: an xterm started from a VTE shell inherits VTE_VERSION but sets
|
|
# XTERM_VERSION itself.
|
|
_REFLOW_TERM_PREFIXES = ("tmux", "screen", "xterm-kitty", "alacritty", "foot", "xterm-ghostty", "wezterm",
|
|
"contour", "vte")
|
|
# TERM is all that survives ssh; these prefixes name terminals that truncate rows in place.
|
|
_NO_REFLOW_TERM_PREFIXES = ("linux", "st", "mosh", "vt", "cons", "rxvt")
|
|
_NO_REFLOW_ENV = ("XTERM_VERSION",)
|
|
_REFLOW_ENV = ("VTE_VERSION", "KITTY_WINDOW_ID", "WT_SESSION", "KONSOLE_VERSION",
|
|
"ALACRITTY_WINDOW_ID", "WEZTERM_PANE", "GHOSTTY_RESOURCES_DIR")
|
|
_REFLOW_TERM_PROGRAMS = ("iTerm.app", "Apple_Terminal", "WezTerm", "vscode", "ghostty", "Tabby", "Hyper")
|
|
|
|
|
|
def _terminal_reflows() -> bool | None:
|
|
"""Whether the terminal re-wraps the rows it shows when its width changes: ``True``
|
|
(tmux, GNU screen, VTE, kitty, iTerm2, Terminal.app, WezTerm, Alacritty, Windows Terminal —
|
|
a shrink pushes the rows that grew into scrollback), ``False`` (xterm, st, urxvt, mosh, the
|
|
Linux console keep every row in place, truncated) or ``None`` when nothing says (xterm or
|
|
iTerm2 over ssh both look like ``TERM=xterm-256color``)."""
|
|
env = os.environ
|
|
term = env.get("TERM", "").lower()
|
|
if term.startswith(_REFLOW_TERM_PREFIXES): # before "vt": TERM=vte-256color
|
|
return True
|
|
if term.startswith(_NO_REFLOW_TERM_PREFIXES):
|
|
return False
|
|
if any(env.get(name) for name in _NO_REFLOW_ENV):
|
|
return False
|
|
if any(env.get(name) for name in _REFLOW_ENV) or env.get("TERM_PROGRAM") in _REFLOW_TERM_PROGRAMS:
|
|
return True
|
|
if env.get("LC_TERMINAL") == "iTerm2": # iTerm2 sets it so that ssh forwards it (LC_*)
|
|
return True
|
|
return None
|
|
|
|
|
|
def _line_rows(line: str, columns: int) -> int:
|
|
"""Rows ``line`` fills when the terminal soft-wraps it at ``columns``."""
|
|
from prompt_toolkit.formatted_text import ANSI, fragment_list_width, to_formatted_text
|
|
width = fragment_list_width(to_formatted_text(ANSI(line)))
|
|
return max(1, -(-width // columns)) if columns and columns > 0 else 1
|
|
|
|
|
|
def _output_tail_fitting(lines: list[str], max_rows: int, columns: int, painted: bool = True) -> list[str]:
|
|
"""Newest ``lines`` filling ``max_rows`` rows, soft-wrapped at ``columns`` — or, with
|
|
``painted``, at the width each was painted at, as a terminal that does not reflow still
|
|
shows it. The oldest one may only partly fit: its bottom rows are kept, the rows above
|
|
them are already in scrollback."""
|
|
kept, used = [], 0
|
|
for line in reversed(lines):
|
|
if used >= max_rows:
|
|
break
|
|
cols = (getattr(line, "width", None) if painted else None) or columns
|
|
height = _line_rows(line, cols)
|
|
if used + height > max_rows:
|
|
kept.append(_ansi_drop_cells(line, (height - (max_rows - used)) * cols))
|
|
break
|
|
used += height
|
|
kept.append(line)
|
|
kept.reverse()
|
|
return kept
|
|
|
|
|
|
def _pt_print_ansi(text: str) -> None:
|
|
"""``_pt_print(ANSI(text))``, falling back to ``print`` when stdout is not a real console."""
|
|
from cli import _PT_ANSI, _pt_print
|
|
try:
|
|
_pt_print(_PT_ANSI(text))
|
|
except Exception:
|
|
# NoConsoleScreenBufferError (Windows) / OSError when stdout is e.g. a worker log file.
|
|
with suppress(Exception):
|
|
print(text)
|
|
|
|
|
|
_HELD_PAINTS: list | None = None
|
|
_HELD_PAINTS_LOCK = threading.Lock()
|
|
_PAINT_SEQ = itertools.count()
|
|
# ``(app, gate)`` while the CLI's app runs: ``gate()`` is True when output must wait for a resize
|
|
# recovery (see ``CLITerminalMixin._output_waits_for_resize``).
|
|
_PAINT_GATE = None
|
|
|
|
|
|
def _set_paint_gate(app, gate) -> None:
|
|
global _PAINT_GATE
|
|
_PAINT_GATE = (app, gate) if gate is not None else None
|
|
|
|
|
|
# Rows from the top of the prompt chrome down to the bottom of the screen, once a refill left
|
|
# the chrome's top at a known row (``None``: unknown). prompt_toolkit learns that only through
|
|
# CPR, which the CLI leaves off; drawn at least this tall, the chrome keeps reaching the bottom
|
|
# row, where ``_transcript_room`` counts the transcript from, also when it shrinks (a narrower
|
|
# status bar, a closed modal) — rows left blank below it would be counted as transcript (#95375).
|
|
_CHROME_FLOOR = None
|
|
|
|
|
|
def _set_chrome_floor(rows) -> None:
|
|
global _CHROME_FLOOR
|
|
_CHROME_FLOOR = rows
|
|
|
|
|
|
def _chrome_floor():
|
|
return _CHROME_FLOOR
|
|
|
|
|
|
# Rows output scrolled into scrollback while the terminal's width changed under it: a terminal
|
|
# that does not reflow may have truncated them first, if it resized before reading all of that
|
|
# output. The next refill repaints them too — twice rather than truncated for good (#95375).
|
|
_SUSPECT_ROWS = 0
|
|
|
|
|
|
def _add_suspect_rows(rows: int) -> None:
|
|
global _SUSPECT_ROWS
|
|
_SUSPECT_ROWS += max(0, rows)
|
|
|
|
|
|
def _take_suspect_rows() -> int:
|
|
global _SUSPECT_ROWS
|
|
rows, _SUSPECT_ROWS = _SUSPECT_ROWS, 0
|
|
return rows
|
|
|
|
|
|
def _hold_paints() -> None:
|
|
"""Hold ``_cprint`` paints until ``_release_paints``, while a resize awaits its recovery.
|
|
|
|
A paint erases the prompt chrome from prompt_toolkit's cursor, which is stale once the
|
|
terminal re-wrapped the chrome to its new width: rows of the old chrome would stay in the
|
|
transcript, where the replay cannot account for them (#95375). On a terminal that does not
|
|
reflow, a paint would scroll rows it truncated into scrollback before the refill restores them.
|
|
"""
|
|
global _HELD_PAINTS
|
|
with _HELD_PAINTS_LOCK:
|
|
if _HELD_PAINTS is None:
|
|
_HELD_PAINTS = []
|
|
|
|
|
|
def _release_paints() -> None:
|
|
"""Paint, in the order they were requested, what ``_hold_paints`` held."""
|
|
global _HELD_PAINTS
|
|
with _HELD_PAINTS_LOCK:
|
|
held, _HELD_PAINTS = _HELD_PAINTS or [], None
|
|
for _seq, paint in sorted(held, key=lambda item: item[0]):
|
|
with suppress(Exception):
|
|
paint()
|
|
|
|
|
|
def _paint_held(seq: int, paint, app=None) -> bool:
|
|
"""Queue ``paint`` (requested ``seq``-th) when paints are held — or must be, because the
|
|
terminal's width changed under ``app`` and its recovery has not run yet."""
|
|
gate = _PAINT_GATE
|
|
if gate is not None and gate[0] is app and gate[1]():
|
|
_hold_paints()
|
|
with _HELD_PAINTS_LOCK:
|
|
if _HELD_PAINTS is None:
|
|
return False
|
|
_HELD_PAINTS.append((seq, paint))
|
|
return True
|
|
|
|
|
|
def _cprint(text: str):
|
|
"""Print ANSI text through prompt_toolkit's renderer (patch_stdout swallows raw ANSI).
|
|
|
|
From a background thread while an Application runs, a direct print races the input
|
|
redraw and gets buried, so those are painted on the app's loop via ``call_soon_threadsafe``.
|
|
"""
|
|
from cli import _PT_ANSI, _output_history_recording, _pt_print, _pt_print_ansi, _record_output_history
|
|
recording = _output_history_recording()
|
|
seq = next(_PAINT_SEQ)
|
|
|
|
def _painted(paint):
|
|
# Recorded when painted, not when requested: a redraw replaying the history must
|
|
# neither print rows still queued for the loop nor size them at a stale width.
|
|
def _paint():
|
|
if recording:
|
|
_record_output_history(text, force=True)
|
|
paint()
|
|
return _paint
|
|
paint_pt = _painted(lambda: _pt_print(_PT_ANSI(text)))
|
|
paint_fallback = _painted(lambda: _pt_print_ansi(text))
|
|
|
|
try:
|
|
from prompt_toolkit.application import get_app_or_none, run_in_terminal
|
|
except Exception:
|
|
paint_pt()
|
|
return
|
|
|
|
try:
|
|
app = get_app_or_none()
|
|
except Exception:
|
|
app = None
|
|
|
|
if app is None or not getattr(app, "_is_running", False):
|
|
_release_paints()
|
|
paint_fallback()
|
|
return
|
|
|
|
import asyncio as _asyncio
|
|
|
|
try:
|
|
loop = app.loop # type: ignore[attr-defined]
|
|
except Exception:
|
|
loop = None
|
|
try:
|
|
# get_running_loop(): get_event_loop() warns from threads with no current loop.
|
|
# Use get_running_loop() instead of get_event_loop() to avoid the DeprecationWarning /
|
|
# RuntimeWarning emitted by Python 3.10+ when get_event_loop() is called from a thread that has no
|
|
# current event loop set (e.g. the process_loop background thread). Fixes #19285.
|
|
current_loop = _asyncio.get_running_loop()
|
|
except Exception:
|
|
current_loop = None
|
|
if loop is None or (current_loop is loop and loop.is_running()):
|
|
if not _paint_held(seq, paint_pt, app):
|
|
paint_pt()
|
|
return
|
|
|
|
def _print_now():
|
|
from prompt_toolkit.formatted_text import to_formatted_text
|
|
from prompt_toolkit.renderer import print_formatted_text as _paint_formatted_text
|
|
from prompt_toolkit.styles import Style
|
|
_paint_formatted_text(app.output, to_formatted_text(_PT_ANSI(text)) + [("", "\n")], Style([]))
|
|
paint_now = _painted(_print_now)
|
|
|
|
def _schedule():
|
|
if not getattr(app, "_is_running", False):
|
|
paint_fallback()
|
|
return
|
|
if _paint_held(seq, _schedule, app):
|
|
return
|
|
with suppress(Exception):
|
|
pending = getattr(app, "_running_in_terminal_f", None)
|
|
if getattr(app, "_running_in_terminal", False) or (pending is not None and not pending.done()):
|
|
# Another run_in_terminal body owns the terminal: paint after it. Never fall back
|
|
# to a bare print on error: run_in_terminal may already have painted.
|
|
import inspect as _inspect
|
|
coro = run_in_terminal(lambda: _paint_held(seq, _schedule, app) or paint_now())
|
|
if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)):
|
|
_asyncio.ensure_future(coro)
|
|
return
|
|
# What run_in_terminal does, but now: its erase and print would run a loop pass
|
|
# later, when a resize may have landed after the check above (#95375).
|
|
renderer = app.renderer
|
|
floor = _CHROME_FLOOR
|
|
if floor is not None: # the chrome, as tall as drawn, moves down by the rows printed
|
|
screen = renderer._last_screen
|
|
floor = max(floor, renderer._min_available_height, screen.height if screen else 0)
|
|
columns = app.output.get_size().columns
|
|
renderer.erase()
|
|
paint_now()
|
|
renderer.reset()
|
|
printed = sum(_line_rows(line, columns) for line in text.split("\n"))
|
|
if app.output.get_size().columns != columns:
|
|
_add_suspect_rows(printed)
|
|
if floor is not None:
|
|
_set_chrome_floor(max(0, floor - printed))
|
|
app._request_absolute_cursor_position()
|
|
app._redraw()
|
|
|
|
try:
|
|
loop.call_soon_threadsafe(_schedule)
|
|
except Exception:
|
|
paint_fallback()
|
|
|
|
|
|
def _prepend_note_to_message(message, note: str):
|
|
"""Prepend a one-shot note to a user message (str, or content-part list when an image is attached).
|
|
|
|
For lists the note is folded into the first text part or inserted as a leading one.
|
|
Unknown shapes are returned unchanged.
|
|
"""
|
|
note = str(note or "").strip()
|
|
if not note:
|
|
return message
|
|
if isinstance(message, str):
|
|
return f"{note}\n\n{message}" if message else note
|
|
if isinstance(message, list):
|
|
parts = list(message)
|
|
for i, part in enumerate(parts):
|
|
if isinstance(part, dict) and part.get("type") == "text":
|
|
text = part.get("text", "")
|
|
parts[i] = {**part, "text": f"{note}\n\n{text}" if text else note}
|
|
return parts
|
|
return [{"type": "text", "text": note}, *parts]
|
|
return message
|
|
|
|
|
|
def _pt_app_is_running() -> bool:
|
|
"""Whether a prompt_toolkit Application currently owns the live terminal."""
|
|
try:
|
|
from prompt_toolkit.application import get_app_or_none
|
|
app = get_app_or_none()
|
|
except Exception:
|
|
return False
|
|
return app is not None and bool(getattr(app, "_is_running", False))
|
|
|
|
|
|
def _cli_visible_print(text: str = "") -> None:
|
|
"""``print`` unless a prompt_toolkit Application owns the terminal (patch_stdout swallows bare prints)."""
|
|
from cli import _cprint, _pt_app_is_running
|
|
if _pt_app_is_running():
|
|
_cprint(text)
|
|
else:
|
|
print(text)
|
|
|
|
|
|
class ChatConsole:
|
|
"""Rich Console drop-in routing rendered ANSI through ``_cprint`` so colors survive patch_stdout."""
|
|
|
|
def __init__(self):
|
|
from io import StringIO
|
|
self._buffer = StringIO()
|
|
self._inner = Console(file=self._buffer, force_terminal=True, color_system="truecolor", highlight=False)
|
|
|
|
def print(self, *args, **kwargs):
|
|
from cli import _OSC_ESCAPE_RE, _cprint
|
|
self._buffer.seek(0)
|
|
self._buffer.truncate()
|
|
self._inner.width = shutil.get_terminal_size((80, 24)).columns
|
|
self._inner.print(*args, **kwargs)
|
|
for line in _OSC_ESCAPE_RE.sub("", self._buffer.getvalue()).rstrip("\n").split("\n"):
|
|
_cprint(line)
|
|
|
|
@contextmanager
|
|
def status(self, *_args, **_kwargs):
|
|
"""No-op ``console.status`` so slash helpers don't duplicate ``_busy_command()``'s indicator."""
|
|
yield self
|
|
|
|
|
|
def _build_compact_banner() -> str:
|
|
"""Build a compact banner that fits the current terminal width."""
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
_skin = get_active_skin()
|
|
except Exception:
|
|
_skin = None
|
|
|
|
def _color(key, default):
|
|
return _skin.get_color(key, default) if _skin else default
|
|
|
|
border_color = _color("banner_border", "#FFD700")
|
|
title_color = _color("banner_title", "#FFBF00")
|
|
dim_color = _color("banner_dim", "#B8860B")
|
|
|
|
if (getattr(_skin, "name", "default") if _skin else "default") == "default":
|
|
tiny_line = "☤ NOUS HERMES"
|
|
else:
|
|
tiny_line = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
|
|
line1 = f"{tiny_line} - AI Agent Framework"
|
|
|
|
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
|
|
from hermes_cli import __release_date__ as _release_date
|
|
from hermes_cli import __version__ as _version
|
|
|
|
version_line = f"Hermes Agent v{_version} ({_release_date})"
|
|
else:
|
|
version_line = format_banner_version_label()
|
|
|
|
w = min(shutil.get_terminal_size().columns - 2, 88)
|
|
if w < 30:
|
|
return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
|
|
|
|
inner = w - 2 # inside the box border
|
|
bar = "═" * w
|
|
content_width = inner - 2
|
|
|
|
line1 = line1[:content_width].ljust(content_width)
|
|
line2 = version_line[:content_width].ljust(content_width)
|
|
|
|
return (
|
|
f"\n[bold {border_color}]╔{bar}╗[/]\n"
|
|
f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
|
|
f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
|
|
f"[bold {border_color}]╚{bar}╝[/]\n"
|
|
)
|
|
|
|
|
|
def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46, max_width: int = 76) -> int:
|
|
"""Stable TUI panel width wide enough for the title and content (incl. borders)."""
|
|
term_cols = shutil.get_terminal_size((100, 20)).columns
|
|
longest = max([len(title)] + [len(line) for line in content_lines] + [min_width - 4])
|
|
inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
|
|
return inner + 2 # leading/trailing space inside the borders
|
|
|
|
|
|
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "", *, keep_ws: bool = False) -> list[str]:
|
|
"""Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews)."""
|
|
kw = dict(replace_whitespace=False, drop_whitespace=False) if keep_ws else dict(break_long_words=False, break_on_hyphens=False)
|
|
wrapped = textwrap.wrap(text, width=max(8, width), subsequent_indent=subsequent_indent, **kw)
|
|
return wrapped or [""]
|
|
|
|
|
|
_wrap_panel_text_keep_ws = functools.partial(_wrap_panel_text, keep_ws=True)
|
|
|
|
|
|
def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
|
|
lines.extend(((border_style, "│ "), (content_style, text.ljust(max(0, box_width - 2))), (border_style, " │\n")))
|
|
|
|
|
|
def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
|
|
lines.append((border_style, "│" + (" " * box_width) + "│\n"))
|