@@ -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