"""Renderers for user and assistant chat messages."""
from __future__ import annotations
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:
"""Return a Rich-compatible hex style string from a Textual Color."""
return color.hex
class UserMessageRenderer(ContentRenderer):
"""Render a user message bubble.
A bubble carries its delivery state: ``queued`` when the server parked it
behind an active run, ``fail_reason`` when it was never confirmed or was
evicted from the queue. Without that the message simply looks sent, which
is exactly the failure the ack protocol exists to expose.
"""
def accepts(self, msg: dict) -> bool:
return msg.get("type") == "user_message"
def render(self, msg: dict) -> RenderableType:
theme = get_active_theme()
text = msg.get("content", "")
body = Text(text, style=_hex_style(theme.text))
title = "You"
border = _hex_style(theme.user_bubble)
reason = msg.get("fail_reason")
if reason:
note = {
"dropped": "dropped from the server's queue — it was never run",
"unconfirmed": "the server never confirmed delivery",
}.get(reason, "delivery failed")
body.append("\n\n", style=_hex_style(theme.text))
body.append(note, style=_hex_style(theme.error))
title = "You · not delivered"
border = _hex_style(theme.error)
elif msg.get("queued"):
title = "You · queued"
return Panel(
body,
title=title,
title_align="left",
border_style=border,
box=ROUNDED,
)
class AssistantMessageRenderer(ContentRenderer):
"""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", "") 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(
body,
title="Navi",
title_align="left",
border_style=_hex_style(theme.assistant_bubble),
box=ROUNDED,
)
def render_plain(self, msg: dict) -> RenderableType:
# In reading mode return the raw markdown text rather than unwrapping the
# Panel's Markdown body: rich Markdown re-wraps fenced code blocks in
# their own Panels, so the borders would leak back into a raw-terminal
# copy. Raw markdown copies clean (fences stay as ```…``` text).
theme = get_active_theme()
return Text(msg.get("content", "") or "", style=_hex_style(theme.text))