feat(cli): strip markdown formatting from final replies
This commit is contained in:
49
cli.py
49
cli.py
@@ -1141,6 +1141,43 @@ def _rich_text_from_ansi(text: str) -> _RichText:
|
|||||||
return _RichText.from_ansi(text or "")
|
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):
|
def _cprint(text: str):
|
||||||
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
"""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)
|
# streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)
|
||||||
self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False)
|
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)
|
# Inline diff previews for write actions (display.inline_diffs in config.yaml)
|
||||||
self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True)
|
self._inline_diffs_enabled = CLI_CONFIG["display"].get("inline_diffs", True)
|
||||||
@@ -2762,6 +2804,8 @@ class HermesCLI:
|
|||||||
_tc = getattr(self, "_stream_text_ansi", "")
|
_tc = getattr(self, "_stream_text_ansi", "")
|
||||||
while "\n" in self._stream_buf:
|
while "\n" in self._stream_buf:
|
||||||
line, self._stream_buf = self._stream_buf.split("\n", 1)
|
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}")
|
_cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}")
|
||||||
|
|
||||||
def _flush_stream(self) -> None:
|
def _flush_stream(self) -> None:
|
||||||
@@ -2779,7 +2823,8 @@ class HermesCLI:
|
|||||||
|
|
||||||
if self._stream_buf:
|
if self._stream_buf:
|
||||||
_tc = getattr(self, "_stream_text_ansi", "")
|
_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 = ""
|
self._stream_buf = ""
|
||||||
|
|
||||||
# Close the response box
|
# Close the response box
|
||||||
@@ -8367,7 +8412,7 @@ class HermesCLI:
|
|||||||
else:
|
else:
|
||||||
_chat_console = ChatConsole()
|
_chat_console = ChatConsole()
|
||||||
_chat_console.print(Panel(
|
_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=f"[{_resp_color} bold]{label}[/]",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
border_style=_resp_color,
|
border_style=_resp_color,
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ DEFAULT_CONFIG = {
|
|||||||
"bell_on_complete": False,
|
"bell_on_complete": False,
|
||||||
"show_reasoning": False,
|
"show_reasoning": False,
|
||||||
"streaming": 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)
|
"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)
|
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||||
"skin": "default",
|
"skin": "default",
|
||||||
|
|||||||
117
tests/cli/test_cli_markdown_rendering.py
Normal file
117
tests/cli/test_cli_markdown_rendering.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user