diff --git a/clients/terminal/tui/renderers/message.py b/clients/terminal/tui/renderers/message.py index 6cfb4b2..75871b1 100644 --- a/clients/terminal/tui/renderers/message.py +++ b/clients/terminal/tui/renderers/message.py @@ -4,12 +4,14 @@ from rich.console import RenderableType from rich.box import ROUNDED +from rich.markdown import Markdown from rich.panel import Panel from rich.text import Text from clients.terminal.tui.themes import get_active_theme from .base import ContentRenderer +from .markdown_content import ThemedMarkdownRenderable, _theme_aware_code_theme def _hex_style(color) -> str: @@ -36,16 +38,27 @@ class AssistantMessageRenderer(ContentRenderer): - """Render a completed assistant response as a panel.""" + """Render an assistant response as a panel with markdown content. + + The body is parsed as markdown (headings, lists, bold/italic, inline and + fenced code blocks with theme-aware syntax highlighting) using the same + themed renderer as the planning cards. Re-rendered on every stream delta + so the formatting appears live as Navi streams. + """ def accepts(self, msg: dict) -> bool: return msg.get("type") == "assistant_message" def render(self, msg: dict) -> RenderableType: theme = get_active_theme() - text = msg.get("content", "") + text = msg.get("content", "") or "" + code_theme = _theme_aware_code_theme(theme.name) + body = ThemedMarkdownRenderable( + Markdown(text, code_theme=code_theme, inline_code_theme=code_theme), + theme.name, + ) return Panel( - Text(text, style=_hex_style(theme.text)), + body, title="Navi", title_align="left", border_style=_hex_style(theme.assistant_bubble), diff --git a/tests/clients/test_message_renderers.py b/tests/clients/test_message_renderers.py new file mode 100644 index 0000000..12ffbf2 --- /dev/null +++ b/tests/clients/test_message_renderers.py @@ -0,0 +1,67 @@ +"""Tests for user/assistant message renderers (markdown in Navi's answers).""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.message import ( + AssistantMessageRenderer, + UserMessageRenderer, +) +from clients.terminal.tui.themes import set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def test_assistant_message_renders_markdown_headings_and_code() -> None: + """Navi's answer is parsed as markdown, not shown as raw syntax.""" + set_active_theme("gnexus-dark") + renderer = AssistantMessageRenderer() + msg = { + "type": "assistant_message", + "content": "## Result\n\nThe value is `42`.\n\n```python\nx = 1\n```", + } + out = _render_text(renderer.render(msg)) + # Heading renders (not as a literal "## Result" line). + assert "Result" in out + assert "##" not in out + # Inline code content present, fenced code present. + assert "42" in out + assert "x = 1" in out + # Backticks are consumed by the markdown renderer, not echoed literally. + assert "```" not in out + + +def test_assistant_message_panel_titled_navi() -> None: + set_active_theme("gnexus-dark") + renderer = AssistantMessageRenderer() + panel = renderer.render({"type": "assistant_message", "content": "hi"}) + assert str(panel.title) == "Navi" + + +def test_assistant_message_empty_content_renders_without_error() -> None: + set_active_theme("gnexus-dark") + renderer = AssistantMessageRenderer() + out = _render_text(renderer.render({"type": "assistant_message", "content": ""})) + assert "Navi" in out + + +def test_assistant_renderer_does_not_accept_user_message() -> None: + assert not AssistantMessageRenderer().accepts({"type": "user_message", "content": "x"}) + + +def test_user_message_stays_plain_text() -> None: + """User messages are NOT markdown-rendered — syntax shows literally.""" + set_active_theme("gnexus-dark") + renderer = UserMessageRenderer() + out = _render_text( + renderer.render({"type": "user_message", "content": "## not a heading\n**bold**"}) + ) + # Raw markdown syntax is preserved for user input. + assert "## not a heading" in out + assert "**bold**" in out + assert str(renderer.render({"type": "user_message", "content": "x"}).title) == "You" \ No newline at end of file