Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/ralphify/_console_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
LOG_ERROR,
STOP_COMPLETED,
CommandsCompletedData,
DelayStartedData,
Event,
EventType,
IterationEndedData,
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
}
Expand Down Expand Up @@ -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"]
Expand Down
14 changes: 14 additions & 0 deletions src/ralphify/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -146,6 +158,8 @@ class LogMessageData(TypedDict):
| CommandsCompletedData
| PromptAssembledData
| AgentActivityData
| DelayStartedData
| DelayEndedData
| LogMessageData
)
"""Union of all typed event data payloads."""
Expand Down
5 changes: 4 additions & 1 deletion src/ralphify/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
BoundEmitter,
CommandsCompletedData,
CommandsStartedData,
DelayEndedData,
DelayStartedData,
EventEmitter,
EventType,
IterationEndedData,
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 24 additions & 1 deletion tests/test_console_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
7 changes: 4 additions & 3 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading