Skip to content

Commit 8cceca4

Browse files
Kasper JungeRalphify
authored andcommitted
refactor: extract _LivePanelBase to deduplicate scroll buffer logic
_IterationPanel and _IterationSpinner had identical implementations of add_scroll_line, clear_scroll, set_peek_message, set_peek_visible, and _build_body — ~50 lines copy-pasted between the two classes. Extract a shared _LivePanelBase that both now inherit from, removing the duplication and simplifying _FullscreenPeek's type annotation. Co-authored-by: Ralphify <noreply@ralphify.co>
1 parent fa28132 commit 8cceca4

File tree

1 file changed

+59
-82
lines changed

1 file changed

+59
-82
lines changed

src/ralphify/_console_emitter.py

Lines changed: 59 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -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__(
540559
class _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

Comments
 (0)