"""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))