diff --git a/clients/terminal/tui/renderers/artifact.py b/clients/terminal/tui/renderers/artifact.py index 0f5db13..eaaee07 100644 --- a/clients/terminal/tui/renderers/artifact.py +++ b/clients/terminal/tui/renderers/artifact.py @@ -4,18 +4,13 @@ from rich.console import RenderableType from rich.panel import Panel -from rich.syntax import Syntax from rich.text import Text from clients.terminal.tui.themes import get_active_theme from .base import ContentRenderer from .language import guess_language - - -def _theme_aware_code_theme(theme_name: str) -> str: - """Pick a Pygments code theme that matches the Navi theme brightness.""" - return "dracula" if theme_name == "gnexus-dark" else "github-light" +from .syntax import highlight_code class ArtifactRenderer(ContentRenderer): @@ -38,12 +33,10 @@ border_style=theme.border.hex, ) - code_theme = _theme_aware_code_theme(theme.name) - syntax = Syntax( + syntax = highlight_code( content, language, - theme=code_theme, - background_color=theme.surface.hex, + theme=theme, line_numbers=True, word_wrap=True, ) diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index 8ad364f..28f21e3 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -27,6 +27,8 @@ from .base import ContentRenderer from .diff import highlight_unified_diff +from .language import guess_language +from .syntax import highlight_code # Actions whose output embeds a unified diff (edit_lines / smart_edit prepend an # "Applied …" summary line; diff is the raw unified diff). @@ -148,50 +150,96 @@ if not lines: return Text(text, style=theme.text_dim.hex) - out = Text() + parts: list[RenderableType] = [] + # Header plaque: "[path | N lines | size]" (or "… lines A–B of N …"). # Highlight the path between "[" and the first " | " in accent; the # rest of the plaque is dim. header = lines[0] inner = header[1:] if header.startswith("[") else header path_part, sep, rest = inner.partition(" | ") - out.append("[" if header.startswith("[") else "", style=theme.text_dim.hex) + header_text = Text() + header_text.append("[" if header.startswith("[") else "", style=theme.text_dim.hex) if sep: - out.append(path_part, style=theme.accent.hex) - out.append(" | " + rest, style=theme.text_dim.hex) + header_text.append(path_part, style=theme.accent.hex) + header_text.append(" | " + rest, style=theme.text_dim.hex) else: - out.append(header, style=theme.text_dim.hex) + header_text.append(header, style=theme.text_dim.hex) + parts.append(header_text) # Optional "⚠ Large file …" warning line. idx = 1 if len(lines) > 1 and lines[1].startswith(_TRUNCATED_MARKER): - out.append("\n") - out.append(lines[1], style=theme.warning.hex) + parts.append(Text(lines[1], style=theme.warning.hex)) idx = 2 - # Body: file contents (no syntax highlighting — rejected by design). - # Default matches FilesystemTool._read (numbered=true) so a read the model - # issued without an explicit `numbered` arg still gets the number column - # highlighted when the tool numbered the output. The line number is the - # accent colour (an anchor for navigation); the content is neutral text — - # the same "number coloured, content neutral" standard the diff renderer - # uses. body = lines[idx:] - if body: - out.append("\n") - if args.get("numbered", True): - for j, ln in enumerate(body): - if j: - out.append("\n") - num, sep2, content = ln.partition(": ") - if sep2: - out.append(num + ": ", style=theme.accent.hex) - out.append(content, style=theme.text.hex) - else: - out.append(ln, style=theme.text.hex) + if not body: + return Group(*parts) + + # Syntax-highlight the file contents with the shared code scheme + # (``Theme.code_theme``), so filesystem ``read`` output matches the + # highlighting used by code artifacts and markdown fenced blocks. The + # language is guessed from the read ``path``; unknown extensions fall + # back to ``"text"`` (plain, unstyled — same as no highlighting). + path = args.get("path") + language = guess_language(path) if path else "text" + + if args.get("numbered", True): + # The server numbers output as ``{num:>width}: {line}``. Strip that + # prefix to recover the raw file content and let Syntax render its + # own line-number column, so multi-line constructs (triple-quoted + # strings, block comments) highlight correctly across line + # boundaries instead of per-line. + raw_lines, start_line = self._strip_number_prefix(body) + if raw_lines is None: + # Unexpected format (no ``num: `` prefix) — render plain. + parts.append(Text("")) + parts.append(Text("\n".join(body), style=theme.text.hex)) + return Group(*parts) + syntax = highlight_code( + "\n".join(raw_lines), + language, + theme=theme, + line_numbers=True, + start_line=start_line, + ) + else: + syntax = highlight_code( + "\n".join(body), + language, + theme=theme, + line_numbers=False, + ) + parts.append(Text("")) + parts.append(syntax) + return Group(*parts) + + @staticmethod + def _strip_number_prefix(body: list[str]) -> tuple[list[str] | None, int]: + """Recover raw content lines + the first line number from a numbered body. + + The server (``filesystem._number_lines``) emits ``{num:>width}: {line}``. + Returns ``(raw_lines, start_line)``. If the first body line does not + match the ``num: `` prefix, returns ``(None, 0)`` so the caller falls + back to plain text instead of mis-parsing the content. + """ + if not body: + return [], 1 + first_num, sep, _ = body[0].partition(": ") + if not sep or not first_num.strip().isdigit(): + return None, 0 + start_line = int(first_num.strip()) + raw: list[str] = [] + for line in body: + num, sep2, content = line.partition(": ") + if sep2 and num.strip().isdigit(): + raw.append(content) else: - out.append("\n".join(body), style=theme.text.hex) - return out + # A later line without the prefix — keep it verbatim so nothing + # is silently dropped (line numbers may drift, content stays). + raw.append(line) + return raw, start_line # ── info ─────────────────────────────────────────────────────────────── diff --git a/clients/terminal/tui/renderers/markdown_content.py b/clients/terminal/tui/renderers/markdown_content.py index d5d9bf3..f754ab0 100644 --- a/clients/terminal/tui/renderers/markdown_content.py +++ b/clients/terminal/tui/renderers/markdown_content.py @@ -8,14 +8,20 @@ from rich.style import Style from rich.theme import Theme as RichTheme -from clients.terminal.tui.themes import get_active_theme +from clients.terminal.tui.themes import ThemeRegistry, get_active_theme from .base import ContentRenderer def _theme_aware_code_theme(theme_name: str) -> str: - """Pick a Pygments code theme that matches the Navi theme brightness.""" - return "dracula" if theme_name == "gnexus-dark" else "github-light" + """Resolve the Pygments code style for a Navi theme name. + + The single source of truth is ``Theme.code_theme``; this is a thin + resolver for callers that only hold the theme name (rich's ``Markdown`` + takes a style *string*, not a Theme). All syntax highlighting in the TUI + flows through here so the whole UI shares one code colour scheme. + """ + return ThemeRegistry.get(theme_name).code_theme class ThemedMarkdownRenderable: diff --git a/clients/terminal/tui/renderers/syntax.py b/clients/terminal/tui/renderers/syntax.py new file mode 100644 index 0000000..2892aaf --- /dev/null +++ b/clients/terminal/tui/renderers/syntax.py @@ -0,0 +1,41 @@ +"""Shared syntax-highlighting helper for code rendered in the TUI. + +The single point of assignment for the code colour scheme is +``Theme.code_theme`` (see ``clients/terminal/tui/themes.py``). Every renderer +that shows code — markdown fenced blocks (via ``_theme_aware_code_theme``), +code artifacts, and the filesystem ``read`` tool output — builds its +``rich.syntax.Syntax`` through :func:`highlight_code` so the whole UI shares +one Pygments style per theme. +""" + +from __future__ import annotations + +from rich.syntax import Syntax + +from clients.terminal.tui.themes import Theme + + +def highlight_code( + code: str, + language: str, + *, + theme: Theme, + line_numbers: bool = False, + start_line: int = 1, + word_wrap: bool = True, +) -> Syntax: + """Return a ``rich.syntax.Syntax`` renderable styled by ``theme.code_theme``. + + ``language`` is a Pygments lexer name (or ``"text"`` for plain output). + The background is the theme's ``surface`` colour so a code block reads as + a distinct surface panel, matching the code-artifact renderer. + """ + return Syntax( + code, + language, + theme=theme.code_theme, + background_color=theme.surface.hex, + line_numbers=line_numbers, + start_line=start_line, + word_wrap=word_wrap, + ) \ No newline at end of file diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py index 73d45a2..29749ed 100644 --- a/clients/terminal/tui/themes.py +++ b/clients/terminal/tui/themes.py @@ -44,6 +44,12 @@ prompt_border: Color selection: Color link: Color + # Pygments style name used for *all* syntax highlighting in the TUI — + # fenced markdown code blocks, code artifacts, and filesystem ``read`` + # output. This is the single point of assignment for the code colour + # scheme; every renderer reads ``theme.code_theme`` instead of hard-coding + # a style, so the whole UI shares one highlighting scheme per theme. + code_theme: str def rich_theme_styles(self) -> dict[str, str]: """Return Rich console theme entries for markdown rendering.""" @@ -183,6 +189,7 @@ prompt_border=Color.parse("#7DCFFF"), # cyan highlight for input selection=Color.parse("#FF00CC"), # magenta — active selection / cursor line link=Color.parse("#73DACA"), # teal — URLs and references + code_theme="dracula", # Pygments style matching the dark Tokyo-Night palette ) @@ -214,6 +221,7 @@ prompt_border=Color.parse("#7AA2F7"), selection=Color.parse("#FF1492"), # hot-pink — active selection / cursor line link=Color.parse("#0D9488"), # darker teal for readability on light background + code_theme="paraiso-light", # Pygments style for the light theme ) diff --git a/tests/clients/test_code_theme.py b/tests/clients/test_code_theme.py new file mode 100644 index 0000000..220b3e8 --- /dev/null +++ b/tests/clients/test_code_theme.py @@ -0,0 +1,94 @@ +"""Single-point-of-assignment guard for the TUI syntax-highlighting scheme. + +All code rendered in the Navi Code TUI — markdown fenced blocks, code +artifacts, and the filesystem ``read`` tool output — must resolve its +Pygments style from one place: ``Theme.code_theme``. These tests pin that +contract so a future renderer cannot silently re-introduce a hard-coded +``dracula``/``github-light`` copy. +""" + +from __future__ import annotations + +from pygments.styles import get_style_by_name +from rich.syntax import Syntax + +from clients.terminal.tui.renderers.artifact import ArtifactRenderer +from clients.terminal.tui.renderers.filesystem import FilesystemToolResultRenderer +from clients.terminal.tui.renderers.markdown_content import _theme_aware_code_theme +from clients.terminal.tui.renderers.syntax import highlight_code +from clients.terminal.tui.themes import ( + GNEXUS_DARK, + GNEXUS_LIGHT, + ThemeRegistry, + get_active_theme, + set_active_theme, +) + + +def _pygments_style_name(syntax: Syntax) -> str: + """The Pygments style class name backing a rich Syntax renderable.""" + return syntax._theme._pygments_style_class.__name__ # type: ignore[attr-defined] + + +def test_theme_code_theme_is_a_real_pygments_style() -> None: + for theme in (GNEXUS_DARK, GNEXUS_LIGHT): + # The configured name resolves to a real Pygments style (no raise), + # and dark/light pick distinct schemes. + get_style_by_name(theme.code_theme) + assert GNEXUS_DARK.code_theme != GNEXUS_LIGHT.code_theme + + +def test_markdown_code_theme_resolver_reads_from_theme() -> None: + for name in ("gnexus-dark", "gnexus-light"): + assert _theme_aware_code_theme(name) == ThemeRegistry.get(name).code_theme + + +def test_highlight_code_uses_theme_code_theme() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + syntax = highlight_code("x = 1\n", "python", theme=theme, line_numbers=True) + assert _pygments_style_name(syntax) == get_style_by_name(theme.code_theme).__name__ + + +def test_artifact_renderer_uses_theme_code_theme() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + panel = ArtifactRenderer().render( + { + "type": "artifact", + "path": "a.py", + "language": "python", + "content": "x = 1\n", + } + ) + syntax = panel.renderable + assert isinstance(syntax, Syntax) + assert _pygments_style_name(syntax) == get_style_by_name(theme.code_theme).__name__ + + +def test_filesystem_read_uses_theme_code_theme() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + panel = FilesystemToolResultRenderer().render( + { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read", "path": "a.py"}, + "result": "[a.py | 1 lines | 7B]\n1: x = 1\n", + "success": True, + } + ) + group = panel.renderable + syntax = next(r for r in group.renderables if isinstance(r, Syntax)) + assert _pygments_style_name(syntax) == get_style_by_name(theme.code_theme).__name__ + + +def test_light_theme_uses_light_code_scheme() -> None: + set_active_theme("gnexus-light") + theme = get_active_theme() + dark = highlight_code("x = 1\n", "python", theme=GNEXUS_DARK, line_numbers=True) + light = highlight_code("x = 1\n", "python", theme=theme, line_numbers=True) + # Same code, different theme field -> different Pygments style class. + assert _pygments_style_name(dark) != _pygments_style_name(light) + assert _pygments_style_name(light) == get_style_by_name(GNEXUS_LIGHT.code_theme).__name__ + set_active_theme("gnexus-dark") \ No newline at end of file diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index 7ef71bc..798bce2 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -2,7 +2,8 @@ from __future__ import annotations -from rich.console import Console +from rich.console import Console, Group +from rich.syntax import Syntax from rich.text import Text from clients.terminal.tui.renderers.filesystem import ( @@ -26,6 +27,19 @@ return r +def _group_parts(group) -> list: + """The renderables inside a rich Group.""" + return list(group.renderables) + + +def _find_syntax(group) -> Syntax | None: + """The first ``Syntax`` renderable inside a Group (or None).""" + for r in _group_parts(group): + if isinstance(r, Syntax): + return r + return None + + def _span_styles_at(text: Text, substring: str) -> set[str]: """Return the styles that cover the first occurrence of ``substring``. @@ -485,19 +499,28 @@ msg = { "type": "tool_call", "tool": "filesystem", - "args": {"action": "read"}, - "result": "[src/main.py | 2 lines | 64B]\nprint('hi')\nprint('bye')\n", + "args": {"action": "read", "path": "src/main.py"}, + "result": "[src/main.py | 2 lines | 64B]\n1: print('hi')\n2: print('bye')\n", "success": True, } body = _body(renderer.render(msg)) - assert isinstance(body, Text) + assert isinstance(body, Group) + header = _group_parts(body)[0] + assert isinstance(header, Text) # Path inside the header plaque is accent. - assert theme.accent.hex in _span_styles_at(body, "src/main.py") + assert theme.accent.hex in _span_styles_at(header, "src/main.py") # The "N lines | size" tail of the plaque is dim. - assert theme.text_dim.hex in _span_styles_at(body, "2 lines | 64B") - # Body is rendered in text (not dim) for readability. - assert _span_styles_at(body, "print('hi')") == {theme.text.hex} - assert "print('bye')" in body.plain + assert theme.text_dim.hex in _span_styles_at(header, "2 lines | 64B") + + # Body is syntax-highlighted: a Syntax renderable whose lexer is Python + # (guessed from the .py path) and whose raw code recovered the file + # content (server ``num: `` prefix stripped). + syntax = _find_syntax(body) + assert syntax is not None + assert syntax.lexer.name == "Python" + assert syntax.code == "print('hi')\nprint('bye')" + assert syntax.line_numbers is True + assert syntax.start_line == 1 def test_read_offset_header() -> None: @@ -507,15 +530,23 @@ msg = { "type": "tool_call", "tool": "filesystem", - "args": {"action": "read", "offset": 5, "limit": 10}, - "result": "[src/main.py | lines 5–14 of 100 | 4.0K]\nbody line\n", + "args": {"action": "read", "offset": 5, "limit": 10, "path": "src/main.py"}, + "result": "[src/main.py | lines 5–14 of 100 | 4.0K]\n 5: body line\n", "success": True, } body = _body(renderer.render(msg)) - assert isinstance(body, Text) - assert theme.accent.hex in _span_styles_at(body, "src/main.py") - assert theme.text_dim.hex in _span_styles_at(body, "lines 5–14 of 100") - assert _span_styles_at(body, "body line") == {theme.text.hex} + assert isinstance(body, Group) + header = _group_parts(body)[0] + assert isinstance(header, Text) + assert theme.accent.hex in _span_styles_at(header, "src/main.py") + assert theme.text_dim.hex in _span_styles_at(header, "lines 5–14 of 100") + + # The line-number column starts at the read offset (5), so the Syntax + # block's own numbering matches the server's, not always 1. + syntax = _find_syntax(body) + assert syntax is not None + assert syntax.start_line == 5 + assert syntax.code == "body line" def test_read_large_file_warning_is_warning_color() -> None: @@ -525,38 +556,100 @@ msg = { "type": "tool_call", "tool": "filesystem", - "args": {"action": "read"}, + "args": {"action": "read", "path": "big.log"}, "result": ( "[big.log | 9000 lines | 1.2M]\n" "⚠ Large file (1.2M) — consider offset/limit next time.\n" - "first line\n" + "1: first line\n" ), "success": True, } body = _body(renderer.render(msg)) - assert isinstance(body, Text) - assert theme.warning.hex in _span_styles_at(body, "Large file") - assert _span_styles_at(body, "first line") == {theme.text.hex} + assert isinstance(body, Group) + parts = _group_parts(body) + # [header_text, warning_text, blank, syntax] + assert isinstance(parts[0], Text) + assert theme.accent.hex in _span_styles_at(parts[0], "big.log") + warning = parts[1] + assert isinstance(warning, Text) + assert theme.warning.hex in _span_styles_at(warning, "Large file") + # The body line still reaches the Syntax block. + syntax = _find_syntax(body) + assert syntax is not None + assert "first line" in syntax.code def test_read_numbered_highlights_line_numbers() -> None: set_active_theme("gnexus-dark") - theme = get_active_theme() renderer = FilesystemToolResultRenderer() msg = { "type": "tool_call", "tool": "filesystem", - "args": {"action": "read", "numbered": True}, + "args": {"action": "read", "numbered": True, "path": "a.py"}, "result": "[a.py | 2 lines | 16B]\n1: foo\n2: bar\n", "success": True, } body = _body(renderer.render(msg)) - assert isinstance(body, Text) - # Line numbers are the accent colour (navigation anchor); content is neutral. - assert theme.accent.hex in _span_styles_at(body, "1: ") - assert theme.accent.hex in _span_styles_at(body, "2: ") - assert _span_styles_at(body, "foo") == {theme.text.hex} - assert _span_styles_at(body, "bar") == {theme.text.hex} + assert isinstance(body, Group) + # With numbered output the server ``num: `` prefix is stripped and Syntax + # renders its own line-number column; the raw code is the file content + # without the numbers. + syntax = _find_syntax(body) + assert syntax is not None + assert syntax.line_numbers is True + assert syntax.start_line == 1 + assert syntax.code == "foo\nbar" + + +def test_read_unnumbered_omits_line_numbers() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read", "numbered": False, "path": "a.py"}, + "result": "[a.py | 2 lines | 16B]\nfoo\nbar\n", + "success": True, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Group) + syntax = _find_syntax(body) + assert syntax is not None + assert syntax.line_numbers is False + assert syntax.code == "foo\nbar" + + +def test_read_guesses_language_from_path() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read", "path": "src/app.ts"}, + "result": "[src/app.ts | 1 lines | 8B]\n1: const x = 1\n", + "success": True, + } + body = _body(renderer.render(msg)) + syntax = _find_syntax(body) + assert syntax is not None + assert syntax.lexer.name in {"TypeScript", "TSX"} + + +def test_read_unknown_extension_falls_back_to_text_lexer() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read", "path": "notes.weirdext"}, + "result": "[notes.weirdext | 1 lines | 3B]\n1: hi\n", + "success": True, + } + body = _body(renderer.render(msg)) + syntax = _find_syntax(body) + assert syntax is not None + # Unknown extension -> "text" lexer (plain, no token colors). + assert syntax.lexer.name in {"Text only", "Text", "Raw token data"} def test_read_error_renders_plain_error_color() -> None: