From 8b5f4b98d771f2983fd4e65d83af4e5b50b42b6a Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 16:39:07 +0100 Subject: [PATCH 1/5] feat: add DELAY_STARTED and DELAY_ENDED event types Add event types and typed data payloads to support live countdown display during inter-iteration delays. Co-authored-by: Ralphify --- src/ralphify/_events.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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.""" From 873592aefd1e65ae6a4c4274d72b6ef52b95c698 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 16:40:06 +0100 Subject: [PATCH 2/5] feat: emit DELAY_STARTED/DELAY_ENDED events instead of log message Replace the static "Waiting 60.0s..." log message with structured delay events so the console emitter can render a live countdown. Co-authored-by: Ralphify --- src/ralphify/engine.py | 5 ++++- tests/test_engine.py | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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_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) From 8c132bb119adcc48564b6df81d356dba2e7e5a97 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 16:40:51 +0100 Subject: [PATCH 3/5] feat: add live countdown timer for inter-iteration delays Replace the static "Waiting 60.0s..." message with a live countdown that ticks down so users can see when the next iteration starts. Co-authored-by: Ralphify --- src/ralphify/_console_emitter.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ralphify/_console_emitter.py b/src/ralphify/_console_emitter.py index 20ee837..525d4c1 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 {remaining:.0f}s...", 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"] From 01b5886de6a9b4420c16cd02212646b6668cef74 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 16:41:41 +0100 Subject: [PATCH 4/5] test: add tests for delay countdown renderable and event handlers Co-authored-by: Ralphify --- tests/test_console_emitter.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 From cadbc9c7feee3fdfd648acc53edab964e51c5494 Mon Sep 17 00:00:00 2001 From: malpou Date: Sun, 22 Mar 2026 16:42:57 +0100 Subject: [PATCH 5/5] refactor: use format_duration for countdown consistency with spinner Co-authored-by: Ralphify --- src/ralphify/_console_emitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ralphify/_console_emitter.py b/src/ralphify/_console_emitter.py index 525d4c1..1696928 100644 --- a/src/ralphify/_console_emitter.py +++ b/src/ralphify/_console_emitter.py @@ -65,7 +65,7 @@ def __init__(self, total: float) -> None: def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: elapsed = time.monotonic() - self._start remaining = max(0, self._total - elapsed) - yield Text(f"Waiting {remaining:.0f}s...", style="dim") + yield Text(f"Waiting {format_duration(remaining)}...", style="dim") class ConsoleEmitter: