Files
Teknium aa28a71341 fix(cli): the glyph layout's reasoning summary never repeats the start of the thought
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.
2026-11-02 07:54:35 -08:00

394 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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