Files
SneakyCode/app/utils/display.py

264 lines
8.4 KiB
Python

"""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.table import Table
from rich.text import Text
from rich.theme import Theme
from app.models.message import Message
from app.utils.logging import console
# Custom theme for consistent styling across the application
SNEAKYCODE_THEME = Theme(
{
"info": "cyan",
"warning": "yellow",
"error": "bold red",
"success": "bold green",
"tool": "magenta",
"dim": "dim white",
}
)
# Apply the theme to the shared console
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:
"""Print the SneakyCode startup banner."""
console.print(
"\n[bold cyan] SneakyCode[/bold cyan] [dim]— Local AI Coding Agent[/dim]\n",
)
def print_info(message: str) -> None:
"""Print an informational message."""
console.print(f"[info]{message}[/info]")
def print_warning(message: str) -> None:
"""Print a warning message."""
console.print(f"[warning]⚠ {message}[/warning]")
def print_error(message: str) -> None:
"""Print an error message."""
console.print(f"[error]✗ {message}[/error]")
def print_success(message: str) -> None:
"""Print a success message."""
console.print(f"[success]✓ {message}[/success]")
def print_user_message(content: str) -> None:
"""Print a user message in a styled panel."""
console.print(Panel(content, title="You", border_style="cyan", expand=False))
def print_assistant_message(content: str) -> None:
"""Print an assistant message in a styled panel."""
console.print(Panel(content, title="Assistant", border_style="green", expand=False))
def print_tool_call(name: str, args: str) -> None:
"""Print a compact tool call line."""
truncated_args = args[:80] + "..." if len(args) > 80 else args
console.print(f" [tool]{name}[/tool] [dim]{truncated_args}[/dim]")
def print_tool_result(name: str, output: str, is_error: bool = False) -> None:
"""Print a compact tool result."""
if is_error:
truncated = output[:200] + "..." if len(output) > 200 else output
console.print(f" [error]{name}: {truncated}[/error]")
else:
lines = output.count("\n") + 1 if output else 0
chars = len(output)
console.print(f" [dim]{name}{lines} lines, {chars} chars[/dim]")
def print_iteration_header(iteration: int, max_iterations: int) -> None:
"""Print the current agent loop iteration."""
console.print(f"[dim]── iteration {iteration}/{max_iterations} ──[/dim]")
def print_token_usage(usage_tokens: int, budget: int) -> None:
"""Print current token usage against budget."""
console.print(f"[dim]Tokens: ~{usage_tokens:,} / {budget:,}[/dim]")
def print_history(messages: list[Message]) -> None:
"""Print conversation history as a Rich table."""
console.print(render_history(messages))