Implement the core autonomy layer — AgentLoop streams LLM responses, parses tool calls, executes them with permission checks, feeds results back, and repeats until the task completes or finish is called. - Add FinishTool for explicit loop termination - Add tools parameter to LLMClient.stream_chat() for function calling - Add compact tool result display (status line, not full output) - Refactor REPL to delegate to AgentLoop.run_turn() - Fix Ollama null content rejection (always send content as string) - Add finish to auto_approve permissions - 9 unit tests for agent loop (34 total, zero regressions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
129 lines
4.0 KiB
Python
129 lines
4.0 KiB
Python
"""Rich terminal display helpers for SneakyCode."""
|
|
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
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)
|
|
|
|
|
|
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 — tool name + truncated key args."""
|
|
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 — status line only for success, detail for errors.
|
|
|
|
Args:
|
|
name: Tool name.
|
|
output: Tool output or error message.
|
|
is_error: Whether this is an error result.
|
|
"""
|
|
if is_error:
|
|
# Errors are shown prominently so the user knows something went wrong
|
|
truncated = output[:200] + "..." if len(output) > 200 else output
|
|
console.print(f" [error]{name}: {truncated}[/error]")
|
|
else:
|
|
# Success: just show a compact byte/line summary
|
|
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.
|
|
|
|
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)
|