From aade96ee9bbf32d74f4c4301e6cab7e6575c7fee Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 15 Sep 2026 16:09:47 +0400 Subject: [PATCH] feat: show average token speed in statusline --- src/lecode/agent/runner.py | 5 +++++ src/lecode/tui/app.py | 7 +++++++ src/lecode/tui/feed.py | 12 ++++++++++-- src/lecode/tui/statusline.py | 9 +++++++++ tests/test_agent_runner.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_tui_app.py | 11 +++++++++++ tests/test_tui_feed.py | 14 ++++++++++++++ tests/test_tui_statusline.py | 10 +++++++++- 8 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/lecode/agent/runner.py b/src/lecode/agent/runner.py index 90f8d8b..a0ab8e8 100644 --- a/src/lecode/agent/runner.py +++ b/src/lecode/agent/runner.py @@ -111,6 +111,8 @@ class LlmResponse: cost_usd: float #: Characters sent in this call's prompt — calibrates live token estimates. prompt_chars: int = 0 + #: Model-call seconds, including latency/retries but excluding tool execution. + elapsed_s: float = 0.0 @dataclass(frozen=True) @@ -320,7 +322,9 @@ async def run( self._partial = None prompt_chars = _prompt_chars(history) await self._emit(on_event, LlmCall(model=self.model, turn=turns + 1)) + call_started_at = time.monotonic() completed = await self._stream_turn(history, on_event) + call_elapsed_s = time.monotonic() - call_started_at turns += 1 in_tok, out_tok, cost = self._turn_cost(completed) @@ -333,6 +337,7 @@ async def run( output_tokens=out_tok, cost_usd=cost, prompt_chars=prompt_chars, + elapsed_s=call_elapsed_s, ), ) input_tokens += in_tok diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 712f5b2..354dd73 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -430,6 +430,8 @@ def switch_session(self, session: Session) -> bool: self._runtime.ctx.permission_checker = checker self._last_response = "" self._reload_history() # also refreshes the statusline usage lines + self._status.timed_output_tokens = 0 + self._status.model_elapsed_s = 0.0 self._input_history.rebind(session) if self._input_area is not None: self._input_area.history = self._input_history @@ -1679,6 +1681,9 @@ def _on_event(self, event: Any) -> None: self._feed.llm_call(event.model, event.turn) self._activity("thinking") elif isinstance(event, LlmResponse): + if event.elapsed_s > 0 and event.output_tokens > 0: + self._status.timed_output_tokens += event.output_tokens + self._status.model_elapsed_s += event.elapsed_s if event.input_tokens > 0 and event.prompt_chars > 0: # Calibrate the live estimate: EMA of chars/token, clamped. ratio = min(max(event.prompt_chars / event.input_tokens, 2.0), 8.0) @@ -1689,6 +1694,7 @@ def _on_event(self, event: Any) -> None: event.input_tokens, event.output_tokens, event.cost_usd, + elapsed_s=event.elapsed_s, ) elif isinstance(event, Done): self._feed.stream_end() @@ -1730,6 +1736,7 @@ def _on_child_event(self, agent: str, event: Any) -> None: event.input_tokens, event.output_tokens, event.cost_usd, + elapsed_s=event.elapsed_s, ) elif isinstance(event, Done): self._feed.info(f"{agent} finished ({event.stop_reason}, {event.turns} turn(s))") diff --git a/src/lecode/tui/feed.py b/src/lecode/tui/feed.py index 8dcf86b..bfc88c1 100644 --- a/src/lecode/tui/feed.py +++ b/src/lecode/tui/feed.py @@ -163,9 +163,15 @@ def llm_call(self, model: str, turn: int) -> None: ) def llm_response( - self, model: str, turn: int, input_tokens: int, output_tokens: int, cost_usd: float + self, + model: str, + turn: int, + input_tokens: int, + output_tokens: int, + cost_usd: float, + elapsed_s: float = 0.0, ) -> None: - """Log one finished LLM call: ``← model (round N) · ↑in · ↓out · $cost``.""" + """Log per-call usage and output tok/s over model-call time when available.""" # The streamed answer text has no trailing newline yet — close it first. self._flush_stream() line = ( @@ -173,6 +179,8 @@ def llm_response( f" · ↑{human_tokens(input_tokens)} in · ↓{human_tokens(output_tokens)} out" f" · {format_cost(cost_usd)}" ) + if elapsed_s > 0 and output_tokens > 0: + line += f" · {output_tokens / elapsed_s:.1f} tok/s" self._console.print(Text(line, style=self._theme.muted)) def tool_call(self, name: str, args_preview: str) -> None: diff --git a/src/lecode/tui/statusline.py b/src/lecode/tui/statusline.py index bbfc66c..3c506b0 100644 --- a/src/lecode/tui/statusline.py +++ b/src/lecode/tui/statusline.py @@ -67,6 +67,9 @@ class StatusState: context_window: int = 200_000 input_tokens: int = 0 output_tokens: int = 0 + #: Completed responses measured in this session since launch. + timed_output_tokens: int = 0 + model_elapsed_s: float = 0.0 cost_usd: float = 0.0 state: StatusLineState = StatusLineState.IDLE queued: int = 0 @@ -170,6 +173,12 @@ def labelled(line: Text, label: str, value: str, style: str, *, first: bool = Fa labelled(line3, "agent", state.agent, theme.accent) labelled(line3, "in", human_tokens(state.input_tokens), theme.muted) labelled(line3, "out", human_tokens(state.output_tokens), theme.muted) + speed = ( + f"{state.timed_output_tokens / state.model_elapsed_s:.1f}" + if state.model_elapsed_s > 0 + else "—" + ) + labelled(line3, "avg tok/s", speed, theme.muted) line3.append_text(sep.copy()) line3.append(state_seg, style=getattr(theme, state_color)) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 4f23131..0d906cd 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace import pytest from tests.fakes import FakeProvider, sample_catalog @@ -110,6 +111,39 @@ async def test_llm_call_event_per_round(tool_ctx): assert all(r.cost_usd >= 0 for r in responses) +async def test_response_timing_excludes_tools(tool_ctx, monkeypatch): + runner, _ = make_runner( + tool_ctx, + [ + {"tool_calls": [{"id": "c1", "name": "echo", "arguments": '{"text": "hi"}'}]}, + {"text": "done", "usage": {"input_tokens": 10, "output_tokens": 15}}, + ], + ) + now = 0.0 + monkeypatch.setattr("lecode.agent.runner.time", SimpleNamespace(monotonic=lambda: now)) + stream_turn = runner._stream_turn + + async def timed_stream(history, on_event): + nonlocal now + completed = await stream_turn(history, on_event) + now += 2.0 + return completed + + monkeypatch.setattr(runner, "_stream_turn", timed_stream) + responses = [] + + def on_event(event): + nonlocal now + if isinstance(event, ToolResult): + now += 100.0 + elif isinstance(event, LlmResponse): + responses.append(event) + + result = await runner.run([{"role": "user", "content": "echo hi"}], on_event) + assert [event.elapsed_s for event in responses] == [2.0, 2.0] + assert result.elapsed_s == 104.0 + + async def test_tool_round_trip(tool_ctx): script = [ {"tool_calls": [{"id": "c1", "name": "echo", "arguments": '{"text": "hi"}'}]}, diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 21f0a4a..0c8f83d 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -489,6 +489,17 @@ async def test_submit_prints_per_answer_stats_line(tmp_path, monkeypatch): assert "1 round" in rendered +def test_status_average_accumulates_response_tokens_and_time(tmp_path, monkeypatch): + from lecode.agent.runner import LlmResponse + + app, _, _ = make_app(tmp_path, monkeypatch, []) + app._on_event(LlmResponse("m", 1, 10, 100, 0.0, elapsed_s=2.0)) + app._on_event(LlmResponse("m", 2, 10, 50, 0.0, elapsed_s=4.0)) + app._on_event(LlmResponse("m", 3, 10, 90, 0.0)) + assert app._status.timed_output_tokens == 150 + assert app._status.model_elapsed_s == 6.0 + + async def test_reasoning_and_tool_events_render(tmp_path, monkeypatch): script = [ { diff --git a/tests/test_tui_feed.py b/tests/test_tui_feed.py index e1114c0..d752005 100644 --- a/tests/test_tui_feed.py +++ b/tests/test_tui_feed.py @@ -111,6 +111,20 @@ def test_llm_response_closes_streamed_line(theme): assert "← m (round 1)" in lines[1] +@pytest.mark.parametrize( + ("output_tokens", "elapsed_s", "speed"), + [(15, 2.0, "7.5 tok/s"), (15, 0.0, None), (15, -1.0, None), (0, 2.0, None)], +) +def test_llm_response_speed(theme, output_tokens, elapsed_s, speed): + feed, out = make_feed(theme) + feed.llm_response("m", 1, 10, output_tokens, 0.001, elapsed_s=elapsed_s) + rendered = out.getvalue() + if speed is None: + assert "tok/s" not in rendered + else: + assert speed in rendered + + def test_assistant_text_renders_markdown(theme): feed, out = make_feed(theme) feed.assistant_text("# Title\n\nsome **bold** text") diff --git a/tests/test_tui_statusline.py b/tests/test_tui_statusline.py index 7907b67..7428569 100644 --- a/tests/test_tui_statusline.py +++ b/tests/test_tui_statusline.py @@ -75,7 +75,15 @@ def test_line2_omits_reasoning_at_baseline(state, theme): def test_line3_session_agent_tokens_state(state, theme): line3 = render_statusline(state, theme, width=200).plain.splitlines()[2] - assert line3 == "session: my-session · agent: default · in: 1.2k · out: 0.4k · ready" + assert line3 == ( + "session: my-session · agent: default · in: 1.2k · out: 0.4k · avg tok/s: — · ready" + ) + + +def test_average_speed(state, theme): + state.timed_output_tokens = 150 + state.model_elapsed_s = 6.0 + assert "out: 0.4k · avg tok/s: 25.0" in render_statusline(state, theme, width=200).plain def test_renders_exactly_three_lines(state, theme):