feat: add render functions and DisplayAdapter for TUI display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
|
||||||
|
|||||||
87
tests/unit/test_display.py
Normal file
87
tests/unit/test_display.py
Normal 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()
|
||||||
Reference in New Issue
Block a user