diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index 9b92031..abb5b24 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -83,8 +83,12 @@ return self._render_list(text, theme) if action == "grep": return self._render_grep(text, args, theme) - # read / info / write / edit / append / move / copy / delete / mkdir / - # exists / find / find_up / query / unknown → plain, unchanged for now. + if action == "read": + return self._render_read(text, args, theme) + if action == "info": + return self._render_info(text, theme) + # write / edit / append / move / copy / delete / mkdir / exists / find / + # find_up / query / unknown → plain, unchanged. return Text(text, style=theme.text_dim.hex) # ── diff ─────────────────────────────────────────────────────────────── @@ -106,6 +110,76 @@ highlight_unified_diff(diff, theme), ) + # ── read ─────────────────────────────────────────────────────────────── + + def _render_read(self, text: str, args: dict, theme: Theme) -> RenderableType: + lines = text.splitlines() + if not lines: + return Text(text, style=theme.text_dim.hex) + + out = Text() + # 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) + if sep: + out.append(path_part, style=theme.accent.hex) + out.append(" | " + rest, style=theme.text_dim.hex) + else: + out.append(header, style=theme.text_dim.hex) + + # 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) + idx = 2 + + # Body: file contents (no syntax highlighting — rejected by design). + body = lines[idx:] + if body: + out.append("\n") + if args.get("numbered", False): + for j, ln in enumerate(body): + if j: + out.append("\n") + num, sep2, content = ln.partition(": ") + if sep2: + out.append(num + ": ", style=theme.text_dim.hex) + out.append(content, style=theme.text.hex) + else: + out.append(ln, style=theme.text.hex) + else: + out.append("\n".join(body), style=theme.text.hex) + return out + + # ── info ─────────────────────────────────────────────────────────────── + + def _render_info(self, text: str, theme: Theme) -> RenderableType: + lines = text.splitlines() + if not lines: + return Text(text, style=theme.text_dim.hex) + + out = Text() + for idx, line in enumerate(lines): + if idx: + out.append("\n") + key, sep, rest = line.partition(":") + if sep: + out.append(key, style=theme.accent.hex) + out.append(":", style=theme.accent.hex) + stripped = rest.lstrip(" ") + leading = rest[: len(rest) - len(stripped)] + if leading: + out.append(leading, style=theme.text_dim.hex) + out.append(stripped, style=theme.text.hex) + else: + out.append(line, style=theme.text.hex) + return out + # ── list ─────────────────────────────────────────────────────────────── def _render_list(self, text: str, theme: Theme) -> RenderableType: diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index 8ad67c7..193b7cd 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -273,7 +273,10 @@ # ── fallback ────────────────────────────────────────────────────────────────── -def test_read_action_falls_back_to_plain_dim() -> None: +# ── read ────────────────────────────────────────────────────────────────────── + + +def test_read_separates_header_and_body() -> None: set_active_theme("gnexus-dark") theme = get_active_theme() renderer = FilesystemToolResultRenderer() @@ -281,14 +284,123 @@ "type": "tool_call", "tool": "filesystem", "args": {"action": "read"}, - "result": "[main.py | 10 lines | 256B]\nprint('hi')\n", + "result": "[src/main.py | 2 lines | 64B]\nprint('hi')\nprint('bye')\n", "success": True, } body = _body(renderer.render(msg)) assert isinstance(body, Text) - assert "print('hi')" in body.plain - # Whole body is dim (no per-part highlighting for read yet). - assert _span_styles_at(body, "print('hi')") == {theme.text_dim.hex} + # Path inside the header plaque is accent. + assert theme.accent.hex in _span_styles_at(body, "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 + + +def test_read_offset_header() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + 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", + "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} + + +def test_read_large_file_warning_is_warning_color() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read"}, + "result": ( + "[big.log | 9000 lines | 1.2M]\n" + "⚠ Large file (1.2M) — consider offset/limit next time.\n" + "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} + + +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}, + "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 dim, line content is text. + assert _span_styles_at(body, "1") == {theme.text_dim.hex} + assert _span_styles_at(body, "foo") == {theme.text.hex} + assert _span_styles_at(body, "bar") == {theme.text.hex} + + +def test_read_error_renders_plain_error_color() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read"}, + "result": "File not found: /missing.py", + "success": False, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Text) + assert _span_styles_at(body, "File not found") == {theme.tool_error.hex} + + +# ── info ────────────────────────────────────────────────────────────────────── + + +def test_info_highlights_keys_and_values() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "info"}, + "result": ( + "path: /proj/main.py\n" + "type: file\n" + "size: 1.2K\n" + "modified: 2026-07-12 14:30\n" + ), + "success": True, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Text) + # Keys are accent. + assert _span_styles_at(body, "path") == {theme.accent.hex} + assert _span_styles_at(body, "type") == {theme.accent.hex} + assert _span_styles_at(body, "modified") == {theme.accent.hex} + # Values are text. + assert _span_styles_at(body, "/proj/main.py") == {theme.text.hex} + assert _span_styles_at(body, "file") == {theme.text.hex} + assert _span_styles_at(body, "1.2K") == {theme.text.hex} def test_unknown_action_falls_back_to_plain() -> None: