diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3e042f65dfa4..ae00610195f1 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -137,7 +137,7 @@ def _strip_yaml_frontmatter(content: str) -> str: "range of tasks including answering questions, writing and editing code, " "analyzing information, creative work, and executing actions via your tools. " "You communicate clearly, admit uncertainty when appropriate, and prioritize " - "being genuinely useful over being verbose unless otherwise directed below. " + "being genuinely useful over being verbose. " "Be targeted and efficient in your exploration and investigations." ) @@ -282,6 +282,54 @@ def _strip_yaml_frontmatter(content: str) -> str: # message representation stays consistent ("system" everywhere). DEVELOPER_ROLE_MODELS = ("gpt-5", "codex") +# Injected for CLI and no-platform sessions to bring Hermes response style +# in line with Claude Code's output discipline. Messaging platforms (WhatsApp, +# Telegram, etc.) have their own conversational register and are excluded. +TONE_AND_STYLE_GUIDANCE = ( + "# Tone and style\n" + "Your responses should be short and concise. Lead with the answer or action.\n" + "Do not use a colon before tool calls. Text like 'Let me read the file:' " + "followed by a tool call should be 'Let me read the file.' with a period.\n" + "Only use emojis if the user explicitly requests it.\n" + "Do not restate what the user said — just do it." +) + +OUTPUT_EFFICIENCY_GUIDANCE = ( + "# Output efficiency\n" + "IMPORTANT: Go straight to the point. Try the simplest approach first. " + "Be extra concise.\n\n" + "Keep text output brief and direct. Skip filler words, preamble, and " + "unnecessary transitions. When explaining, include only what is necessary " + "for the user to understand. If you can say it in one sentence, don't use three. " + "Prefer short, direct sentences over long explanations. " + "This does not apply to code or tool calls.\n\n" + "Focus text output on:\n" + "- Decisions that need the user's input\n" + "- High-level status updates at natural milestones\n" + "- Errors or blockers that change the plan\n\n" + "FORBIDDEN patterns — never do these:\n" + "- Announce what a tool found before showing it: " + "WRONG: 'The file contains:\\nX' RIGHT: 'X'\n" + " If a value fits on one line, output only that value with no framing.\n" + "- Repeat the result in a summary sentence: " + "WRONG: 'X\\nSo the value is X.' RIGHT: 'X'\n" + "- Wrap a single-value answer in a sentence: " + "WRONG: 'The file has 9 lines.' RIGHT: '9' — " + "WRONG: 'The hostname is TESSERACT.' RIGHT: 'TESSERACT'\n" + "- Narrate what you are about to do: " + "WRONG: 'I will now read the file.' RIGHT: [just call the tool]\n" + "- Confirm what you just did: " + "WRONG: 'I have read the file. The hostname is X.' RIGHT: 'X'" +) + +# Platform keys that use a conversational register and must NOT receive the +# CLI verbosity-reducing instructions (TONE_AND_STYLE_GUIDANCE / OUTPUT_EFFICIENCY_GUIDANCE). +# Keep this in sync with PLATFORM_HINTS keys whenever a new messaging platform is added. +MESSAGING_PLATFORMS: frozenset[str] = frozenset({ + "whatsapp", "telegram", "discord", "slack", + "signal", "email", "sms", +}) + PLATFORM_HINTS = { "whatsapp": ( "You are on a text messaging communication platform, WhatsApp. " @@ -343,8 +391,17 @@ def _strip_yaml_frontmatter(content: str) -> str: "destination — put the primary content directly in your response." ), "cli": ( - "You are a CLI AI Agent. Try not to use markdown but simple text " - "renderable inside a terminal." + "You are a CLI AI Agent. Your terminal supports full markdown " + "rendering. Use markdown freely for headings, bold, italic, " + "code blocks, tables, lists, blockquotes, and links to make " + "responses clear and well-structured. " + "Keep responses concise — the user is at a terminal, not reading a document." + ), + # Used automatically when the user disables markdown rendering (/markdown off). + "cli_no_markdown": ( + "You are a CLI AI Agent. Use plain text only — no markdown " + "formatting. Avoid headers, bold, italic, code fences, or tables. " + "Keep responses concise — the user is at a terminal, not reading a document." ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " diff --git a/cli.py b/cli.py index c9ce95e9f2ed..1e14acc13937 100644 --- a/cli.py +++ b/cli.py @@ -15,6 +15,7 @@ import logging import os +import re import shutil import sys import json @@ -349,6 +350,7 @@ def load_cli_config() -> Dict[str, Any]: "busy_input_mode": "interrupt", "skin": "default", + "markdown": True, }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding @@ -652,6 +654,7 @@ def load_cli_config() -> Dict[str, Any]: from rich.console import Console from rich.markup import escape as _escape from rich.panel import Panel +from rich.markdown import Markdown as _RichMarkdown from rich.text import Text as _RichText import fire @@ -1147,6 +1150,51 @@ def _rich_text_from_ansi(text: str) -> _RichText: return _RichText.from_ansi(text or "") +# Matches common markdown characters: #heading, *bold*, `code`, |table|, +# >blockquote, [link], ~strikethrough, double-newline (block break), +# and numbered lists (1. item). +_MD_SYNTAX_RE = re.compile(r'[#*`|>\[~]|\n\n|^\d+\.\s|\n\d+\.\s', re.MULTILINE) + +# How many characters to scan when checking for markdown syntax. +# 8 KB covers agentic responses with a plain-text preamble before tables/code; +# re.search short-circuits on the first match so early markdown costs nothing. +_MD_SCAN_LIMIT = 8192 + + +def _has_markdown_syntax(text: str) -> bool: + """Fast-path check: skip Rich Markdown parser when text is plain. + + Avoids the overhead of markdown lexing for responses that contain no + markdown syntax at all (common for short answers and tool output). + Inspired by claude-code's hasMarkdownSyntax() optimisation. + """ + return bool(_MD_SYNTAX_RE.search(text[:_MD_SCAN_LIMIT])) + + +def _render_response(text: str, as_markdown: bool = True, + code_theme: str = "monokai", + text_color: str = ""): + """Render assistant response as Rich Markdown or plain ANSI text. + + When *as_markdown* is True the text is parsed through Rich's Markdown + renderer with Pygments syntax-highlighted code blocks. *code_theme* + selects the Pygments colour scheme (defaults to monokai); *text_color* + sets the base paragraph colour so plain prose matches the active skin + while headings, bold, and code keep their own element-specific styles + (Rich Markdown's ``style`` parameter layers underneath element styles, + unlike Panel's ``style`` which overrides everything). + """ + if not as_markdown or not text or not _has_markdown_syntax(text): + return _rich_text_from_ansi(text) + try: + # Use skin text colour as base style; "none" means default terminal colour + md_style = text_color if text_color else "none" + return _RichMarkdown(text, code_theme=code_theme, style=md_style) + except Exception as _e: + logger.debug("Markdown render failed, falling back to plain text: %s", _e) + return _rich_text_from_ansi(text) + + def _cprint(text: str): """Print ANSI-colored text through prompt_toolkit's native renderer. @@ -1725,6 +1773,9 @@ def __init__( # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) + # Markdown rendering for assistant responses (display.markdown in config.yaml) + self.markdown_enabled = CLI_CONFIG["display"].get("markdown", True) + # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) @@ -1868,6 +1919,8 @@ def __init__( # Conversation state self.conversation_history: List[Dict[str, Any]] = [] self.session_start = datetime.now() + self._inference_total_seconds: float = 0.0 # cumulative inference time across session + self._last_inference_seconds: float = 0.0 # inference time for the last response self._resumed = False # Initialize SQLite session store early so /title works before first message self._session_db = None @@ -1980,7 +2033,10 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]: if len(model_short) > 26: model_short = f"{model_short[:23]}..." - elapsed_seconds = max(0.0, (datetime.now() - self.session_start).total_seconds()) + _agent_running = getattr(self, '_agent_running', False) + _inference_total = getattr(self, '_inference_total_seconds', 0.0) + # Always show live counter during inference; frozen total when idle + elapsed_seconds = max(0.0, (datetime.now() - self.session_start).total_seconds()) if _agent_running else _inference_total snapshot = { "model_name": model_name, "model_short": model_short, @@ -2192,15 +2248,19 @@ def _get_status_bar_fragments(self): # line and produce duplicated status bar rows over long sessions. width = self._get_tui_terminal_width() duration_label = snapshot["duration"] + _show_timer = getattr(self, '_agent_running', False) or getattr(self, '_inference_total_seconds', 0.0) > 0 if width < 52: frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), ] + if _show_timer: + frags += [ + ("class:status-bar-dim", " · "), + ("class:status-bar-dim", duration_label), + ] + frags.append(("class:status-bar", " ")) else: percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" @@ -2210,10 +2270,13 @@ def _get_status_bar_fragments(self): ("class:status-bar-strong", snapshot["model_short"]), ("class:status-bar-dim", " · "), (self._status_bar_context_style(percent), percent_label), - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), ] + if _show_timer: + frags += [ + ("class:status-bar-dim", " · "), + ("class:status-bar-dim", duration_label), + ] + frags.append(("class:status-bar", " ")) else: if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) @@ -2223,19 +2286,43 @@ def _get_status_bar_fragments(self): context_label = "ctx --" bar_style = self._status_bar_context_style(percent) - frags = [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", context_label), - ("class:status-bar-dim", " │ "), - (bar_style, self._build_context_bar(percent)), - ("class:status-bar-dim", " "), - (bar_style, percent_label), - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), - ] + # After a response, show ∑ total and ↩ last inference times + _has_response = getattr(self, '_response_received', False) + if _has_response: + total_label = format_duration_compact(getattr(self, '_inference_total_seconds', 0.0)) + last_label = format_duration_compact(getattr(self, '_last_inference_seconds', 0.0)) + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", context_label), + ("class:status-bar-dim", " │ "), + (bar_style, self._build_context_bar(percent)), + ("class:status-bar-dim", " "), + (bar_style, percent_label), + ("class:status-bar-dim", " │ ∑ "), + ("class:status-bar-dim", total_label), + ("class:status-bar-dim", " │ ↩ "), + ("class:status-bar-dim", last_label), + ("class:status-bar", " "), + ] + else: + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", context_label), + ("class:status-bar-dim", " │ "), + (bar_style, self._build_context_bar(percent)), + ("class:status-bar-dim", " "), + (bar_style, percent_label), + ] + if _show_timer: + frags += [ + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", duration_label), + ] + frags.append(("class:status-bar", " ")) total_width = sum(self._status_bar_display_width(text) for _, text in frags) if total_width > width: @@ -2568,34 +2655,26 @@ def _stream_delta(self, text) -> None: # 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) + if self.markdown_enabled: + self._emit_stream_markdown(preceding) + else: + 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 @@ -2610,7 +2689,10 @@ def _stream_delta(self, text) -> None: safe = self._stream_prefilt[:-i] break if safe: - self._emit_stream_text(safe) + if self.markdown_enabled: + self._emit_stream_markdown(safe) + else: + self._emit_stream_text(safe) self._stream_last_was_newline = safe.endswith("\n") self._stream_prefilt = self._stream_prefilt[len(safe):] return @@ -2687,9 +2769,7 @@ def _emit_stream_text(self, text: str) -> None: self._stream_text_ansi = f"\033[38;2;{_r};{_g};{_b}m" except (ValueError, IndexError): self._stream_text_ansi = "" - w = shutil.get_terminal_size().columns - fill = w - 2 - len(label) - _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") + _cprint("") # blank line between user bubble and response self._stream_buf += text @@ -2712,15 +2792,142 @@ def _flush_stream(self) -> None: # Close reasoning box if still open (in case no content tokens arrived) self._close_reasoning_box() - if self._stream_buf: + # Render any trailing incomplete markdown block that was held in + # buffer waiting for more tokens (e.g. unclosed paragraph at EOT). + if self.markdown_enabled and self._stream_md_buf: + remaining = self._stream_md_buf[self._stream_md_rendered:] + if remaining.strip(): + self._render_markdown_chunk(remaining) + self._stream_md_buf = "" + self._stream_md_rendered = 0 + elif self._stream_buf: _tc = getattr(self, "_stream_text_ansi", "") _cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}") self._stream_buf = "" - # Close the response box - if self._stream_box_opened: + # No trailing blank line — the next user bubble provides visual separation + + # -- Streaming markdown helpers ------------------------------------------ + + def _find_block_boundary(self, buf: str, start: int) -> int: + """Find the last safe markdown block split point. + + Scans *buf* from *start* looking for double-newline paragraph + breaks (``\\n\\n``) that are **not** inside a fenced code block + (backtick or tilde). Returns the char offset just after the last + safe boundary found, or *start* if no safe split exists yet. + + This prevents the streaming renderer from chopping a code block + in half — the block is held in buffer until the closing fence + arrives, then rendered in one piece with syntax highlighting. + """ + in_fence = self._stream_md_fence_open + last_boundary = start + i = start + while i < len(buf): + if buf[i:i + 3] in ('```', '~~~'): + in_fence = not in_fence + eol = buf.find('\n', i) + i = (eol + 1) if eol != -1 else len(buf) + continue + if not in_fence and buf[i:i + 2] == '\n\n': + last_boundary = i + 2 + i += 2 + continue + i += 1 + self._stream_md_fence_open = in_fence + return last_boundary + + def _render_markdown_chunk(self, chunk: str) -> None: + """Render a complete markdown block to the terminal. + + Uses a lazily-created Rich Console + StringIO buffer (reused + across calls to avoid per-chunk allocation). Output is routed + through ``_cprint`` so it renders correctly inside prompt_toolkit's + ``patch_stdout`` context. Falls back to plain-text printing if + the Markdown parser raises for any reason. + """ + if not chunk.strip(): + return + # Lazy-init: create Console + buffer once per streaming session + if self._stream_md_console is None: + from io import StringIO + self._stream_md_iobuf = StringIO() + self._stream_md_console = Console( + file=self._stream_md_iobuf, + force_terminal=True, + color_system="truecolor", + highlight=False, + width=shutil.get_terminal_size((80, 24)).columns, + ) + buf = self._stream_md_iobuf + buf.seek(0) + buf.truncate() + # Use cached terminal width set at stream-open time; avoids a syscall per chunk + self._stream_md_console.width = getattr(self, "_stream_md_term_width", shutil.get_terminal_size((80, 24)).columns) + try: + # Use skin-aware code theme and text colour for streamed blocks + _theme = getattr(self, "_stream_md_code_theme", "monokai") + _color = getattr(self, "_stream_md_text_color", "") + _md_style = _color if _color else "none" + self._stream_md_console.print( + _RichMarkdown(chunk, code_theme=_theme, style=_md_style) + ) + except Exception as _e: + logger.debug("Streaming markdown render failed, falling back to plain text: %s", _e) + self._stream_md_console.print(chunk) + for line in buf.getvalue().rstrip("\n").split("\n"): + _cprint(line) + + def _emit_stream_markdown(self, text: str) -> None: + """Accumulate streamed tokens and render complete markdown blocks. + + Tokens are appended to ``_stream_md_buf``. On each call we scan + for the last double-newline boundary outside a code fence (via + ``_find_block_boundary``). Everything before that boundary is a + complete markdown block — we render it through Rich Markdown once + and trim it from the buffer. The trailing incomplete block stays + buffered until more tokens arrive or ``_flush_stream`` renders it. + + This is O(unstable-block-size) per token, not O(full-text), + inspired by claude-code's StreamingMarkdown component. + """ + if not text: + return + + self._close_reasoning_box() + self._stream_md_buf += text + + # Open box header on first visible text + if not self._stream_box_opened: + stripped = self._stream_md_buf.lstrip("\n") + if not stripped: + return + self._stream_md_buf = stripped + 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") + # Capture skin settings once per response stream open. + # A /skin change mid-stream won't affect the current response + # but will take effect from the next response onward. + self._stream_md_code_theme = _skin.get_color("code_theme", "monokai") + self._stream_md_text_color = _skin.get_color("banner_text", "#FFF8DC") + except Exception: + label = "⚕ Hermes" w = shutil.get_terminal_size().columns - _cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") + self._stream_md_term_width = w # cache for chunk rendering; avoids syscall per chunk + _cprint("") # blank line between user bubble and response + + # Find the safe render boundary + boundary = self._find_block_boundary(self._stream_md_buf, self._stream_md_rendered) + + if boundary > self._stream_md_rendered: + self._render_markdown_chunk(self._stream_md_buf[self._stream_md_rendered:boundary]) + # Trim rendered prefix to avoid unbounded buffer growth + self._stream_md_buf = self._stream_md_buf[boundary:] + self._stream_md_rendered = 0 def _reset_stream_state(self) -> None: """Reset streaming state before each agent invocation.""" @@ -2735,6 +2942,15 @@ def _reset_stream_state(self) -> None: self._reasoning_buf = "" self._reasoning_preview_buf = "" self._deferred_content = "" + # Markdown streaming state — block-by-block rendering inspired by + # claude-code's monotonic stable-prefix boundary approach. + self._stream_md_buf = "" # accumulated text awaiting render + self._stream_md_rendered = 0 # char offset of already-rendered content + self._stream_md_fence_open = False # True when inside a ``` or ~~~ code fence + self._stream_md_console = None # lazily-created Console for chunk rendering + self._stream_md_iobuf = None # StringIO backing the streaming Console + self._stream_md_code_theme = "monokai" # Pygments theme from active skin + self._stream_md_text_color = "" # base text colour from active skin def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" @@ -3010,7 +3226,9 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No provider_require_parameters=self._provider_require_params, provider_data_collection=self._provider_data_collection, session_id=self.session_id, - platform="cli", + # Use a markdown-off platform hint when rendering is disabled + # so the LLM doesn't produce markdown that would display raw. + platform="cli" if self.markdown_enabled else "cli_no_markdown", session_db=self._session_db, clarify_callback=self._clarify_callback, reasoning_callback=self._current_reasoning_callback(), @@ -5795,6 +6013,8 @@ def process_command(self, command: str) -> bool: _cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") elif canonical == "skin": self._handle_skin_command(cmd_original) + elif canonical == "markdown": + self._handle_markdown_command(cmd_original) elif canonical == "voice": self._handle_voice_command(cmd_original) else: @@ -6015,10 +6235,9 @@ def _bg_thinking(text: str) -> None: import time as _tmod _tmod.sleep(0.05) # brief pause for refresh print() - ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") _cprint(f" ✅ Background task #{task_num} complete") _cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"") - ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + print() if response: try: from hermes_cli.skin_engine import get_active_skin @@ -6032,15 +6251,16 @@ def _bg_thinking(text: str) -> None: _resp_text = "#FFF8DC" _chat_console = ChatConsole() - _chat_console.print(Panel( - _rich_text_from_ansi(response), - title=f"[{_resp_color} bold]{label} (background #{task_num})[/]", - title_align="left", - border_style=_resp_color, - style=_resp_text, - box=rich_box.HORIZONTALS, - padding=(1, 4), - )) + # Skin-aware markdown rendering for background task output + _code_theme = _skin.get_color("code_theme", "monokai") + _renderable = _render_response( + response, self.markdown_enabled, + code_theme=_code_theme, text_color=_resp_text, + ) + # Borderless output for background task response. + _chat_console.print() + _chat_console.print(_renderable) + _chat_console.print() else: _cprint(" (No response generated)") @@ -6157,14 +6377,17 @@ def run_btw(): except Exception: _resp_color = "#4F6D4A" - ChatConsole().print(Panel( - _rich_text_from_ansi(response), - title=f"[{_resp_color} bold]⚕ /btw[/]", - title_align="left", - border_style=_resp_color, - box=rich_box.HORIZONTALS, - padding=(1, 4), + # Skin-aware markdown rendering for /btw output + _code_theme = _skin.get_color("code_theme", "monokai") + _btw_text = _skin.get_color("banner_text", "#FFF8DC") + # Borderless output for /btw response. + _cc = ChatConsole() + _cc.print() + _cc.print(_render_response( + response, self.markdown_enabled, + code_theme=_code_theme, text_color=_btw_text, )) + _cc.print() else: _cprint(" 💬 /btw: (no response)") @@ -6405,6 +6628,32 @@ def _handle_browser_command(self, cmd: str): print(" status Show current browser mode") print() + def _handle_markdown_command(self, cmd: str): + """Handle /markdown [on|off] — toggle Rich Markdown rendering. + + With no argument, shows current state. Persists the preference + to ``display.markdown`` in the user's config.yaml. + """ + parts = cmd.strip().split(maxsplit=1) + + if len(parts) < 2 or not parts[1].strip(): + state = "on" if self.markdown_enabled else "off" + _cprint(f" {_GOLD}Markdown rendering: {state}{_RST}") + _cprint(f" {_DIM}Usage: /markdown on|off{_RST}") + return + + arg = parts[1].strip().lower() + if arg in ("on", "true", "1"): + self.markdown_enabled = True + save_config_value("display.markdown", True) + _cprint(f" {_GOLD}Markdown rendering: ON (saved){_RST}") + elif arg in ("off", "false", "0"): + self.markdown_enabled = False + save_config_value("display.markdown", False) + _cprint(f" {_GOLD}Markdown rendering: OFF (saved){_RST}") + else: + _cprint(f" {_DIM}Unknown argument: {arg}. Use on or off.{_RST}") + def _handle_skin_command(self, cmd: str): """Handle /skin [name] — show or change the display skin.""" try: @@ -6666,13 +6915,33 @@ def _manual_compress(self, cmd_original: str = ""): except Exception as e: print(f" ❌ Compression failed: {e}") - def _handle_debug_command(self): - """Handle /debug — upload debug report + logs and print paste URLs.""" - from hermes_cli.debug import run_debug_share - from types import SimpleNamespace + def _print_post_response_summary(self): + """Print model/context info after response (replaces status bar during response).""" + if not self.agent: + return + try: + snapshot = self._get_status_bar_snapshot() + model_short = snapshot["model_short"] + ctx_used = format_token_count_compact(snapshot["context_tokens"]) if snapshot["context_tokens"] else "0" + ctx_total = _format_context_length(snapshot["context_length"]) if snapshot["context_length"] else "--" + percent = snapshot["context_percent"] + percent_label = f"{percent}%" if percent is not None else "--" + total_label = format_duration_compact(self._inference_total_seconds) + last_label = format_duration_compact(self._last_inference_seconds) - args = SimpleNamespace(lines=200, expire=7, local=False) - run_debug_share(args) + # Format: ⚕ Model │ Context │ Percent │ total Xs │ last Xs + summary = f" ⚕ {model_short} │ {ctx_used}/{ctx_total} │ [{self._context_bar_visual(percent)}] {percent_label} │ ∑ {total_label} │ ↩ {last_label} " + print(summary) + except Exception: + pass # Silently skip on error + + def _context_bar_visual(self, percent: Optional[int]) -> str: + """Return a visual bar for context usage.""" + if percent is None: + percent = 0 + width = 10 + filled = int((percent / 100) * width) + return "█" * filled + "░" * (width - filled) def _show_usage(self): """Show rate limits (if available) and session token usage.""" @@ -6725,7 +6994,6 @@ def _show_usage(self): elapsed = format_duration_compact((datetime.now() - self.session_start).total_seconds()) print(" 📊 Session Token Usage") - print(f" {'─' * 40}") print(f" Model: {agent.model}") print(f" Input tokens: {input_tokens:>10,}") print(f" Cache read tokens: {cache_read_tokens:>10,}") @@ -6745,7 +7013,6 @@ def _show_usage(self): print(f" Total cost: {'included':>10}") else: print(f" Total cost: {'n/a':>10}") - print(f" {'─' * 40}") print(f" Current context: {last_prompt:,} / {ctx_len:,} ({pct:.0f}%)") print(f" Messages: {msg_count}") print(f" Compressions: {compressions}") @@ -7870,22 +8137,26 @@ def _clear_secret_input_buffer(self) -> None: def chat(self, message, images: list = None) -> Optional[str]: """ Send a message to the agent and get a response. - + Handles streaming output, interrupt detection (user typing while agent is working), and re-queueing of interrupted messages. - + Uses a dedicated _interrupt_queue (separate from _pending_input) to avoid race conditions between the process_loop and interrupt monitoring. Messages typed while the agent is running go to _interrupt_queue; messages typed while idle go to _pending_input. - + Args: message: The user's message (str or multimodal content list) images: Optional list of Path objects for attached images - + Returns: The agent's response, or None on error """ + # Reset per-turn flags for this new message + self._summary_printed_this_turn = False + self._response_received = False + # Single-query and direct chat callers do not go through run(), so # register secure secret capture here as well. set_secret_capture_callback(self._secret_capture_callback) @@ -7949,10 +8220,10 @@ def chat(self, message, images: list = None) -> Optional[str]: # Add user message to history self.conversation_history.append({"role": "user", "content": message}) - ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") - print(flush=True) - try: + # Reset per-message inference timer (snapshot uses session_start for its duration label) + self.session_start = datetime.now() + # Run the conversation with interrupt monitoring result = None @@ -8003,11 +8274,8 @@ def display_callback(sentence: str): nonlocal _streaming_box_opened if not _streaming_box_opened: _streaming_box_opened = True - w = self.console.width - label = " ⚕ Hermes " - fill = w - 2 - len(label) - _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") - _cprint(f"{_STREAM_PAD}{sentence.rstrip()}") + _cprint("") # blank line before TTS output + _cprint(sentence.rstrip()) tts_thread = threading.Thread( target=stream_tts_to_speaker, @@ -8139,6 +8407,10 @@ def run_agent(): # but guard against edge cases. agent_thread.join(timeout=30) + # Update inference counters + self._last_inference_seconds = (datetime.now() - self.session_start).total_seconds() + self._inference_total_seconds += self._last_inference_seconds + # Proactively clean up async clients whose event loop is dead. # The agent thread may have created AsyncOpenAI clients bound # to a per-thread event loop; if that loop is now closed, those @@ -8248,25 +8520,29 @@ def run_agent(): is_error_response = result and (result.get("failed") or result.get("partial")) already_streamed = self._stream_started and self._stream_box_opened and not is_error_response if use_streaming_tts and _streaming_box_opened and not is_error_response: - # Text was already printed sentence-by-sentence; just close the box - w = shutil.get_terminal_size().columns - _cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") + # Text was already printed sentence-by-sentence; add trailing newline + _cprint("") elif already_streamed: # Response was already streamed token-by-token with box framing; # _flush_stream() already closed the box. Skip Rich Panel. pass else: _chat_console = ChatConsole() - _chat_console.print(Panel( - _rich_text_from_ansi(response), - title=f"[{_resp_color} bold]{label}[/]", - title_align="left", - border_style=_resp_color, - style=_resp_text, - box=rich_box.HORIZONTALS, - padding=(1, 4), - )) + # Skin-aware markdown: code_theme from skin (or monokai), + # banner_text as base paragraph colour. + _code_theme = _skin.get_color("code_theme", "monokai") if hasattr(_skin, "get_color") else "monokai" + _renderable = _render_response( + response, self.markdown_enabled, + code_theme=_code_theme, text_color=_resp_text, + ) + # Borderless output — blank line separates user bubble from response. + _cprint("") + _chat_console.print(_renderable) + # Print post-response summary (model/context info that was hidden during response) + # Signal that a response has been received (status bar switches to ∑/↩ mode) + if response and not response_previewed: + self._response_received = True # Play terminal bell when agent finishes (if enabled). # Works over SSH — the bell propagates to the user's terminal. @@ -8551,24 +8827,22 @@ def _build_tui_layout_children( ordering. """ return [ - item for item in [ - Window(height=0), - sudo_widget, - secret_widget, - approval_widget, - clarify_widget, - model_picker_widget, - spinner_widget, - spacer, - *self._get_extra_tui_widgets(), - status_bar, - input_rule_top, - image_bar, - input_area, - input_rule_bot, - voice_status_bar, - completions_menu, - ] if item is not None + Window(height=0), + sudo_widget, + secret_widget, + approval_widget, + clarify_widget, + spinner_widget, + spacer, + *self._get_extra_tui_widgets(), + Window(height=1), # blank line between response output and status bar + status_bar, + input_rule_top, + image_bar, + input_area, + input_rule_bot, + voice_status_bar, + completions_menu, ] def run(self): @@ -10037,9 +10311,7 @@ def _expand_ref(m): expanded = _paste_ref_re.sub(_expand_ref, user_input) total_lines = expanded.count('\n') + 1 n_pastes = len(paste_refs) - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" - print() - ChatConsole().print(_user_bar) + _cprint("") # Show any surrounding user text alongside the paste summary split_parts = _paste_ref_re.split(user_input) visible_user_text = " ".join( @@ -10056,19 +10328,16 @@ def _expand_ref(m): ) user_input = expanded else: - _user_bar = f"[{_accent_hex()}]{'─' * 40}[/]" if '\n' in user_input: first_line = user_input.split('\n')[0] line_count = user_input.count('\n') + 1 - print() - ChatConsole().print(_user_bar) + _cprint("") ChatConsole().print( f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] " f"[dim](+{line_count - 1} lines)[/]" ) else: - print() - ChatConsole().print(_user_bar) + _cprint("") ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]") # Show image attachment count diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f753d6f3a73b..3f1b359703c8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -125,6 +125,9 @@ class CommandDef: subcommands=("normal", "fast", "status", "on", "off")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", args_hint="[name]"), + CommandDef("markdown", "Toggle markdown rendering for responses", "Configuration", + cli_only=True, aliases=("md",), args_hint="[on|off]", + subcommands=("on", "off")), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), diff --git a/run_agent.py b/run_agent.py index c87bd3515284..475eb5502271 100644 --- a/run_agent.py +++ b/run_agent.py @@ -94,7 +94,7 @@ from agent.context_compressor import ContextCompressor from agent.subdirectory_hints import SubdirectoryHintTracker from agent.prompt_caching import apply_anthropic_cache_control -from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE +from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, TONE_AND_STYLE_GUIDANCE, OUTPUT_EFFICIENCY_GUIDANCE, MESSAGING_PLATFORMS from agent.usage_pricing import estimate_usage_cost, normalize_usage from agent.display import ( KawaiiSpinner, build_tool_preview as _build_tool_preview, @@ -3686,6 +3686,16 @@ def _build_system_prompt(self, system_message: str = None) -> str: # Fallback to hardcoded identity prompt_parts = [DEFAULT_AGENT_IDENTITY] + # Output style guidance — injected immediately after the identity so it + # carries high weight with local/open-source models that prioritize early + # instructions. Messaging platforms and cron are excluded (they have their + # own register; see MESSAGING_PLATFORMS in prompt_builder.py). + # cli_no_markdown is included by design — it passes the check. + _platform_key_early = (self.platform or "").lower().strip() + if _platform_key_early not in MESSAGING_PLATFORMS and _platform_key_early != "cron": + prompt_parts.append(TONE_AND_STYLE_GUIDANCE) + prompt_parts.append(OUTPUT_EFFICIENCY_GUIDANCE) + # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] if "memory" in self.valid_tool_names: