Files
hermes-agent/hermes_cli/cli_render.py
teknium1 2ca53fc386 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.
2026-09-20 20:42:17 -07:00

705 lines
27 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 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"))