Newer
Older
navi-1 / clients / terminal / tui / renderers / diff.py
"""Renderer for unified diff messages."""

from __future__ import annotations

import re

from rich.console import RenderableType
from rich.panel import Panel
from rich.text import Text

from clients.terminal.tui.themes import Theme, get_active_theme

from .base import ContentRenderer

# ``{marker} {num}│ {content}`` — the line-number prefix the server
# (``navi/tools/filesystem.py:_number_diff``) prepends to each diff content line.
# A fixed space follows the marker, then the right-aligned number (with its own
# leading spaces, one per digit of width) and ``│ ``. We capture the number's
# leading spaces separately so the column stays right-aligned when we render
# the number first (``{num} {marker}``) instead of the server's marker-first
# order — and so files with more than 9 lines (width > 1) still match, which the
# old single-space regex silently dropped, falling back to whole-line colour.
_DIFF_NUM_RE = re.compile(r"^( +)(\d+)│ ?(.*)$", re.DOTALL)


def _diff_marker_style(marker: str, theme: Theme) -> str:
    if marker == "+":
        return theme.success.hex
    if marker == "-":
        return theme.error.hex
    return theme.text.hex


def highlight_unified_diff(text: str, theme: Theme) -> Text:
    """Color a unified-diff string: ``+`` green, ``-`` red, ``@@`` dim, rest text.

    When a content line carries a server-prefixed line number
    (``{marker} {num}│ {content}``), the number and marker are shown in the
    marker color (green/red) and the content is rendered neutral (``theme.text``)
    — the number and marker act as a coloured anchor, the code itself reads
    plainly. The display order is ``{num} {marker}  {content}`` (number first);
    the server still emits marker-first in the model-facing text, so the agent's
    contract is unchanged. Lines without the prefix (e.g. the standalone
    ``diff`` event, or older callers) are colored whole, as before.

    Shared by the standalone ``diff`` event renderer and the ``filesystem``
    tool-call renderer (``edit_lines`` / ``smart_edit`` / ``diff`` actions embed
    a unified diff as plain text in ``ToolResult.output``).
    """
    out = Text()
    for idx, line in enumerate(text.splitlines()):
        if idx:
            out.append("\n")
        if line.startswith("@@"):
            out.append(line, style=theme.text_dim.hex)
            continue
        if line.startswith("+++") or line.startswith("---"):
            out.append(line, style=theme.text.hex)
            continue
        marker = line[0] if line else ""
        rest = line[1:]
        m = _DIFF_NUM_RE.match(rest)
        if m:
            # {leading}{num} keeps the server's right-alignment (leading spaces
            # come from {num:>width}); we then show the marker and two spaces
            # before the content. Number+marker in the marker colour, content
            # neutral.
            out.append(
                f"{m.group(1)}{m.group(2)} {marker}  ",
                style=_diff_marker_style(marker, theme),
            )
            out.append(m.group(3), style=theme.text.hex)
        else:
            out.append(line, style=_diff_marker_style(marker, theme))
    return out


class DiffRenderer(ContentRenderer):
    """Render a unified diff with added/removed line highlighting."""

    def accepts(self, msg: dict) -> bool:
        return msg.get("type") == "diff"

    def render(self, msg: dict) -> RenderableType:
        theme = get_active_theme()
        content = msg.get("content", "")
        old_label = msg.get("old_label", "---")
        new_label = msg.get("new_label", "+++")

        highlighted = highlight_unified_diff(content, theme)

        return Panel(
            highlighted,
            title=f"diff: {old_label} → {new_label}",
            title_align="left",
            border_style=theme.border.hex,
        )