eb74a00c71
326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.
987 lines
45 KiB
Python
987 lines
45 KiB
Python
"""Streaming output, reasoning preview, tool progress callbacks, and busy-command spinner for the interactive CLI
|
|
|
|
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
|
|
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
|
|
never imports ``cli`` at module load time (import cycle).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import textwrap
|
|
import time
|
|
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from rich.markup import escape as _escape
|
|
|
|
|
|
class CLIStreamMixin:
|
|
"""Streaming output, reasoning preview, tool progress callbacks, and busy-command spinner for the interactive CLI"""
|
|
|
|
def _on_thinking(self, text: str) -> None:
|
|
"""Called by agent when thinking starts/stops. Updates TUI spinner."""
|
|
if not text:
|
|
self._flush_reasoning_preview(force=True)
|
|
self._spinner_text = text or ""
|
|
self._tool_start_time = 0.0 # clear tool timer when switching to thinking
|
|
self._invalidate()
|
|
|
|
def _on_notice(self, notice) -> None:
|
|
"""Queue an out-of-band AgentNotice for rendering at the next clean boundary.
|
|
|
|
Notices fire from inside the agent turn (cold-start seed during _init_agent,
|
|
per-turn _capture_credits after the API call) — printing immediately races the
|
|
streaming response and the line gets buried behind the prompt (see _cprint's
|
|
bg-thread caveat). So we QUEUE here and flush in _flush_credit_notices(), called
|
|
right after run_conversation returns. Fail-soft: never break the turn.
|
|
"""
|
|
try:
|
|
text = getattr(notice, "text", "") or ""
|
|
if not text:
|
|
return
|
|
level = getattr(notice, "level", "info") or "info"
|
|
if not hasattr(self, "_pending_credit_notices"):
|
|
self._pending_credit_notices = []
|
|
self._pending_credit_notices.append((level, text))
|
|
except Exception:
|
|
pass
|
|
|
|
def _flush_credit_notices(self) -> None:
|
|
"""Print any queued credit notices as level-colored lines. Called at turn end
|
|
(after run_conversation) where _cprint paints cleanly above the prompt."""
|
|
from cli import _DIM, _RST, _cprint
|
|
try:
|
|
pending = getattr(self, "_pending_credit_notices", None)
|
|
if not pending:
|
|
return
|
|
self._pending_credit_notices = []
|
|
for level, text in pending:
|
|
color = {
|
|
"error": "\033[31m",
|
|
"warn": "\033[33m",
|
|
"success": "\033[32m",
|
|
"info": _DIM,
|
|
}.get(level, _DIM)
|
|
_cprint(f" {color}{text}{_RST}")
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_notice_clear(self, key: str) -> None:
|
|
"""Notice cleared. The REPL prints lines (no persistent slot to wipe), so
|
|
this drops any still-queued notice with that key is not tracked by key here;
|
|
it's a no-op for rendering — kept so the agent's clear callback is bound
|
|
symmetrically with the show callback (and so future REPL UIs can hook it)."""
|
|
return
|
|
|
|
def _current_reasoning_callback(self):
|
|
"""Return the active reasoning display callback for the current mode."""
|
|
if self.show_reasoning and self.streaming_enabled:
|
|
return self._stream_reasoning_delta
|
|
if self.verbose and not self.show_reasoning:
|
|
return self._on_reasoning
|
|
return None
|
|
|
|
def _emit_reasoning_preview(self, reasoning_text: str) -> None:
|
|
"""Render a buffered reasoning preview as a single [thinking] block."""
|
|
from cli import _DIM, _RST, _cprint
|
|
preview_text = reasoning_text.strip()
|
|
if not preview_text:
|
|
return
|
|
|
|
try:
|
|
term_width = shutil.get_terminal_size().columns
|
|
except Exception:
|
|
term_width = 80
|
|
prefix = " [thinking] "
|
|
wrap_width = max(30, term_width - len(prefix) - 2)
|
|
|
|
paragraphs = []
|
|
raw_paragraphs = re.split(r"\n\s*\n+", preview_text.replace("\r\n", "\n"))
|
|
for paragraph in raw_paragraphs:
|
|
compact = " ".join(line.strip() for line in paragraph.splitlines() if line.strip())
|
|
if compact:
|
|
paragraphs.append(textwrap.fill(compact, width=wrap_width))
|
|
preview_text = "\n".join(paragraphs)
|
|
if not preview_text:
|
|
return
|
|
|
|
if self.verbose:
|
|
_cprint(f" {_DIM}[thinking] {preview_text}{_RST}")
|
|
return
|
|
|
|
lines = preview_text.splitlines()
|
|
if len(lines) > 5:
|
|
preview = "\n".join(lines[:5])
|
|
preview += f"\n ... ({len(lines) - 5} more lines)"
|
|
else:
|
|
preview = preview_text
|
|
_cprint(f" {_DIM}[thinking] {preview}{_RST}")
|
|
|
|
def _flush_reasoning_preview(self, *, force: bool = False) -> None:
|
|
"""Flush buffered reasoning text at natural boundaries.
|
|
|
|
Some providers stream reasoning in tiny word or punctuation chunks.
|
|
Buffer them here so the preview path does not print one `[thinking]`
|
|
line per token.
|
|
"""
|
|
buf = getattr(self, "_reasoning_preview_buf", "")
|
|
if not buf:
|
|
return
|
|
|
|
try:
|
|
term_width = shutil.get_terminal_size().columns
|
|
except Exception:
|
|
term_width = 80
|
|
target_width = max(40, term_width - len(" [thinking] ") - 4)
|
|
|
|
flush_text = ""
|
|
|
|
if force:
|
|
flush_text = buf
|
|
buf = ""
|
|
else:
|
|
line_break = buf.rfind("\n")
|
|
min_newline_flush = max(16, target_width // 3)
|
|
if line_break != -1 and (
|
|
line_break >= min_newline_flush
|
|
or buf.endswith("\n\n")
|
|
or buf.endswith(".\n")
|
|
or buf.endswith("!\n")
|
|
or buf.endswith("?\n")
|
|
or buf.endswith(":\n")
|
|
):
|
|
flush_text = buf[: line_break + 1]
|
|
buf = buf[line_break + 1 :]
|
|
elif len(buf) >= target_width:
|
|
search_start = max(20, target_width // 2)
|
|
search_end = min(len(buf), max(target_width + (target_width // 3), target_width + 8))
|
|
cut = -1
|
|
for boundary in (" ", "\t", ".", "!", "?", ",", ";", ":"):
|
|
cut = max(cut, buf.rfind(boundary, search_start, search_end))
|
|
if cut != -1:
|
|
flush_text = buf[: cut + 1]
|
|
buf = buf[cut + 1 :]
|
|
|
|
self._reasoning_preview_buf = buf.lstrip() if flush_text else buf
|
|
if flush_text:
|
|
self._emit_reasoning_preview(flush_text)
|
|
|
|
def _format_submitted_user_message_preview(self, user_input: str) -> str:
|
|
"""Format the submitted user-message scrollback preview."""
|
|
from cli import _accent_hex, datetime
|
|
ts_suffix = (
|
|
f" [dim]{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}[/]"
|
|
if getattr(self, "show_timestamps", False) else ""
|
|
)
|
|
lines = user_input.split("\n")
|
|
if len(lines) <= 1:
|
|
return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]{ts_suffix}"
|
|
|
|
first_lines = int(getattr(self, "user_message_preview_first_lines", 2))
|
|
last_lines = int(getattr(self, "user_message_preview_last_lines", 2))
|
|
first_lines = max(1, first_lines)
|
|
last_lines = max(0, last_lines)
|
|
head = lines[:first_lines]
|
|
remaining_after_head = max(0, len(lines) - len(head))
|
|
tail_count = min(last_lines, remaining_after_head)
|
|
tail = lines[-tail_count:] if tail_count else []
|
|
|
|
hidden_middle_count = len(lines) - len(head) - len(tail)
|
|
if hidden_middle_count < 0:
|
|
hidden_middle_count = 0
|
|
tail = []
|
|
|
|
preview_lines = [
|
|
f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]{ts_suffix}"
|
|
]
|
|
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:])
|
|
|
|
if hidden_middle_count > 0:
|
|
noun = "line" if hidden_middle_count == 1 else "lines"
|
|
preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]")
|
|
|
|
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail)
|
|
return "\n".join(preview_lines)
|
|
|
|
def _expand_paste_references(self, text: str | None) -> str:
|
|
"""Expand [Pasted text #N -> file] placeholders into file contents."""
|
|
from cli import logger
|
|
if not isinstance(text, str) or "[Pasted text #" not in text:
|
|
return text or ""
|
|
paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
|
|
|
def _expand_ref(match):
|
|
path = Path(match.group(1))
|
|
# Use try/except instead of path.exists() to avoid TOCTOU race:
|
|
# the paste file may be deleted between check and read, causing
|
|
# the input to be silently dropped (#17666).
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except (OSError, IOError):
|
|
logger.warning("Paste file gone or unreadable, returning placeholder: %s", path)
|
|
return match.group(0)
|
|
|
|
return paste_ref_re.sub(_expand_ref, text)
|
|
|
|
def _print_user_message_preview(self, user_input: str) -> None:
|
|
"""Render a user message using the normal chat scrollback style."""
|
|
from cli import ChatConsole, _accent_hex
|
|
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
|
text = str(user_input or "")
|
|
if "\n" in text:
|
|
ChatConsole().print(self._format_submitted_user_message_preview(text))
|
|
else:
|
|
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]")
|
|
|
|
def _stream_reasoning_delta(self, text: str) -> None:
|
|
"""Stream reasoning/thinking tokens into a dim box above the response.
|
|
|
|
Opens a dim reasoning box on first token, streams line-by-line.
|
|
The box is closed automatically when content tokens start arriving
|
|
(via _stream_delta → _emit_stream_text).
|
|
|
|
Once the response box is open, suppress any further reasoning
|
|
rendering — a late thinking block (e.g. after an interrupt) would
|
|
otherwise draw a reasoning box inside the response box.
|
|
"""
|
|
from cli import _DIM, _RST, _cprint
|
|
if not text:
|
|
return
|
|
self._reasoning_shown_this_turn = True
|
|
if getattr(self, "_stream_box_opened", False):
|
|
return
|
|
|
|
# Open reasoning box on first reasoning token
|
|
if not getattr(self, "_reasoning_box_opened", False):
|
|
self._reasoning_box_opened = True
|
|
w = self._scrollback_box_width()
|
|
r_label = " Reasoning "
|
|
r_fill = w - 2 - len(r_label)
|
|
_cprint(f"\n{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}")
|
|
|
|
self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text
|
|
|
|
# Emit complete lines, and force-flush long partial lines so
|
|
# reasoning is visible in real-time even without newlines.
|
|
while "\n" in self._reasoning_buf:
|
|
line, self._reasoning_buf = self._reasoning_buf.split("\n", 1)
|
|
_cprint(f"{_DIM}{line}{_RST}")
|
|
if len(self._reasoning_buf) > 80:
|
|
_cprint(f"{_DIM}{self._reasoning_buf}{_RST}")
|
|
self._reasoning_buf = ""
|
|
|
|
def _close_reasoning_box(self) -> None:
|
|
"""Close the live reasoning box if it's open."""
|
|
from cli import _DIM, _RST, _cprint
|
|
if getattr(self, "_reasoning_box_opened", False):
|
|
# Flush remaining reasoning buffer
|
|
buf = getattr(self, "_reasoning_buf", "")
|
|
if buf:
|
|
_cprint(f"{_DIM}{buf}{_RST}")
|
|
self._reasoning_buf = ""
|
|
w = self._scrollback_box_width()
|
|
_cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}")
|
|
self._reasoning_box_opened = False
|
|
|
|
# Flush any content that was deferred while reasoning was rendering.
|
|
deferred = getattr(self, "_deferred_content", "")
|
|
if deferred:
|
|
self._deferred_content = ""
|
|
self._emit_stream_text(deferred)
|
|
|
|
def _stream_delta(self, text) -> None:
|
|
"""Line-buffered streaming callback for real-time token rendering.
|
|
|
|
Receives text deltas from the agent as tokens arrive. Buffers
|
|
partial lines and emits complete lines via _cprint to work
|
|
reliably with prompt_toolkit's patch_stdout.
|
|
|
|
Reasoning/thinking blocks (<REASONING_SCRATCHPAD>, <think>, etc.)
|
|
are suppressed during streaming since they'd display raw XML tags.
|
|
The agent strips them from the final response anyway.
|
|
|
|
A ``None`` value signals an intermediate turn boundary (tools are
|
|
about to execute). Flushes any open boxes and resets state so
|
|
tool feed lines render cleanly between turns.
|
|
"""
|
|
if text is None:
|
|
self._flush_stream()
|
|
self._reset_stream_state()
|
|
return
|
|
if not text:
|
|
return
|
|
|
|
self._stream_started = True
|
|
|
|
# ── Tag-based reasoning suppression ──
|
|
# Track whether we're inside a reasoning/thinking block.
|
|
# These tags are model-generated (system prompt tells the model
|
|
# to use them) and get stripped from final_response. We must
|
|
# suppress them during streaming too — unless show_reasoning is
|
|
# enabled, in which case we route the inner content to the
|
|
# reasoning display box instead of discarding it.
|
|
_OPEN_TAGS = ("<REASONING_SCRATCHPAD>", "<think>", "<reasoning>", "<THINKING>", "<thinking>", "<thought>")
|
|
_CLOSE_TAGS = ("</REASONING_SCRATCHPAD>", "</think>", "</reasoning>", "</THINKING>", "</thinking>", "</thought>")
|
|
|
|
# Append to a pre-filter buffer first
|
|
self._stream_prefilt = getattr(self, "_stream_prefilt", "") + text
|
|
|
|
# Check if we're entering a reasoning block.
|
|
# Only match tags that appear at a "block boundary": start of the
|
|
# stream, after a newline (with optional whitespace), or when nothing
|
|
# but whitespace has been emitted on the current line.
|
|
# This prevents false positives when models *mention* tags in prose
|
|
# like "(/think not producing <think> tags)".
|
|
#
|
|
# _stream_last_was_newline tracks whether the last character emitted
|
|
# (or the start of the stream) is a line boundary. It's True at
|
|
# stream start and set True whenever emitted text ends with '\n'.
|
|
if not hasattr(self, "_stream_last_was_newline"):
|
|
self._stream_last_was_newline = True # start of stream = boundary
|
|
|
|
if not getattr(self, "_in_reasoning_block", False):
|
|
# Case-insensitive matching against a lowercased view so
|
|
# mixed-case tag variants (<Think>, <THINKING>, …) are caught.
|
|
prefilt_lower = self._stream_prefilt.lower()
|
|
for tag in _OPEN_TAGS:
|
|
tag_lower = tag.lower()
|
|
search_start = 0
|
|
while True:
|
|
idx = prefilt_lower.find(tag_lower, search_start)
|
|
if idx == -1:
|
|
break
|
|
# Check if this is a block boundary position
|
|
preceding = self._stream_prefilt[:idx]
|
|
if idx == 0:
|
|
# At buffer start — only a boundary if we're at
|
|
# a line start (stream start or last emit ended
|
|
# with newline)
|
|
is_block_boundary = getattr(self, "_stream_last_was_newline", True)
|
|
else:
|
|
# Find last newline in the buffer before the tag
|
|
last_nl = preceding.rfind("\n")
|
|
if last_nl == -1:
|
|
# No newline in buffer — boundary only if
|
|
# last emit was a newline AND only whitespace
|
|
# has accumulated before the tag
|
|
is_block_boundary = (
|
|
getattr(self, "_stream_last_was_newline", True)
|
|
and preceding.strip() == ""
|
|
)
|
|
else:
|
|
# Text between last newline and tag must be
|
|
# whitespace-only
|
|
is_block_boundary = preceding[last_nl + 1:].strip() == ""
|
|
if is_block_boundary:
|
|
# Emit everything before the tag
|
|
if preceding:
|
|
self._emit_stream_text(preceding)
|
|
self._stream_last_was_newline = preceding.endswith("\n")
|
|
self._in_reasoning_block = True
|
|
self._stream_prefilt = self._stream_prefilt[idx + len(tag):]
|
|
break
|
|
# Not a block boundary — keep searching after this occurrence
|
|
search_start = idx + 1
|
|
if getattr(self, "_in_reasoning_block", False):
|
|
break
|
|
|
|
# Could also be a partial open tag at the end — hold it back
|
|
if not getattr(self, "_in_reasoning_block", False):
|
|
# Check for partial tag match at the end (case-insensitive)
|
|
safe = self._stream_prefilt
|
|
for tag in _OPEN_TAGS:
|
|
tag_lower = tag.lower()
|
|
for i in range(1, len(tag)):
|
|
if prefilt_lower.endswith(tag_lower[:i]):
|
|
safe = self._stream_prefilt[:-i]
|
|
break
|
|
if safe:
|
|
self._emit_stream_text(safe)
|
|
self._stream_last_was_newline = safe.endswith("\n")
|
|
self._stream_prefilt = self._stream_prefilt[len(safe):]
|
|
return
|
|
|
|
# Inside a reasoning block — look for close tag.
|
|
# Keep accumulating _stream_prefilt because close tags can arrive
|
|
# split across multiple tokens (e.g. "</REASONING_SCRATCH" + "PAD>...").
|
|
if getattr(self, "_in_reasoning_block", False):
|
|
prefilt_lower = self._stream_prefilt.lower()
|
|
for tag in _CLOSE_TAGS:
|
|
idx = prefilt_lower.find(tag.lower())
|
|
if idx != -1:
|
|
self._in_reasoning_block = False
|
|
# When show_reasoning is on, route inner content to
|
|
# the reasoning display box instead of discarding.
|
|
if self.show_reasoning:
|
|
inner = self._stream_prefilt[:idx]
|
|
if inner:
|
|
self._stream_reasoning_delta(inner)
|
|
after = self._stream_prefilt[idx + len(tag):]
|
|
self._stream_prefilt = ""
|
|
# Process remaining text after close tag through full
|
|
# filtering (it could contain another open tag)
|
|
if after:
|
|
self._stream_delta(after)
|
|
return
|
|
# When show_reasoning is on, stream reasoning content live
|
|
# instead of silently accumulating. Keep only the tail that
|
|
# could be a partial close tag prefix.
|
|
max_tag_len = max(len(t) for t in _CLOSE_TAGS)
|
|
if len(self._stream_prefilt) > max_tag_len:
|
|
if self.show_reasoning:
|
|
# Route the safe prefix to reasoning display
|
|
safe_reasoning = self._stream_prefilt[:-max_tag_len]
|
|
self._stream_reasoning_delta(safe_reasoning)
|
|
self._stream_prefilt = self._stream_prefilt[-max_tag_len:]
|
|
return
|
|
|
|
def _emit_stream_text(self, text: str) -> None:
|
|
"""Emit filtered text to the streaming display."""
|
|
from cli import (
|
|
HermesCLI,
|
|
_ACCENT,
|
|
_RST,
|
|
_STREAM_PAD,
|
|
_STREAM_PARTIAL_PREVIEW_LEN,
|
|
_cprint,
|
|
_strip_markdown_syntax,
|
|
_terminal_width_for_streaming,
|
|
datetime,
|
|
is_table_divider,
|
|
looks_like_table_row,
|
|
realign_markdown_tables,
|
|
)
|
|
if not text:
|
|
return
|
|
|
|
# When show_reasoning is on and reasoning is still rendering,
|
|
# defer content until the reasoning box closes. This ensures the
|
|
# reasoning block always appears BEFORE the response in the terminal.
|
|
if self.show_reasoning and getattr(self, "_reasoning_box_opened", False):
|
|
self._deferred_content = getattr(self, "_deferred_content", "") + text
|
|
return
|
|
|
|
# Close the live reasoning box before opening the response box
|
|
self._close_reasoning_box()
|
|
|
|
# Open the response box header on the very first visible text
|
|
if not self._stream_box_opened:
|
|
# Strip leading whitespace/newlines before first visible content
|
|
text = text.lstrip("\n")
|
|
if not text:
|
|
return
|
|
self._stream_box_opened = True
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
_skin = get_active_skin()
|
|
label = _skin.get_branding("response_label", "⚕ Hermes")
|
|
_text_hex = _skin.get_color("banner_text", "#FFF8DC")
|
|
except Exception:
|
|
label = "⚕ Hermes"
|
|
_text_hex = "#FFF8DC"
|
|
# Build a true-color ANSI escape for the response text color
|
|
# so streamed content matches the Rich Panel appearance.
|
|
try:
|
|
_r = int(_text_hex[1:3], 16)
|
|
_g = int(_text_hex[3:5], 16)
|
|
_b = int(_text_hex[5:7], 16)
|
|
self._stream_text_ansi = f"\033[38;2;{_r};{_g};{_b}m"
|
|
except (ValueError, IndexError):
|
|
self._stream_text_ansi = ""
|
|
if self.show_timestamps:
|
|
label = f"{label} {datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}"
|
|
w = self._scrollback_box_width()
|
|
fill = w - 2 - HermesCLI._status_bar_display_width(label)
|
|
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
|
|
|
|
self._stream_buf += text
|
|
|
|
# Emit complete lines, keep partial remainder in buffer
|
|
_tc = getattr(self, "_stream_text_ansi", "")
|
|
|
|
def _emit_one(printed_line: str) -> None:
|
|
_cprint(f"{_STREAM_PAD}{_tc}{printed_line}{_RST}" if _tc else f"{_STREAM_PAD}{printed_line}")
|
|
|
|
def _flush_table_buf() -> None:
|
|
buf = self._stream_table_buf
|
|
self._stream_table_buf = []
|
|
self._in_stream_table = False
|
|
if not buf:
|
|
return
|
|
# Strip cell-level markdown (`code`, **bold**, ~~strike~~) FIRST
|
|
# so the realigner pads to the final visible cell width, not
|
|
# the marker-decorated source width. Otherwise a body row
|
|
# like `` | Bold | `**bold**` | `` lands narrower than its
|
|
# header column once the markers are removed.
|
|
joined = "\n".join(buf)
|
|
if self.final_response_markdown == "strip":
|
|
joined = _strip_markdown_syntax(joined)
|
|
block = realign_markdown_tables(joined, _terminal_width_for_streaming())
|
|
for ln in block.split("\n"):
|
|
_emit_one(ln)
|
|
|
|
while "\n" in self._stream_buf:
|
|
line, self._stream_buf = self._stream_buf.split("\n", 1)
|
|
|
|
# Hold table-shaped lines in a side-buffer so we can re-pad
|
|
# the whole block once it ends. Streaming line-by-line, we
|
|
# cannot re-align mid-table without reflowing already-printed
|
|
# rows; the cost is that the user sees the table appear in a
|
|
# single batch when the block closes instead of row-by-row.
|
|
if self._in_stream_table:
|
|
if looks_like_table_row(line) or is_table_divider(line):
|
|
self._stream_table_buf.append(line)
|
|
continue
|
|
# Block ended — flush the realigned table, then fall
|
|
# through to print the current (non-table) line.
|
|
_flush_table_buf()
|
|
elif looks_like_table_row(line):
|
|
self._stream_table_buf.append(line)
|
|
self._in_stream_table = True
|
|
continue
|
|
|
|
if self.final_response_markdown == "strip":
|
|
line = _strip_markdown_syntax(line)
|
|
_emit_one(line)
|
|
|
|
# Long partial lines are emitted ONLY at real newlines — we no
|
|
# longer hard-wrap paragraphs at terminal width ourselves. Each
|
|
# logical line lands in scrollback as one line; the TERMINAL
|
|
# soft-wraps it visually, and emulators (iTerm2/kitty/VTE/
|
|
# xterm.js/Windows Terminal) rejoin soft-wrapped rows on copy,
|
|
# so highlight-copy yields the original unwrapped text — same
|
|
# outcome as the TUI's selection copy. (The pre-July-2026 chunk
|
|
# emitter baked real '\n's into every long paragraph, which is
|
|
# exactly what polluted copy/paste.)
|
|
#
|
|
# TTFT perception: while a long opening paragraph accumulates
|
|
# without a newline, mirror its tail into the status-bar spinner
|
|
# line so the user sees tokens arriving instead of a blank box.
|
|
if (
|
|
self._stream_buf
|
|
and not self._in_stream_table
|
|
and not self._stream_buf.lstrip().startswith("|")
|
|
and len(self._stream_buf) >= 80
|
|
):
|
|
preview = self._stream_buf[-int(_STREAM_PARTIAL_PREVIEW_LEN):]
|
|
cut = preview.find(" ")
|
|
if 0 < cut < len(preview) - 1:
|
|
preview = preview[cut + 1:]
|
|
try:
|
|
self._spinner_text = f"… {preview}"
|
|
self._invalidate()
|
|
except Exception:
|
|
pass
|
|
|
|
def _flush_stream(self) -> None:
|
|
"""Emit any remaining partial line from the stream buffer and close the box."""
|
|
from cli import (
|
|
_ACCENT,
|
|
_RST,
|
|
_STREAM_PAD,
|
|
_cprint,
|
|
_strip_markdown_syntax,
|
|
_terminal_width_for_streaming,
|
|
is_table_divider,
|
|
looks_like_table_row,
|
|
realign_markdown_tables,
|
|
)
|
|
# If we're still inside a "reasoning block" at end-of-stream, it was
|
|
# a false positive — the model mentioned a tag like <think> in prose
|
|
# but never closed it. Recover the buffered content as regular text.
|
|
if getattr(self, "_in_reasoning_block", False) and getattr(self, "_stream_prefilt", ""):
|
|
self._in_reasoning_block = False
|
|
self._emit_stream_text(self._stream_prefilt)
|
|
self._stream_prefilt = ""
|
|
|
|
# Close reasoning box if still open (in case no content tokens arrived)
|
|
self._close_reasoning_box()
|
|
|
|
_tc = getattr(self, "_stream_text_ansi", "")
|
|
|
|
# If the stream buffer has a trailing partial line that looks like
|
|
# a table row, fold it into the table buffer so the whole block
|
|
# gets re-aligned together. Otherwise the final row prints raw
|
|
# (with the model's original under-padded spacing) while the rows
|
|
# above it are aligned.
|
|
if (
|
|
self._stream_buf
|
|
and getattr(self, "_in_stream_table", False)
|
|
and (looks_like_table_row(self._stream_buf) or is_table_divider(self._stream_buf))
|
|
):
|
|
self._stream_table_buf.append(self._stream_buf)
|
|
self._stream_buf = ""
|
|
|
|
# Flush any buffered table rows first so their padding is
|
|
# finalised before the stream remainder lands.
|
|
if getattr(self, "_stream_table_buf", None):
|
|
joined = "\n".join(self._stream_table_buf)
|
|
self._stream_table_buf = []
|
|
self._in_stream_table = False
|
|
if self.final_response_markdown == "strip":
|
|
joined = _strip_markdown_syntax(joined)
|
|
block = realign_markdown_tables(joined, _terminal_width_for_streaming())
|
|
for ln in block.split("\n"):
|
|
_cprint(f"{_STREAM_PAD}{_tc}{ln}{_RST}" if _tc else f"{_STREAM_PAD}{ln}")
|
|
|
|
if self._stream_buf:
|
|
line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf
|
|
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
|
|
self._stream_buf = ""
|
|
|
|
# Close the response box
|
|
if self._stream_box_opened:
|
|
w = self._scrollback_box_width()
|
|
_cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}")
|
|
|
|
def _reset_stream_state(self) -> None:
|
|
"""Reset streaming state before each agent invocation."""
|
|
self._stream_buf = ""
|
|
self._stream_started = False
|
|
self._stream_box_opened = False
|
|
self._stream_text_ansi = ""
|
|
self._stream_prefilt = ""
|
|
self._in_reasoning_block = False
|
|
self._stream_last_was_newline = True
|
|
self._reasoning_box_opened = False
|
|
self._reasoning_buf = ""
|
|
self._reasoning_preview_buf = ""
|
|
self._deferred_content = ""
|
|
self._stream_table_buf = []
|
|
self._in_stream_table = False
|
|
|
|
def _slow_command_status(self, command: str) -> str:
|
|
"""Return a user-facing status message for slower slash commands."""
|
|
cmd_lower = command.lower().strip()
|
|
if cmd_lower.startswith("/skills search"):
|
|
return "Searching skills..."
|
|
if cmd_lower.startswith("/skills browse"):
|
|
return "Loading skills..."
|
|
if cmd_lower.startswith("/skills inspect"):
|
|
return "Inspecting skill..."
|
|
if cmd_lower.startswith("/skills install"):
|
|
return "Installing skill..."
|
|
if cmd_lower.startswith("/skills"):
|
|
return "Processing skills command..."
|
|
if cmd_lower == "/reload-mcp":
|
|
return "Reloading MCP servers..."
|
|
if cmd_lower == "/reload-skills" or cmd_lower == "/reload_skills":
|
|
return "Reloading skills..."
|
|
if cmd_lower.startswith("/browser"):
|
|
return "Configuring browser..."
|
|
return "Processing command..."
|
|
|
|
def _command_spinner_frame(self) -> str:
|
|
"""Return the current spinner frame for slow slash commands."""
|
|
from cli import _COMMAND_SPINNER_FRAMES
|
|
frame_idx = int(time.monotonic() * 10) % len(_COMMAND_SPINNER_FRAMES)
|
|
return _COMMAND_SPINNER_FRAMES[frame_idx]
|
|
|
|
@contextmanager
|
|
def _busy_command(self, status: str, *, blocks_input: bool = True):
|
|
"""Expose a temporary busy state in the TUI while a slash command runs.
|
|
|
|
Most synchronous slash commands must reserve the composer because their
|
|
completion changes the active session state. Manual compression is safe
|
|
to draft through: the queued input is processed against the compacted
|
|
history after the command completes.
|
|
"""
|
|
previous_blocks_input = getattr(self, "_command_blocks_input", False)
|
|
self._command_running = True
|
|
self._command_blocks_input = blocks_input
|
|
self._command_status = status
|
|
self._invalidate(min_interval=0.0)
|
|
try:
|
|
print(f"⏳ {status}")
|
|
yield
|
|
finally:
|
|
self._command_running = False
|
|
self._command_blocks_input = previous_blocks_input
|
|
self._command_status = ""
|
|
self._invalidate(min_interval=0.0)
|
|
|
|
def _preprocess_images_with_vision(self, text: str, images: list, *, announce: bool = True) -> str:
|
|
"""Analyze attached images via the vision tool and return enriched text.
|
|
|
|
Instead of embedding raw base64 ``image_url`` content parts in the
|
|
conversation (which only works with vision-capable models), this
|
|
pre-processes each image through the auxiliary vision model (Gemini
|
|
Flash) and prepends the descriptions to the user's message — the
|
|
same approach the messaging gateway uses.
|
|
|
|
The local file path is included so the agent can re-examine the
|
|
image later with ``vision_analyze`` if needed.
|
|
"""
|
|
from cli import _DIM, _RST, _cprint
|
|
import asyncio as _asyncio
|
|
from tools.vision_tools import vision_analyze_tool
|
|
|
|
analysis_prompt = (
|
|
"Describe everything visible in this image in thorough detail. "
|
|
"Include any text, code, data, objects, people, layout, colors, "
|
|
"and any other notable visual information."
|
|
)
|
|
|
|
enriched_parts = []
|
|
for img_path in images:
|
|
if not img_path.exists():
|
|
continue
|
|
size_kb = img_path.stat().st_size // 1024
|
|
if announce:
|
|
_cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}")
|
|
try:
|
|
result_json = _asyncio.run(
|
|
vision_analyze_tool(image_url=str(img_path), user_prompt=analysis_prompt)
|
|
)
|
|
result = json.loads(result_json)
|
|
if result.get("success"):
|
|
description = result.get("analysis", "")
|
|
enriched_parts.append(
|
|
f"[The user attached an image. Here's what it contains:\n{description}]\n"
|
|
f"[If you need a closer look, use vision_analyze with "
|
|
f"image_url: {img_path}]"
|
|
)
|
|
if announce:
|
|
_cprint(f" {_DIM}✓ image analyzed{_RST}")
|
|
else:
|
|
enriched_parts.append(
|
|
f"[The user attached an image but it couldn't be analyzed. "
|
|
f"You can try examining it with vision_analyze using "
|
|
f"image_url: {img_path}]"
|
|
)
|
|
if announce:
|
|
_cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}")
|
|
except Exception as e:
|
|
enriched_parts.append(
|
|
f"[The user attached an image but analysis failed ({e}). "
|
|
f"You can try examining it with vision_analyze using "
|
|
f"image_url: {img_path}]"
|
|
)
|
|
if announce:
|
|
_cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}")
|
|
|
|
# Combine: vision descriptions first, then the user's original text
|
|
user_text = text if isinstance(text, str) and text else ""
|
|
if enriched_parts:
|
|
prefix = "\n\n".join(enriched_parts)
|
|
return f"{prefix}\n\n{user_text}" if user_text else prefix
|
|
return user_text or "What do you see in this image?"
|
|
|
|
def _output_console(self):
|
|
"""Use prompt_toolkit-safe Rich rendering once the TUI is live."""
|
|
from cli import ChatConsole
|
|
if getattr(self, "_app", None):
|
|
return ChatConsole()
|
|
return self.console
|
|
|
|
def _console_print(self, *args, **kwargs):
|
|
"""Print through the active command-safe console."""
|
|
self._output_console().print(*args, **kwargs)
|
|
|
|
def _on_tool_gen_start(self, tool_name: str) -> None:
|
|
"""Called when the model begins generating tool-call arguments.
|
|
|
|
Closes any open streaming boxes (reasoning / response) exactly once,
|
|
then prints a short status line so the user sees activity instead of
|
|
a frozen screen while a large payload (e.g. 45 KB write_file) streams.
|
|
"""
|
|
from cli import _cprint
|
|
if getattr(self, "_stream_box_opened", False):
|
|
self._flush_stream()
|
|
self._stream_box_opened = False
|
|
self._close_reasoning_box()
|
|
|
|
from agent.display import get_tool_emoji
|
|
emoji = get_tool_emoji(tool_name, default="⚡")
|
|
_cprint(f" ┊ {emoji} preparing {tool_name}…")
|
|
|
|
def _on_tool_progress(self, event_type: str, function_name: str = None, preview: str = None, function_args: dict = None, **kwargs):
|
|
"""Called on tool lifecycle events (tool.started, tool.completed, reasoning.available, etc.).
|
|
|
|
Updates the TUI spinner widget so the user can see what the agent
|
|
is doing during tool execution (fills the gap between thinking
|
|
spinner and next response).
|
|
|
|
On tool.started, records a monotonic timestamp so get_spinner_text()
|
|
can show a live elapsed timer (the TUI poll loop already invalidates
|
|
every ~0.15s, so the counter updates automatically).
|
|
|
|
When tool_progress_mode is "all" or "new", also prints a persistent
|
|
stacked line to scrollback on tool.completed so users can see the
|
|
full history of tool calls (not just the current one in the spinner).
|
|
"""
|
|
from cli import CLI_CONFIG, _DIM, _RST, _cprint, _hermes_home
|
|
# MoA reference-model outputs: render each reference's answer as a
|
|
# labelled thinking-style block BEFORE the aggregator acts, so the user
|
|
# sees the mixture-of-agents process instead of a silent pause. These
|
|
# are display-only events emitted by the MoA facade (agent_init relay);
|
|
# they never enter message history.
|
|
if event_type == "moa.reference":
|
|
label = function_name or "reference"
|
|
text = preview or ""
|
|
idx = kwargs.get("moa_index")
|
|
count = kwargs.get("moa_count")
|
|
header = f"Reference {idx}/{count} — {label}" if idx and count else f"Reference — {label}"
|
|
try:
|
|
self._flush_reasoning_preview(force=True)
|
|
except Exception:
|
|
pass
|
|
_cprint(f" {_DIM}┊ ◇ {header}{_RST}")
|
|
try:
|
|
self._emit_reasoning_preview(text)
|
|
except Exception:
|
|
# Fallback: print the raw text dimmed if the preview helper fails.
|
|
if text.strip():
|
|
_cprint(f" {_DIM}{text.strip()}{_RST}")
|
|
self._invalidate()
|
|
return
|
|
if event_type == "moa.aggregating":
|
|
agg = function_name or ""
|
|
self._spinner_text = f"◆ aggregating ({agg})" if agg else "◆ aggregating"
|
|
self._invalidate()
|
|
return
|
|
|
|
# Feed the pet: tools mean "running" (not reasoning); a failed tool
|
|
# latches the turn so it ends on a sulk.
|
|
if event_type == "tool.started":
|
|
self._pet_reasoning = False
|
|
elif event_type == "tool.completed" and kwargs.get("is_error"):
|
|
self._pet_turn_error = True
|
|
elif event_type and event_type.startswith("reasoning"):
|
|
self._pet_reasoning = True
|
|
|
|
if event_type == "tool.completed":
|
|
self._tool_start_time = 0.0
|
|
# Per-turn accounting: this feed already sees every tool call with
|
|
# its result, so the summary line needs no agent-loop state.
|
|
self._turn_summary_record(
|
|
function_name, kwargs.get("result"), kwargs.get("is_error", False)
|
|
)
|
|
# Focus view: count the scrollback line we are NOT printing, so the
|
|
# post-turn recovery line can report how much was hidden. Counted
|
|
# against the pre-focus tool-progress mode, so a user who already
|
|
# had /verbose off is never told focus hid something it didn't.
|
|
if getattr(self, "_focus_view_enabled", False):
|
|
try:
|
|
self._note_focus_hidden_line(function_name or "")
|
|
except Exception:
|
|
pass
|
|
# Print stacked scrollback line for "new" / "all" / "verbose" modes.
|
|
# "verbose" was previously omitted here, so non-streaming model
|
|
# calls (MoA aggregator, copilot-acp) rendered each tool only into
|
|
# the transient spinner line — which overwrites itself, so no
|
|
# scrollable tool history accumulated. Streaming models hid the bug
|
|
# because _on_tool_gen_start commits a "preparing" line per tool;
|
|
# non-streaming calls never emit that, leaving verbose mode with no
|
|
# committed line at all. "verbose" is strictly more than "all", so
|
|
# it must commit at least the same line.
|
|
if function_name and self.tool_progress_mode in {"new", "all", "verbose"}:
|
|
duration = kwargs.get("duration", 0.0)
|
|
# Pop stored args from tool.started for this function
|
|
stored = self._pending_tool_info.get(function_name)
|
|
stored_args = stored.pop(0) if stored else {}
|
|
if stored is not None and not stored:
|
|
del self._pending_tool_info[function_name]
|
|
# "new" mode: skip consecutive repeats of the same tool
|
|
if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool:
|
|
self._invalidate()
|
|
return
|
|
self._last_scrollback_tool = function_name
|
|
try:
|
|
from agent.display import get_cute_tool_message
|
|
line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get("result"))
|
|
_cprint(f" {line}")
|
|
except Exception:
|
|
pass
|
|
# First-touch onboarding: on the first tool in this process
|
|
# that takes longer than the threshold while we're in the
|
|
# noisiest progress mode, print a one-time hint about
|
|
# /verbose. Latched on self so it fires at most once per
|
|
# process; persisted to config.yaml so it never fires again
|
|
# across processes either.
|
|
try:
|
|
if (
|
|
not getattr(self, "_long_tool_hint_fired", False)
|
|
and self.tool_progress_mode == "all"
|
|
and duration >= 30.0
|
|
):
|
|
from agent.onboarding import (
|
|
TOOL_PROGRESS_FLAG,
|
|
is_seen,
|
|
mark_seen,
|
|
tool_progress_hint_cli,
|
|
)
|
|
if not is_seen(CLI_CONFIG, TOOL_PROGRESS_FLAG):
|
|
self._long_tool_hint_fired = True
|
|
_cprint(f" {_DIM}{tool_progress_hint_cli()}{_RST}")
|
|
mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG)
|
|
CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[TOOL_PROGRESS_FLAG] = True
|
|
except Exception:
|
|
pass
|
|
self._invalidate()
|
|
return
|
|
if event_type != "tool.started":
|
|
return
|
|
if function_name and not function_name.startswith("_"):
|
|
from agent.display import get_tool_emoji
|
|
emoji = get_tool_emoji(function_name)
|
|
label = preview or function_name
|
|
from agent.display import get_tool_preview_max_len
|
|
_pl = get_tool_preview_max_len()
|
|
if _pl > 0 and len(label) > _pl:
|
|
label = label[:_pl - 3] + "..."
|
|
self._spinner_text = f"{emoji} {label}"
|
|
self._tool_start_time = time.monotonic()
|
|
# Store args for stacked scrollback line on completion
|
|
self._pending_tool_info.setdefault(function_name, []).append(
|
|
function_args if function_args is not None else {}
|
|
)
|
|
self._invalidate()
|
|
|
|
def _on_tool_start(self, tool_call_id: str, function_name: str, function_args: dict):
|
|
"""Capture local before-state for write-capable tools."""
|
|
from cli import logger
|
|
try:
|
|
from agent.display import capture_local_edit_snapshot
|
|
|
|
snapshot = capture_local_edit_snapshot(function_name, function_args)
|
|
if snapshot is not None:
|
|
self._pending_edit_snapshots[tool_call_id] = snapshot
|
|
except Exception:
|
|
logger.debug("Edit snapshot capture failed for %s", function_name, exc_info=True)
|
|
|
|
def _on_tool_complete(self, tool_call_id: str, function_name: str, function_args: dict, function_result: str):
|
|
"""Render file edits with inline diff after write-capable tools complete."""
|
|
from cli import _cprint, logger
|
|
# A top-level delegate_task dispatches in the background and re-enters as
|
|
# a fresh turn when done. Say so once — no spinner, nothing to poll — so
|
|
# the idle prompt doesn't read as "nothing happened" (⛓ tracks the work).
|
|
if function_name == "delegate_task":
|
|
try:
|
|
parsed = json.loads(function_result) if isinstance(function_result, str) else (function_result or {})
|
|
except Exception:
|
|
parsed = {}
|
|
if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background":
|
|
n = parsed.get("count") or 1
|
|
noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish")
|
|
try:
|
|
_cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m")
|
|
except Exception:
|
|
pass
|
|
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
|
|
try:
|
|
from agent.display import render_edit_diff_with_delta
|
|
|
|
render_edit_diff_with_delta(
|
|
function_name,
|
|
function_result,
|
|
function_args=function_args,
|
|
snapshot=snapshot,
|
|
print_fn=_cprint,
|
|
)
|
|
except Exception:
|
|
logger.debug("Edit diff preview failed for %s", function_name, exc_info=True)
|