From 177e6eb3da2adb05e0e0b54d6192be36d8e1cdff Mon Sep 17 00:00:00 2001 From: Lumen Radley <261797239+lumenradley@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:28:37 +0200 Subject: [PATCH] feat(cli): strip markdown formatting from final replies --- cli.py | 49 +++++++++- hermes_cli/config.py | 1 + tests/cli/test_cli_markdown_rendering.py | 117 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/cli/test_cli_markdown_rendering.py diff --git a/cli.py b/cli.py index 15bf303d8..13ec2ad5a 100644 --- a/cli.py +++ b/cli.py @@ -1141,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText: return _RichText.from_ansi(text or "") +def _strip_markdown_syntax(text: str) -> str: + """Best-effort markdown marker removal for plain-text display.""" + import re + + plain = _rich_text_from_ansi(text or "").plain + plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE) + plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE) + # Preserve blockquotes, lists, and checkboxes because they carry structure. + plain = re.sub(r"(```+|~~~+)", "", plain) + plain = re.sub(r"`([^`]*)`", r"\1", plain) + plain = re.sub(r"!\[([^\]]*)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\[([^\]]+)\]\([^\)]*\)", r"\1", plain) + plain = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", plain) + plain = re.sub(r"___([^_]+)___", r"\1", plain) + plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain) + plain = re.sub(r"__([^_]+)__", r"\1", plain) + plain = re.sub(r"\*([^*]+)\*", r"\1", plain) + plain = re.sub(r"_([^_]+)_", r"\1", plain) + plain = re.sub(r"~~([^~]+)~~", r"\1", plain) + plain = re.sub(r"\n{3,}", "\n\n", plain) + return plain.strip("\n") + + +def _render_final_assistant_content(text: str, mode: str = "render"): + """Render final assistant content as markdown, stripped text, or raw text.""" + from rich.markdown import Markdown + + normalized_mode = str(mode or "render").strip().lower() + if normalized_mode == "strip": + return _RichText(_strip_markdown_syntax(text)) + if normalized_mode == "raw": + return _rich_text_from_ansi(text or "") + + plain = _rich_text_from_ansi(text or "").plain + return Markdown(plain) + + def _cprint(text: str): """Print ANSI-colored text through prompt_toolkit's native renderer. @@ -1718,6 +1755,11 @@ class HermesCLI: # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) + self.final_response_markdown = str( + CLI_CONFIG["display"].get("final_response_markdown", "strip") + ).strip().lower() or "strip" + if self.final_response_markdown not in {"render", "strip", "raw"}: + self.final_response_markdown = "strip" # Inline diff previews for write actions (display.inline_diffs in config.yaml) self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True) @@ -2762,6 +2804,8 @@ class HermesCLI: _tc = getattr(self, "_stream_text_ansi", "") while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) + if self.final_response_markdown == "strip": + line = _strip_markdown_syntax(line) _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") def _flush_stream(self) -> None: @@ -2779,7 +2823,8 @@ class HermesCLI: if self._stream_buf: _tc = getattr(self, "_stream_text_ansi", "") - _cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}") + line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf + _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") self._stream_buf = "" # Close the response box @@ -8367,7 +8412,7 @@ class HermesCLI: else: _chat_console = ChatConsole() _chat_console.print(Panel( - _rich_text_from_ansi(response), + _render_final_assistant_content(response, mode=self.final_response_markdown), title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d50be112f..5dc32d008 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -565,6 +565,7 @@ DEFAULT_CONFIG = { "bell_on_complete": False, "show_reasoning": False, "streaming": False, + "final_response_markdown": "strip", # render | strip | raw "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", diff --git a/tests/cli/test_cli_markdown_rendering.py b/tests/cli/test_cli_markdown_rendering.py new file mode 100644 index 000000000..97ed1c751 --- /dev/null +++ b/tests/cli/test_cli_markdown_rendering.py @@ -0,0 +1,117 @@ +from io import StringIO + +from rich.console import Console +from rich.markdown import Markdown + +from cli import _render_final_assistant_content + + +def _render_to_text(renderable) -> str: + buf = StringIO() + Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable) + return buf.getvalue() + + +def test_final_assistant_content_uses_markdown_renderable(): + renderable = _render_final_assistant_content("# Title\n\n- one\n- two") + + assert isinstance(renderable, Markdown) + output = _render_to_text(renderable) + assert "Title" in output + assert "one" in output + assert "two" in output + + +def test_final_assistant_content_strips_ansi_before_markdown_rendering(): + renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m") + + output = _render_to_text(renderable) + assert "Title" in output + assert "\x1b" not in output + + +def test_final_assistant_content_can_strip_markdown_syntax(): + renderable = _render_final_assistant_content( + "***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "Bold italic" in output + assert "Strike" in output + assert "item" in output + assert "Title" in output + assert "code" in output + assert "***" not in output + assert "~~" not in output + assert "`" not in output + + +def test_strip_mode_preserves_lists(): + renderable = _render_final_assistant_content( + "**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "- Ran prettier" in output + assert "- Files changed" in output + assert "- Verified clean" in output + assert "**" not in output + + +def test_strip_mode_preserves_ordered_lists(): + renderable = _render_final_assistant_content( + "1. First item\n2. Second item\n3. Third item", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "1. First" in output + assert "2. Second" in output + assert "3. Third" in output + + +def test_strip_mode_preserves_blockquotes(): + renderable = _render_final_assistant_content( + "> This is quoted text\n> Another quoted line", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "> This is quoted" in output + assert "> Another quoted" in output + + +def test_strip_mode_preserves_checkboxes(): + renderable = _render_final_assistant_content( + "- [ ] Todo item\n- [x] Done item", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "- [ ] Todo" in output + assert "- [x] Done" in output + + +def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown(): + renderable = _render_final_assistant_content( + "| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |", + mode="strip", + ) + + output = _render_to_text(renderable) + assert "| Syntax | Example |" in output + assert "|---|---|" in output + assert "| Bold | bold |" in output + assert "| Strike | strike |" in output + assert "**" not in output + assert "~~" not in output + assert "`" not in output + + +def test_final_assistant_content_can_leave_markdown_raw(): + renderable = _render_final_assistant_content("***Bold italic***", mode="raw") + + output = _render_to_text(renderable) + assert "***Bold italic***" in output