refactor(cli): move cli.py module-level helper clusters into topical siblings; cli.py lands under 2,000 lines (#116911)
Mechanical, behaviour-neutral extraction along the existing hermes_cli/cli_*.py
pattern. Six clusters of module-level helpers leave cli.py:
- cli_config_load.py prefill messages, reasoning/service-tier parsing, terminal
env mirroring, CLI defaults + YAML merge, logging bootstrap
- cli_render.py reasoning-tag stripping, ANSI/skin colours, light-mode
detection, markdown/final rendering, output-history
recording, _cprint, ChatConsole, compact banner, panel wrap
- cli_terminal_input.py file drops/attachments, bracketed-paste patch, extended
Enter keys, CPR guards, TUI input height, query images
- cli_shutdown.py process session-id sync, exit watchdog, cleanup steps,
session-finalize notifications, one-shot finalize
- cli_single_query.py kanban goal loops, exit-code mapping, quiet -q runner,
image routing, signal handlers, single-query mode
- cli_auto_maintenance.py state-db / checkpoint startup maintenance
Every moved body is AST-identical to the base copy modulo two mechanical
edits: cli-level names are late-bound with a call-time `from cli import ...`
(the cli_init_mixin pattern) and mutable cli state is read through `_cli().NAME`
(the gateway_service_unit `_gw()` pattern), so every monkeypatch seam on the
`cli` facade still intercepts moved code and no read snapshots stale state.
Mutable module state and every `global`-writing function stay in cli.py; the
facade re-exports the moved names in one import block.
test_bracketed_paste_timeout AST-loads the paste helper from its new home.
cli.py 3959 -> 1820 lines.
This commit is contained in:
704
hermes_cli/cli_render.py
Normal file
704
hermes_cli/cli_render.py
Normal file
@@ -0,0 +1,704 @@
|
||||
"""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 os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
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()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _record_output_history(text: str) -> None:
|
||||
from cli import _output_history_recording
|
||||
if _output_history_recording():
|
||||
_cli()._OUTPUT_HISTORY.extend(str(text).replace("\r", "").rstrip("\n").splitlines())
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 go through ``run_in_terminal`` via ``call_soon_threadsafe``.
|
||||
"""
|
||||
from cli import _PT_ANSI, _pt_print, _pt_print_ansi, _record_output_history
|
||||
_record_output_history(text)
|
||||
|
||||
try:
|
||||
from prompt_toolkit.application import get_app_or_none, run_in_terminal
|
||||
except Exception:
|
||||
_pt_print(_PT_ANSI(text))
|
||||
return
|
||||
|
||||
try:
|
||||
app = get_app_or_none()
|
||||
except Exception:
|
||||
app = None
|
||||
|
||||
if app is None or not getattr(app, "_is_running", False):
|
||||
_pt_print_ansi(text)
|
||||
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()):
|
||||
_pt_print(_PT_ANSI(text))
|
||||
return
|
||||
|
||||
def _schedule():
|
||||
# run_in_terminal() returns an awaitable (pt >= 3.0) that must be scheduled or the
|
||||
# output is dropped, or None (mocks / older pt) when it already ran synchronously.
|
||||
# Never fall back to a bare print on error: the sync path already printed.
|
||||
with suppress(Exception):
|
||||
import inspect as _inspect
|
||||
coro = run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
|
||||
if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)):
|
||||
_asyncio.ensure_future(coro)
|
||||
|
||||
try:
|
||||
loop.call_soon_threadsafe(_schedule)
|
||||
except Exception:
|
||||
_pt_print_ansi(text)
|
||||
|
||||
|
||||
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"))
|
||||
Reference in New Issue
Block a user