Newer
Older
navi-1 / clients / terminal / tui / renderers / message.py
"""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))