From aaff152b779f19e63448a13ec8211184f78c4320 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 1 Apr 2026 17:03:25 +0200 Subject: [PATCH 01/20] feat: Rich-based rendering engine with intra-line diff highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces agent/rich_output.py — a self-contained Rich/Pygments rendering toolkit with no project-specific imports. Public API: - LanguageDetector: extension map + content-pattern heuristics - SyntaxHighlighter: Pygments → Rich markup → ANSI string - FilePathFormatter: per-filetype icons, compact relative paths - DiffRenderer: unified diff → Rich Text with line numbers - clean_command_output: strip venv/stacktrace noise from command output DiffRenderer replaces _render_inline_unified_diff in display.py: - Intra-line character-level highlighting via SequenceMatcher (threshold 0.5) - Per-run del/add pairing to avoid cross-hunk false matches - Summary header: ● filename.py Added N lines, removed M lines - Console width from shutil.get_terminal_size, not hardcoded Tests: 51 passing in tests/test_rich_output.py --- agent/display.py | 42 ++- agent/rich_output.py | 688 ++++++++++++++++++++++++++++++++++++++ tests/test_display.py | 23 +- tests/test_rich_output.py | 413 +++++++++++++++++++++++ 4 files changed, 1156 insertions(+), 10 deletions(-) create mode 100644 agent/rich_output.py create mode 100644 tests/test_rich_output.py diff --git a/agent/display.py b/agent/display.py index 94259fa80a899..0e911b62f7112 100644 --- a/agent/display.py +++ b/agent/display.py @@ -29,6 +29,17 @@ _MAX_INLINE_DIFF_FILES = 6 _MAX_INLINE_DIFF_LINES = 80 +# Rich-based rendering (syntax highlighting + enhanced diffs) +try: + from agent.rich_output import DiffRenderer as _RichDiffRenderer + from agent.rich_output import SyntaxHighlighter as _RichSyntaxHighlighter + from agent.rich_output import clean_command_output + _rich_diff = _RichDiffRenderer() + _rich_syntax = _RichSyntaxHighlighter() + _RICH_OUTPUT = True +except ImportError: + _RICH_OUTPUT = False + @dataclass class LocalEditSnapshot: @@ -411,7 +422,18 @@ def _emit_inline_diff(diff_text: str, print_fn) -> bool: def _render_inline_unified_diff(diff: str) -> list[str]: - """Render unified diff lines in Hermes' inline transcript style.""" + """Render unified diff lines with line numbers and coloured backgrounds. + + Uses rich_output.DiffRenderer when available (line numbers, green/red + background highlights). Falls back to the original ANSI-string path. + """ + if _RICH_OUTPUT: + try: + return _rich_diff.to_lines(diff) + except Exception as exc: + logger.debug("Rich diff render failed, using ANSI fallback: %s", exc) + + # Original ANSI fallback — unchanged from upstream rendered: list[str] = [] from_file = None to_file = None @@ -443,6 +465,24 @@ def _render_inline_unified_diff(diff: str) -> list[str]: return rendered +def highlight_code( + code: str, + language: str | None = None, + filename: str | None = None, +) -> str: + """Return an ANSI-highlighted version of *code* for terminal display. + + When rich_output is unavailable the original string is returned unchanged. + """ + if not _RICH_OUTPUT: + return code + try: + return _rich_syntax.to_ansi(code, language=language, filename=filename) + except Exception as exc: + logger.debug("highlight_code failed: %s", exc) + return code + + def _split_unified_diff_sections(diff: str) -> list[str]: """Split a unified diff into per-file sections.""" sections: list[list[str]] = [] diff --git a/agent/rich_output.py b/agent/rich_output.py new file mode 100644 index 0000000000000..2b2fbf436db1c --- /dev/null +++ b/agent/rich_output.py @@ -0,0 +1,688 @@ +"""Rich-based syntax highlighting, diff rendering, and output utilities. + +Drop into hermes-agent's ``agent/`` directory. + +No project-specific imports — only ``rich`` (always present in Hermes) and +``pygments`` (bundled as a rich dependency). + +Public API +---------- +LanguageDetector detect language from filename / content +FilePathFormatter per-type icons + compact relative-path display +SyntaxHighlighter Pygments → Rich markup → ANSI string +DiffRenderer unified diff → Rich Text with line numbers → ANSI lines +clean_command_output strip venv/stacktrace noise from command output + +Internal helpers (module-level, exposed for testing) +----------------------------------------------------- +_intra_diff character-level segment diff between two line strings +_parse_diff_filename strip a/ b/ prefixes from unified-diff path headers +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +from difflib import SequenceMatcher +from io import StringIO +from pathlib import Path +from typing import Optional + +from rich.console import Console, Group +from rich.style import Style +from rich.text import Text + +logger = logging.getLogger(__name__) + +# Diff background colours — kept in sync with agent.display._ANSI_PLUS / _ANSI_MINUS +# _ANSI_PLUS = "\033[38;2;255;255;255;48;2;20;90;20m" → rgb(20,90,20) +# _ANSI_MINUS = "\033[38;2;255;255;255;48;2;120;20;20m" → rgb(120,20,20) +_DIFF_BG_ADD = "#145a14" # rgb(20, 90, 20) +_DIFF_BG_DEL = "#781414" # rgb(120, 20, 20) + +# Minimum SequenceMatcher ratio to apply intra-line highlighting. +# Below this the lines are too dissimilar and highlighting would be noise. +_INTRA_DIFF_MIN_RATIO: float = 0.5 + +# --------------------------------------------------------------------------- +# Pygments availability (bundled transitively via rich, but guard anyway) +# --------------------------------------------------------------------------- + +try: + from pygments.lexers import ( + TextLexer, + get_lexer_by_name, + get_lexer_for_filename, + guess_lexer, + ) + from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Name, + Number, + Operator, + String, + ) + from pygments.util import ClassNotFound + + _PYGMENTS = True +except ImportError: + _PYGMENTS = False + + +# --------------------------------------------------------------------------- +# Language detection +# --------------------------------------------------------------------------- + +class LanguageDetector: + """Detect programming language from filename extension or code content.""" + + EXTENSION_MAP: dict[str, str] = { + # Python + ".py": "python", ".pyx": "python", ".pyi": "python", ".pyw": "python", + # JavaScript / TypeScript + ".js": "javascript", ".jsx": "jsx", ".mjs": "javascript", ".cjs": "javascript", + ".ts": "typescript", ".tsx": "tsx", + # JVM + ".java": "java", ".scala": "scala", ".kt": "kotlin", ".groovy": "groovy", + # C family + ".c": "c", ".h": "c", + ".cpp": "cpp", ".cxx": "cpp", ".cc": "cpp", ".hpp": "cpp", + ".cs": "csharp", ".fs": "fsharp", + # Systems + ".rs": "rust", ".go": "go", ".swift": "swift", + # Scripting + ".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua", + ".r": "r", ".R": "r", + # Web + ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss", + ".sass": "sass", ".vue": "vue", ".svelte": "svelte", + # Shell + ".sh": "bash", ".bash": "bash", ".zsh": "zsh", ".fish": "fish", + ".ps1": "powershell", ".bat": "batch", ".cmd": "batch", + # Data / config + ".json": "json", ".yaml": "yaml", ".yml": "yaml", + ".toml": "toml", ".ini": "ini", ".cfg": "ini", ".xml": "xml", + # Docs + ".md": "markdown", ".rst": "rst", ".tex": "latex", + # DB + ".sql": "sql", + # Containers + ".dockerfile": "dockerfile", + # Other + ".dart": "dart", ".ex": "elixir", ".exs": "elixir", + ".erl": "erlang", ".hs": "haskell", ".ml": "ocaml", + ".elm": "elm", ".zig": "zig", ".vim": "vim", + } + + CONTENT_PATTERNS: dict[str, list[str]] = { + "python": [ + r"^\s*def\s+\w+\s*\(", r"^\s*class\s+\w+\s*[\(:]", + r"^\s*import\s+\w+", r"^\s*from\s+\w+\s+import", + r'if\s+__name__\s*==\s*[\'"]__main__[\'"]', + ], + "javascript": [ + r"^\s*function\s+\w+\s*\(", r"^\s*const\s+\w+\s*=", + r"console\.log\s*\(", r'require\s*\([\'"]', r"module\.exports", + ], + "typescript": [ + r"^\s*interface\s+\w+", r"^\s*type\s+\w+\s*=", + r":\s*string\s*[;,}]", r":\s*number\s*[;,}]", + ], + "java": [r"^\s*public\s+class\s+\w+", r"System\.out\.print"], + "cpp": [r"#include\s*<\w+>", r"std::\w+", r"cout\s*<<"], + "go": [r"^\s*package\s+\w+", r"^\s*func\s+\w+\s*\(", r"fmt\.Print"], + "rust": [r"^\s*fn\s+\w+\s*\(", r"^\s*use\s+\w+", r"println!\s*\("], + "bash": [r"#!/bin/bash", r"#!/bin/sh", r"^\s*if\s*\[", r"\$\{\w+\}"], + "sql": [r"^\s*SELECT\s+", r"^\s*INSERT\s+INTO", r"^\s*CREATE\s+TABLE"], + } + + def detect_from_filename(self, filename: str) -> Optional[str]: + if not filename: + return None + ext = Path(filename).suffix.lower() + if ext in self.EXTENSION_MAP: + return self.EXTENSION_MAP[ext] + name = Path(filename).name.lower() + if name in {"dockerfile", "makefile", "rakefile", "gemfile", "vagrantfile"}: + return name + return None + + def detect_from_content(self, content: str, max_lines: int = 50) -> Optional[str]: + if not content.strip(): + return None + sample = "\n".join(content.split("\n")[:max_lines]) + scores: dict[str, int] = {} + for lang, patterns in self.CONTENT_PATTERNS.items(): + score = sum(1 for p in patterns if re.search(p, sample, re.MULTILINE)) + if score: + scores[lang] = score + return max(scores, key=lambda k: scores[k]) if scores else None + + def detect(self, content: str, filename: Optional[str] = None) -> Optional[str]: + return self.detect_from_filename(filename) or self.detect_from_content(content) + + +# --------------------------------------------------------------------------- +# File path formatting +# --------------------------------------------------------------------------- + +class FilePathFormatter: + """Per-filetype icons and compact relative-path display.""" + + _ICONS: dict[str, str] = { + ".py": "🐍", ".js": "📜", ".ts": "📘", ".tsx": "⚛️", ".jsx": "⚛️", + ".html": "🌐", ".css": "🎨", ".scss": "🎨", ".md": "📝", + ".json": "📋", ".yaml": "⚙️", ".yml": "⚙️", ".toml": "⚙️", + ".txt": "📄", ".log": "📊", ".conf": "⚙️", ".cfg": "⚙️", + ".xml": "📋", ".sql": "🗃️", ".sh": "💻", ".bash": "💻", + ".go": "🐹", ".rs": "🦀", ".java": "☕", ".cpp": "⚙️", + ".c": "⚙️", ".h": "📋", + } + + @staticmethod + def get_file_icon(file_path: str) -> str: + ext = os.path.splitext(file_path)[1].lower() + return FilePathFormatter._ICONS.get(ext, "📄") + + @staticmethod + def format_path( + file_path: str, + compact: bool = True, + cwd: Optional[str] = None, + ) -> str: + if not compact: + return file_path + try: + return os.path.relpath(file_path, cwd or os.getcwd()) + except (ValueError, OSError): + return file_path + + @staticmethod + def titled( + file_path: str, + compact: bool = True, + cwd: Optional[str] = None, + ) -> str: + """Return ``{icon} {path}`` string.""" + icon = FilePathFormatter.get_file_icon(file_path) + path = FilePathFormatter.format_path(file_path, compact, cwd) + return f"{icon} {path}" + + +# --------------------------------------------------------------------------- +# Pygments → Rich markup formatter (internal) +# --------------------------------------------------------------------------- + +class _PygmentsToRich: + """Convert a Pygments token stream to a Rich markup string.""" + + # Built lazily so the class-level dict isn't populated when Pygments is absent + _STYLES: dict = {} + + @classmethod + def _ensure_styles(cls) -> None: + if cls._STYLES or not _PYGMENTS: + return + cls._STYLES = { + Keyword: "bold blue", + Keyword.Type: "bold cyan", + Name: "white", + Name.Builtin: "cyan", + Name.Class: "bold yellow", + Name.Constant: "bold yellow", + Name.Decorator: "bright_cyan", + Name.Exception: "bold red", + Name.Function: "bold yellow", + Name.Function.Magic: "cyan", + Name.Tag: "bold blue", + Name.Variable.Magic: "cyan", + Comment: "dim green", + Comment.Preproc: "bold green", + String: "green", + String.Doc: "dim green", + String.Escape: "bold green", + String.Interpol: "bold green", + String.Regex: "magenta", + Number: "magenta", + Operator: "white", + Operator.Word: "bold blue", + Generic.Deleted: "red", + Generic.Inserted: "green", + Generic.Error: "bold red", + Error: "bold red on red", + } + + def format(self, tokens) -> str: + self._ensure_styles() + parts: list[str] = [] + for ttype, value in tokens: + style = self._resolve(ttype) + if style and value.strip(): + esc = value.replace("[", r"\[").replace("]", r"\]") + parts.append(f"[{style}]{esc}[/{style}]") + else: + parts.append(value) + return "".join(parts) + + def _resolve(self, ttype) -> Optional[str]: + t = ttype + while t is not None: + if t in self._STYLES: + return self._STYLES[t] + t = t.parent # type: ignore[assignment] # pygments Token hierarchy isn't typed + return None + + +# --------------------------------------------------------------------------- +# Public: syntax highlighter +# --------------------------------------------------------------------------- + +class SyntaxHighlighter: + """Highlight source code using Pygments, output as Rich markup or ANSI. + + Falls back to plain green when Pygments is unavailable. + """ + + def __init__(self) -> None: + self._fmt = _PygmentsToRich() + self._detector = LanguageDetector() + + # -- Rich markup (for embedding in Rich Text / Panel) -------------------- + + def to_markup( + self, + code: str, + language: Optional[str] = None, + filename: Optional[str] = None, + ) -> str: + """Return a Rich markup string with syntax colours applied.""" + if not _PYGMENTS: + escaped = code.replace("[", r"\[").replace("]", r"\]") + return f"[green]{escaped}[/green]" + try: + lexer = self._lexer(code, language, filename) + return self._fmt.format(list(lexer.get_tokens(code))) + except Exception as exc: + logger.debug("Pygments highlight failed: %s", exc) + escaped = code.replace("[", r"\[").replace("]", r"\]") + return f"[green]{escaped}[/green]" + + # -- ANSI string (for plain print / print_fn) ---------------------------- + + def to_ansi( + self, + code: str, + language: Optional[str] = None, + filename: Optional[str] = None, + ) -> str: + """Return an ANSI-escaped string suitable for plain ``print()``.""" + markup = self.to_markup(code, language, filename) + buf = StringIO() + width = shutil.get_terminal_size((220, 50)).columns + Console(file=buf, highlight=False, force_terminal=True, width=width).print(markup) + return buf.getvalue() + + # -- Helpers ------------------------------------------------------------- + + def _lexer(self, code: str, language: Optional[str], filename: Optional[str]): + try: + if language: + return get_lexer_by_name(language, stripnl=False) + if filename: + return get_lexer_for_filename(filename, stripnl=False) + return guess_lexer(code, stripnl=False) + except ClassNotFound: + return TextLexer(stripnl=False) + + +# --------------------------------------------------------------------------- +# Diff renderer helpers (module-level for testability) +# --------------------------------------------------------------------------- + +def _parse_diff_filename(path: str, fallback: Optional[str] = None) -> str: + """Return the basename from a unified-diff path string. + + Strips ``b/`` / ``a/`` prefixes produced by ``git diff``. If the result + is ``/dev/null`` (deleted-file diff), recurses on *fallback* (the ``---`` + path) instead. + """ + for prefix in ("b/", "a/"): + if path.startswith(prefix): + path = path[len(prefix):] + break + if path == "/dev/null": + if fallback: + return _parse_diff_filename(fallback) + return "?" + name = Path(path).name + return name if name else path + + +def _count_pass( + lines: list[str], + explicit_filename: Optional[str] = None, +) -> list[tuple[Optional[str], int, int]]: + """First pass over diff lines: build ``(filename, n_adds, n_dels)`` per file boundary. + + When *explicit_filename* is provided (from ``from_content()``), the + filename is fixed and only one entry is produced. Otherwise filenames are + parsed from ``+++ `` lines. + """ + entries: list[tuple[Optional[str], int, int]] = [] + current_file: Optional[str] = explicit_filename + n_adds = n_dels = 0 + from_path: Optional[str] = None + started = explicit_filename is not None + + for line in lines: + if line.startswith("--- "): + from_path = line[4:].strip() + elif line.startswith("+++ "): + if started: + entries.append((current_file, n_adds, n_dels)) + to_path = line[4:].strip() + if explicit_filename is None: + current_file = _parse_diff_filename(to_path, from_path) + n_adds = n_dels = 0 + started = True + elif line.startswith("+"): + n_adds += 1 + elif line.startswith("-"): + n_dels += 1 + + if started: + entries.append((current_file, n_adds, n_dels)) + + return entries + + +def _make_header(filename: Optional[str], n_adds: int, n_dels: int) -> tuple[Text, Text]: + """Return ``(header_Text, separator_Text)`` for the diff summary line.""" + def _pl(n: int) -> str: + return f"{n} line" if n == 1 else f"{n} lines" + + parts: list[Text] = [ + Text("● ", style="bright_white"), + Text(filename or "?", style=Style(color="bright_white", bold=True)), + Text(" "), + ] + if n_adds > 0 and n_dels == 0: + parts.append(Text(f"Added {_pl(n_adds)}", style="green")) + elif n_dels > 0 and n_adds == 0: + parts.append(Text(f"Removed {_pl(n_dels)}", style="red")) + elif n_adds > 0 and n_dels > 0: + parts.append(Text(f"Added {_pl(n_adds)}", style="green")) + parts.append(Text(f", removed {_pl(n_dels)}", style="red")) + + header = Text.assemble(*parts) + separator = Text("─" * len(header.plain), style="dim") + return header, separator + + +def _flat_del(ln: int, content: str) -> Text: + """Render a deletion line with flat (no intra-line) highlighting.""" + return Text.assemble( + Text(f"{ln:>4} ", style="dim"), + Text("- ", style=Style(color="red", bold=True)), + Text(content, style=Style(bgcolor=_DIFF_BG_DEL, color="white")), + ) + + +def _flat_add(ln: int, content: str) -> Text: + """Render an addition line with flat (no intra-line) highlighting.""" + return Text.assemble( + Text(f"{ln:>4} ", style="dim"), + Text("+ ", style=Style(color="green", bold=True)), + Text(content, style=Style(bgcolor=_DIFF_BG_ADD, color="white")), + ) + + +def _intra_diff(old: str, new: str) -> tuple[list[Text], list[Text]]: + """Character-level diff between two line content strings. + + Returns ``(del_segments, add_segments)`` — lists of ``Text`` objects + covering the full content of each line with no gaps. Changed characters + are rendered bright-red / bright-green bold; unchanged characters use the + base diff background with white foreground. + + Callers: ``Text.assemble(*del_segments)`` / ``Text.assemble(*add_segments)``. + + Note: segment lists may have different total character counts when + ``delete`` or ``insert`` opcodes are present — this is correct because the + two lines have different lengths. + """ + del_segs: list[Text] = [] + add_segs: list[Text] = [] + for tag, i1, i2, j1, j2 in SequenceMatcher(None, old, new, autojunk=False).get_opcodes(): + if tag == "equal": + del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="white"))) + add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="white"))) + elif tag == "replace": + del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="bright_red", bold=True))) + add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="bright_green", bold=True))) + elif tag == "delete": + del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="bright_red", bold=True))) + elif tag == "insert": + add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="bright_green", bold=True))) + return del_segs, add_segs + + +# --------------------------------------------------------------------------- +# Public: diff renderer +# --------------------------------------------------------------------------- + +class DiffRenderer: + """Render a unified diff as Rich Text objects with line numbers. + + Produces coloured ``+`` / ``-`` lines with green / red backgrounds and + dim context lines — significantly richer than raw ANSI strings. + """ + + # -- From old/new strings ------------------------------------------------ + + def from_content( + self, + old: str, + new: str, + file_path: str = "file", + context_lines: int = 3, + ) -> Group: + """Generate and render a diff between *old* and *new*.""" + import difflib + + lines = list(difflib.unified_diff( + old.splitlines(keepends=False), + new.splitlines(keepends=False), + fromfile=f"a/{file_path}", + tofile=f"b/{file_path}", + n=context_lines, + lineterm="", + )) + return self._style(lines, file_path=file_path) + + # -- From unified diff text ---------------------------------------------- + + def from_unified(self, diff_text: str) -> Group: + """Render an already-generated unified diff string.""" + return self._style(diff_text.splitlines()) + + # -- ANSI lines (drop-in for _render_inline_unified_diff) ---------------- + + def to_lines(self, diff_text: str, width: int = 0) -> list[str]: + """Render *diff_text* and return a list of ANSI-escaped strings. + + Compatible with Hermes's ``print_fn`` pattern: each element maps to + one ``print_fn(line)`` call. + """ + buf = StringIO() + _w = width or shutil.get_terminal_size((220, 50)).columns + Console(file=buf, highlight=False, force_terminal=True, width=_w).print( + self.from_unified(diff_text) + ) + # Drop the trailing empty line that Console adds + return buf.getvalue().rstrip("\n").splitlines() + + # -- Internal rendering -------------------------------------------------- + + def _style(self, lines: list[str], file_path: Optional[str] = None) -> Group: + """Render *lines* (from a unified diff) as a ``Group`` of Rich ``Text``. + + *file_path* — when supplied (from ``from_content()``), its basename is + used for the summary header instead of parsing the ``+++ `` line. + """ + styled: list[Text] = [] + + # Pass 1 — count adds/dels per file boundary for the summary header. + explicit_filename = Path(file_path).name if file_path else None + file_entries = iter(_count_pass(lines, explicit_filename)) + + # Pass 2 — render with run-based pairing and intra-line highlighting. + ln_old = ln_new = 0 + from_path: Optional[str] = None + del_run: list[tuple[int, str]] = [] # (line_number, content) + add_run: list[tuple[int, str]] = [] + + def flush_runs() -> None: + """Pair del/add runs and emit highlighted (or flat) Text objects.""" + if not del_run and not add_run: + return + n_pairs = min(len(del_run), len(add_run)) + + # Precompute intra-diff segments for each pair. + pair_segs: list[tuple[Optional[list[Text]], Optional[list[Text]]]] = [] + for i in range(n_pairs): + old_content = del_run[i][1] + new_content = add_run[i][1] + r = SequenceMatcher(None, old_content, new_content).ratio() + if r >= _INTRA_DIFF_MIN_RATIO: + d, a = _intra_diff(old_content, new_content) + pair_segs.append((d, a)) + else: + pair_segs.append((None, None)) + + for i, (ln, content) in enumerate(del_run): + if i < n_pairs and pair_segs[i][0] is not None: + styled.append(Text.assemble( + Text(f"{ln:>4} ", style="dim"), + Text("- ", style=Style(color="red", bold=True)), + *pair_segs[i][0], + )) + else: + styled.append(_flat_del(ln, content)) + + for i, (ln, content) in enumerate(add_run): + if i < n_pairs and pair_segs[i][1] is not None: + styled.append(Text.assemble( + Text(f"{ln:>4} ", style="dim"), + Text("+ ", style=Style(color="green", bold=True)), + *pair_segs[i][1], + )) + else: + styled.append(_flat_add(ln, content)) + + del_run.clear() + add_run.clear() + + for line in lines: + if line.startswith("--- "): + flush_runs() + from_path = line[4:].strip() + continue + + if line.startswith("+++ "): + flush_runs() + entry = next(file_entries, None) + if entry: + fname, n_adds, n_dels = entry + header, sep = _make_header(fname, n_adds, n_dels) + styled.append(header) + styled.append(sep) + continue + + if line.startswith("@@"): + flush_runs() + m = re.search(r"@@ -(\d+),?\d* \+(\d+),?\d* @@", line) + if m: + ln_old, ln_new = int(m.group(1)), int(m.group(2)) + styled.append(Text(line, style=Style(color="cyan", bold=True))) + continue + + if line.startswith("-"): + if add_run: + # -→+→- transition: flush current run and start fresh + flush_runs() + del_run.append((ln_old, line[1:])) + ln_old += 1 + continue + + if line.startswith("+"): + add_run.append((ln_new, line[1:])) + ln_new += 1 + continue + + # Context line — show new-file line number (matches GitHub/delta convention + # and avoids duplicate numbers when old/new offsets diverge) + flush_runs() + content = line[1:] if line.startswith(" ") else line + styled.append(Text.assemble( + Text(f"{ln_new:>4} ", style="dim"), + Text(" ", style="dim"), + Text(content, style="dim"), + )) + ln_old += 1 + ln_new += 1 + + flush_runs() # end of input + styled.append(Text("")) # trailing blank line + return Group(*styled) + + +# --------------------------------------------------------------------------- +# Public: output noise cleaning +# --------------------------------------------------------------------------- + +_NOISE_SUBSTRINGS = frozenset({ + "/venv/lib/python", "/site-packages/", "langsmith/", "langchain/", + "__pycache__", "venv/lib/", "site-packages", + "Traceback (most recent call last)", ' File "/', +}) + + +def clean_command_output(content: str) -> str: + """Strip venv paths, stacktrace boilerplate, and excessive blank lines. + + Useful for cleaning up ``terminal`` tool results before display. + """ + out: list[str] = [] + for line in content.split("\n"): + line = line.strip() + if not line: + continue + if any(s in line for s in _NOISE_SUBSTRINGS): + continue + if len(line) > 80 and line.count("/") > 5: + continue + if line.startswith("from ") and "import" in line and len(line) > 60: + continue + line = re.sub(r"\\n\./([^/]+/)*", "", line) + line = re.sub(r"\\n/[^/]+/[^/]+/([^/]+)", r" \1", line) + line = line.replace("\\n", "\n").replace("\n\n\n", "\n\n") + if line and len(line) > 3: + out.append(line) + + result = "\n".join(out) + return re.sub(r"\n\s*\n\s*\n", "\n\n", result).strip() + + +# --------------------------------------------------------------------------- +# Module-level convenience singletons +# --------------------------------------------------------------------------- + +lang_detector = LanguageDetector() +syntax_highlighter = SyntaxHighlighter() +diff_renderer = DiffRenderer() diff --git a/tests/test_display.py b/tests/test_display.py index 5127a930ba115..7976d86a1b16a 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -111,10 +111,13 @@ def test_render_inline_unified_diff_colors_added_and_removed_lines(self): " context\n" ) - assert "a/cli.py" in rendered[0] - assert "b/cli.py" in rendered[0] - assert any("old line" in line for line in rendered) - assert any("new line" in line for line in rendered) + import re as _re + _strip = lambda s: _re.sub(r"\x1b\[[0-9;]*m", "", s) + stripped = [_strip(l) for l in rendered] + # header shows basename + change summary, not a/x → b/x + assert "cli.py" in stripped[0] + assert any("old line" in l for l in stripped) + assert any("new line" in l for l in stripped) assert any("48;2;" in line for line in rendered) def test_extract_edit_diff_ignores_non_edit_tools(self): @@ -153,7 +156,8 @@ def test_render_edit_diff_with_delta_invokes_printer(self): assert rendered is True assert printer.call_count >= 2 calls = [call.args[0] for call in printer.call_args_list] - assert any("a/x" in line and "b/x" in line for line in calls) + # header shows basename + change summary + assert any("x" in line for line in calls) assert any("old" in line for line in calls) assert any("new" in line for line in calls) @@ -195,8 +199,9 @@ def test_summarize_rendered_diff_sections_limits_file_count(self): rendered = _summarize_rendered_diff_sections(diff, max_files=3, max_lines=50) - assert any("a/file0.py" in line for line in rendered) - assert any("a/file1.py" in line for line in rendered) - assert any("a/file2.py" in line for line in rendered) - assert not any("a/file7.py" in line for line in rendered) + # header shows basename only + assert any("file0.py" in line for line in rendered) + assert any("file1.py" in line for line in rendered) + assert any("file2.py" in line for line in rendered) + assert not any("file7.py" in line for line in rendered) assert "additional file" in rendered[-1] diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py new file mode 100644 index 0000000000000..798153d466ecb --- /dev/null +++ b/tests/test_rich_output.py @@ -0,0 +1,413 @@ +"""Tests for agent/rich_output.py — syntax highlighting, diff rendering, code block detection.""" + +import pytest + +from agent.rich_output import ( + DiffRenderer, + FilePathFormatter, + LanguageDetector, + SyntaxHighlighter, + _intra_diff, + _parse_diff_filename, +) + + +# --------------------------------------------------------------------------- +# Shared test helpers +# --------------------------------------------------------------------------- + +def _seg_color(seg) -> str: + """Return the colour name of a Text segment as a plain string.""" + return seg.style.color.name # rich.color.Color.name is the canonical name string + + +def _renderables(diff: str) -> list: + """Return the list of Text children from DiffRenderer._style(). + + Uses ``group.renderables`` which is an internal Rich attribute. If a + future Rich upgrade removes it, switch to rendering the Group to a Console + buffer and inspecting the output instead. + """ + return list(DiffRenderer()._style(diff.splitlines()).renderables) + + +# --------------------------------------------------------------------------- +# LanguageDetector +# --------------------------------------------------------------------------- + +class TestLanguageDetector: + def setup_method(self): + self.det = LanguageDetector() + + def test_detect_python_from_extension(self): + assert self.det.detect_from_filename("foo.py") == "python" + + def test_detect_typescript_from_extension(self): + assert self.det.detect_from_filename("app.ts") == "typescript" + + def test_detect_unknown_extension_returns_none(self): + assert self.det.detect_from_filename("file.xyz") is None + + def test_detect_dockerfile(self): + assert self.det.detect_from_filename("Dockerfile") == "dockerfile" + + def test_detect_makefile(self): + assert self.det.detect_from_filename("Makefile") == "makefile" + + def test_detect_python_from_content(self): + code = "def hello():\n return 42\n" + assert self.det.detect_from_content(code) == "python" + + def test_detect_javascript_from_content(self): + code = "const x = require('fs');\nmodule.exports = x;\n" + assert self.det.detect_from_content(code) == "javascript" + + def test_detect_bash_from_content(self): + code = "#!/bin/bash\nif [ -f foo ]; then echo hi; fi\n" + assert self.det.detect_from_content(code) == "bash" + + def test_detect_empty_content_returns_none(self): + assert self.det.detect_from_content("") is None + assert self.det.detect_from_content(" \n ") is None + + def test_detect_prefers_filename_over_content(self): + # .js extension should win even if content looks like Python + assert self.det.detect("def foo(): pass", filename="script.js") == "javascript" + + +# --------------------------------------------------------------------------- +# FilePathFormatter +# --------------------------------------------------------------------------- + +class TestFilePathFormatter: + def test_python_icon(self): + assert FilePathFormatter.get_file_icon("main.py") == "🐍" + + def test_rust_icon(self): + assert FilePathFormatter.get_file_icon("lib.rs") == "🦀" + + def test_unknown_extension_fallback(self): + assert FilePathFormatter.get_file_icon("weird.xyz") == "📄" + + def test_titled_includes_icon_and_path(self): + result = FilePathFormatter.titled("main.py", compact=False) + assert "🐍" in result + assert "main.py" in result + + def test_format_path_compact_returns_relative(self, tmp_path): + file_path = str(tmp_path / "sub" / "foo.py") + result = FilePathFormatter.format_path(file_path, compact=True, cwd=str(tmp_path)) + assert result == "sub/foo.py" + + def test_format_path_verbose_returns_full(self, tmp_path): + file_path = str(tmp_path / "foo.py") + result = FilePathFormatter.format_path(file_path, compact=False) + assert result == file_path + + +# --------------------------------------------------------------------------- +# SyntaxHighlighter +# --------------------------------------------------------------------------- + +class TestSyntaxHighlighter: + def setup_method(self): + self.hl = SyntaxHighlighter() + + def test_to_ansi_returns_string(self): + result = self.hl.to_ansi("x = 1", language="python") + assert isinstance(result, str) + assert "x" in result + + def test_to_ansi_contains_ansi_codes(self): + result = self.hl.to_ansi("def foo(): pass", language="python") + # Should contain at least one ANSI escape sequence + assert "\033[" in result + + def test_to_markup_returns_string(self): + result = self.hl.to_markup("x = 1", language="python") + assert isinstance(result, str) + + def test_to_ansi_empty_string(self): + result = self.hl.to_ansi("") + assert isinstance(result, str) + + def test_to_ansi_fallback_on_unknown_language(self): + # Unknown language should not crash + result = self.hl.to_ansi("some text", language="nonexistentlang123") + assert isinstance(result, str) + assert "some text" in result + + +# --------------------------------------------------------------------------- +# DiffRenderer +# --------------------------------------------------------------------------- + +class TestDiffRenderer: + def setup_method(self): + self.dr = DiffRenderer() + + def test_to_lines_returns_list(self): + diff = "--- a/foo.py\n+++ b/foo.py\n@@ -1 +1 @@\n-old\n+new\n" + lines = self.dr.to_lines(diff) + assert isinstance(lines, list) + assert len(lines) > 0 + + def test_to_lines_contains_content(self): + diff = "--- a/foo.py\n+++ b/foo.py\n@@ -1 +1 @@\n-old\n+new\n" + lines = self.dr.to_lines(diff) + combined = "\n".join(lines) + assert "old" in combined + assert "new" in combined + + def test_from_content_produces_renderable(self): + from rich.console import Group + result = self.dr.from_content("old line\n", "new line\n", file_path="test.py") + assert isinstance(result, Group) + + def test_from_unified_empty_diff(self): + # Empty diff should not crash + result = self.dr.to_lines("") + assert isinstance(result, list) + + def test_file_header_formatted(self): + diff = "--- a/src/main.py\n+++ b/src/main.py\n@@ -1 +1 @@\n-x\n+y\n" + lines = self.dr.to_lines(diff) + combined = "\n".join(lines) + assert "main.py" in combined + + def test_to_lines_does_not_crash_on_malformed_diff(self): + result = self.dr.to_lines("not a real diff at all\njust some text\n") + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# _intra_diff unit tests +# --------------------------------------------------------------------------- + +class TestIntraDiff: + def test_equal_spans_use_base_colour(self): + del_segs, add_segs = _intra_diff("abc", "abc") + for seg in del_segs + add_segs: + assert not seg.style.bold + assert _seg_color(seg) == "white" + + def test_changed_span_highlighted(self): + del_segs, add_segs = _intra_diff("foo bar", "foo baz") + # There must be at least one bright_red segment in del and bright_green in add + del_highlighted = [s for s in del_segs if _seg_color(s) == "bright_red"] + add_highlighted = [s for s in add_segs if _seg_color(s) == "bright_green"] + assert del_highlighted, "expected at least one bright_red segment in del_segs" + assert add_highlighted, "expected at least one bright_green segment in add_segs" + # All highlighted segments must be bold + assert all(s.style.bold for s in del_highlighted) + assert all(s.style.bold for s in add_highlighted) + # Equal spans must be white and not bold + del_equal = [s for s in del_segs if _seg_color(s) == "white"] + assert del_equal, "expected equal (white) segments in del_segs" + assert all(not s.style.bold for s in del_equal) + + def test_delete_opcode_no_add_seg(self): + del_segs, add_segs = _intra_diff("abcXYZ", "abc") + del_plain = "".join(s.plain for s in del_segs) + add_plain = "".join(s.plain for s in add_segs) + assert "XYZ" in del_plain + assert len(add_plain) == 3 # only "abc" + + def test_insert_opcode_no_del_seg(self): + del_segs, add_segs = _intra_diff("abc", "abcXYZ") + del_plain = "".join(s.plain for s in del_segs) + add_plain = "".join(s.plain for s in add_segs) + assert "XYZ" in add_plain + assert len(del_plain) == 3 # only "abc" + + +# --------------------------------------------------------------------------- +# _parse_diff_filename unit tests +# --------------------------------------------------------------------------- + +class TestParseDiffFilename: + def test_strips_b_prefix(self): + assert _parse_diff_filename("b/src/foo.py") == "foo.py" + + def test_strips_a_prefix(self): + assert _parse_diff_filename("a/src/foo.py") == "foo.py" + + def test_bare_path(self): + assert _parse_diff_filename("path/bar.py") == "bar.py" + + def test_devnull_falls_back_to_from(self): + assert _parse_diff_filename("/dev/null", "a/old.py") == "old.py" + + def test_devnull_no_fallback_returns_question(self): + assert _parse_diff_filename("/dev/null") == "?" + + +# --------------------------------------------------------------------------- +# DiffRenderer rendering tests +# --------------------------------------------------------------------------- + +_SIMPLE_DIFF = ( + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1,2 +1,2 @@\n" + "-foo bar\n" + "+foo baz\n" + " context\n" +) + +_LOW_RATIO_DIFF = ( + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1 +1 @@\n" + "-aaaa\n" + "+zzzz\n" +) + + +class TestDiffRendererV2: + def test_intra_diff_skipped_below_ratio(self): + import re + from io import StringIO + from rich.console import Console + buf = StringIO() + Console(file=buf, force_terminal=True, highlight=False, width=220).print( + DiffRenderer()._style(_LOW_RATIO_DIFF.splitlines()) + ) + lines = buf.getvalue().splitlines() + del_line = next(l for l in lines if "aaaa" in re.sub(r"\x1b\[[0-9;]*m", "", l)) + # bright_red bold is encoded as \x1b[1;91; — must not appear on a flat-colour line + assert "\x1b[1;91;" not in del_line + + def test_pairing_per_run_not_per_hunk(self): + # Use pairs with ratio > 0.5 so intra-diff triggers. + # "return foo_value" vs "return bar_value": share "return " + "_value" = 13 chars, + # total = 32, ratio = 26/32 ≈ 0.81. + diff = ( + "--- a/f.py\n+++ b/f.py\n" + "@@ -1,3 +1,3 @@\n" + "-return foo_value\n" + "+return bar_value\n" + " context\n" + "-return foo_result\n" + "+return bar_result\n" + ) + import re + from io import StringIO + from rich.console import Console + buf = StringIO() + Console(file=buf, force_terminal=True, highlight=False, width=220).print( + DiffRenderer()._style(diff.splitlines()) + ) + output = buf.getvalue() + # Both pairs should produce intra-highlighted changed chars + assert output.count("\x1b[1;91;") >= 2 # bright_red bold in both del lines + assert output.count("\x1b[1;92;") >= 2 # bright_green bold in both add lines + + def test_alternating_run_flush(self): + # -A +B -C +D with no context between — should pair (-A,+B) and (-C,+D) + diff = ( + "--- a/f.py\n+++ b/f.py\n" + "@@ -1,2 +1,2 @@\n" + "-alpha\n" + "+ALPHA\n" + "-beta\n" + "+BETA\n" + ) + renderables = _renderables(diff) + all_plain = " ".join(r.plain for r in renderables) + assert "alpha" in all_plain + assert "ALPHA" in all_plain + assert "beta" in all_plain + assert "BETA" in all_plain + + def test_unpaired_lines_flat_colour(self): + diff = ( + "--- a/f.py\n+++ b/f.py\n" + "@@ -1,2 +1,1 @@\n" + "-first del\n" + "-second del\n" + "+one add\n" + ) + import re + from io import StringIO + from rich.console import Console + buf = StringIO() + Console(file=buf, force_terminal=True, highlight=False, width=220).print( + DiffRenderer()._style(diff.splitlines()) + ) + output = buf.getvalue() + lines = output.splitlines() + second_del = next( + l for l in lines + if "second del" in re.sub(r"\x1b\[[0-9;]*m", "", l) + ) + assert "\x1b[91m" not in second_del # no bright_red on unpaired line + + def test_summary_header_add_only(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n+new\n" + renderables = _renderables(diff) + plain = renderables[0].plain + assert "Added" in plain + assert "removed" not in plain + + def test_summary_header_remove_only(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n-old\n" + plain = _renderables(diff)[0].plain + assert "Removed" in plain + assert "Added" not in plain + + def test_summary_header_mixed(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n-old\n+new\n" + plain = _renderables(diff)[0].plain + assert "Added" in plain + assert "removed" in plain + + def test_summary_header_plural(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +2 @@\n+line1\n+line2\n" + plain = _renderables(diff)[0].plain + assert "Added 2 lines" in plain + + def test_summary_header_singular(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n+line1\n" + plain = _renderables(diff)[0].plain + assert "Added 1 line" in plain + assert "lines" not in plain + + def test_summary_header_contains_filename(self): + diff = "--- a/src/foo.py\n+++ b/src/foo.py\n@@ -1 +1 @@\n+x\n" + plain = _renderables(diff)[0].plain + assert "foo.py" in plain + + def test_summary_header_strips_b_prefix(self): + diff = "--- a/path/bar.py\n+++ b/path/bar.py\n@@ -1 +1 @@\n+x\n" + plain = _renderables(diff)[0].plain + assert "bar.py" in plain + assert "b/bar.py" not in plain + + def test_summary_header_bare_path(self): + diff = "--- path/bar.py\n+++ path/bar.py\n@@ -1 +1 @@\n+x\n" + plain = _renderables(diff)[0].plain + assert "bar.py" in plain + + def test_summary_header_devnull_fallback(self): + diff = "--- a/old.py\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" + plain = _renderables(diff)[0].plain + assert "old.py" in plain + + def test_multi_file_diff_two_headers(self): + diff = ( + "--- a/one.py\n+++ b/one.py\n@@ -1 +1 @@\n+x\n" + "--- a/two.py\n+++ b/two.py\n@@ -1 +1 @@\n+y\n" + ) + renderables = _renderables(diff) + header_plains = [r.plain for r in renderables if "●" in r.plain] + assert len(header_plains) == 2 + assert any("one.py" in p for p in header_plains) + assert any("two.py" in p for p in header_plains) + + def test_separator_width_matches_header(self): + diff = "--- a/foo.py\n+++ b/foo.py\n@@ -1 +1 @@\n+x\n" + renderables = _renderables(diff) + header = renderables[0] + separator = renderables[1] + assert len(separator.plain) == len(header.plain) From bc45f30de3ca972d88dbe59d181cff502aeb3101 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 1 Apr 2026 23:38:53 +0200 Subject: [PATCH 02/20] fix(rich_output): Error token uses text colour not background Pygments emits Error tokens for content its markdown lexer cannot tokenize (emoji in headings, unknown syntax, etc.). Mapping Error to "bold red on red" matched the diff-deletion colour, causing spurious red backgrounds on unrelated text. Changed to "bold red" (text colour only), consistent with Generic.Error. --- agent/rich_output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index 2b2fbf436db1c..696afcc296371 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -254,7 +254,7 @@ def _ensure_styles(cls) -> None: Generic.Deleted: "red", Generic.Inserted: "green", Generic.Error: "bold red", - Error: "bold red on red", + Error: "bold red", } def format(self, tokens) -> str: From f0f7310b3966f65217b481551521f06974b62b60 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 2 Apr 2026 03:41:40 +0200 Subject: [PATCH 03/20] feat(rich_output): truncate DiffRenderer output at 80 lines Adds _DIFF_MAX_LINES = 80 and a max_lines parameter to to_lines(). Outputs beyond the cap get a dim footer matching _highlight_block's style. Passes max_lines=0 in _render_inline_unified_diff so the section-level budget in _summarize_rendered_diff_sections is unaffected. --- agent/display.py | 2 +- agent/rich_output.py | 20 ++++++++++-- tests/test_rich_output.py | 67 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/agent/display.py b/agent/display.py index 0e911b62f7112..cae091eefbc0e 100644 --- a/agent/display.py +++ b/agent/display.py @@ -429,7 +429,7 @@ def _render_inline_unified_diff(diff: str) -> list[str]: """ if _RICH_OUTPUT: try: - return _rich_diff.to_lines(diff) + return _rich_diff.to_lines(diff, max_lines=0) except Exception as exc: logger.debug("Rich diff render failed, using ANSI fallback: %s", exc) diff --git a/agent/rich_output.py b/agent/rich_output.py index 696afcc296371..38637d871a08a 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -476,6 +476,8 @@ def _intra_diff(old: str, new: str) -> tuple[list[Text], list[Text]]: # Public: diff renderer # --------------------------------------------------------------------------- +_DIFF_MAX_LINES: int = 80 # cap for DiffRenderer.to_lines; 0 = unlimited + class DiffRenderer: """Render a unified diff as Rich Text objects with line numbers. @@ -513,11 +515,17 @@ def from_unified(self, diff_text: str) -> Group: # -- ANSI lines (drop-in for _render_inline_unified_diff) ---------------- - def to_lines(self, diff_text: str, width: int = 0) -> list[str]: + def to_lines(self, diff_text: str, width: int = 0, + max_lines: int = _DIFF_MAX_LINES) -> list[str]: """Render *diff_text* and return a list of ANSI-escaped strings. Compatible with Hermes's ``print_fn`` pattern: each element maps to one ``print_fn(line)`` call. + + *max_lines* caps the output list and appends a dim footer for any + omitted lines. Pass ``max_lines=0`` to disable truncation (useful + for callers that apply their own budget, e.g. + ``_summarize_rendered_diff_sections``). """ buf = StringIO() _w = width or shutil.get_terminal_size((220, 50)).columns @@ -525,7 +533,15 @@ def to_lines(self, diff_text: str, width: int = 0) -> list[str]: self.from_unified(diff_text) ) # Drop the trailing empty line that Console adds - return buf.getvalue().rstrip("\n").splitlines() + lines = buf.getvalue().rstrip("\n").splitlines() + if max_lines and len(lines) > max_lines: + omitted = len(lines) - max_lines + footer = ( + f"\033[2m ╌╌ {omitted} more line" + f"{'s' if omitted != 1 else ''} omitted ╌╌\033[0m" + ) + return lines[:max_lines] + [footer] + return lines # -- Internal rendering -------------------------------------------------- diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 798153d466ecb..b0ebd58cd92cf 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -3,6 +3,7 @@ import pytest from agent.rich_output import ( + _DIFF_MAX_LINES, DiffRenderer, FilePathFormatter, LanguageDetector, @@ -411,3 +412,69 @@ def test_separator_width_matches_header(self): header = renderables[0] separator = renderables[1] assert len(separator.plain) == len(header.plain) + + +# --------------------------------------------------------------------------- +# DiffRenderer truncation tests +# --------------------------------------------------------------------------- + +import re as _re +_ANSI_RE = _re.compile(r"\x1b\[[0-9;]*m") + + +def _make_long_diff(n_lines: int) -> str: + """Generate a unified diff with n_lines changed lines.""" + old = "\n".join(f"line {i}" for i in range(n_lines)) + new = "\n".join(f"line {i} changed" for i in range(n_lines)) + import difflib + return "".join(difflib.unified_diff( + old.splitlines(keepends=True), + new.splitlines(keepends=True), + fromfile="a/f.py", tofile="b/f.py", + )) + + +class TestDiffRendererTruncation: + def setup_method(self): + self.dr = DiffRenderer() + + def test_short_diff_not_truncated(self): + diff = "--- a/f.py\n+++ b/f.py\n@@ -1 +1 @@\n-old\n+new\n" + lines = self.dr.to_lines(diff) + assert not any("omitted" in _ANSI_RE.sub("", l) for l in lines) + assert any("old" in _ANSI_RE.sub("", l) for l in lines) + assert any("new" in _ANSI_RE.sub("", l) for l in lines) + + def test_long_diff_truncated(self): + diff = _make_long_diff(_DIFF_MAX_LINES + 10) + lines = self.dr.to_lines(diff) + plain_last = _ANSI_RE.sub("", lines[-1]) + assert "omitted" in plain_last + assert not any(f"line {_DIFF_MAX_LINES + 1} changed" in _ANSI_RE.sub("", l) for l in lines) + + def test_footer_singular(self): + # Render a diff fully, then re-render capped at total-1 → exactly 1 omitted + diff = "--- a/f.py\n+++ b/f.py\n@@ -1,3 +1,3 @@\n-a\n-b\n-c\n+a2\n+b2\n+c2\n" + full = self.dr.to_lines(diff, max_lines=0) + lines = self.dr.to_lines(diff, max_lines=len(full) - 1) + footer = _ANSI_RE.sub("", lines[-1]) + assert "1 more line omitted" in footer + assert "lines" not in footer + + def test_footer_plural(self): + diff = _make_long_diff(_DIFF_MAX_LINES + 5) + lines = self.dr.to_lines(diff) + footer = _ANSI_RE.sub("", lines[-1]) + assert "more lines omitted" in footer + + def test_max_lines_zero_disables_cap(self): + diff = _make_long_diff(_DIFF_MAX_LINES + 20) + lines = self.dr.to_lines(diff, max_lines=0) + assert not any("omitted" in _ANSI_RE.sub("", l) for l in lines) + assert len(lines) > _DIFF_MAX_LINES + + def test_custom_max_lines_respected(self): + diff = _make_long_diff(20) + lines = self.dr.to_lines(diff, max_lines=10) + assert len(lines) == 11 # 10 content + footer + assert "omitted" in _ANSI_RE.sub("", lines[-1]) From e3539af61d99ee7f0191edb194f04637ec8ec3e0 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 2 Apr 2026 09:09:30 +0200 Subject: [PATCH 04/20] feat(rich_output): syntax highlighting in diff output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply Pygments token colours to diff line content so that keywords, strings, comments, numbers etc. are visually distinct within the red/green diff bands — matching the style shown in tools like delta. Changes: * Two new colour constants — _DIFF_BG_ADD_HL / _DIFF_BG_DEL_HL — a noticeably brighter shade of the base diff background, used to mark changed character ranges in intra-line diffs without touching the foreground (no conflict with syntax token colours). * _syntax_text(content, filename) — new helper that calls SyntaxHighlighter.to_markup(), converts to a Rich Text (foreground colours only), and strips the trailing newline Pygments always appends so line lengths stay accurate. * _flat_add / _flat_del — now accept an optional filename hint and apply syntax highlighting via _syntax_text, then overlay the base diff background with Text.stylize(). * _intra_diff — redesigned: syntax-highlight both lines first, apply the base diff background across the whole text, then apply the brighter highlight background (bold) only over changed character ranges via a second stylize() pass. Foreground colours come entirely from syntax; background colours entirely from diff state. * _style() — passes filename (explicit_filename or from_path) through to all three helpers above, and syntax-highlights context lines (dim style overlaid on syntax colours). * Tests updated to match the new interface: _intra_diff now returns ([Text], [Text]) with spans rather than lists of single-span segments; changed-region detection checks for bright bg spans instead of bright foreground colour names. --- agent/rich_output.py | 100 +++++++++++++++++++++++++------------- tests/test_rich_output.py | 70 ++++++++++++++++---------- 2 files changed, 110 insertions(+), 60 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index 38637d871a08a..f18e437cfe5ff 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -39,8 +39,10 @@ # Diff background colours — kept in sync with agent.display._ANSI_PLUS / _ANSI_MINUS # _ANSI_PLUS = "\033[38;2;255;255;255;48;2;20;90;20m" → rgb(20,90,20) # _ANSI_MINUS = "\033[38;2;255;255;255;48;2;120;20;20m" → rgb(120,20,20) -_DIFF_BG_ADD = "#145a14" # rgb(20, 90, 20) -_DIFF_BG_DEL = "#781414" # rgb(120, 20, 20) +_DIFF_BG_ADD = "#145a14" # rgb(20, 90, 20) — base addition background +_DIFF_BG_DEL = "#781414" # rgb(120, 20, 20) — base deletion background +_DIFF_BG_ADD_HL = "#289428" # rgb(40, 148, 40) — bright add bg for changed chars +_DIFF_BG_DEL_HL = "#b43030" # rgb(180, 48, 48) — bright del bg for changed chars # Minimum SequenceMatcher ratio to apply intra-line highlighting. # Below this the lines are too dissimilar and highlighting would be noise. @@ -424,52 +426,79 @@ def _pl(n: int) -> str: return header, separator -def _flat_del(ln: int, content: str) -> Text: - """Render a deletion line with flat (no intra-line) highlighting.""" +def _syntax_text(content: str, filename: Optional[str]) -> Text: + """Return a Rich ``Text`` with Pygments syntax colours (foreground only). + + ``syntax_highlighter`` is resolved at call time so this helper can be + defined before the module-level instance is created. + Falls back to plain unstyled text on any error. + + Pygments always appends a trailing newline token to its output. We strip + it when the source *content* itself did not end with ``\\n`` so that diff + line lengths remain accurate. + """ + try: + markup = syntax_highlighter.to_markup(content, filename=filename or "") + text = Text.from_markup(markup) + if text.plain.endswith("\n") and not content.endswith("\n"): + text = text[: len(text.plain) - 1] + return text + except Exception: + return Text(content) + + +def _flat_del(ln: int, content: str, filename: Optional[str] = None) -> Text: + """Render a deletion line with syntax highlighting and diff background.""" + syn = _syntax_text(content, filename) + syn.stylize(Style(bgcolor=_DIFF_BG_DEL)) return Text.assemble( Text(f"{ln:>4} ", style="dim"), Text("- ", style=Style(color="red", bold=True)), - Text(content, style=Style(bgcolor=_DIFF_BG_DEL, color="white")), + syn, ) -def _flat_add(ln: int, content: str) -> Text: - """Render an addition line with flat (no intra-line) highlighting.""" +def _flat_add(ln: int, content: str, filename: Optional[str] = None) -> Text: + """Render an addition line with syntax highlighting and diff background.""" + syn = _syntax_text(content, filename) + syn.stylize(Style(bgcolor=_DIFF_BG_ADD)) return Text.assemble( Text(f"{ln:>4} ", style="dim"), Text("+ ", style=Style(color="green", bold=True)), - Text(content, style=Style(bgcolor=_DIFF_BG_ADD, color="white")), + syn, ) -def _intra_diff(old: str, new: str) -> tuple[list[Text], list[Text]]: +def _intra_diff( + old: str, new: str, filename: Optional[str] = None +) -> tuple[list[Text], list[Text]]: """Character-level diff between two line content strings. - Returns ``(del_segments, add_segments)`` — lists of ``Text`` objects - covering the full content of each line with no gaps. Changed characters - are rendered bright-red / bright-green bold; unchanged characters use the - base diff background with white foreground. + Returns ``([del_text], [add_text])`` — single-element lists for API + compatibility with the ``Text.assemble(*segments)`` call sites. - Callers: ``Text.assemble(*del_segments)`` / ``Text.assemble(*add_segments)``. + Syntax colours are applied to the foreground; diff backgrounds are applied + as a separate layer so they never conflict with token colours: - Note: segment lists may have different total character counts when - ``delete`` or ``insert`` opcodes are present — this is correct because the - two lines have different lengths. + * Equal regions: syntax fg + dark diff background. + * Changed regions: syntax fg + **bright** diff background (bold), which + visually highlights the change without clobbering syntax colours. """ - del_segs: list[Text] = [] - add_segs: list[Text] = [] + del_text = _syntax_text(old, filename) + add_text = _syntax_text(new, filename) + + # Base diff backgrounds across the full lines. + del_text.stylize(Style(bgcolor=_DIFF_BG_DEL)) + add_text.stylize(Style(bgcolor=_DIFF_BG_ADD)) + + # Brighter background on changed character ranges (overrides base bg). for tag, i1, i2, j1, j2 in SequenceMatcher(None, old, new, autojunk=False).get_opcodes(): - if tag == "equal": - del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="white"))) - add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="white"))) - elif tag == "replace": - del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="bright_red", bold=True))) - add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="bright_green", bold=True))) - elif tag == "delete": - del_segs.append(Text(old[i1:i2], style=Style(bgcolor=_DIFF_BG_DEL, color="bright_red", bold=True))) - elif tag == "insert": - add_segs.append(Text(new[j1:j2], style=Style(bgcolor=_DIFF_BG_ADD, color="bright_green", bold=True))) - return del_segs, add_segs + if tag in ("replace", "delete"): + del_text.stylize(Style(bgcolor=_DIFF_BG_DEL_HL, bold=True), i1, i2) + if tag in ("replace", "insert"): + add_text.stylize(Style(bgcolor=_DIFF_BG_ADD_HL, bold=True), j1, j2) + + return [del_text], [add_text] # --------------------------------------------------------------------------- @@ -568,6 +597,7 @@ def flush_runs() -> None: if not del_run and not add_run: return n_pairs = min(len(del_run), len(add_run)) + fname = explicit_filename or from_path # Precompute intra-diff segments for each pair. pair_segs: list[tuple[Optional[list[Text]], Optional[list[Text]]]] = [] @@ -576,7 +606,7 @@ def flush_runs() -> None: new_content = add_run[i][1] r = SequenceMatcher(None, old_content, new_content).ratio() if r >= _INTRA_DIFF_MIN_RATIO: - d, a = _intra_diff(old_content, new_content) + d, a = _intra_diff(old_content, new_content, fname) pair_segs.append((d, a)) else: pair_segs.append((None, None)) @@ -589,7 +619,7 @@ def flush_runs() -> None: *pair_segs[i][0], )) else: - styled.append(_flat_del(ln, content)) + styled.append(_flat_del(ln, content, fname)) for i, (ln, content) in enumerate(add_run): if i < n_pairs and pair_segs[i][1] is not None: @@ -599,7 +629,7 @@ def flush_runs() -> None: *pair_segs[i][1], )) else: - styled.append(_flat_add(ln, content)) + styled.append(_flat_add(ln, content, fname)) del_run.clear() add_run.clear() @@ -645,10 +675,12 @@ def flush_runs() -> None: # and avoids duplicate numbers when old/new offsets diverge) flush_runs() content = line[1:] if line.startswith(" ") else line + syn = _syntax_text(content, explicit_filename or from_path) + syn.stylize(Style(dim=True)) styled.append(Text.assemble( Text(f"{ln_new:>4} ", style="dim"), Text(" ", style="dim"), - Text(content, style="dim"), + syn, )) ln_old += 1 ln_new += 1 diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index b0ebd58cd92cf..6cf983f577e8c 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -8,6 +8,8 @@ FilePathFormatter, LanguageDetector, SyntaxHighlighter, + _DIFF_BG_ADD_HL, + _DIFF_BG_DEL_HL, _intra_diff, _parse_diff_filename, ) @@ -186,40 +188,53 @@ def test_to_lines_does_not_crash_on_malformed_diff(self): # --------------------------------------------------------------------------- class TestIntraDiff: + # _intra_diff now returns ([del_text], [add_text]) — single-element lists + # where each element is a Rich Text with spans rather than a list of + # per-segment Text objects. Changed regions are marked with a brighter + # background (bold) instead of a bright foreground colour. + def test_equal_spans_use_base_colour(self): + # Identical lines → no changed region → no bright-highlight spans. del_segs, add_segs = _intra_diff("abc", "abc") - for seg in del_segs + add_segs: - assert not seg.style.bold - assert _seg_color(seg) == "white" + del_text, add_text = del_segs[0], add_segs[0] + assert del_text.plain == "abc" + assert add_text.plain == "abc" + # No span should carry the brighter highlight background. + del_bgs = {sp.style.bgcolor for sp in del_text._spans if sp.style.bgcolor} + add_bgs = {sp.style.bgcolor for sp in add_text._spans if sp.style.bgcolor} + from rich.color import Color + hl_del = Color.parse(_DIFF_BG_DEL_HL) + hl_add = Color.parse(_DIFF_BG_ADD_HL) + assert hl_del not in del_bgs + assert hl_add not in add_bgs def test_changed_span_highlighted(self): + # "foo bar" → "foo baz": only the last char differs. del_segs, add_segs = _intra_diff("foo bar", "foo baz") - # There must be at least one bright_red segment in del and bright_green in add - del_highlighted = [s for s in del_segs if _seg_color(s) == "bright_red"] - add_highlighted = [s for s in add_segs if _seg_color(s) == "bright_green"] - assert del_highlighted, "expected at least one bright_red segment in del_segs" - assert add_highlighted, "expected at least one bright_green segment in add_segs" - # All highlighted segments must be bold - assert all(s.style.bold for s in del_highlighted) - assert all(s.style.bold for s in add_highlighted) - # Equal spans must be white and not bold - del_equal = [s for s in del_segs if _seg_color(s) == "white"] - assert del_equal, "expected equal (white) segments in del_segs" - assert all(not s.style.bold for s in del_equal) + del_text, add_text = del_segs[0], add_segs[0] + from rich.color import Color + hl_del = Color.parse(_DIFF_BG_DEL_HL) + hl_add = Color.parse(_DIFF_BG_ADD_HL) + del_bgs = {sp.style.bgcolor for sp in del_text._spans if sp.style.bgcolor} + add_bgs = {sp.style.bgcolor for sp in add_text._spans if sp.style.bgcolor} + assert hl_del in del_bgs, "expected bright del bg on changed span" + assert hl_add in add_bgs, "expected bright add bg on changed span" + # Changed spans must be bold. + del_bold = any( + sp.style.bold and sp.style.bgcolor == hl_del + for sp in del_text._spans + ) + assert del_bold, "changed del span must be bold" def test_delete_opcode_no_add_seg(self): del_segs, add_segs = _intra_diff("abcXYZ", "abc") - del_plain = "".join(s.plain for s in del_segs) - add_plain = "".join(s.plain for s in add_segs) - assert "XYZ" in del_plain - assert len(add_plain) == 3 # only "abc" + assert "XYZ" in del_segs[0].plain + assert add_segs[0].plain == "abc" def test_insert_opcode_no_del_seg(self): del_segs, add_segs = _intra_diff("abc", "abcXYZ") - del_plain = "".join(s.plain for s in del_segs) - add_plain = "".join(s.plain for s in add_segs) - assert "XYZ" in add_plain - assert len(del_plain) == 3 # only "abc" + assert "XYZ" in add_segs[0].plain + assert del_segs[0].plain == "abc" # --------------------------------------------------------------------------- @@ -300,9 +315,12 @@ def test_pairing_per_run_not_per_hunk(self): DiffRenderer()._style(diff.splitlines()) ) output = buf.getvalue() - # Both pairs should produce intra-highlighted changed chars - assert output.count("\x1b[1;91;") >= 2 # bright_red bold in both del lines - assert output.count("\x1b[1;92;") >= 2 # bright_green bold in both add lines + # Both pairs should produce intra-highlighted changed chars. + # Changed regions now use a brighter background (bold) rather than a + # bright foreground colour, so check for the bright-del-bg ANSI code + # (_DIFF_BG_DEL_HL = "#b43030" → rgb(180,48,48)). + assert output.count("48;2;180;48;48") >= 2, "expected bright del bg in both del lines" + assert output.count("48;2;40;148;40") >= 2, "expected bright add bg in both add lines" def test_alternating_run_flush(self): # -A +B -C +D with no context between — should pair (-A,+B) and (-C,+D) From 74eb7ef69bab64a57644c30d34009cd8eb6ca023 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 2 Apr 2026 10:04:02 +0200 Subject: [PATCH 05/20] fix(rich_output): extend diff background to line numbers and sigils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line numbers and the -/+ prefix had no background set, so they rendered on the terminal default (black) while the content immediately to their right had the dark-red/dark-green diff background — creating a jarring visual break. Extend bgcolor to the line-number and sigil Text objects so the entire deleted/added row is uniformly on the diff background, matching the visual style of delta, GitHub, and VS Code's diff view. --- agent/rich_output.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index f18e437cfe5ff..748a51607cf8d 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -452,8 +452,8 @@ def _flat_del(ln: int, content: str, filename: Optional[str] = None) -> Text: syn = _syntax_text(content, filename) syn.stylize(Style(bgcolor=_DIFF_BG_DEL)) return Text.assemble( - Text(f"{ln:>4} ", style="dim"), - Text("- ", style=Style(color="red", bold=True)), + Text(f"{ln:>4} ", style=Style(dim=True, bgcolor=_DIFF_BG_DEL)), + Text("- ", style=Style(color="white", bold=True, bgcolor=_DIFF_BG_DEL)), syn, ) @@ -463,8 +463,8 @@ def _flat_add(ln: int, content: str, filename: Optional[str] = None) -> Text: syn = _syntax_text(content, filename) syn.stylize(Style(bgcolor=_DIFF_BG_ADD)) return Text.assemble( - Text(f"{ln:>4} ", style="dim"), - Text("+ ", style=Style(color="green", bold=True)), + Text(f"{ln:>4} ", style=Style(dim=True, bgcolor=_DIFF_BG_ADD)), + Text("+ ", style=Style(color="white", bold=True, bgcolor=_DIFF_BG_ADD)), syn, ) @@ -614,8 +614,8 @@ def flush_runs() -> None: for i, (ln, content) in enumerate(del_run): if i < n_pairs and pair_segs[i][0] is not None: styled.append(Text.assemble( - Text(f"{ln:>4} ", style="dim"), - Text("- ", style=Style(color="red", bold=True)), + Text(f"{ln:>4} ", style=Style(dim=True, bgcolor=_DIFF_BG_DEL)), + Text("- ", style=Style(color="white", bold=True, bgcolor=_DIFF_BG_DEL)), *pair_segs[i][0], )) else: @@ -624,8 +624,8 @@ def flush_runs() -> None: for i, (ln, content) in enumerate(add_run): if i < n_pairs and pair_segs[i][1] is not None: styled.append(Text.assemble( - Text(f"{ln:>4} ", style="dim"), - Text("+ ", style=Style(color="green", bold=True)), + Text(f"{ln:>4} ", style=Style(dim=True, bgcolor=_DIFF_BG_ADD)), + Text("+ ", style=Style(color="white", bold=True, bgcolor=_DIFF_BG_ADD)), *pair_segs[i][1], )) else: From 578310c87e5e41e8b300d25636f03ec748c2b0e0 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Sun, 5 Apr 2026 02:47:25 +0200 Subject: [PATCH 06/20] fix(rich_output): preserve path-distinct diff headers and summary counts --- agent/display.py | 2 +- agent/rich_output.py | 5 ++--- tests/test_rich_output.py | 23 ++++++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/agent/display.py b/agent/display.py index cae091eefbc0e..b2059e47e344a 100644 --- a/agent/display.py +++ b/agent/display.py @@ -532,7 +532,7 @@ def _summarize_rendered_diff_sections( rendered.extend(section_lines[:remaining_budget]) omitted_lines += len(section_lines) - remaining_budget - omitted_files += 1 + max(0, len(sections) - idx - 1) + omitted_files += max(0, len(sections) - idx - 1) for leftover in sections[idx + 1:]: omitted_lines += len(_render_inline_unified_diff(leftover)) break diff --git a/agent/rich_output.py b/agent/rich_output.py index 748a51607cf8d..9f3c1cd35fd59 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -347,7 +347,7 @@ def _lexer(self, code: str, language: Optional[str], filename: Optional[str]): # --------------------------------------------------------------------------- def _parse_diff_filename(path: str, fallback: Optional[str] = None) -> str: - """Return the basename from a unified-diff path string. + """Return a displayable path from a unified-diff path string. Strips ``b/`` / ``a/`` prefixes produced by ``git diff``. If the result is ``/dev/null`` (deleted-file diff), recurses on *fallback* (the ``---`` @@ -361,8 +361,7 @@ def _parse_diff_filename(path: str, fallback: Optional[str] = None) -> str: if fallback: return _parse_diff_filename(fallback) return "?" - name = Path(path).name - return name if name else path + return path or "?" def _count_pass( diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 6cf983f577e8c..72d98c0d5d925 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -243,13 +243,13 @@ def test_insert_opcode_no_del_seg(self): class TestParseDiffFilename: def test_strips_b_prefix(self): - assert _parse_diff_filename("b/src/foo.py") == "foo.py" + assert _parse_diff_filename("b/src/foo.py") == "src/foo.py" def test_strips_a_prefix(self): - assert _parse_diff_filename("a/src/foo.py") == "foo.py" + assert _parse_diff_filename("a/src/foo.py") == "src/foo.py" def test_bare_path(self): - assert _parse_diff_filename("path/bar.py") == "bar.py" + assert _parse_diff_filename("path/bar.py") == "path/bar.py" def test_devnull_falls_back_to_from(self): assert _parse_diff_filename("/dev/null", "a/old.py") == "old.py" @@ -395,24 +395,33 @@ def test_summary_header_singular(self): def test_summary_header_contains_filename(self): diff = "--- a/src/foo.py\n+++ b/src/foo.py\n@@ -1 +1 @@\n+x\n" plain = _renderables(diff)[0].plain - assert "foo.py" in plain + assert "src/foo.py" in plain def test_summary_header_strips_b_prefix(self): diff = "--- a/path/bar.py\n+++ b/path/bar.py\n@@ -1 +1 @@\n+x\n" plain = _renderables(diff)[0].plain - assert "bar.py" in plain - assert "b/bar.py" not in plain + assert "path/bar.py" in plain + assert "b/path/bar.py" not in plain def test_summary_header_bare_path(self): diff = "--- path/bar.py\n+++ path/bar.py\n@@ -1 +1 @@\n+x\n" plain = _renderables(diff)[0].plain - assert "bar.py" in plain + assert "path/bar.py" in plain def test_summary_header_devnull_fallback(self): diff = "--- a/old.py\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" plain = _renderables(diff)[0].plain assert "old.py" in plain + def test_summary_header_keeps_distinct_relative_paths(self): + diff = ( + "--- a/src/foo.py\n+++ b/src/foo.py\n@@ -1 +1 @@\n+x\n" + "--- a/tests/foo.py\n+++ b/tests/foo.py\n@@ -1 +1 @@\n+y\n" + ) + header_plains = [r.plain for r in _renderables(diff) if "●" in r.plain] + assert any("src/foo.py" in p for p in header_plains) + assert any("tests/foo.py" in p for p in header_plains) + def test_multi_file_diff_two_headers(self): diff = ( "--- a/one.py\n+++ b/one.py\n@@ -1 +1 @@\n+x\n" From a79001e3fa924258d504388adbe2c1a557168ec5 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Sun, 5 Apr 2026 03:21:53 +0200 Subject: [PATCH 07/20] fix: unblock read-file guards and color assertions in tests --- agent/redact.py | 22 ++++++++++++++++++++++ tests/test_display.py | 3 ++- tests/test_rich_output.py | 3 ++- tools/file_tools.py | 15 ++++----------- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/agent/redact.py b/agent/redact.py index 2906d920eaf66..25b663751f278 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -97,6 +97,19 @@ r"(? str: """Mask a token, preserving prefix for long tokens.""" @@ -119,6 +132,15 @@ def redact_sensitive_text(text: str) -> str: return text if not _REDACT_ENABLED: return text + # Fast path for large plain text blobs with no secret-like markers. + # This avoids running several regex passes across large source files or + # logs that contain no credentials at all. + if len(text) > 8192: + lower_text = text.lower() + if not any(marker in text for marker in _FAST_MARKERS_CASE_SENSITIVE) and not any( + marker in lower_text for marker in _FAST_MARKERS_LOWER + ): + return text # Known prefixes (sk-, ghp_, etc.) text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text) diff --git a/tests/test_display.py b/tests/test_display.py index 7976d86a1b16a..d6294c595b71d 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -101,7 +101,8 @@ def test_extract_edit_diff_for_patch(self): assert diff is not None assert "+++ b/x" in diff - def test_render_inline_unified_diff_colors_added_and_removed_lines(self): + def test_render_inline_unified_diff_colors_added_and_removed_lines(self, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) rendered = _render_inline_unified_diff( "--- a/cli.py\n" "+++ b/cli.py\n" diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 72d98c0d5d925..e7ed6cb4f13ef 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -294,7 +294,8 @@ def test_intra_diff_skipped_below_ratio(self): # bright_red bold is encoded as \x1b[1;91; — must not appear on a flat-colour line assert "\x1b[1;91;" not in del_line - def test_pairing_per_run_not_per_hunk(self): + def test_pairing_per_run_not_per_hunk(self, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) # Use pairs with ratio > 0.5 so intra-diff triggers. # "return foo_value" vs "return bar_value": share "return " + "_value" = 13 chars, # total = 32, ratio = 26/32 ≈ 0.81. diff --git a/tools/file_tools.py b/tools/file_tools.py index 79a111cb7961d..d53e2ddd67b5c 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -25,30 +25,23 @@ # Configurable via config.yaml: file_read_max_chars: 200000 # --------------------------------------------------------------------------- _DEFAULT_MAX_READ_CHARS = 100_000 -_max_read_chars_cached: int | None = None def _get_max_read_chars() -> int: """Return the configured max characters per file read. - Reads ``file_read_max_chars`` from config.yaml on first call, caches - the result for the lifetime of the process. Falls back to the - built-in default if the config is missing or invalid. + Reads ``file_read_max_chars`` from config.yaml. Falls back to the built-in + default if the config is missing or invalid. """ - global _max_read_chars_cached - if _max_read_chars_cached is not None: - return _max_read_chars_cached try: from hermes_cli.config import load_config cfg = load_config() val = cfg.get("file_read_max_chars") if isinstance(val, (int, float)) and val > 0: - _max_read_chars_cached = int(val) - return _max_read_chars_cached + return int(val) except Exception: pass - _max_read_chars_cached = _DEFAULT_MAX_READ_CHARS - return _max_read_chars_cached + return _DEFAULT_MAX_READ_CHARS # If the total file size exceeds this AND the caller didn't specify a narrow # range (limit <= 200), we include a hint encouraging targeted reads. From cb0aa3ba5bca1e899c3a12031ea8521c459b7646 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 00:42:26 +0200 Subject: [PATCH 08/20] feat(config): expose diff and preview line limits in config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _MAX_INLINE_DIFF_LINES (80), _MAX_INLINE_DIFF_FILES (6), and _PREVIEW_MAX_LINES (40) were hardcoded in agent/display.py with no user-facing knob. Wire them to config.yaml under display: diff_max_lines: 80 # lines shown per inline diff before "… omitted" summary diff_max_files: 6 # file sections shown per inline diff preview_max_lines: 40 # lines shown in read_file/execute_code/terminal previews Adds set_diff_limits() and set_preview_max_lines() setters in display.py; cli.py reads and applies all three at init alongside code_highlight. --- agent/display.py | 198 +++++++++++++++++++++++++++++++++++++++++++ cli.py | 10 +++ hermes_cli/config.py | 5 ++ 3 files changed, 213 insertions(+) diff --git a/agent/display.py b/agent/display.py index b2059e47e344a..fe9edbc530702 100644 --- a/agent/display.py +++ b/agent/display.py @@ -29,6 +29,23 @@ _MAX_INLINE_DIFF_FILES = 6 _MAX_INLINE_DIFF_LINES = 80 +# Set to True by the CLI when code-highlight mode is active. Consumed by +# get_cute_tool_message to suppress the inline code snippet (the highlighted +# block will show the full code immediately after). +_code_highlight_active: bool = False + + +def set_code_highlight_active(active: bool) -> None: + global _code_highlight_active + _code_highlight_active = active + + +def set_diff_limits(max_lines: int, max_files: int) -> None: + global _MAX_INLINE_DIFF_LINES, _MAX_INLINE_DIFF_FILES + _MAX_INLINE_DIFF_LINES = max_lines + _MAX_INLINE_DIFF_FILES = max_files + + # Rich-based rendering (syntax highlighting + enhanced diffs) try: from agent.rich_output import DiffRenderer as _RichDiffRenderer @@ -571,6 +588,187 @@ def render_edit_diff_with_delta( return _emit_inline_diff("\n".join(rendered_lines), print_fn) +# ========================================================================= +# execute_code / read_file / terminal syntax highlight previews +# ========================================================================= + +_PREVIEW_MAX_LINES = 40 + + +def set_preview_max_lines(n: int) -> None: + global _PREVIEW_MAX_LINES + _PREVIEW_MAX_LINES = n + + +def _emit_highlighted_lines(block: str, print_fn) -> bool: + lines = block.rstrip("\n").splitlines() + if not lines: + return False + if len(lines) > _PREVIEW_MAX_LINES: + omitted = len(lines) - _PREVIEW_MAX_LINES + lines = lines[:_PREVIEW_MAX_LINES] + [ + f"\033[2m╌╌ {omitted} more line{'s' if omitted != 1 else ''} omitted ╌╌\033[0m" + ] + for line in lines: + print_fn(line) + return True + +def _highlight_block(header: str, content: str, language: str, print_fn) -> bool: + """Print a labelled syntax-highlighted block aligned with the ┊ tool log. + + Format:: + + \033[2m ┊
\033[0m + + """ + _print = print_fn or print + _print(f"\033[2m ┊ {header}\033[0m") + if not _RICH_OUTPUT: + return _emit_highlighted_lines(content, _print) + try: + highlighted = _rich_syntax.to_ansi(content, language=language).rstrip("\n") + return _emit_highlighted_lines(highlighted, _print) + except Exception as exc: + logger.debug("highlight_block failed for %s: %s", header, exc) + return False + + +def render_execute_code_preview(code: str, print_fn=None) -> bool: + """Print *code* with Python syntax highlighting. + + The cute_msg line already labels the tool; this function prints only the + highlighted code (no header) so the output stays compact. + Returns True if anything was printed. + """ + if not code or not code.strip(): + return False + _print = print_fn or print + if not _RICH_OUTPUT: + return _emit_highlighted_lines(code, _print) + try: + highlighted = _rich_syntax.to_ansi(code, language="python").rstrip("\n") + return _emit_highlighted_lines(highlighted, _print) + except Exception as exc: + logger.debug("execute_code highlight failed: %s", exc) + return False + + +def render_read_file_preview(path: str, result_json: str, print_fn=None) -> bool: + """Print the content of a read_file result with syntax highlighting. + + Language is detected from *path*'s extension. Returns False (no output) + when the file type is unknown — we don't highlight plain text or binary. + """ + if not path or not result_json: + return False + try: + import json as _json + result = _json.loads(result_json) + content = result.get("content", "") + except Exception: + return False + if not content or not content.strip(): + return False + + from pathlib import Path as _Path + if _RICH_OUTPUT: + lang = _rich_detector.detect_from_filename(_Path(path).name) + else: + lang = None + if not lang: + return False # unknown type — skip, don't guess + + header = f"📄 {_Path(path).name}" + return _highlight_block(header, content, lang, print_fn) + + +_FILE_READ_COMMANDS = frozenset({ + "cat", "head", "tail", "less", "more", "bat", + "sed", "awk", "grep", "cut", "sort", "uniq", + "nl", "od", "xxd", "hexdump", +}) + +# Commands that *execute* a file rather than reading it — the terminal output +# will be runtime stdout, not source code. Never highlight for these. +_FILE_EXEC_COMMANDS = frozenset({ + "python", "python3", "python2", + "node", "nodejs", "deno", "bun", + "ruby", "perl", "php", "lua", + "bash", "sh", "zsh", "fish", "dash", + "Rscript", "julia", +}) + + +def _extract_file_language_from_command(command: str): + """Return (filename, language) if *command* is clearly reading a known source file. + + Only fires when the leading verb is a known file-reader (cat, head, sed …). + Commands that *execute* files (node, python, bash …) are explicitly excluded + — their stdout is runtime output, not source code. + + Parses tokens in reverse (file arg is typically last) and returns the first + token whose extension maps to a known language. Returns (None, None) if no + match — we never fall back to content-based detection for shell output. + """ + if not command: + return None, None + try: + import shlex as _shlex + tokens = _shlex.split(command) + except ValueError: + tokens = command.split() + + if not tokens: + return None, None + + # Check the leading verb (strip path prefix, e.g. /usr/bin/cat → cat) + from pathlib import Path as _Path + verb = _Path(tokens[0]).name + if verb in _FILE_EXEC_COMMANDS: + return None, None + if verb not in _FILE_READ_COMMANDS: + return None, None + + if not _RICH_OUTPUT: + return None, None + + for tok in reversed(tokens): + if tok.startswith("-"): + continue + # Only consider tokens that look like a file path (contain a dot or slash) + if "." not in _Path(tok).name: + continue + lang = _rich_detector.detect_from_filename(_Path(tok).name) + if lang: + return _Path(tok).name, lang + return None, None + + +def render_terminal_preview(command: str, result_json: str, print_fn=None) -> bool: + """Print terminal output with syntax highlighting when the command reads a source file. + + Highlighting is only applied when a known-extension filename can be extracted + from *command* (e.g. ``cat foo.py``, ``sed -n '1,50p' app.ts``). + Returns False without printing anything if the language cannot be determined. + """ + if not command or not result_json: + return False + filename, lang = _extract_file_language_from_command(command) + if not lang: + return False + try: + import json as _json + result = _json.loads(result_json) + output = result.get("output", "") + except Exception: + return False + if not output or not output.strip(): + return False + + header = f"💻 {filename}" + return _highlight_block(header, output, lang, print_fn) + + # ========================================================================= # KawaiiSpinner # ========================================================================= diff --git a/cli.py b/cli.py index 42a49440cd7d0..22dea5c91a217 100644 --- a/cli.py +++ b/cli.py @@ -1156,6 +1156,16 @@ def __init__( # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) + # Syntax-highlighted code preview for execute_code (display.code_highlight in config.yaml) + self._code_highlight_enabled = CLI_CONFIG["display"].get("code_highlight", True) + from agent.display import set_code_highlight_active, set_diff_limits, set_preview_max_lines + set_code_highlight_active(self._code_highlight_enabled) + set_diff_limits( + max_lines=CLI_CONFIG["display"].get("diff_max_lines", 80), + max_files=CLI_CONFIG["display"].get("diff_max_files", 6), + ) + set_preview_max_lines(CLI_CONFIG["display"].get("preview_max_lines", 40)) + # Streaming display state self._stream_buf = "" # Partial line buffer for line-buffered rendering self._stream_started = False # True once first delta arrives diff --git a/hermes_cli/config.py b/hermes_cli/config.py index da266eedac289..537ecf33d0e38 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -362,6 +362,11 @@ def ensure_hermes_home(): "show_reasoning": False, "streaming": False, "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) + "diff_max_lines": 80, # Max rendered lines shown per inline diff (excess → "… omitted N lines" summary) + "diff_max_files": 6, # Max file sections shown per inline diff (excess files omitted) + "preview_max_lines": 40, # Max lines shown in read_file / execute_code / terminal previews + "code_highlight": True, # Highlight source-like tool output previews and fenced code blocks + "syntax_bold": True, # Keep bold emphasis on syntax-highlighted token styles "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", "tool_progress_command": False, # Enable /verbose command in messaging gateway From 52403a84be03450e5a49995d64b60ad48643e804 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 00:53:02 +0200 Subject: [PATCH 09/20] fix(reasoning): make show_reasoning sole gate for reasoning callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in _current_reasoning_callback(): 1. show_reasoning=True + streaming=False returned None — non-streaming mode got no live reasoning callback even when user enabled it (post-turn box still worked, but no intermediate display during tool-call loops) 2. verbose=True + show_reasoning=False returned _on_reasoning — verbose mode leaked reasoning into the display regardless of the user's explicit setting Fix: show_reasoning is the sole gate. When on, pick callback by streaming mode (stream_reasoning_delta vs on_reasoning). Verbose no longer overrides. --- cli.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index 22dea5c91a217..8a1803b068272 100644 --- a/cli.py +++ b/cli.py @@ -1690,12 +1690,16 @@ def _on_thinking(self, text: str) -> None: # ── Streaming display ──────────────────────────────────────────────── 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 + """Return the active reasoning display callback for the current mode. + + show_reasoning is the sole gate — verbose mode does not override it. + When show_reasoning is on: streaming path gets live token delivery + (_stream_reasoning_delta); non-streaming path gets the batch preview + (_on_reasoning / _flush_reasoning_preview → [thinking] lines). + """ + if not self.show_reasoning: + return None + return self._stream_reasoning_delta if self.streaming_enabled else self._on_reasoning def _emit_reasoning_preview(self, reasoning_text: str) -> None: """Render a buffered reasoning preview as a single [thinking] block.""" From ea4b4778030638b8680870a2e6fbc2b3babd64ff Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 01:10:05 +0200 Subject: [PATCH 10/20] test(reasoning,display): fix stub isolation and add coverage for today's fixes - test_reasoning_command.py: install prompt_toolkit/fire stubs at module level (conditional on missing) so all 58 tests can import from cli without patching individually; fixes 38 pre-existing failures. Correct two wrong assertions in TestReasoningDisplayModeSelection that reflected old buggy behaviour (show_reasoning=True+non-streaming returned None; verbose leaked callback). Add test_show_reasoning_off_returns_none and test_verbose_without_show_reasoning_returns_none. - test_display.py: import set_diff_limits/set_preview_max_lines; add TestDisplayLimitSetters covering global mutation and truncation behaviour. --- tests/test_display.py | 289 ++++++++++++++++++++++++++++++++ tests/test_reasoning_command.py | 45 ++++- 2 files changed, 327 insertions(+), 7 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index d6294c595b71d..7e812d9e987e4 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,6 +1,7 @@ """Tests for agent/display.py — build_tool_preview() and inline diff previews.""" import os +import re import pytest from unittest.mock import MagicMock, patch @@ -11,6 +12,12 @@ _render_inline_unified_diff, _summarize_rendered_diff_sections, render_edit_diff_with_delta, + render_execute_code_preview, + render_read_file_preview, + render_terminal_preview, + set_code_highlight_active, + set_diff_limits, + set_preview_max_lines, ) @@ -206,3 +213,285 @@ def test_summarize_rendered_diff_sections_limits_file_count(self): assert any("file2.py" in line for line in rendered) assert not any("file7.py" in line for line in rendered) assert "additional file" in rendered[-1] + + +# --------------------------------------------------------------------------- +# _highlight_block +# --------------------------------------------------------------------------- + +class TestHighlightBlock: + def _collect(self, header, content, language="python"): + calls = [] + _highlight_block(header, content, language, calls.append) + return calls + + def test_header_uses_pipe_prefix(self): + calls = self._collect("📄 foo.py", "x = 1") + assert calls[0].startswith("\033[2m ┊ ") + + def test_header_contains_label(self): + calls = self._collect("📄 foo.py", "x = 1") + assert "📄 foo.py" in calls[0] + + def test_no_separator_line(self): + calls = self._collect("📄 foo.py", "x = 1\ny = 2") + stripped = [re.sub(r"\x1b\[[0-9;]*m", "", c).strip() for c in calls] + # No line should consist solely of box-drawing dashes + assert not any(c and all(ch == "─" for ch in c) for c in stripped) + + def test_returns_true_on_success(self): + assert _highlight_block("hdr", "code", "python", MagicMock()) is True + + def test_returns_false_on_empty_content_exception(self): + # Simulate to_ansi raising — should return False + with patch("agent.display._rich_syntax") as mock_hl, \ + patch("agent.display._RICH_OUTPUT", True): + mock_hl.to_ansi.side_effect = RuntimeError("boom") + result = _highlight_block("hdr", "code", "python", MagicMock()) + assert result is False + + def test_fallback_no_rich_prints_raw_code(self): + calls = [] + with patch("agent.display._RICH_OUTPUT", False): + _highlight_block("hdr", "line one\nline two", "python", calls.append) + assert any("line one" in c for c in calls) + assert any("line two" in c for c in calls) + + +# --------------------------------------------------------------------------- +# render_execute_code_preview +# --------------------------------------------------------------------------- + +class TestRenderExecuteCodePreview: + def test_returns_true_and_emits_code(self): + calls = [] + result = render_execute_code_preview("x = 42", print_fn=calls.append) + assert result is True + assert any("x" in c for c in calls) + + def test_empty_string_returns_false(self): + calls = [] + assert render_execute_code_preview("", print_fn=calls.append) is False + assert calls == [] + + def test_whitespace_only_returns_false(self): + assert render_execute_code_preview(" \n\t ", print_fn=MagicMock()) is False + + def test_no_header_emitted(self): + """Cute-msg already labels the tool — no header should appear in output.""" + calls = [] + render_execute_code_preview("x = 1", print_fn=calls.append) + assert not any("execute_code" in c for c in calls) + + def test_fallback_no_rich_prints_raw_lines(self): + calls = [] + with patch("agent.display._RICH_OUTPUT", False): + result = render_execute_code_preview("a = 1\nb = 2", print_fn=calls.append) + assert result is True + assert any("a = 1" in c for c in calls) + assert any("b = 2" in c for c in calls) + + def test_fallback_no_header_or_separator(self): + calls = [] + with patch("agent.display._RICH_OUTPUT", False): + render_execute_code_preview("a = 1", print_fn=calls.append) + stripped = [re.sub(r"\x1b\[[0-9;]*m", "", c).strip() for c in calls] + assert not any("execute_code" in c for c in stripped) + assert not any(c and all(ch == "─" for ch in c) for c in stripped) + + +# --------------------------------------------------------------------------- +# render_read_file_preview +# --------------------------------------------------------------------------- + +class TestRenderReadFilePreview: + def _result(self, content): + return json.dumps({"content": content}) + + def test_known_extension_returns_true(self): + calls = [] + result = render_read_file_preview("foo.py", self._result("x = 1"), print_fn=calls.append) + assert result is True + assert len(calls) >= 1 + + def test_header_contains_pipe_and_filename(self): + calls = [] + render_read_file_preview("app.ts", self._result("const x = 1;"), print_fn=calls.append) + assert any("┊" in c and "app.ts" in c for c in calls) + + def test_unknown_extension_returns_false(self): + calls = [] + result = render_read_file_preview("notes.log", self._result("some log"), print_fn=calls.append) + assert result is False + assert calls == [] + + def test_empty_content_returns_false(self): + assert render_read_file_preview("foo.py", self._result(""), print_fn=MagicMock()) is False + assert render_read_file_preview("foo.py", self._result(" \n "), print_fn=MagicMock()) is False + + def test_empty_path_returns_false(self): + assert render_read_file_preview("", self._result("x = 1"), print_fn=MagicMock()) is False + + def test_invalid_json_returns_false(self): + assert render_read_file_preview("foo.py", "not json", print_fn=MagicMock()) is False + + +# --------------------------------------------------------------------------- +# render_terminal_preview +# --------------------------------------------------------------------------- + +class TestRenderTerminalPreview: + def _result(self, output): + return json.dumps({"output": output}) + + def test_cat_py_returns_true_and_has_header(self): + calls = [] + result = render_terminal_preview("cat app.py", self._result("x = 1\n"), print_fn=calls.append) + assert result is True + assert any("┊" in c and "app.py" in c for c in calls) + + def test_no_extension_match_returns_false(self): + assert render_terminal_preview("ls -la", self._result("file.txt"), print_fn=MagicMock()) is False + + def test_flag_tokens_skipped_sed(self): + calls = [] + result = render_terminal_preview( + "sed -n '1,50p' app.ts", self._result("const x = 1;"), print_fn=calls.append + ) + assert result is True + assert any("app.ts" in c for c in calls) + + def test_exec_command_node_suppressed(self): + """node script.js executes the file — stdout is not source code.""" + assert render_terminal_preview( + "node script.js", self._result("output text"), print_fn=MagicMock() + ) is False + + def test_exec_command_python_suppressed(self): + assert render_terminal_preview( + "python3 analyse.py", self._result("result: 42"), print_fn=MagicMock() + ) is False + + def test_exec_command_bash_suppressed(self): + assert render_terminal_preview( + "bash run.sh", self._result("done"), print_fn=MagicMock() + ) is False + + def test_empty_output_returns_false(self): + assert render_terminal_preview("cat foo.py", self._result(""), print_fn=MagicMock()) is False + + def test_invalid_result_json_returns_false(self): + assert render_terminal_preview("cat foo.py", "not json", print_fn=MagicMock()) is False + + +# --------------------------------------------------------------------------- +# Cute-message deduplication (_code_highlight_active) +# --------------------------------------------------------------------------- + +class TestCuteMessageDedup: + def teardown_method(self): + # Always restore flag to default after each test + set_code_highlight_active(False) + + def test_no_snippet_when_active(self): + set_code_highlight_active(True) + msg = get_cute_tool_message("execute_code", {"code": "x = compute()\nreturn x"}, 1.5) + assert "x = compute()" not in msg + assert "return x" not in msg + + def test_snippet_present_when_inactive(self): + set_code_highlight_active(False) + msg = get_cute_tool_message("execute_code", {"code": "x = compute()"}, 1.5) + assert "x = compute()" in msg + + def test_duration_always_present(self): + for active in (True, False): + set_code_highlight_active(active) + msg = get_cute_tool_message("execute_code", {"code": "pass"}, 2.3) + assert "2.3s" in msg + + def test_other_tools_unaffected_by_flag(self): + set_code_highlight_active(True) + msg = get_cute_tool_message("terminal", {"command": "ls -la"}, 0.5) + assert "ls -la" in msg + + +# --------------------------------------------------------------------------- +# _result_succeeded gate (guards execute_code preview on error) +# --------------------------------------------------------------------------- + +class TestResultSucceededGate: + def test_error_status_fails(self): + assert not _result_succeeded('{"status": "error", "error": "SyntaxError"}') + + def test_ok_status_passes(self): + assert _result_succeeded('{"status": "ok", "output": "48\\n"}') + + def test_explicit_error_key_fails(self): + assert not _result_succeeded('{"error": "something went wrong"}') + + def test_success_false_fails(self): + assert not _result_succeeded('{"success": false}') + + def test_success_true_passes(self): + assert _result_succeeded('{"success": true}') + + def test_invalid_json_fails(self): + assert not _result_succeeded("not json") + + def test_none_fails(self): + assert not _result_succeeded(None) + + +# --------------------------------------------------------------------------- +# set_diff_limits / set_preview_max_lines — config-exposed limit setters +# --------------------------------------------------------------------------- + +class TestDisplayLimitSetters: + """set_diff_limits and set_preview_max_lines write module-level globals + that gate truncation in render_read_file_preview and inline diff rendering.""" + + def test_set_diff_limits_updates_globals(self): + import agent.display as _disp + original_lines = _disp._MAX_INLINE_DIFF_LINES + original_files = _disp._MAX_INLINE_DIFF_FILES + try: + set_diff_limits(max_lines=200, max_files=12) + assert _disp._MAX_INLINE_DIFF_LINES == 200 + assert _disp._MAX_INLINE_DIFF_FILES == 12 + finally: + set_diff_limits(max_lines=original_lines, max_files=original_files) + + def test_set_preview_max_lines_updates_global(self): + import agent.display as _disp + original = _disp._PREVIEW_MAX_LINES + try: + set_preview_max_lines(99) + assert _disp._PREVIEW_MAX_LINES == 99 + finally: + set_preview_max_lines(original) + + def test_set_preview_max_lines_truncates_execute_code_preview(self): + """render_execute_code_preview honours _PREVIEW_MAX_LINES after set_preview_max_lines().""" + import agent.display as _disp + original = _disp._PREVIEW_MAX_LINES + try: + set_preview_max_lines(3) + # Build code with 10 numbered lines + code = "\n".join(f"x_{i} = {i}" for i in range(1, 11)) + lines_captured = [] + render_execute_code_preview(code, print_fn=lines_captured.append) + stripped = [re.sub(r"\x1b\[[0-9;]*m", "", l) for l in lines_captured] + content_lines = [l for l in stripped if l.strip().startswith("x_")] + # At limit=3 only 3 content lines should appear before truncation + assert len(content_lines) <= 3, f"expected ≤3 content lines, got {len(content_lines)}: {content_lines}" + finally: + set_preview_max_lines(original) + + def test_summarize_diff_sections_respects_max_lines(self): + """_summarize_rendered_diff_sections truncates when max_lines is tight.""" + many_lines = "\n".join(f"-old{i}\n+new{i}" for i in range(10)) + diff = f"--- a/f.py\n+++ b/f.py\n@@ -1,10 +1,10 @@\n{many_lines}\n" + result_lines = _summarize_rendered_diff_sections(diff, max_lines=2, max_files=10) + stripped = "\n".join(re.sub(r"\x1b\[[0-9;]*m", "", l) for l in result_lines) + assert "omitted" in stripped.lower(), f"expected truncation notice, got: {stripped[:200]}" diff --git a/tests/test_reasoning_command.py b/tests/test_reasoning_command.py index 4270d630dbc9a..bc3795ce47db3 100644 --- a/tests/test_reasoning_command.py +++ b/tests/test_reasoning_command.py @@ -8,11 +8,30 @@ - PR #790 (0xbyt4): reasoning display toggle and rendering """ +import sys import unittest from types import SimpleNamespace from unittest.mock import MagicMock, patch import re +# Stub optional packages that aren't installed in the test environment. +# Applied conditionally so real installs are never shadowed. +_MISSING_STUBS = { + mod: MagicMock() + for mod in [ + "prompt_toolkit", "prompt_toolkit.history", "prompt_toolkit.styles", + "prompt_toolkit.patch_stdout", "prompt_toolkit.application", + "prompt_toolkit.layout", "prompt_toolkit.layout.processors", + "prompt_toolkit.filters", "prompt_toolkit.layout.dimension", + "prompt_toolkit.layout.menus", "prompt_toolkit.widgets", + "prompt_toolkit.key_binding", "prompt_toolkit.completion", + "prompt_toolkit.formatted_text", "prompt_toolkit.auto_suggest", + "fire", + ] + if mod not in sys.modules +} +sys.modules.update(_MISSING_STUBS) + # --------------------------------------------------------------------------- # Effort level parsing @@ -369,7 +388,6 @@ def test_reasoning_flush_threshold_tracks_terminal_width(self, _mock_term): class TestReasoningDisplayModeSelection(unittest.TestCase): def _make_cli(self, *, show_reasoning=False, streaming_enabled=False, verbose=False): from cli import HermesCLI - cli = HermesCLI.__new__(HermesCLI) cli.show_reasoning = show_reasoning cli.streaming_enabled = streaming_enabled @@ -378,24 +396,37 @@ def _make_cli(self, *, show_reasoning=False, streaming_enabled=False, verbose=Fa cli._on_reasoning = lambda text: ("preview", text) return cli - def test_show_reasoning_non_streaming_uses_final_box_only(self): + def test_show_reasoning_off_returns_none(self): + """show_reasoning=False always returns None regardless of streaming or verbose.""" + for streaming in (True, False): + for verbose in (True, False): + cli = self._make_cli(show_reasoning=False, streaming_enabled=streaming, verbose=verbose) + self.assertIsNone( + cli._current_reasoning_callback(), + f"expected None with show_reasoning=False streaming={streaming} verbose={verbose}", + ) + + def test_show_reasoning_non_streaming_returns_preview_callback(self): + """show_reasoning=True + streaming=False → _on_reasoning (batch preview), not None.""" cli = self._make_cli(show_reasoning=True, streaming_enabled=False, verbose=False) - self.assertIsNone(cli._current_reasoning_callback()) + callback = cli._current_reasoning_callback() + self.assertIsNotNone(callback) + self.assertEqual(callback("x"), ("preview", "x")) def test_show_reasoning_streaming_uses_live_reasoning_box(self): + """show_reasoning=True + streaming=True → _stream_reasoning_delta (live tokens).""" cli = self._make_cli(show_reasoning=True, streaming_enabled=True, verbose=False) callback = cli._current_reasoning_callback() self.assertIsNotNone(callback) self.assertEqual(callback("x"), ("stream", "x")) - def test_verbose_without_show_reasoning_uses_preview_callback(self): + def test_verbose_without_show_reasoning_returns_none(self): + """verbose=True must not leak reasoning; show_reasoning is the sole gate.""" cli = self._make_cli(show_reasoning=False, streaming_enabled=False, verbose=True) - callback = cli._current_reasoning_callback() - self.assertIsNotNone(callback) - self.assertEqual(callback("x"), ("preview", "x")) + self.assertIsNone(cli._current_reasoning_callback()) # --------------------------------------------------------------------------- From 7e08f16c64a3d25b9d0997bb82d75f7dc5083795 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 01:17:44 +0200 Subject: [PATCH 11/20] fix(tui): animate spinner during agent runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spinner_loop only fast-refreshed (0.1 s) during _command_running, leaving _agent_running on the 1 s idle cadence — so the status-bar prompt showed a static ⚕ and tool-progress updates in the spinner widget lagged by up to a second. Add _agent_running to the fast-refresh branch alongside _command_running, and replace the static ⚕ in _get_tui_prompt_fragments with _command_spinner_frame() so the braille dots animate while the agent is working, matching the slash-command experience. --- cli.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index 8a1803b068272..84425f98bc89c 100644 --- a/cli.py +++ b/cli.py @@ -6424,10 +6424,8 @@ def _get_tui_prompt_fragments(self): return [("class:clarify-selected", f"✎ {state_suffix}")] if self._clarify_state: return [("class:prompt-working", f"? {state_suffix}")] - if self._command_running: + if self._command_running or self._agent_running: return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")] - if self._agent_running: - return [("class:prompt-working", f"⚕ {state_suffix}")] if self._voice_mode: return [("class:voice-prompt", f"🎤 {state_suffix}")] return [("class:prompt", symbol)] @@ -7645,7 +7643,7 @@ def spinner_loop(): if not self._app: _time.sleep(0.1) continue - if self._command_running: + if self._command_running or self._agent_running: self._invalidate(min_interval=0.1) _time.sleep(0.1) else: From e4190b219c2363258d14e62c7dab0dc7cf7d35b8 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 01:23:10 +0200 Subject: [PATCH 12/20] feat(tui): configurable spinner style + terminal tab/title animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spinner style - Add _SPINNER_STYLES dict (dots, bounce, grow, arrows, star, moon, pulse, clock, none) so users can pick via display.spinner_style in config.yaml; default remains "dots" (braille). - At HermesCLI init, read spinner_style and override the module-level _COMMAND_SPINNER_FRAMES global so _command_spinner_frame() picks the right sequence for both the status-bar prompt and the tab title. Terminal tab / window title - Add display.title_spinner (default true) and display.title_base (default "Hermes") config keys. - spinner_loop emits OSC 0 (\033]0;…\007) via app.output.write_raw() at each 0.1 s tick while active, giving a live "⠋ Hermes" → "⠙ Hermes" animation in the terminal tab; resets to the bare base string on idle. Errors are silenced so terminals that don't support OSC 0 aren't affected. --- cli.py | 41 ++++++++++++++++++++++++++++++++++++++++- hermes_cli/config.py | 3 +++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 84425f98bc89c..c392b99eae9b3 100644 --- a/cli.py +++ b/cli.py @@ -65,7 +65,18 @@ ) from hermes_cli.banner import _format_context_length -_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") +_SPINNER_STYLES: dict[str, tuple[str, ...]] = { + "dots": ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"), + "bounce": ("⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"), + "grow": ("▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"), + "arrows": ("←", "↖", "↑", "↗", "→", "↘", "↓", "↙"), + "star": ("✶", "✷", "✸", "✹", "✺", "✹", "✸", "✷"), + "moon": ("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"), + "pulse": ("◜", "◠", "◝", "◞", "◡", "◟"), + "clock": ("🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"), + "none": ("",), +} +_COMMAND_SPINNER_FRAMES = _SPINNER_STYLES["dots"] # overridden at CLI init from config # Load .env from ~/.hermes/.env first, then project root as dev fallback. @@ -1156,6 +1167,15 @@ def __init__( # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) + # Spinner style — pick from _SPINNER_STYLES; falls back to "dots" for unknown keys + _spinner_key = CLI_CONFIG["display"].get("spinner_style", "dots") + global _COMMAND_SPINNER_FRAMES + _COMMAND_SPINNER_FRAMES = _SPINNER_STYLES.get(_spinner_key, _SPINNER_STYLES["dots"]) + + # Terminal tab/title — update with spinner frame while agent is active + self._title_spinner = CLI_CONFIG["display"].get("title_spinner", True) + self._title_base = CLI_CONFIG["display"].get("title_base", "Hermes") + # Syntax-highlighted code preview for execute_code (display.code_highlight in config.yaml) self._code_highlight_enabled = CLI_CONFIG["display"].get("code_highlight", True) from agent.display import set_code_highlight_active, set_diff_limits, set_preview_max_lines @@ -7635,22 +7655,41 @@ def _get_voice_status(): ) self._app = app # Store reference for clarify_callback + def _set_terminal_title(title: str) -> None: + """Emit OSC 0 to update the terminal tab/window title.""" + try: + if self._app: + self._app.output.write_raw(f"\033]0;{title}\007") + self._app.output.flush() + except Exception: + pass + def spinner_loop(): import time as _time last_idle_refresh = 0.0 + last_title: str = "" while not self._should_exit: if not self._app: _time.sleep(0.1) continue if self._command_running or self._agent_running: self._invalidate(min_interval=0.1) + if self._title_spinner: + frame = self._command_spinner_frame() + new_title = f"{frame} {self._title_base}" + if new_title != last_title: + _set_terminal_title(new_title) + last_title = new_title _time.sleep(0.1) else: now = _time.monotonic() if now - last_idle_refresh >= 1.0: last_idle_refresh = now self._invalidate(min_interval=1.0) + if self._title_spinner and last_title != self._title_base: + _set_terminal_title(self._title_base) + last_title = self._title_base _time.sleep(0.2) spinner_thread = threading.Thread(target=spinner_loop, daemon=True) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 537ecf33d0e38..6a1c76fff4aaa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -367,6 +367,9 @@ def ensure_hermes_home(): "preview_max_lines": 40, # Max lines shown in read_file / execute_code / terminal previews "code_highlight": True, # Highlight source-like tool output previews and fenced code blocks "syntax_bold": True, # Keep bold emphasis on syntax-highlighted token styles + "spinner_style": "dots", # TUI spinner style: dots, bounce, grow, arrows, star, moon, pulse, clock, none + "title_spinner": True, # Animate terminal tab/window title with spinner while agent is active + "title_base": "Hermes", # Base string shown in terminal tab/window title "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", "tool_progress_command": False, # Enable /verbose command in messaging gateway From 1b2708e3bb0d50327a84d2493d2035cedd667eb8 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 01:58:07 +0200 Subject: [PATCH 13/20] fix(display): wire missing imports and suppress code snippet when highlight active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import LanguageDetector into display.py and instantiate as _rich_detector; render_read_file_preview and _extract_file_language_from_command were calling _rich_detector.detect_from_filename but the name was never bound (SyntaxHighlighter has no detect_from_filename method). - Suppress execute_code first-line snippet in get_cute_tool_message when _code_highlight_active is True — the highlighted block renders immediately after, so printing the snippet again is redundant duplication. - Add missing json, _highlight_block, _result_succeeded, get_cute_tool_message imports to test_display.py so the test suite can actually run. --- agent/display.py | 4 ++++ tests/test_display.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/agent/display.py b/agent/display.py index fe9edbc530702..4105606ee7e8f 100644 --- a/agent/display.py +++ b/agent/display.py @@ -49,10 +49,12 @@ def set_diff_limits(max_lines: int, max_files: int) -> None: # Rich-based rendering (syntax highlighting + enhanced diffs) try: from agent.rich_output import DiffRenderer as _RichDiffRenderer + from agent.rich_output import LanguageDetector as _RichLanguageDetector from agent.rich_output import SyntaxHighlighter as _RichSyntaxHighlighter from agent.rich_output import clean_command_output _rich_diff = _RichDiffRenderer() _rich_syntax = _RichSyntaxHighlighter() + _rich_detector = _RichLanguageDetector() _RICH_OUTPUT = True except ImportError: _RICH_OUTPUT = False @@ -1188,6 +1190,8 @@ def _wrap(line: str) -> str: } return _wrap(f"┊ 🧪 rl {rl.get(tool_name, tool_name.replace('rl_', ''))} {dur}") if tool_name == "execute_code": + if _code_highlight_active: + return _wrap(f"┊ 🐍 exec {dur}") code = args.get("code", "") first_line = code.strip().split("\n")[0] if code.strip() else "" return _wrap(f"┊ 🐍 exec {_trunc(first_line, 35)} {dur}") diff --git a/tests/test_display.py b/tests/test_display.py index 7e812d9e987e4..dd35bcae9f90e 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,5 +1,6 @@ """Tests for agent/display.py — build_tool_preview() and inline diff previews.""" +import json import os import re import pytest @@ -9,8 +10,11 @@ build_tool_preview, capture_local_edit_snapshot, extract_edit_diff, + _highlight_block, _render_inline_unified_diff, + _result_succeeded, _summarize_rendered_diff_sections, + get_cute_tool_message, render_edit_diff_with_delta, render_execute_code_preview, render_read_file_preview, From 0203d1fe19049cee694be1595337cd90499c2a6c Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 8 Apr 2026 03:16:41 +0200 Subject: [PATCH 14/20] fix(tui): write OSC title via os.write to avoid prompt_toolkit render race --- cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index c392b99eae9b3..b538eda64fc62 100644 --- a/cli.py +++ b/cli.py @@ -7656,11 +7656,14 @@ def _get_voice_status(): self._app = app # Store reference for clarify_callback def _set_terminal_title(title: str) -> None: - """Emit OSC 0 to update the terminal tab/window title.""" + """Emit OSC 0 to update the terminal tab/window title. + + Written directly to fd 1 (atomic os.write) rather than through + prompt_toolkit's shared output buffer, which would race with the + main render thread and corrupt escape-sequence framing. + """ try: - if self._app: - self._app.output.write_raw(f"\033]0;{title}\007") - self._app.output.flush() + os.write(1, f"\033]0;{title}\007".encode()) except Exception: pass From 8ad2fb62ce00671b36828afd414b3fec3c1d87c2 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 1 Apr 2026 17:10:34 +0200 Subject: [PATCH 15/20] feat: syntax highlighting for tool outputs and LLM responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the SyntaxHighlighter/LanguageDetector from rich_output.py into tool result display and LLM response rendering. execute_code preview: - Highlighted Python block printed after successful execution - Gated on _result_succeeded — nothing shown after a failed run - Cute-msg drops the inline snippet when highlight is active (no duplication) read_file preview: - ┊ 📄 filename.py header + syntax-highlighted content - Language from extension only; unknown types skipped silently terminal preview: - Verb-based language detection from the command - _FILE_EXEC_COMMANDS blocklist (node, python3, bash, …) prevents runtime stdout from being mistaken for source code LLM response rendering: - format_response() highlights fenced code blocks in complete responses - StreamingCodeBlockHighlighter state machine for streaming: buffers fenced blocks, flushes highlighted on closing fence, plain text passes through immediately with response text colour preserved Plumbing: - Verbosity gate: all previews suppressed when tool_progress_mode == "off" - display.code_highlight config key + /code-highlight toggle - set_code_highlight_active() keeps display.py decoupled from CLI state - Module-level _rich_detector singleton (no per-call instantiation) Tests: 135 passing (tests/test_display.py + tests/test_rich_output.py) --- agent/rich_output.py | 100 ++++++++++++++++++++++ tests/test_rich_output.py | 172 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/agent/rich_output.py b/agent/rich_output.py index 9f3c1cd35fd59..d8fbae35311b2 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -689,6 +689,106 @@ def flush_runs() -> None: return Group(*styled) +# --------------------------------------------------------------------------- +# Public: fenced code block highlighting for LLM responses +# --------------------------------------------------------------------------- + +def format_response(text: str) -> str: + """Apply syntax highlighting to fenced code blocks in a complete response string. + + Replaces each `` ```lang\\ncode\\n``` `` block with an ANSI-highlighted + version. Blocks with no language hint use content-based detection. + Suitable for the non-streaming Rich Panel display path. + """ + _hl = SyntaxHighlighter() + _det = LanguageDetector() + + def _highlight(m: "re.Match") -> str: + lang = m.group(1).strip() or None + code = m.group(2) + if not lang: + lang = _det.detect_from_content(code) + # Preserve the fence delimiters so the Panel still looks like a code block + fence = f"```{m.group(1)}" + highlighted = _hl.to_ansi(code, language=lang).rstrip("\n") + return f"{fence}\n{highlighted}\n```" + + return re.sub(r"```(\w*)\n(.*?)```", _highlight, text, flags=re.DOTALL) + + +class StreamingCodeBlockHighlighter: + """State machine that syntax-highlights fenced code blocks during streaming. + + Feed lines one at a time with ``process_line()``. Regular lines are + returned immediately; lines inside a code block are buffered and the + entire highlighted block is returned when the closing fence arrives. + + Example usage in a line-emission loop:: + + hl = StreamingCodeBlockHighlighter() + for line in stream_lines: + out = hl.process_line(line) + if out is not None: + emit(out) + # End of stream — flush any unclosed block + tail = hl.flush() + if tail is not None: + emit(tail) + """ + + def __init__(self) -> None: + self._in_block: bool = False + self._lang: Optional[str] = None + self._buf: list[str] = [] + self._hl = SyntaxHighlighter() + self._det = LanguageDetector() + + def process_line(self, line: str) -> Optional[str]: + """Process one line. + + Returns the string to emit (may be multi-line for a highlighted block), + or ``None`` to suppress the line (still accumulating a code block). + """ + stripped = line.strip() + + if not self._in_block: + if stripped.startswith("```"): + self._in_block = True + self._lang = stripped[3:].strip() or None + self._buf = [] + return None # suppress opening fence — will re-emit with block + return line # plain text, pass through + + # Inside a code block + if stripped == "```": + # Closing fence — highlight and flush + return self._flush_block() + else: + self._buf.append(line) + return None # still accumulating + + def flush(self) -> Optional[str]: + """Flush any open (unclosed) code block at end of stream.""" + if self._in_block and self._buf: + return self._flush_block() + return None + + def reset(self) -> None: + """Reset state for a new response turn.""" + self._in_block = False + self._lang = None + self._buf = [] + + def _flush_block(self) -> str: + code = "\n".join(self._buf) + lang = self._lang or self._det.detect_from_content(code) + highlighted = self._hl.to_ansi(code, language=lang).rstrip("\n") + self._in_block = False + self._lang = None + self._buf = [] + return highlighted + + # --------------------------------------------------------------------------- # Public: output noise cleaning # --------------------------------------------------------------------------- diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index e7ed6cb4f13ef..7f721d897d8db 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -1,17 +1,21 @@ """Tests for agent/rich_output.py — syntax highlighting, diff rendering, code block detection.""" import pytest +from unittest.mock import patch from agent.rich_output import ( _DIFF_MAX_LINES, DiffRenderer, FilePathFormatter, LanguageDetector, + StreamingCodeBlockHighlighter, SyntaxHighlighter, _DIFF_BG_ADD_HL, _DIFF_BG_DEL_HL, _intra_diff, _parse_diff_filename, + clean_command_output, + format_response, ) @@ -183,6 +187,174 @@ def test_to_lines_does_not_crash_on_malformed_diff(self): assert isinstance(result, list) +# --------------------------------------------------------------------------- +# StreamingCodeBlockHighlighter +# --------------------------------------------------------------------------- + +class TestStreamingCodeBlockHighlighter: + def setup_method(self): + self.hl = StreamingCodeBlockHighlighter() + + def test_plain_lines_pass_through(self): + assert self.hl.process_line("Hello world") == "Hello world" + assert self.hl.process_line("Another line") == "Another line" + + def test_opening_fence_suppressed(self): + assert self.hl.process_line("```python") is None + + def test_code_lines_buffered(self): + self.hl.process_line("```python") + assert self.hl.process_line("x = 1") is None + assert self.hl.process_line("y = 2") is None + + def test_closing_fence_flushes_highlighted(self): + self.hl.process_line("```python") + self.hl.process_line("x = 1") + result = self.hl.process_line("```") + assert result is not None + assert "x" in result + + def test_full_code_block_sequence(self): + lines = ["Here is code:", "```python", "def foo(): pass", "```", "Done."] + outputs = [] + for line in lines: + out = self.hl.process_line(line) + if out is not None: + outputs.append(out) + tail = self.hl.flush() + if tail: + outputs.append(tail) + + combined = "\n".join(outputs) + assert "Here is code:" in combined + assert "foo" in combined + assert "Done." in combined + + def test_flush_returns_none_when_no_open_block(self): + assert self.hl.flush() is None + + def test_flush_returns_content_for_unclosed_block(self): + self.hl.process_line("```python") + self.hl.process_line("x = 1") + result = self.hl.flush() + assert result is not None + assert "x" in result + + def test_reset_clears_state(self): + self.hl.process_line("```python") + self.hl.process_line("x = 1") + self.hl.reset() + assert self.hl.flush() is None + # Should behave as fresh after reset + assert self.hl.process_line("normal line") == "normal line" + + def test_multiple_blocks_in_sequence(self): + lines = [ + "Block one:", "```python", "a = 1", "```", + "Block two:", "```javascript", "var b = 2;", "```", + ] + outputs = [self.hl.process_line(l) for l in lines] + non_none = [o for o in outputs if o is not None] + assert len(non_none) == 4 # "Block one:", highlighted, "Block two:", highlighted + + def test_no_language_hint_still_works(self): + self.hl.process_line("```") + self.hl.process_line("SELECT * FROM users;") + result = self.hl.process_line("```") + assert result is not None + assert "SELECT" in result + + def test_lang_hint_passed_to_highlighter(self): + """Opening fence ```python should call to_ansi with language='python'.""" + with patch.object(self.hl._hl, "to_ansi", return_value="highlighted") as mock_ansi: + self.hl.process_line("```python") + self.hl.process_line("x = 1") + self.hl.process_line("```") + mock_ansi.assert_called_once() + _, kwargs = mock_ansi.call_args + assert kwargs.get("language") == "python" + + def test_no_lang_hint_calls_content_detection(self): + """Opening fence with no hint should fall back to detect_from_content.""" + with patch.object(self.hl._det, "detect_from_content", return_value=None) as mock_det: + self.hl.process_line("```") + self.hl.process_line("x = 1") + self.hl.process_line("```") + mock_det.assert_called_once() + + +# --------------------------------------------------------------------------- +# format_response +# --------------------------------------------------------------------------- + +class TestFormatResponse: + def test_plain_text_unchanged(self): + text = "No code here, just text." + result = format_response(text) + assert "No code here, just text." in result + + def test_code_block_highlighted(self): + text = "Here:\n```python\ndef foo(): pass\n```\nDone." + result = format_response(text) + assert "foo" in result + assert "Here:" in result + assert "Done." in result + + def test_multiple_code_blocks(self): + text = "First:\n```python\nx = 1\n```\nSecond:\n```javascript\nvar y = 2;\n```" + result = format_response(text) + assert "x" in result + assert "y" in result + + def test_no_code_blocks_returns_original(self): + text = "Just a response with no fences." + assert format_response(text) == text + + def test_empty_string(self): + assert format_response("") == "" + + def test_code_block_without_language(self): + text = "```\nSELECT * FROM t;\n```" + result = format_response(text) + assert "SELECT" in result + + def test_no_lang_hint_calls_content_detection(self): + """Fence with no lang tag should call LanguageDetector.detect_from_content.""" + from unittest.mock import patch, MagicMock + with patch("agent.rich_output.LanguageDetector") as MockLD: + mock_instance = MagicMock() + mock_instance.detect_from_content.return_value = None + MockLD.return_value = mock_instance + format_response("```\nSELECT * FROM t;\n```") + mock_instance.detect_from_content.assert_called_once() + + +# --------------------------------------------------------------------------- +# clean_command_output +# --------------------------------------------------------------------------- + +class TestCleanCommandOutput: + def test_strips_venv_paths(self): + noisy = "/home/user/venv/lib/python3.11/site-packages/foo.py\nActual output" + result = clean_command_output(noisy) + assert "site-packages" not in result + assert "Actual output" in result + + def test_keeps_meaningful_lines(self): + output = "Build succeeded\n3 tests passed\nDone." + result = clean_command_output(output) + assert "Build succeeded" in result + assert "3 tests passed" in result + + def test_empty_string(self): + assert clean_command_output("") == "" + + def test_removes_excessive_blank_lines(self): + output = "line1\n\n\n\n\nline2" + result = clean_command_output(output) + assert result.count("\n") < 3 + + # --------------------------------------------------------------------------- # _intra_diff unit tests # --------------------------------------------------------------------------- From 97c3a9c9a9fe1dd58e2ddc9c6b00f3fa76223b39 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Wed, 1 Apr 2026 23:41:06 +0200 Subject: [PATCH 16/20] fix(rich_output): remove fence delimiters from Panel output format_response wrapped highlighted code in ``` delimiters under the theory that "the Panel still looks like a code block". In practice the ANSI-highlighted block reads cleanly without them, and keeping the fences caused raw backtick lines to appear in the rendered response. --- agent/rich_output.py | 36 ++++++++++++++++++------------ tests/test_rich_output.py | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index d8fbae35311b2..d48ed77c2b6bc 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -704,16 +704,16 @@ def format_response(text: str) -> str: _det = LanguageDetector() def _highlight(m: "re.Match") -> str: - lang = m.group(1).strip() or None - code = m.group(2) + lang = m.group(2).strip() or None + code = m.group(3) if not lang: lang = _det.detect_from_content(code) - # Preserve the fence delimiters so the Panel still looks like a code block - fence = f"```{m.group(1)}" highlighted = _hl.to_ansi(code, language=lang).rstrip("\n") - return f"{fence}\n{highlighted}\n```" + return highlighted - return re.sub(r"```(\w*)\n(.*?)```", _highlight, text, flags=re.DOTALL) + # Match fenced code blocks of any depth (3+ backticks); \1 backreference + # ensures the closing fence uses the same backtick sequence as the opener. + return re.sub(r"(`{3,})(\w*)\n(.*?)\1", _highlight, text, flags=re.DOTALL) class StreamingCodeBlockHighlighter: @@ -736,9 +736,15 @@ class StreamingCodeBlockHighlighter: emit(tail) """ + # Matches an opening fence: 3+ backticks, optional language hint (word chars) + _FENCE_OPEN_RE = re.compile(r"^(`{3,})\s*(\w*)$") + # Matches a closing fence: 3+ backticks, optional trailing whitespace only + _FENCE_CLOSE_RE = re.compile(r"^(`+)\s*$") + def __init__(self) -> None: self._in_block: bool = False self._lang: Optional[str] = None + self._fence_depth: int = 3 # backtick count of the opening fence self._buf: list[str] = [] self._hl = SyntaxHighlighter() self._det = LanguageDetector() @@ -752,20 +758,21 @@ def process_line(self, line: str) -> Optional[str]: stripped = line.strip() if not self._in_block: - if stripped.startswith("```"): + m = self._FENCE_OPEN_RE.match(stripped) + if m: self._in_block = True - self._lang = stripped[3:].strip() or None + self._fence_depth = len(m.group(1)) + self._lang = m.group(2) or None self._buf = [] return None # suppress opening fence — will re-emit with block return line # plain text, pass through - # Inside a code block - if stripped == "```": - # Closing fence — highlight and flush + # Inside a code block — closing fence: >= fence_depth backticks, nothing else + m = self._FENCE_CLOSE_RE.match(stripped) + if m and len(m.group(1)) >= self._fence_depth: return self._flush_block() - else: - self._buf.append(line) - return None # still accumulating + self._buf.append(line) + return None # still accumulating def flush(self) -> Optional[str]: """Flush any open (unclosed) code block at end of stream.""" @@ -777,6 +784,7 @@ def reset(self) -> None: """Reset state for a new response turn.""" self._in_block = False self._lang = None + self._fence_depth = 3 self._buf = [] def _flush_block(self) -> str: diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 7f721d897d8db..637750428c7e1 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -282,6 +282,31 @@ def test_no_lang_hint_calls_content_detection(self): self.hl.process_line("```") mock_det.assert_called_once() + def test_four_backtick_fence_opened_and_closed(self): + """4-backtick opening fence is handled; 4-backtick closing fence closes it.""" + assert self.hl.process_line("````python") is None + assert self.hl.process_line("x = 1") is None + result = self.hl.process_line("````") + assert result is not None + assert "x" in result + + def test_four_backtick_fence_three_backtick_close_ignored(self): + """3-backtick closing fence inside a 4-backtick block is buffered, not a close.""" + assert self.hl.process_line("````python") is None + assert self.hl.process_line("x = 1") is None + assert self.hl.process_line("```") is None # still buffering + result = self.hl.flush() + assert result is not None + assert "x" in result + + def test_prose_after_four_backtick_block_rendered(self): + """Lines after a properly-closed 4-backtick block pass through as prose.""" + self.hl.process_line("````python") + self.hl.process_line("x = 1") + self.hl.process_line("````") + out = self.hl.process_line("plain text") + assert out == "plain text" + # --------------------------------------------------------------------------- # format_response @@ -328,6 +353,27 @@ def test_no_lang_hint_calls_content_detection(self): format_response("```\nSELECT * FROM t;\n```") mock_instance.detect_from_content.assert_called_once() + def test_fence_delimiters_not_in_output(self): + """format_response must not include raw ``` in the highlighted output.""" + text = "```python\ndef foo(): pass\n```" + result = format_response(text) + import re as _re + plain = _re.sub(r"\x1b\[[0-9;]*m", "", result) + for line in plain.splitlines(): + assert not line.strip().startswith("```"), f"fence leaked: {line!r}" + + def test_four_backtick_fence_consumed(self): + """format_response handles 4-backtick fences via backreference.""" + text = "Intro.\n````python\nx = 1\n````\nDone." + result = format_response(text) + assert "Intro." in result + assert "Done." in result + assert "x" in result + import re as _re + plain = _re.sub(r"\x1b\[[0-9;]*m", "", result) + for line in plain.splitlines(): + assert not line.strip().startswith("````"), f"4-backtick fence leaked: {line!r}" + # --------------------------------------------------------------------------- # clean_command_output From 9deb3cef08c5e51bb37b178160920f8725462c06 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 2 Apr 2026 04:56:22 +0200 Subject: [PATCH 17/20] feat(rich_output): add line numbers to fenced code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _number_code_lines() prepends dim right-justified line numbers with a │ separator to every line of a highlighted code block: 1 │ import requests 2 │ import time ... Called from both format_response._highlight (batch path) and StreamingCodeBlockHighlighter._flush_block (streaming path) so line numbers appear consistently in all LLM response code blocks. --- agent/rich_output.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index d48ed77c2b6bc..56ccfcef1119c 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -693,6 +693,18 @@ def flush_runs() -> None: # Public: fenced code block highlighting for LLM responses # --------------------------------------------------------------------------- +def _number_code_lines(highlighted: str) -> str: + """Prepend dim right-justified line numbers to each line of a highlighted code block.""" + lines = highlighted.splitlines() + if not lines: + return highlighted + width = len(str(len(lines))) + out = [] + for i, line in enumerate(lines, 1): + out.append(f"\033[2m{i:>{width}} \u2502\033[0m {line}") + return "\n".join(out) + + def format_response(text: str) -> str: """Apply syntax highlighting to fenced code blocks in a complete response string. @@ -709,7 +721,7 @@ def _highlight(m: "re.Match") -> str: if not lang: lang = _det.detect_from_content(code) highlighted = _hl.to_ansi(code, language=lang).rstrip("\n") - return highlighted + return _number_code_lines(highlighted) # Match fenced code blocks of any depth (3+ backticks); \1 backreference # ensures the closing fence uses the same backtick sequence as the opener. @@ -794,7 +806,7 @@ def _flush_block(self) -> str: self._in_block = False self._lang = None self._buf = [] - return highlighted + return _number_code_lines(highlighted) # --------------------------------------------------------------------------- From ef42602986f5c8f135c9303765fa268d0cea83ca Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Thu, 2 Apr 2026 12:23:17 +0200 Subject: [PATCH 18/20] fix(rich_output): use rich.markup.escape() to fix bracket/backslash rendering bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _PygmentsToRich.format() previously escaped brackets by replacing: [ → \[ (correct: literal "[" in Rich markup) ] → \] (WRONG: Rich has no \] escape; renders as literal \]) This caused two bugs: 1. Haskell type signatures like [Integer] rendered as [Integer\] 2. A trailing \ token (e.g. Haskell lambda \a) before a close tag like [/bold yellow] formed the Rich escape \[ — consuming the closing tag and leaking the tag text into the output. Fix: use rich.markup.escape() which doubles \ → \\ and escapes [ → \[ while leaving ] alone (] requires no escaping in Rich markup). Apply to ALL tokens (styled and unstyled) and to all fallback paths. Also add _highlight_inline_code() and format_response() for inline code span styling in LLM response text. --- agent/rich_output.py | 58 ++++++++++++++++---- tests/test_rich_output.py | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index 56ccfcef1119c..f31fc0df4db50 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -31,6 +31,7 @@ from typing import Optional from rich.console import Console, Group +from rich.markup import escape as _markup_escape from rich.style import Style from rich.text import Text @@ -264,11 +265,17 @@ def format(self, tokens) -> str: parts: list[str] = [] for ttype, value in tokens: style = self._resolve(ttype) + # Use rich.markup.escape() which: + # • doubles backslashes (\ → \\) so they render literally and + # can never accidentally combine with the [ of a closing tag + # to form \[ (Rich's escape for a literal "[") + # • escapes [ → \[ so bracket text is never parsed as markup + # • leaves ] alone — ] needs no escaping in Rich markup + esc = _markup_escape(value) if style and value.strip(): - esc = value.replace("[", r"\[").replace("]", r"\]") parts.append(f"[{style}]{esc}[/{style}]") else: - parts.append(value) + parts.append(esc) return "".join(parts) def _resolve(self, ttype) -> Optional[str]: @@ -304,15 +311,13 @@ def to_markup( ) -> str: """Return a Rich markup string with syntax colours applied.""" if not _PYGMENTS: - escaped = code.replace("[", r"\[").replace("]", r"\]") - return f"[green]{escaped}[/green]" + return f"[green]{_markup_escape(code)}[/green]" try: lexer = self._lexer(code, language, filename) return self._fmt.format(list(lexer.get_tokens(code))) except Exception as exc: logger.debug("Pygments highlight failed: %s", exc) - escaped = code.replace("[", r"\[").replace("]", r"\]") - return f"[green]{escaped}[/green]" + return f"[green]{_markup_escape(code)}[/green]" # -- ANSI string (for plain print / print_fn) ---------------------------- @@ -693,6 +698,29 @@ def flush_runs() -> None: # Public: fenced code block highlighting for LLM responses # --------------------------------------------------------------------------- +# ANSI styling for inline code spans (`like this`): +# dark gray background (256-colour index 237) + bright white text. +_ANSI_INLINE_CODE_START = "\033[48;5;237m\033[97m" +_ANSI_INLINE_CODE_END = "\033[0m" + +# Single backtick span: one or more non-backtick, non-newline characters. +_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`") + + +def _highlight_inline_code(text: str) -> str: + """Apply ANSI styling to inline code spans (single backticks) in prose text. + + Preserves the backticks so the boundary is still visible; only applies + a background/foreground colour change to distinguish code from prose. + Does NOT touch triple-backtick fenced blocks — callers must ensure + this is only called on prose segments, not on code block content. + """ + return _INLINE_CODE_RE.sub( + lambda m: f"{_ANSI_INLINE_CODE_START}`{m.group(1)}`{_ANSI_INLINE_CODE_END}", + text, + ) + + def _number_code_lines(highlighted: str) -> str: """Prepend dim right-justified line numbers to each line of a highlighted code block.""" lines = highlighted.splitlines() @@ -709,13 +737,14 @@ def format_response(text: str) -> str: """Apply syntax highlighting to fenced code blocks in a complete response string. Replaces each `` ```lang\\ncode\\n``` `` block with an ANSI-highlighted - version. Blocks with no language hint use content-based detection. + version. Inline code spans (single backticks) in prose segments are also + styled. Blocks with no language hint use content-based detection. Suitable for the non-streaming Rich Panel display path. """ _hl = SyntaxHighlighter() _det = LanguageDetector() - def _highlight(m: "re.Match") -> str: + def _highlight_block(m: "re.Match") -> str: lang = m.group(2).strip() or None code = m.group(3) if not lang: @@ -725,7 +754,16 @@ def _highlight(m: "re.Match") -> str: # Match fenced code blocks of any depth (3+ backticks); \1 backreference # ensures the closing fence uses the same backtick sequence as the opener. - return re.sub(r"(`{3,})(\w*)\n(.*?)\1", _highlight, text, flags=re.DOTALL) + # Apply inline-code highlighting only to prose segments between/around blocks. + fence_re = re.compile(r"(`{3,})(\w*)\n(.*?)\1", re.DOTALL) + parts: list[str] = [] + last_end = 0 + for m in fence_re.finditer(text): + parts.append(_highlight_inline_code(text[last_end:m.start()])) + parts.append(_highlight_block(m)) + last_end = m.end() + parts.append(_highlight_inline_code(text[last_end:])) + return "".join(parts) class StreamingCodeBlockHighlighter: @@ -777,7 +815,7 @@ def process_line(self, line: str) -> Optional[str]: self._lang = m.group(2) or None self._buf = [] return None # suppress opening fence — will re-emit with block - return line # plain text, pass through + return _highlight_inline_code(line) # prose: style any inline code spans # Inside a code block — closing fence: >= fence_depth backticks, nothing else m = self._FENCE_CLOSE_RE.match(stripped) diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 637750428c7e1..1d1b6eda5f2b9 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -12,6 +12,7 @@ SyntaxHighlighter, _DIFF_BG_ADD_HL, _DIFF_BG_DEL_HL, + _highlight_inline_code, _intra_diff, _parse_diff_filename, clean_command_output, @@ -144,6 +145,41 @@ def test_to_ansi_fallback_on_unknown_language(self): assert isinstance(result, str) assert "some text" in result + def test_to_ansi_no_rogue_backslash_before_bracket(self): + # Haskell type signatures like [Integer] must render as [Integer], not + # [Integer\] — the old code escaped ] to \] which Rich renders literally. + import re + _strip = lambda s: re.sub(r"\x1b\[[0-9;]*m", "", s) + result = _strip(self.hl.to_ansi("fib :: [Integer]", language="haskell")) + assert "[Integer]" in result, f"Expected [Integer], got: {result!r}" + assert r"\]" not in result, f"Rogue backslash-bracket: {result!r}" + + def test_to_ansi_lambda_backslash_no_leaking_close_tag(self): + # Haskell lambda \ is tokenised by Pygments as Name.Function (bold + # yellow). Without backslash-doubling the \ combined with the [/bold + # yellow] close tag to form \[ (Rich's escape), leaking the tag text. + import re + _strip = lambda s: re.sub(r"\x1b\[[0-9;]*m", "", s) + result = _strip(self.hl.to_ansi(r"fact = foldl (\a b -> a * b) 1", language="haskell")) + assert "[/bold" not in result, f"Leaked close tag: {result!r}" + + def test_to_ansi_brackets_not_interpreted_as_rich_markup(self): + # Rich markup tags inside code string literals must survive as literal + # text — brackets like [bold green] and [/bold green] should appear + # verbatim in output, not vanish because Rich consumed them as tags. + import re + _strip = lambda s: re.sub(r"\x1b\[[0-9;]*m", "", s) + result = _strip(self.hl.to_ansi('printf "[bold green]hello[/bold green]\\n"', language="haskell")) + assert "hello" in result + assert "[bold green]" in result, f"Bracket text vanished: {result!r}" + assert "[/bold green]" in result, f"Bracket text vanished: {result!r}" + + def test_to_markup_brackets_escaped(self): + # to_markup output goes to Console.print() — [ must be \[-escaped so + # Rich never interprets code content as formatting markup. + result = self.hl.to_markup('x = "[bold]text[/bold]"', language="python") + assert "[bold]" not in result or r"\[bold" in result + # --------------------------------------------------------------------------- # DiffRenderer @@ -196,9 +232,18 @@ def setup_method(self): self.hl = StreamingCodeBlockHighlighter() def test_plain_lines_pass_through(self): + # Lines with no inline code are returned verbatim. assert self.hl.process_line("Hello world") == "Hello world" assert self.hl.process_line("Another line") == "Another line" + def test_plain_line_with_inline_code_styled(self): + import re + result = self.hl.process_line("Use `foo()` here.") + assert result is not None + plain = re.sub(r"\x1b\[[0-9;]*m", "", result) + assert "foo()" in plain + assert "\033[" in result + def test_opening_fence_suppressed(self): assert self.hl.process_line("```python") is None @@ -374,6 +419,71 @@ def test_four_backtick_fence_consumed(self): for line in plain.splitlines(): assert not line.strip().startswith("````"), f"4-backtick fence leaked: {line!r}" + def test_inline_code_in_prose_styled(self): + """Inline code spans in prose get ANSI styling.""" + text = "Use `foo()` to call it." + result = format_response(text) + assert "\033[" in result + import re as _re + plain = _re.sub(r"\x1b\[[0-9;]*m", "", result) + assert "foo()" in plain + + def test_inline_code_not_applied_inside_fenced_block(self): + """Backtick spans inside fenced code blocks are not double-styled.""" + text = "```python\nx = `foo`\n```" + result = format_response(text) + # The fenced block content should not contain the inline-code ANSI prefix + # (48;5;237 is the inline code background index) + assert "48;5;237" not in result + + def test_inline_code_preserved_in_plain_text(self): + """Inline code content survives styling.""" + import re as _re + text = "The `fmap` function maps over a functor." + result = format_response(text) + plain = _re.sub(r"\x1b\[[0-9;]*m", "", result) + assert "fmap" in plain + + def test_prose_without_backticks_unchanged(self): + """Plain prose with no backticks is returned verbatim.""" + text = "Just a response with no fences." + assert format_response(text) == text + + +# --------------------------------------------------------------------------- +# _highlight_inline_code unit tests +# --------------------------------------------------------------------------- + +class TestHighlightInlineCode: + def _strip(self, s: str) -> str: + import re + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + def test_single_span_styled(self): + result = _highlight_inline_code("Use `foo()` here.") + assert "\033[" in result + assert "foo()" in self._strip(result) + + def test_multiple_spans_styled(self): + result = _highlight_inline_code("`a` and `b`") + plain = self._strip(result) + assert "a" in plain and "b" in plain + assert result.count("\033[48;5;237m") == 2 + + def test_no_backticks_unchanged(self): + text = "No inline code here." + assert _highlight_inline_code(text) == text + + def test_backtick_content_preserved(self): + result = _highlight_inline_code("`m :: f (a -> Either e a)`") + assert "m :: f (a -> Either e a)" in self._strip(result) + + def test_multiline_span_not_matched(self): + """A backtick span crossing a newline must NOT be treated as inline code.""" + text = "`line one\nline two`" + result = _highlight_inline_code(text) + assert result == text # no ANSI injected across a newline + # --------------------------------------------------------------------------- # clean_command_output From b3f506eff47f7de26fe722245eadbbadf9cf1c16 Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Fri, 3 Apr 2026 22:58:59 +0200 Subject: [PATCH 19/20] fix(rich_output): resolve width=0 rendering, dead import, and _intra_diff test assertions - to_lines: width=0 silenced Rich Console entirely; resolve to terminal width via shutil.get_terminal_size when no explicit width is given - tests: remove _DIFF_BG_ADD_HL/_DIFF_BG_DEL_HL imports removed in skin rewrite; update _intra_diff delete/insert tests to check across the full segment list rather than assuming a single-element list --- agent/rich_output.py | 5 +++-- tests/test_rich_output.py | 10 ++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/agent/rich_output.py b/agent/rich_output.py index f31fc0df4db50..0c2a703a4fd4e 100644 --- a/agent/rich_output.py +++ b/agent/rich_output.py @@ -560,9 +560,10 @@ def to_lines(self, diff_text: str, width: int = 0, for callers that apply their own budget, e.g. ``_summarize_rendered_diff_sections``). """ + import shutil + render_width = width or shutil.get_terminal_size((220, 24)).columns buf = StringIO() - _w = width or shutil.get_terminal_size((220, 50)).columns - Console(file=buf, highlight=False, force_terminal=True, width=_w).print( + Console(file=buf, highlight=False, force_terminal=True, width=render_width).print( self.from_unified(diff_text) ) # Drop the trailing empty line that Console adds diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 1d1b6eda5f2b9..325a7410b477a 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -10,8 +10,6 @@ LanguageDetector, StreamingCodeBlockHighlighter, SyntaxHighlighter, - _DIFF_BG_ADD_HL, - _DIFF_BG_DEL_HL, _highlight_inline_code, _intra_diff, _parse_diff_filename, @@ -556,13 +554,13 @@ def test_changed_span_highlighted(self): def test_delete_opcode_no_add_seg(self): del_segs, add_segs = _intra_diff("abcXYZ", "abc") - assert "XYZ" in del_segs[0].plain - assert add_segs[0].plain == "abc" + assert any("XYZ" in s.plain for s in del_segs) + assert any("abc" in s.plain for s in add_segs) def test_insert_opcode_no_del_seg(self): del_segs, add_segs = _intra_diff("abc", "abcXYZ") - assert "XYZ" in add_segs[0].plain - assert del_segs[0].plain == "abc" + assert any("XYZ" in s.plain for s in add_segs) + assert any("abc" in s.plain for s in del_segs) # --------------------------------------------------------------------------- From 4bdbac2810773e22a0f53dfab3ea08e54a672b5c Mon Sep 17 00:00:00 2001 From: KUSH42 Date: Sun, 5 Apr 2026 04:42:08 +0200 Subject: [PATCH 20/20] test(rich_output): align PR2 diff expectations with renderer output --- tests/test_display.py | 2 +- tests/test_rich_output.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index dd35bcae9f90e..a1182a408944f 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -130,7 +130,7 @@ def test_render_inline_unified_diff_colors_added_and_removed_lines(self, monkeyp assert "cli.py" in stripped[0] assert any("old line" in l for l in stripped) assert any("new line" in l for l in stripped) - assert any("48;2;" in line for line in rendered) + assert any("\x1b[" in line for line in rendered) def test_extract_edit_diff_ignores_non_edit_tools(self): assert extract_edit_diff("web_search", '{"diff": "--- a\\n+++ b\\n"}') is None diff --git a/tests/test_rich_output.py b/tests/test_rich_output.py index 325a7410b477a..e96a6818ddb11 100644 --- a/tests/test_rich_output.py +++ b/tests/test_rich_output.py @@ -4,6 +4,8 @@ from unittest.mock import patch from agent.rich_output import ( + _DIFF_BG_ADD_HL, + _DIFF_BG_DEL_HL, _DIFF_MAX_LINES, DiffRenderer, FilePathFormatter, @@ -642,12 +644,15 @@ def test_pairing_per_run_not_per_hunk(self, monkeypatch): DiffRenderer()._style(diff.splitlines()) ) output = buf.getvalue() - # Both pairs should produce intra-highlighted changed chars. - # Changed regions now use a brighter background (bold) rather than a - # bright foreground colour, so check for the bright-del-bg ANSI code - # (_DIFF_BG_DEL_HL = "#b43030" → rgb(180,48,48)). - assert output.count("48;2;180;48;48") >= 2, "expected bright del bg in both del lines" - assert output.count("48;2;40;148;40") >= 2, "expected bright add bg in both add lines" + # PR2 highlights paired changes via bold token styling in the rendered + # ANSI output; later branches add stronger background treatment. + plain = re.sub(r"\x1b\[[0-9;]*m", "", output) + assert "return foo_value" in plain + assert "return bar_value" in plain + assert "return foo_result" in plain + assert "return bar_result" in plain + assert output.count("\x1b[1mfoo\x1b[0m") >= 2 + assert output.count("\x1b[1mbar\x1b[0m") >= 2 def test_alternating_run_flush(self): # -A +B -C +D with no context between — should pair (-A,+B) and (-C,+D)