Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 76 additions & 44 deletions code_puppy/messaging/rich_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ async def stop(self) -> None:
"context": "dim",
}

GROUPABLE_TYPES = frozenset(
{
FileContentMessage,
GrepResultMessage,
DiffMessage,
FileListingMessage,
ShellStartMessage,
}
)
TRANSPARENT_TYPES = frozenset({SpinnerControl})


# =============================================================================
# Rich Console Renderer
Expand All @@ -111,6 +122,12 @@ class RichConsoleRenderer:
It uses a background thread for synchronous compatibility with the main loop.
"""

# Message types that support consecutive grouping under a single banner
_GROUPABLE_TYPES = GROUPABLE_TYPES

# Message types that are "transparent" - they don't break an active group
_TRANSPARENT_TYPES = TRANSPARENT_TYPES

def __init__(
self,
bus: MessageBus,
Expand All @@ -132,6 +149,8 @@ def __init__(
self._running = False
self._thread: Optional[threading.Thread] = None
self._spinners: Dict[str, object] = {} # spinner_id -> status context
# Grouping: track last rendered message type for consecutive grouping
self._last_rendered_type: Optional[type] = None

@property
def console(self) -> Console:
Expand Down Expand Up @@ -172,6 +191,16 @@ def _should_suppress_subagent_output(self) -> bool:
"""
return is_subagent() and not get_subagent_verbose()

def _is_continuation(self, msg_type: type) -> bool:
"""Check if this message should be rendered as a grouped child (no banner).

Returns True if the previous rendered message was the same groupable type,
meaning the banner was already printed and we just need the child line.
"""
return (
msg_type in self._GROUPABLE_TYPES and self._last_rendered_type == msg_type
)

# =========================================================================
# Lifecycle (Synchronous - for compatibility with main.py)
# =========================================================================
Expand Down Expand Up @@ -226,9 +255,12 @@ def _render_sync(self, message: AnyMessage) -> None:
"""Render a message synchronously with error handling."""
try:
self._do_render(message)
# Track type for grouping (transparent types don't break groups)
msg_type = type(message)
if msg_type not in self._TRANSPARENT_TYPES:
self._last_rendered_type = msg_type
except Exception as e:
# Don't let rendering errors crash the loop
# Escape the error message to prevent nested markup errors
self._last_rendered_type = None
safe_error = escape_rich_markup(str(e))
self._console.print(f"[dim red]Render error: {safe_error}[/dim red]")

Expand Down Expand Up @@ -327,8 +359,8 @@ async def render(self, message: AnyMessage) -> None:
elif isinstance(message, SelectionRequest):
await self._render_selection_request(message)
else:
# Use sync render for everything else
self._do_render(message)
# Use sync renderer for shared rendering and grouping behavior
self._render_sync(message)

# =========================================================================
# Text Messages
Expand Down Expand Up @@ -383,12 +415,13 @@ def _render_file_listing(self, msg: FileListingMessage) -> None:
import os
from collections import defaultdict

# Header on single line
rec_flag = f"(recursive={msg.recursive})"
banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
if not self._is_continuation(FileListingMessage):
banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
self._console.print(f"\n{banner}")

self._console.print(
f"\n{banner} "
f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
f" ├─ 📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
)

# Build a tree structure: {parent_path: {files: [], dirs: set(), size: int}}
Expand Down Expand Up @@ -498,11 +531,7 @@ def get_recursive_file_count(d: str) -> int:
)

def _render_file_content(self, msg: FileContentMessage) -> None:
"""Render a file read - just show the header, not the content.

The file content is for the LLM only, not for display in the UI.
"""
# Skip for sub-agents unless verbose mode
"""Render a file read - just show the header, not the content."""
if self._should_suppress_subagent_output():
return

Expand All @@ -512,11 +541,13 @@ def _render_file_content(self, msg: FileContentMessage) -> None:
end_line = msg.start_line + msg.num_lines - 1
line_info = f" [dim](lines {msg.start_line}-{end_line})[/dim]"

# Just print the header - content is for LLM only
banner = self._format_banner("read_file", "READ FILE")
self._console.print(
f"\n{banner} 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}"
)
# Print banner only if this is NOT a continuation of the same type
if not self._is_continuation(FileContentMessage):
banner = self._format_banner("read_file", "READ FILE")
self._console.print(f"\n{banner}")

# Always print as tree child
self._console.print(f" ├─ 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}")

def _render_grep_result(self, msg: GrepResultMessage) -> None:
"""Render grep results grouped by file matching old format."""
Expand All @@ -526,15 +557,17 @@ def _render_grep_result(self, msg: GrepResultMessage) -> None:

import re

# Header
banner = self._format_banner("grep", "GREP")
if not self._is_continuation(GrepResultMessage):
banner = self._format_banner("grep", "GREP")
self._console.print(f"\n{banner}")

self._console.print(
f"\n{banner} 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
f" ├─ 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
)

if not msg.matches:
self._console.print(
f"[dim]No matches found for '{msg.search_term}' "
f"[dim]No matches found for '{msg.search_term}' "
f"in {msg.directory}[/dim]"
)
return
Expand All @@ -551,7 +584,7 @@ def _render_grep_result(self, msg: GrepResultMessage) -> None:
file_matches = by_file[file_path]
match_word = "match" if len(file_matches) == 1 else "matches"
self._console.print(
f"\n[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
)

# Show each match with line number and content
Expand All @@ -577,29 +610,27 @@ def _render_grep_result(self, msg: GrepResultMessage) -> None:
highlighted_line = line

ln = match.line_number
self._console.print(f" [dim]{ln:4d}[/dim] │ {highlighted_line}")
self._console.print(
f" │ [dim]{ln:4d}[/dim] │ {highlighted_line}"
)
else:
# Concise mode (default): Show only file summaries
self._console.print("")
for file_path in sorted(by_file.keys()):
file_matches = by_file[file_path]
match_word = "match" if len(file_matches) == 1 else "matches"
self._console.print(
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
)

# Summary - subtle
match_word = "match" if msg.total_matches == 1 else "matches"
file_word = "file" if len(by_file) == 1 else "files"
num_files = len(by_file)
self._console.print(
f"[dim]Found {msg.total_matches} {match_word} "
f"[dim]Found {msg.total_matches} {match_word} "
f"across {num_files} {file_word}[/dim]"
)

# Trailing newline for spinner separation
self._console.print()

# =========================================================================
# Diff
# =========================================================================
Expand All @@ -616,11 +647,12 @@ def _render_diff(self, msg: DiffMessage) -> None:
icon = op_icons.get(msg.operation, "📄")
op_color = op_colors.get(msg.operation, "white")

# Header on single line
banner = self._format_banner("edit_file", "EDIT FILE")
if not self._is_continuation(DiffMessage):
banner = self._format_banner("edit_file", "EDIT FILE")
self._console.print(f"\n{banner}")

self._console.print(
f"\n{banner} "
f"{icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
f" ├─ {icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
f"[bold cyan]{msg.path}[/bold cyan]"
)

Expand Down Expand Up @@ -654,33 +686,33 @@ def _render_diff(self, msg: DiffMessage) -> None:

def _render_shell_start(self, msg: ShellStartMessage) -> None:
"""Render shell command start notification."""
# Skip for sub-agents unless verbose mode
if self._should_suppress_subagent_output():
return

# Escape command to prevent Rich markup injection
safe_command = escape_rich_markup(msg.command)
# Header showing command is starting
banner = self._format_banner("shell_command", "SHELL COMMAND")

# Add background indicator if running in background mode
if not self._is_continuation(ShellStartMessage):
banner = self._format_banner("shell_command", "SHELL COMMAND")
self._console.print(f"\n{banner}")

# Tree child with command
if msg.background:
self._console.print(
f"\n{banner} 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
f" ├─ 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
)
else:
self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
self._console.print(f" ├─ 🚀 [dim]$ {safe_command}[/dim]")

# Show working directory if specified
if msg.cwd:
safe_cwd = escape_rich_markup(msg.cwd)
self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")

# Show timeout or background status
if msg.background:
self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
else:
self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")

def _render_shell_line(self, msg: ShellLineMessage) -> None:
"""Render shell output line preserving ANSI codes and carriage returns."""
Expand Down
Loading
Loading