diff --git a/src/ralphify/_console_emitter.py b/src/ralphify/_console_emitter.py index 20ee837..1696928 100644 --- a/src/ralphify/_console_emitter.py +++ b/src/ralphify/_console_emitter.py @@ -21,6 +21,7 @@ LOG_ERROR, STOP_COMPLETED, CommandsCompletedData, + DelayStartedData, Event, EventType, IterationEndedData, @@ -54,6 +55,19 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR yield text +class _DelayCountdown: + """Rich renderable that shows a countdown timer for inter-iteration delays.""" + + def __init__(self, total: float) -> None: + self._total = total + self._start = time.monotonic() + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + elapsed = time.monotonic() - self._start + remaining = max(0, self._total - elapsed) + yield Text(f"Waiting {format_duration(remaining)}...", style="dim") + + class ConsoleEmitter: """Renders engine events to the Rich console.""" @@ -67,6 +81,8 @@ def __init__(self, console: Console) -> None: EventType.ITERATION_FAILED: partial(self._on_iteration_ended, color="red", icon=_ICON_FAILURE), EventType.ITERATION_TIMED_OUT: partial(self._on_iteration_ended, color="yellow", icon=_ICON_TIMEOUT), EventType.COMMANDS_COMPLETED: self._on_commands_completed, + EventType.DELAY_STARTED: self._on_delay_started, + EventType.DELAY_ENDED: self._on_delay_ended, EventType.LOG_MESSAGE: self._on_log_message, EventType.RUN_STOPPED: self._on_run_stopped, } @@ -130,6 +146,19 @@ def _on_commands_completed(self, data: CommandsCompletedData) -> None: if count: self._console.print(f" [bold]Commands:[/bold] {count} ran") + def _on_delay_started(self, data: DelayStartedData) -> None: + countdown = _DelayCountdown(data["delay"]) + self._live = Live( + countdown, + console=self._console, + transient=True, + refresh_per_second=_LIVE_REFRESH_RATE, + ) + self._live.start() + + def _on_delay_ended(self, data: dict) -> None: # noqa: ARG002 + self._stop_live() + def _on_log_message(self, data: LogMessageData) -> None: msg = escape_markup(data["message"]) level = data["level"] diff --git a/src/ralphify/_events.py b/src/ralphify/_events.py index 6954e51..e8b3ca5 100644 --- a/src/ralphify/_events.py +++ b/src/ralphify/_events.py @@ -74,6 +74,10 @@ class EventType(Enum): # ── Agent activity (live streaming) ───────────────────────── AGENT_ACTIVITY = "agent_activity" + # ── Delay ───────────────────────────────────────────────────── + DELAY_STARTED = "delay_started" + DELAY_ENDED = "delay_ended" + # ── Other ─────────────────────────────────────────────────── LOG_MESSAGE = "log_message" @@ -131,6 +135,14 @@ class AgentActivityData(TypedDict): iteration: int +class DelayStartedData(TypedDict): + delay: float + + +class DelayEndedData(TypedDict): + pass + + class LogMessageData(TypedDict): message: str level: LogLevel @@ -146,6 +158,8 @@ class LogMessageData(TypedDict): | CommandsCompletedData | PromptAssembledData | AgentActivityData + | DelayStartedData + | DelayEndedData | LogMessageData ) """Union of all typed event data payloads.""" diff --git a/src/ralphify/engine.py b/src/ralphify/engine.py index 897e6fc..d39acbc 100644 --- a/src/ralphify/engine.py +++ b/src/ralphify/engine.py @@ -21,6 +21,8 @@ BoundEmitter, CommandsCompletedData, CommandsStartedData, + DelayEndedData, + DelayStartedData, EventEmitter, EventType, IterationEndedData, @@ -253,12 +255,13 @@ def _delay_if_needed(config: RunConfig, state: RunState, emit: BoundEmitter) -> if config.delay > 0 and ( config.max_iterations is None or state.iteration < config.max_iterations ): - emit.log_info(f"Waiting {config.delay}s...") + emit(EventType.DELAY_STARTED, DelayStartedData(delay=config.delay)) remaining = config.delay while remaining > 0 and not state.stop_requested: chunk = min(remaining, _PAUSE_POLL_INTERVAL) time.sleep(chunk) remaining -= chunk + emit(EventType.DELAY_ENDED, DelayEndedData()) def run_loop( diff --git a/tests/test_console_emitter.py b/tests/test_console_emitter.py index 136a0ea..f03bc92 100644 --- a/tests/test_console_emitter.py +++ b/tests/test_console_emitter.py @@ -3,7 +3,7 @@ import pytest from rich.console import Console -from ralphify._console_emitter import ConsoleEmitter, _IterationSpinner +from ralphify._console_emitter import ConsoleEmitter, _DelayCountdown, _IterationSpinner from ralphify._events import Event, EventType @@ -318,3 +318,26 @@ def test_full_iteration_lifecycle_cleans_up_live(self): iteration=1, detail="completed (1s)", log_file=None, result_text=None, )) assert emitter._live is None + + +class TestDelayCountdown: + def test_renders_remaining_time(self): + countdown = _DelayCountdown(60.0) + console = Console(record=True, width=80) + console.print(countdown) + output = console.export_text() + assert "Waiting" in output + assert "s..." in output + + def test_delay_started_creates_live(self): + emitter, _ = _capture_emitter() + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=10.0)) + assert emitter._live is not None + emitter._stop_live() + + def test_delay_ended_stops_live(self): + emitter, _ = _capture_emitter() + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=5.0)) + assert emitter._live is not None + emitter.emit(_make_event(EventType.DELAY_ENDED)) + assert emitter._live is None diff --git a/tests/test_engine.py b/tests/test_engine.py index 29e7902..259c488 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -516,9 +516,10 @@ def test_delay_sleeps_between_iterations(self, tmp_path): assert elapsed >= 0.1 events = drain_events(q) - assert len(events) == 1 - assert events[0].type == EventType.LOG_MESSAGE - assert "Waiting" in events[0].data["message"] + assert len(events) == 2 + assert events[0].type == EventType.DELAY_STARTED + assert events[0].data["delay"] == 0.15 + assert events[1].type == EventType.DELAY_ENDED def test_no_delay_on_last_iteration(self, tmp_path): config = make_config(tmp_path, delay=0.5, max_iterations=3)