@@ -260,28 +260,18 @@ def _tool_style_for(name: str) -> tuple[str, str]:
260260 return _TOOL_STYLES .get (name , _DEFAULT_TOOL_STYLE )
261261
262262
263- class _IterationPanel :
264- """Rich renderable for the live peek panel.
265-
266- Shows a bordered Rich :class:`Panel` whose title carries elapsed time
267- and token counts, body holds the most recent activity rows, and
268- footer holds a spinner + tool counters + model name.
263+ class _LivePanelBase :
264+ """Shared scroll-buffer state for iteration Live renderables.
269265
270- The activity feed is the centerpiece — each row shows a colored tool
271- name (color-coded by intent: blue for read/search, orange for
272- mutating, green for execution, lavender for web) followed by its
273- primary argument (file path, pattern, command) in dim text .
266+ Both :class:`_IterationPanel` (Claude structured output) and
267+ :class:`_IterationSpinner` (raw agent output) need the same scroll
268+ buffer, peek visibility toggle, and body-rendering logic. This base
269+ class provides all of that so neither subclass has to duplicate it .
274270 """
275271
276272 def __init__ (self ) -> None :
277273 self ._spinner = Spinner ("dots" , style = f"bold { _brand .PURPLE } " )
278274 self ._start = time .monotonic ()
279- self ._model : str = ""
280- self ._tool_count : int = 0
281- self ._tool_categories : dict [str , int ] = {}
282- self ._input_tokens : int = 0
283- self ._output_tokens : int = 0
284- self ._cache_read_tokens : int = 0
285275 self ._scroll_lines : list [Text ] = []
286276 self ._peek_message : Text | None = None
287277 # Visibility flag controlled by toggle_peek. Buffering keeps
@@ -309,6 +299,53 @@ def set_peek_visible(self, visible: bool) -> None:
309299 """Show or hide the scroll feed without touching the buffer."""
310300 self ._peek_visible = visible
311301
302+ # ── Shared rendering ─────────────────────────────────────────────
303+
304+ def _build_footer (self ) -> Table :
305+ """Subclasses must override to provide the footer summary row."""
306+ raise NotImplementedError
307+
308+ def _build_body (self ) -> Group :
309+ """Body group: scroll lines (or peek message) + spacer + footer."""
310+ rows : list [Any ] = []
311+ if self ._peek_visible :
312+ visible = self ._scroll_lines [- _MAX_VISIBLE_SCROLL :]
313+ if visible :
314+ for line in visible :
315+ line .no_wrap = True
316+ line .overflow = "ellipsis"
317+ rows .append (line )
318+ elif self ._peek_message is not None :
319+ rows .append (self ._peek_message )
320+ elif self ._peek_message is not None :
321+ rows .append (self ._peek_message )
322+ rows .append (Text ("" )) # spacer above footer
323+ rows .append (self ._build_footer ())
324+ return Group (* rows )
325+
326+
327+ class _IterationPanel (_LivePanelBase ):
328+ """Rich renderable for the live peek panel.
329+
330+ Shows a bordered Rich :class:`Panel` whose title carries elapsed time
331+ and token counts, body holds the most recent activity rows, and
332+ footer holds a spinner + tool counters + model name.
333+
334+ The activity feed is the centerpiece — each row shows a colored tool
335+ name (color-coded by intent: blue for read/search, orange for
336+ mutating, green for execution, lavender for web) followed by its
337+ primary argument (file path, pattern, command) in dim text.
338+ """
339+
340+ def __init__ (self ) -> None :
341+ super ().__init__ ()
342+ self ._model : str = ""
343+ self ._tool_count : int = 0
344+ self ._tool_categories : dict [str , int ] = {}
345+ self ._input_tokens : int = 0
346+ self ._output_tokens : int = 0
347+ self ._cache_read_tokens : int = 0
348+
312349 # ── Stream-json processing ───────────────────────────────────────
313350
314351 def apply (self , raw : dict [str , Any ]) -> None :
@@ -494,24 +531,6 @@ def _build_footer(self) -> Table:
494531 grid .add_row (self ._spinner , summary , hint )
495532 return grid
496533
497- def _build_body (self ) -> Group :
498- """Body group: scroll lines (or peek message) + spacer + footer."""
499- rows : list [Any ] = []
500- if self ._peek_visible :
501- visible = self ._scroll_lines [- _MAX_VISIBLE_SCROLL :]
502- if visible :
503- for line in visible :
504- line .no_wrap = True
505- line .overflow = "ellipsis"
506- rows .append (line )
507- elif self ._peek_message is not None :
508- rows .append (self ._peek_message )
509- elif self ._peek_message is not None :
510- rows .append (self ._peek_message )
511- rows .append (Text ("" )) # spacer above footer
512- rows .append (self ._build_footer ())
513- return Group (* rows )
514-
515534 def __rich_console__ (
516535 self , console : Console , options : ConsoleOptions
517536 ) -> RenderResult :
@@ -540,8 +559,8 @@ def __rich_console__(
540559class _FullscreenPeek :
541560 """Scrollable alt-screen view of the activity buffer.
542561
543- Reads ``_scroll_lines`` from a source :class:`_IterationPanel` or
544- :class:`_IterationSpinner` (they both expose the same attribute ).
562+ Reads ``_scroll_lines`` from a source :class:`_LivePanelBase` subclass
563+ (either :class:`_IterationPanel` or :class:`_IterationSpinner` ).
545564 The source keeps receiving agent events in the background, so as new
546565 lines land the view follows the tail when ``_auto_scroll`` is set.
547566
@@ -551,7 +570,7 @@ class _FullscreenPeek:
551570 auto-scroll just means "keep offset at 0".
552571 """
553572
554- def __init__ (self , source : _IterationPanel | _IterationSpinner ) -> None :
573+ def __init__ (self , source : _LivePanelBase ) -> None :
555574 self ._source = source
556575 self ._offset : int = 0
557576 self ._auto_scroll : bool = True
@@ -929,7 +948,7 @@ def enter_fullscreen(self) -> bool:
929948 with self ._console_lock :
930949 if self ._fullscreen_view is not None :
931950 return True # already active — no-op
932- source : _IterationPanel | _IterationSpinner | None = (
951+ source : _LivePanelBase | None = (
933952 self ._iteration_panel or self ._iteration_spinner
934953 )
935954 if source is None :
@@ -978,7 +997,7 @@ def _restart_compact_unlocked(self) -> None:
978997 an iteration is still running. No-op when the iteration has
979998 already ended.
980999 """
981- source : _IterationPanel | _IterationSpinner | None = (
1000+ source : _LivePanelBase | None = (
9821001 self ._iteration_panel or self ._iteration_spinner
9831002 )
9841003 if source is None :
@@ -1133,39 +1152,14 @@ def _on_run_stopped(self, data: RunStoppedData) -> None:
11331152 self ._console .print (f"[bold { _brand .GREEN } ]Done:[/] { summary } " )
11341153
11351154
1136- class _IterationSpinner :
1155+ class _IterationSpinner ( _LivePanelBase ) :
11371156 """Rich renderable for non-Claude agents that emit raw stdout.
11381157
11391158 Same panel chrome as :class:`_IterationPanel` so the visual feels
11401159 consistent across agents — only the body content differs (raw text
11411160 lines vs. structured tool rows).
11421161 """
11431162
1144- def __init__ (self ) -> None :
1145- self ._spinner = Spinner ("dots" , style = f"bold { _brand .PURPLE } " )
1146- self ._start = time .monotonic ()
1147- self ._scroll_lines : list [Text ] = []
1148- self ._peek_message : Text | None = None
1149- self ._peek_visible : bool = True
1150-
1151- def add_scroll_line (self , markup : str ) -> None :
1152- """Append a Rich-markup scroll line to the transient buffer."""
1153- self ._scroll_lines .append (Text .from_markup (markup ))
1154- if len (self ._scroll_lines ) > _MAX_SCROLL_LINES :
1155- self ._scroll_lines .pop (0 )
1156-
1157- def clear_scroll (self ) -> None :
1158- """Drop all buffered scroll lines."""
1159- self ._scroll_lines .clear ()
1160-
1161- def set_peek_message (self , markup : str ) -> None :
1162- """Set a transient status message shown inside the Live region."""
1163- self ._peek_message = Text .from_markup (markup )
1164-
1165- def set_peek_visible (self , visible : bool ) -> None :
1166- """Show or hide the scroll feed without touching the buffer."""
1167- self ._peek_visible = visible
1168-
11691163 def _build_title (self ) -> Text :
11701164 elapsed = time .monotonic () - self ._start
11711165 title = Text ()
@@ -1195,23 +1189,6 @@ def _build_footer(self) -> Table:
11951189 grid .add_row (self ._spinner , summary , hint )
11961190 return grid
11971191
1198- def _build_body (self ) -> Group :
1199- rows : list [Any ] = []
1200- if self ._peek_visible :
1201- visible = self ._scroll_lines [- _MAX_VISIBLE_SCROLL :]
1202- if visible :
1203- for line in visible :
1204- line .no_wrap = True
1205- line .overflow = "ellipsis"
1206- rows .append (line )
1207- elif self ._peek_message is not None :
1208- rows .append (self ._peek_message )
1209- elif self ._peek_message is not None :
1210- rows .append (self ._peek_message )
1211- rows .append (Text ("" ))
1212- rows .append (self ._build_footer ())
1213- return Group (* rows )
1214-
12151192 def __rich_console__ (
12161193 self , console : Console , options : ConsoleOptions
12171194 ) -> RenderResult :
0 commit comments