Skip to content

Commit e9584b9

Browse files
janfeddersen-wqJan Feddersen
andauthored
feat(renderer): group consecutive tool outputs under shared tree banners (#183)
* feat(renderer): group consecutive tool outputs under shared tree banners * fix(renderer): align async grouping state and freeze type sets --------- Co-authored-by: Jan Feddersen <janfeddersen@jans-macbook-pro.home>
1 parent 87975db commit e9584b9

2 files changed

Lines changed: 372 additions & 44 deletions

File tree

code_puppy/messaging/rich_renderer.py

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ async def stop(self) -> None:
9898
"context": "dim",
9999
}
100100

101+
GROUPABLE_TYPES = frozenset(
102+
{
103+
FileContentMessage,
104+
GrepResultMessage,
105+
DiffMessage,
106+
FileListingMessage,
107+
ShellStartMessage,
108+
}
109+
)
110+
TRANSPARENT_TYPES = frozenset({SpinnerControl})
111+
101112

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

125+
# Message types that support consecutive grouping under a single banner
126+
_GROUPABLE_TYPES = GROUPABLE_TYPES
127+
128+
# Message types that are "transparent" - they don't break an active group
129+
_TRANSPARENT_TYPES = TRANSPARENT_TYPES
130+
114131
def __init__(
115132
self,
116133
bus: MessageBus,
@@ -132,6 +149,8 @@ def __init__(
132149
self._running = False
133150
self._thread: Optional[threading.Thread] = None
134151
self._spinners: Dict[str, object] = {} # spinner_id -> status context
152+
# Grouping: track last rendered message type for consecutive grouping
153+
self._last_rendered_type: Optional[type] = None
135154

136155
@property
137156
def console(self) -> Console:
@@ -172,6 +191,16 @@ def _should_suppress_subagent_output(self) -> bool:
172191
"""
173192
return is_subagent() and not get_subagent_verbose()
174193

194+
def _is_continuation(self, msg_type: type) -> bool:
195+
"""Check if this message should be rendered as a grouped child (no banner).
196+
197+
Returns True if the previous rendered message was the same groupable type,
198+
meaning the banner was already printed and we just need the child line.
199+
"""
200+
return (
201+
msg_type in self._GROUPABLE_TYPES and self._last_rendered_type == msg_type
202+
)
203+
175204
# =========================================================================
176205
# Lifecycle (Synchronous - for compatibility with main.py)
177206
# =========================================================================
@@ -226,9 +255,12 @@ def _render_sync(self, message: AnyMessage) -> None:
226255
"""Render a message synchronously with error handling."""
227256
try:
228257
self._do_render(message)
258+
# Track type for grouping (transparent types don't break groups)
259+
msg_type = type(message)
260+
if msg_type not in self._TRANSPARENT_TYPES:
261+
self._last_rendered_type = msg_type
229262
except Exception as e:
230-
# Don't let rendering errors crash the loop
231-
# Escape the error message to prevent nested markup errors
263+
self._last_rendered_type = None
232264
safe_error = escape_rich_markup(str(e))
233265
self._console.print(f"[dim red]Render error: {safe_error}[/dim red]")
234266

@@ -327,8 +359,8 @@ async def render(self, message: AnyMessage) -> None:
327359
elif isinstance(message, SelectionRequest):
328360
await self._render_selection_request(message)
329361
else:
330-
# Use sync render for everything else
331-
self._do_render(message)
362+
# Use sync renderer for shared rendering and grouping behavior
363+
self._render_sync(message)
332364

333365
# =========================================================================
334366
# Text Messages
@@ -383,12 +415,13 @@ def _render_file_listing(self, msg: FileListingMessage) -> None:
383415
import os
384416
from collections import defaultdict
385417

386-
# Header on single line
387418
rec_flag = f"(recursive={msg.recursive})"
388-
banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
419+
if not self._is_continuation(FileListingMessage):
420+
banner = self._format_banner("directory_listing", "DIRECTORY LISTING")
421+
self._console.print(f"\n{banner}")
422+
389423
self._console.print(
390-
f"\n{banner} "
391-
f"📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
424+
f" ├─ 📂 [bold cyan]{msg.directory}[/bold cyan] [dim]{rec_flag}[/dim]\n"
392425
)
393426

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

500533
def _render_file_content(self, msg: FileContentMessage) -> None:
501-
"""Render a file read - just show the header, not the content.
502-
503-
The file content is for the LLM only, not for display in the UI.
504-
"""
505-
# Skip for sub-agents unless verbose mode
534+
"""Render a file read - just show the header, not the content."""
506535
if self._should_suppress_subagent_output():
507536
return
508537

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

515-
# Just print the header - content is for LLM only
516-
banner = self._format_banner("read_file", "READ FILE")
517-
self._console.print(
518-
f"\n{banner} 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}"
519-
)
544+
# Print banner only if this is NOT a continuation of the same type
545+
if not self._is_continuation(FileContentMessage):
546+
banner = self._format_banner("read_file", "READ FILE")
547+
self._console.print(f"\n{banner}")
548+
549+
# Always print as tree child
550+
self._console.print(f" ├─ 📂 [bold cyan]{msg.path}[/bold cyan]{line_info}")
520551

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

527558
import re
528559

529-
# Header
530-
banner = self._format_banner("grep", "GREP")
560+
if not self._is_continuation(GrepResultMessage):
561+
banner = self._format_banner("grep", "GREP")
562+
self._console.print(f"\n{banner}")
563+
531564
self._console.print(
532-
f"\n{banner} 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
565+
f" ├─ 📂 [dim]{msg.directory} for '{msg.search_term}'[/dim]"
533566
)
534567

535568
if not msg.matches:
536569
self._console.print(
537-
f"[dim]No matches found for '{msg.search_term}' "
570+
f"[dim]No matches found for '{msg.search_term}' "
538571
f"in {msg.directory}[/dim]"
539572
)
540573
return
@@ -551,7 +584,7 @@ def _render_grep_result(self, msg: GrepResultMessage) -> None:
551584
file_matches = by_file[file_path]
552585
match_word = "match" if len(file_matches) == 1 else "matches"
553586
self._console.print(
554-
f"\n[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
587+
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
555588
)
556589

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

579612
ln = match.line_number
580-
self._console.print(f" [dim]{ln:4d}[/dim] │ {highlighted_line}")
613+
self._console.print(
614+
f" │ [dim]{ln:4d}[/dim] │ {highlighted_line}"
615+
)
581616
else:
582617
# Concise mode (default): Show only file summaries
583-
self._console.print("")
584618
for file_path in sorted(by_file.keys()):
585619
file_matches = by_file[file_path]
586620
match_word = "match" if len(file_matches) == 1 else "matches"
587621
self._console.print(
588-
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
622+
f"[dim]📄 {file_path} ({len(file_matches)} {match_word})[/dim]"
589623
)
590624

591625
# Summary - subtle
592626
match_word = "match" if msg.total_matches == 1 else "matches"
593627
file_word = "file" if len(by_file) == 1 else "files"
594628
num_files = len(by_file)
595629
self._console.print(
596-
f"[dim]Found {msg.total_matches} {match_word} "
630+
f"[dim]Found {msg.total_matches} {match_word} "
597631
f"across {num_files} {file_word}[/dim]"
598632
)
599633

600-
# Trailing newline for spinner separation
601-
self._console.print()
602-
603634
# =========================================================================
604635
# Diff
605636
# =========================================================================
@@ -616,11 +647,12 @@ def _render_diff(self, msg: DiffMessage) -> None:
616647
icon = op_icons.get(msg.operation, "📄")
617648
op_color = op_colors.get(msg.operation, "white")
618649

619-
# Header on single line
620-
banner = self._format_banner("edit_file", "EDIT FILE")
650+
if not self._is_continuation(DiffMessage):
651+
banner = self._format_banner("edit_file", "EDIT FILE")
652+
self._console.print(f"\n{banner}")
653+
621654
self._console.print(
622-
f"\n{banner} "
623-
f"{icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
655+
f" ├─ {icon} [{op_color}]{msg.operation.upper()}[/{op_color}] "
624656
f"[bold cyan]{msg.path}[/bold cyan]"
625657
)
626658

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

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

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

666-
# Add background indicator if running in background mode
694+
if not self._is_continuation(ShellStartMessage):
695+
banner = self._format_banner("shell_command", "SHELL COMMAND")
696+
self._console.print(f"\n{banner}")
697+
698+
# Tree child with command
667699
if msg.background:
668700
self._console.print(
669-
f"\n{banner} 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
701+
f" ├─ 🚀 [dim]$ {safe_command}[/dim] [bold magenta][BACKGROUND 🌙][/bold magenta]"
670702
)
671703
else:
672-
self._console.print(f"\n{banner} 🚀 [dim]$ {safe_command}[/dim]")
704+
self._console.print(f" ├─ 🚀 [dim]$ {safe_command}[/dim]")
673705

674706
# Show working directory if specified
675707
if msg.cwd:
676708
safe_cwd = escape_rich_markup(msg.cwd)
677-
self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
709+
self._console.print(f"[dim]📂 Working directory: {safe_cwd}[/dim]")
678710

679711
# Show timeout or background status
680712
if msg.background:
681-
self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
713+
self._console.print("[dim]⏱ Runs detached (no timeout)[/dim]")
682714
else:
683-
self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
715+
self._console.print(f"[dim]⏱ Timeout: {msg.timeout}s[/dim]")
684716

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

0 commit comments

Comments
 (0)