mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The "∴ Thought for Ns" line that closes a reasoning block appended the reasoning's first bold heading, or — with no heading — its first sentence. Models whose reasoning has no headings (e.g. Claude) therefore got their opening sentence printed twice: once in the reasoning body streamed above the line, and again as the summary's title. Drop the title entirely; the reasoning is already shown in full right above, so the summary is just the duration. Removes the now-dead _full_title/thought_title/_TITLE_MAX helpers and the write-only _reasoning_full_text buffer in the stream mixin.
394 lines
18 KiB
Python
394 lines
18 KiB
Python
"""Glyph-grammar transcript and bottom-anchored chrome for the classic CLI (``display.cli_layout``).
|
||
|
||
Every row this module prints into scrollback is width-independent (a column-0 glyph, no boxes, no
|
||
full-width rules), so resizing the terminal cannot re-wrap and garble what is already printed.
|
||
Width-dependent chrome (the composer's top rule and the footer) lives only in prompt_toolkit's live
|
||
region, which is redrawn on every resize. ``display.cli_layout: classic`` restores the boxed look.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
from datetime import datetime
|
||
|
||
from agent.i18n import t
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||
_RST, _BOLD, _DIM = "\x1b[0m", "\x1b[1m", "\x1b[2m"
|
||
_RED = "\x1b[38;2;239;83;80m"
|
||
|
||
|
||
def is_modern(cli) -> bool:
|
||
return bool(getattr(cli, "_modern_layout", False))
|
||
|
||
|
||
def layout_from_config(display: dict) -> bool:
|
||
"""True for the glyph layout (default); ``classic`` keeps the boxed transcript."""
|
||
return str((display or {}).get("cli_layout", "modern") or "modern").strip().lower() != "classic"
|
||
|
||
|
||
def busy_input_mode_from_config(display: dict) -> str:
|
||
"""``display.busy_input_mode``; unset interrupts in both layouts (Tab still queues in this one)."""
|
||
return str((display or {}).get("busy_input_mode") or "interrupt").strip().lower()
|
||
|
||
|
||
def _skin_hex(key: str, fallback: str) -> str:
|
||
from hermes_cli.skin_engine import get_active_skin
|
||
return get_active_skin().get_color(key, fallback) or fallback
|
||
|
||
|
||
def _fg(hex_color: str, bold: bool = False) -> str:
|
||
try:
|
||
r, g, b = (int(hex_color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
|
||
return f"\x1b[{'1;' if bold else ''}38;2;{r};{g};{b}m"
|
||
except (ValueError, IndexError):
|
||
return _BOLD if bold else ""
|
||
|
||
|
||
def _brand_glyph() -> tuple[str, str]:
|
||
"""(glyph, name) from the skin's response label, e.g. ``" ☤ Hermes "`` → ("☤", "Hermes")."""
|
||
from hermes_cli.skin_engine import get_active_skin
|
||
label = get_active_skin().get_branding("response_label", " ☤ Hermes ")
|
||
parts = label.split(None, 1)
|
||
return (parts[0], parts[1].strip()) if len(parts) == 2 else ("☤", label.strip() or "Hermes")
|
||
|
||
|
||
def prompt_glyph() -> str:
|
||
from hermes_cli.skin_engine import get_active_skin
|
||
return get_active_skin().get_branding("prompt_symbol", "❯").strip() or "❯"
|
||
|
||
|
||
# ── transcript rows (printed into scrollback) ───────────────────────────────────────────────
|
||
|
||
# Fixed width, not the terminal's: a 40-cell rule never re-wraps on resize (classic's user rule).
|
||
_SEPARATOR_WIDTH = 40
|
||
|
||
|
||
# Every block (user message, reasoning, answer) is framed the same way: blank line, bar, content,
|
||
# bar, blank line. A closing bar leaves the blank line owed; whatever prints next (a tool row, the
|
||
# turn footer) pays it, and an opening bar pays it with its own leading blank line.
|
||
_GAP_PENDING = False
|
||
|
||
|
||
def separator(accent: bool) -> str:
|
||
"""An opening bar: accent for user messages and answers, dim for reasoning. Every caller prints
|
||
a blank line ahead of it, so it also settles a gap the previous block owes."""
|
||
global _GAP_PENDING
|
||
_GAP_PENDING = False
|
||
color = _fg(_skin_hex("ui_accent", "#FFBF00")) if accent else _DIM
|
||
return f"{color}{'─' * _SEPARATOR_WIDTH}{_RST}"
|
||
|
||
|
||
def closing_separator(accent: bool) -> str:
|
||
"""The bar that closes a block; the blank line after it is owed to whatever prints next."""
|
||
global _GAP_PENDING
|
||
bar = separator(accent)
|
||
_GAP_PENDING = True
|
||
return bar
|
||
|
||
|
||
def _take_gap() -> str:
|
||
global _GAP_PENDING
|
||
gap, _GAP_PENDING = ("\n" if _GAP_PENDING else ""), False
|
||
return gap
|
||
|
||
|
||
def user_echo_markup(text: str, accent_hex: str, ts_suffix: str = "") -> str:
|
||
"""Rich markup for a submitted single-line user message: ``❯ text``."""
|
||
from rich.markup import escape
|
||
return f"[bold {accent_hex}]{escape(prompt_glyph())}[/] [bold]{escape(text)}[/]{ts_suffix}"
|
||
|
||
|
||
def answer_label(timestamp: str = "") -> str:
|
||
"""The rows that open an answer: the accent bar, then the skin's brand glyph + name."""
|
||
glyph, name = _brand_glyph()
|
||
stamp = f" {_DIM}{timestamp}{_RST}" if timestamp else ""
|
||
return f"\n{separator(True)}\n{_fg(_skin_hex('ui_accent', '#FFBF00'), bold=True)}{glyph} {name}{_RST}{stamp}"
|
||
|
||
|
||
def thought_line(seconds: float | None) -> str:
|
||
"""``∴ Thought for 3s`` (dim italic), closing a reasoning block. No title: the reasoning is
|
||
printed right above, so any excerpt of it would repeat what the user just read."""
|
||
took = t("cli.modern.thought_for", seconds=f"{max(seconds or 0.0, 0.0):.0f}") if seconds else t("cli.modern.thought")
|
||
return f"\x1b[2;3m∴ {took}{_RST}"
|
||
|
||
|
||
_REASONING_PREVIEW_LINES = 10 # classic's post-hoc reasoning box shows this many unless /reasoning full
|
||
|
||
|
||
def reasoning_text_line(line: str) -> str:
|
||
"""One streamed reasoning line: dim, no frame (the dim bar above opens the block)."""
|
||
return f"{_DIM}{line}{_RST}"
|
||
|
||
|
||
def thought_rows(seconds: float | None, text: str, full: bool) -> list[str]:
|
||
"""A reasoning block printed after the fact (no live stream): dim bar, its text (first 10
|
||
lines unless ``full``, as classic), then the ``∴ Thought for 3s`` summary."""
|
||
lines = (text or "").strip().splitlines()
|
||
shown = lines if full or len(lines) <= _REASONING_PREVIEW_LINES else lines[:_REASONING_PREVIEW_LINES]
|
||
rows = ["", separator(False), *(reasoning_text_line(line) for line in shown)]
|
||
if len(shown) < len(lines):
|
||
rows.append(f"{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - len(shown))}{_RST}")
|
||
return [*rows, thought_line(seconds), closing_separator(False)]
|
||
|
||
|
||
def completed_tool_line(cli, function_name: str, args: dict | None, duration: float, result=None,
|
||
is_error: bool = False) -> str:
|
||
"""The scrollback line for a finished tool call in the active layout."""
|
||
if is_modern(cli):
|
||
return _take_gap() + tool_row(function_name, args, duration, result, is_error)
|
||
from agent.display import get_cute_tool_message
|
||
return f" {get_cute_tool_message(function_name, args, duration, result=result)}"
|
||
|
||
|
||
def tool_row(function_name: str, args: dict | None, duration: float, result=None, is_error: bool = False) -> str:
|
||
"""``📖 Read calc.py 0.2s`` / ``✗ $ pytest -q 0.4s [exit 1]`` — one row per completed call, led by
|
||
the tool's emoji (the classic ``┊`` row's), or a red ``✗`` when that entry failed. A tool in
|
||
``NEUTRAL_FAILURE_TOOLS`` keeps its emoji and shows the reason dim."""
|
||
from agent.display import get_tool_row_parts, is_neutral_failure
|
||
rows = get_tool_row_parts(function_name, args or {}, result, is_error=bool(is_error))
|
||
neutral = is_neutral_failure(function_name, result)
|
||
out = []
|
||
for index, (emoji, verb, detail, suffix) in enumerate(rows):
|
||
verb = (verb or function_name).strip()
|
||
mark = f"{_RED}✗{_RST}" if suffix is not None and not neutral else (emoji or "●")
|
||
line = f"{mark} {_BOLD}{verb[:1].upper()}{verb[1:]}{_RST}"
|
||
if detail:
|
||
line += f" {detail}"
|
||
if index == len(rows) - 1 and duration >= 0.1:
|
||
line += f" {_DIM}{duration:.1f}s{_RST}"
|
||
if suffix:
|
||
line += f"{_DIM if neutral else _RED}{suffix}{_RST}"
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
_ACTIVE = False # one CLI per process; read by printers that get no CLI handle (gateway inline diffs)
|
||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||
_DIFF_FILE_RE = re.compile(r"^a/(.+?) → b/(.+)$")
|
||
|
||
|
||
def set_active(enabled: bool) -> None:
|
||
global _ACTIVE
|
||
_ACTIVE = bool(enabled)
|
||
|
||
|
||
def _display_path(path: str) -> str:
|
||
path = path[1:] if path.startswith("//") else path
|
||
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
|
||
try:
|
||
rel = os.path.relpath(path, cwd) if os.path.isabs(path) else path
|
||
except ValueError:
|
||
return path
|
||
return path if rel.startswith("..") else rel
|
||
|
||
|
||
def gutter_print(print_fn):
|
||
"""Wrap a line printer so a diff hangs under its tool row behind a dim ``│``: the ``review
|
||
diff`` header is dropped and ``a/<abs> → b/<abs>`` collapses to one cwd-relative path."""
|
||
header = _ANSI_RE.sub("", t("display.diff.review_header")).strip()
|
||
|
||
def _print(text):
|
||
for line in str(text).split("\n"):
|
||
plain = _ANSI_RE.sub("", line).strip()
|
||
if plain == header:
|
||
continue
|
||
match = _DIFF_FILE_RE.match(plain)
|
||
if match:
|
||
old, new = _display_path(match.group(1)), _display_path(match.group(2))
|
||
line = f"{_BOLD}{new if old == new else f'{old} → {new}'}{_RST}"
|
||
print_fn(f"{_DIM}│{_RST} {line}" if plain else f"{_DIM}│{_RST}")
|
||
return _print
|
||
|
||
|
||
def diff_printer(print_fn):
|
||
"""``print_fn`` with the gutter when the glyph layout is active in this process."""
|
||
return gutter_print(print_fn) if _ACTIVE else print_fn
|
||
|
||
|
||
def turn_footer(summary: str) -> str:
|
||
"""``─ 12.4s · read 3 files · edited 1 file +4 -0 · 7:02 AM`` closing each turn."""
|
||
body = summary.lstrip("⋯ ").strip()
|
||
clock = datetime.now().strftime("%-I:%M %p") if os.name != "nt" else datetime.now().strftime("%I:%M %p")
|
||
return _take_gap() + (f"{_DIM}─ {body} · {clock}{_RST}" if body else f"{_DIM}─ {clock}{_RST}")
|
||
|
||
|
||
# ── live region (prompt_toolkit fragments, redrawn every frame) ─────────────────────────────
|
||
|
||
def _cwd_label() -> str:
|
||
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
|
||
home = os.path.expanduser("~")
|
||
short = "~" + cwd[len(home):] if cwd == home or cwd.startswith(home + os.sep) else cwd
|
||
from hermes_cli.status_bar_git import current_git_branch
|
||
branch = current_git_branch(cwd)
|
||
return f"{short} ({branch})" if branch else short
|
||
|
||
|
||
def activity_text(cli) -> str:
|
||
"""What the agent is doing: the classic spinner's text (kaomoji face or tool emoji included)."""
|
||
if getattr(cli, "_command_running", False):
|
||
return str(getattr(cli, "_command_status", "") or "")
|
||
text = str(getattr(cli, "_spinner_text", "") or "").strip()
|
||
return text or (t("cli.modern.working") if getattr(cli, "_agent_running", False) else "")
|
||
|
||
|
||
def _turn_elapsed(cli) -> str:
|
||
started = getattr(cli, "_prompt_start_time", None)
|
||
if started is None:
|
||
return ""
|
||
from cli import format_duration_compact
|
||
return format_duration_compact(max(0.0, time.time() - float(started)))
|
||
|
||
|
||
def rule_style() -> str:
|
||
"""Composer rules are quiet chrome: the skin's session border, not the accent."""
|
||
return f"fg:{_skin_hex('session_border', '#8B8682')}"
|
||
|
||
|
||
def _no_bg(frags: list) -> list:
|
||
"""The footer sits on the terminal background, not the classic status bar's fill."""
|
||
return [(f"{style} bg:default".strip(), text) for style, text in frags]
|
||
|
||
|
||
def _fit(cli, frags: list, width: int) -> list:
|
||
used = sum(cli._status_bar_display_width(text) for _, text in frags)
|
||
if used <= width:
|
||
return frags
|
||
plain = "".join(text for _, text in frags)
|
||
return [(rule_style(), cli._trim_status_bar_text(plain, width))]
|
||
|
||
|
||
def top_rule_fragments(cli) -> list:
|
||
"""``── ⠹ Running tests · 14s ─────────── calc helpers ─``: live status left, session title right."""
|
||
width = max(10, cli._get_tui_terminal_width())
|
||
rule, accent = rule_style(), f"fg:{_skin_hex('ui_accent', '#FFBF00')} bold"
|
||
busy = getattr(cli, "_agent_running", False) or getattr(cli, "_command_running", False)
|
||
left: list = []
|
||
if busy:
|
||
frame = _SPIN[int(time.monotonic() * 10) % len(_SPIN)]
|
||
left = [(rule, "── "), (accent, frame + " "), ("", activity_text(cli))]
|
||
# Classic's spinner suffix: turn time and the streamed-token count (``↓ 547 tok``).
|
||
for extra in (_turn_elapsed(cli), cli._spinner_token_flow()):
|
||
if extra:
|
||
left.append(("class:status-bar-dim bg:default", f" · {extra}"))
|
||
left.append((rule, " "))
|
||
title = cli._get_status_bar_session_title()
|
||
right = [(rule, " "), (f"fg:{_skin_hex('ui_label', '#DAA520')}", title), (rule, " ─")] if title else []
|
||
used = sum(cli._status_bar_display_width(text) for _, text in left + right)
|
||
if used >= width:
|
||
right, used = [], sum(cli._status_bar_display_width(text) for _, text in left)
|
||
return _fit(cli, left + [(rule, "─" * max(0, width - used))] + right, width)
|
||
|
||
|
||
def hint_pairs(cli) -> list[tuple[str, str]]:
|
||
"""Keys that do something right now, for the footer's right half."""
|
||
if any(getattr(cli, attr, None) for attr in (
|
||
"_approval_state", "_clarify_state", "_sudo_state", "_secret_state", "_connection_state",
|
||
"_slash_confirm_state", "_model_picker_state", "_command_palette_state")):
|
||
return []
|
||
if getattr(cli, "_agent_running", False):
|
||
enter = {"steer": "steer", "queue": "queue"}.get(getattr(cli, "busy_input_mode", ""), "interrupt")
|
||
pairs = [("enter", t(f"cli.modern.hint_{enter}"))]
|
||
if enter != "queue":
|
||
pairs.append(("tab", t("cli.modern.hint_queue")))
|
||
return pairs + [("ctrl+c", t("cli.modern.hint_stop"))]
|
||
return [("/", t("cli.modern.hint_commands")), ("@", t("cli.modern.hint_files")), ("!", t("cli.modern.hint_shell"))]
|
||
|
||
|
||
def status_line_fragments(cli) -> list:
|
||
"""Footer under the composer: model · context gauge · cwd (branch) left, live key hints right.
|
||
An explicit ``display.status_bar.fields`` list keeps the user's chosen segments on the left."""
|
||
return _render_safely(_status_line_fragments, cli)
|
||
|
||
|
||
def _render_safely(render, cli) -> list:
|
||
"""Boundary for prompt_toolkit render callbacks: a bad frame degrades to a bare line."""
|
||
try:
|
||
return render(cli)
|
||
except Exception: # health: allow BLE001 -- a render callback must never take down the app frame
|
||
logger.debug("%s failed", render.__name__, exc_info=True)
|
||
return [("", f" {getattr(cli, 'model', '') or 'Hermes'}")]
|
||
|
||
|
||
def _status_line_fragments(cli) -> list:
|
||
"""The classic bar's segments (same fields, same width tiers, same ``display.status_bar.fields``
|
||
filter), then cwd (branch) and the live key hints when there is room."""
|
||
width = max(10, cli._get_tui_terminal_width())
|
||
dim, strong = "class:status-bar-dim", "class:status-bar-strong"
|
||
snapshot = cli._get_status_bar_snapshot()
|
||
field_set = cli._get_status_bar_field_set()
|
||
|
||
def _on(name):
|
||
return field_set is None or name in field_set
|
||
|
||
left: list = []
|
||
for seg in cli._status_bar_segments(snapshot, width, field_set, cli._is_session_yolo_active(), styled=True):
|
||
if left:
|
||
left.append((dim, " · "))
|
||
left.extend(seg)
|
||
stash = cli._prompt_stash.indicator() if _on("stash") and hasattr(cli, "_prompt_stash") else ""
|
||
if stash: # a parked draft is never silently forgotten
|
||
left += [(dim, " · "), (strong, stash)]
|
||
battery = snapshot.get("battery_label") or "" # only set while /battery is on
|
||
if battery and _on("battery"):
|
||
left[0:0] = [("", " "), (cli._battery_status_style(snapshot.get("battery_category", "dim")), battery),
|
||
(dim, " ·")]
|
||
vim = cli._vim_mode_label()
|
||
if vim:
|
||
left += [(dim, " · "), (strong, vim)]
|
||
right: list = []
|
||
for key, text in hint_pairs(cli) if width >= 76 else []:
|
||
if right:
|
||
right.append((dim, " · "))
|
||
right += [(strong, key), (dim, f" {text}")]
|
||
used_left = sum(cli._status_bar_display_width(text) for _, text in left)
|
||
used_right = sum(cli._status_bar_display_width(text) for _, text in right)
|
||
room = width - used_left - used_right - 6
|
||
if field_set is None and room >= 12: # cwd takes whatever the hints leave, trimmed from the left
|
||
cwd = _cwd_label()
|
||
cwd = cwd if len(cwd) <= room else "…" + cwd[-(room - 1):]
|
||
left += [(dim, " · "), (dim, cwd)]
|
||
used_left += 3 + cli._status_bar_display_width(cwd)
|
||
if right and used_left + used_right + 2 > width:
|
||
right, used_right = [], 0
|
||
pad = [("", " " * max(1, width - used_left - used_right - 1))] if right else []
|
||
return _no_bg(_fit(cli, left + pad + right + ([("", " ")] if right else []), width))
|
||
|
||
|
||
def modern_input_rules(cli, kb):
|
||
"""The composer's rules for this layout (the top one carries live status), plus Tab-to-queue."""
|
||
from prompt_toolkit.filters import Condition
|
||
from prompt_toolkit.layout import FormattedTextControl, Window
|
||
|
||
top = Window(FormattedTextControl(lambda: _render_safely(top_rule_fragments, cli)),
|
||
height=lambda: cli._tui_input_rule_height("top"), wrap_lines=False)
|
||
bottom = Window(char="─", height=lambda: cli._tui_input_rule_height("bottom"), style=rule_style)
|
||
|
||
def _can_queue() -> bool:
|
||
from prompt_toolkit.application import get_app
|
||
from cli import _looks_like_slash_command
|
||
from hermes_cli.cli_subagent_monitor import modal_prompt_active
|
||
buf = get_app().current_buffer
|
||
text = buf.text.strip()
|
||
return bool(getattr(cli, "_agent_running", False) and text and buf.complete_state is None
|
||
and not _looks_like_slash_command(text) and not text.startswith("!")
|
||
and not modal_prompt_active(cli))
|
||
|
||
@kb.add("tab", filter=Condition(_can_queue), eager=True)
|
||
def _queue_while_busy(event):
|
||
# Not _tui_handle_enter: its paste/continuation rules would turn this key into a newline.
|
||
buf = event.app.current_buffer
|
||
text, images = buf.text.strip(), list(cli._attached_images)
|
||
cli._attached_images.clear()
|
||
cli._tui_enter_while_busy(text, images, (text, images) if images else text, mode="queue")
|
||
cli._inline_pastes(buf)
|
||
buf.reset(append_to_history=True)
|
||
event.app.invalidate()
|
||
|
||
return top, bottom
|