feat: add render functions and DisplayAdapter for TUI display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:41:04 -05:00
parent 08da2c542a
commit 8335978583
2 changed files with 263 additions and 41 deletions

View File

@@ -1,7 +1,18 @@
"""Rich terminal display helpers for SneakyCode.""" """Rich terminal display helpers for SneakyCode.
Provides two interfaces:
- render_* functions: return Rich renderables (for use in Textual RichLog)
- DisplayAdapter: wraps a RichLog widget and provides write_* convenience methods
- print_* functions: legacy console.print wrappers (for non-TUI fallback)
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
from rich.text import Text
from rich.theme import Theme from rich.theme import Theme
from app.models.message import Message from app.models.message import Message
@@ -23,6 +34,166 @@ SNEAKYCODE_THEME = Theme(
console.push_theme(SNEAKYCODE_THEME) console.push_theme(SNEAKYCODE_THEME)
if TYPE_CHECKING:
from rich.console import RenderableType
# ---------------------------------------------------------------------------
# Render functions — return Rich renderables
# ---------------------------------------------------------------------------
def render_user_message(content: str) -> Panel:
"""Render a user message as a styled panel."""
return Panel(content, title="You", border_style="cyan", expand=False)
def render_assistant_message(content: str) -> Panel:
"""Render an assistant message as a styled panel."""
return Panel(content, title="Assistant", border_style="green", expand=True)
def render_tool_call(name: str, args: str) -> Text:
"""Render a compact tool call line."""
truncated_args = args[:80] + "..." if len(args) > 80 else args
text = Text()
text.append(" ")
text.append(name, style="magenta")
text.append(f" {truncated_args}", style="dim")
return text
def render_tool_result(name: str, output: str, is_error: bool = False) -> Text:
"""Render a compact tool result line."""
text = Text()
text.append(" ")
if is_error:
truncated = output[:200] + "..." if len(output) > 200 else output
text.append(f"{name}: {truncated}", style="bold red")
else:
lines = output.count("\n") + 1 if output else 0
chars = len(output)
text.append(f"{name}{lines} lines, {chars} chars", style="dim")
return text
def render_iteration_header(iteration: int, max_iterations: int) -> Text:
"""Render the current agent loop iteration."""
return Text(f"── iteration {iteration}/{max_iterations} ──", style="dim")
def render_token_usage(usage_tokens: int, budget: int) -> Text:
"""Render token usage as styled text."""
return Text(f"Tokens: ~{usage_tokens:,} / {budget:,}", style="dim")
def render_warning(message: str) -> Text:
"""Render a warning message."""
return Text(f"{message}", style="yellow")
def render_error(message: str) -> Text:
"""Render an error message."""
return Text(f"{message}", style="bold red")
def render_success(message: str) -> Text:
"""Render a success message."""
return Text(f"{message}", style="bold green")
def render_info(message: str) -> Text:
"""Render an info message."""
return Text(message, style="cyan")
def render_history(messages: list[Message]) -> Table | Text:
"""Render conversation history as a Rich table."""
if not messages:
return Text("No messages in history.", style="dim")
table = Table(title="Conversation History")
table.add_column("#", style="dim", width=4)
table.add_column("Role", width=10)
table.add_column("Content")
role_styles = {
"user": "cyan",
"assistant": "green",
"system": "yellow",
"tool": "magenta",
}
for i, msg in enumerate(messages, 1):
style = role_styles.get(msg.role, "white")
content = msg.content or "(no content)"
if len(content) > 120:
content = content[:117] + "..."
table.add_row(str(i), f"[{style}]{msg.role}[/{style}]", content)
return table
# ---------------------------------------------------------------------------
# DisplayAdapter — wraps a writable target (RichLog or similar)
# ---------------------------------------------------------------------------
class WritableLog(Protocol):
"""Protocol for anything that accepts Rich renderables via write()."""
def write(self, content: "RenderableType") -> None: ...
class DisplayAdapter:
"""Bridges agent loop display calls to a RichLog widget.
All write_* methods call the corresponding render_* function and
write the result to the target log.
"""
def __init__(self, log: WritableLog) -> None:
self._log = log
def write_user_message(self, content: str) -> None:
self._log.write(render_user_message(content))
def write_assistant_message(self, content: str) -> None:
self._log.write(render_assistant_message(content))
def write_tool_call(self, name: str, args: str) -> None:
self._log.write(render_tool_call(name, args))
def write_tool_result(self, name: str, output: str, is_error: bool = False) -> None:
self._log.write(render_tool_result(name, output, is_error))
def write_iteration_header(self, iteration: int, max_iterations: int) -> None:
self._log.write(render_iteration_header(iteration, max_iterations))
def write_token_usage(self, usage_tokens: int, budget: int) -> None:
self._log.write(render_token_usage(usage_tokens, budget))
def write_warning(self, message: str) -> None:
self._log.write(render_warning(message))
def write_error(self, message: str) -> None:
self._log.write(render_error(message))
def write_success(self, message: str) -> None:
self._log.write(render_success(message))
def write_info(self, message: str) -> None:
self._log.write(render_info(message))
def write_history(self, messages: list[Message]) -> None:
self._log.write(render_history(messages))
# ---------------------------------------------------------------------------
# Legacy print functions — for non-TUI fallback and pre-TUI startup
# ---------------------------------------------------------------------------
def print_banner() -> None: def print_banner() -> None:
"""Print the SneakyCode startup banner.""" """Print the SneakyCode startup banner."""
console.print( console.print(
@@ -61,25 +232,17 @@ def print_assistant_message(content: str) -> None:
def print_tool_call(name: str, args: str) -> None: def print_tool_call(name: str, args: str) -> None:
"""Print a compact tool call line — tool name + truncated key args.""" """Print a compact tool call line."""
truncated_args = args[:80] + "..." if len(args) > 80 else args truncated_args = args[:80] + "..." if len(args) > 80 else args
console.print(f" [tool]{name}[/tool] [dim]{truncated_args}[/dim]") console.print(f" [tool]{name}[/tool] [dim]{truncated_args}[/dim]")
def print_tool_result(name: str, output: str, is_error: bool = False) -> None: def print_tool_result(name: str, output: str, is_error: bool = False) -> None:
"""Print a compact tool result — status line only for success, detail for errors. """Print a compact tool result."""
Args:
name: Tool name.
output: Tool output or error message.
is_error: Whether this is an error result.
"""
if is_error: if is_error:
# Errors are shown prominently so the user knows something went wrong
truncated = output[:200] + "..." if len(output) > 200 else output truncated = output[:200] + "..." if len(output) > 200 else output
console.print(f" [error]{name}: {truncated}[/error]") console.print(f" [error]{name}: {truncated}[/error]")
else: else:
# Success: just show a compact byte/line summary
lines = output.count("\n") + 1 if output else 0 lines = output.count("\n") + 1 if output else 0
chars = len(output) chars = len(output)
console.print(f" [dim]{name}{lines} lines, {chars} chars[/dim]") console.print(f" [dim]{name}{lines} lines, {chars} chars[/dim]")
@@ -96,33 +259,5 @@ def print_token_usage(usage_tokens: int, budget: int) -> None:
def print_history(messages: list[Message]) -> None: def print_history(messages: list[Message]) -> None:
"""Print conversation history as a Rich table. """Print conversation history as a Rich table."""
console.print(render_history(messages))
Args:
messages: List of conversation messages to display.
"""
if not messages:
console.print("[dim]No messages in history.[/dim]")
return
table = Table(title="Conversation History")
table.add_column("#", style="dim", width=4)
table.add_column("Role", width=10)
table.add_column("Content")
role_styles = {
"user": "cyan",
"assistant": "green",
"system": "yellow",
"tool": "magenta",
}
for i, msg in enumerate(messages, 1):
style = role_styles.get(msg.role, "white")
content = msg.content or "[dim](no content)[/dim]"
# Truncate long content for display
if len(content) > 120:
content = content[:117] + "..."
table.add_row(str(i), f"[{style}]{msg.role}[/{style}]", content)
console.print(table)

View File

@@ -0,0 +1,87 @@
"""Tests for display render functions and DisplayAdapter."""
from unittest.mock import MagicMock
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from app.models.message import Message
from app.utils.display import (
DisplayAdapter,
render_assistant_message,
render_error,
render_iteration_header,
render_tool_call,
render_tool_result,
render_token_usage,
render_user_message,
render_warning,
)
class TestRenderFunctions:
def test_render_user_message_returns_panel(self) -> None:
result = render_user_message("hello")
assert isinstance(result, Panel)
assert result.title == "You"
def test_render_assistant_message_returns_panel(self) -> None:
result = render_assistant_message("response")
assert isinstance(result, Panel)
assert result.title == "Assistant"
def test_render_tool_call_returns_text(self) -> None:
result = render_tool_call("read_file", '{"path": "foo.py"}')
assert isinstance(result, Text)
assert "read_file" in result.plain
def test_render_tool_result_success(self) -> None:
result = render_tool_result("read_file", "file contents here", is_error=False)
assert isinstance(result, Text)
assert "read_file" in result.plain
def test_render_tool_result_error(self) -> None:
result = render_tool_result("read_file", "not found", is_error=True)
assert isinstance(result, Text)
assert "read_file" in result.plain
def test_render_iteration_header(self) -> None:
result = render_iteration_header(3, 25)
assert isinstance(result, Text)
assert "3/25" in result.plain
def test_render_token_usage(self) -> None:
result = render_token_usage(1500, 32000)
assert isinstance(result, Text)
assert "1,500" in result.plain
def test_render_warning(self) -> None:
result = render_warning("something happened")
assert isinstance(result, Text)
def test_render_error(self) -> None:
result = render_error("bad thing")
assert isinstance(result, Text)
class TestDisplayAdapter:
def test_write_user_message(self) -> None:
mock_log = MagicMock()
adapter = DisplayAdapter(mock_log)
adapter.write_user_message("hello")
mock_log.write.assert_called_once()
arg = mock_log.write.call_args[0][0]
assert isinstance(arg, Panel)
def test_write_tool_call(self) -> None:
mock_log = MagicMock()
adapter = DisplayAdapter(mock_log)
adapter.write_tool_call("read_file", '{"path": "x"}')
mock_log.write.assert_called_once()
def test_write_warning(self) -> None:
mock_log = MagicMock()
adapter = DisplayAdapter(mock_log)
adapter.write_warning("oops")
mock_log.write.assert_called_once()