diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 4d3f8dd..feba280 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -23,6 +23,7 @@ "status", "planning_status", "plan_ready", + "turn_meta", ] content: str = "" meta: dict = field(default_factory=dict) @@ -208,6 +209,12 @@ it.kind in ("assistant_message", "thinking_block") and not it.content ) ] + # Record the total time the agent spent on this request (all steps) + # as a dim metadata line below the answer. elapsed_seconds is the + # authoritative value measured by the backend over the whole turn. + self.items.append( + ChatItem(kind="turn_meta", meta={"elapsed_seconds": msg.get("elapsed_seconds")}) + ) self._current_assistant = None self._current_thinking = None return None diff --git a/clients/terminal/tui/duration.py b/clients/terminal/tui/duration.py new file mode 100644 index 0000000..e4a3bab --- /dev/null +++ b/clients/terminal/tui/duration.py @@ -0,0 +1,23 @@ +"""Compact human-readable duration formatting for the TUI. + +Shared by the live elapsed-timer widget (next to the activity indicator) and +the turn-metadata renderer (the dim ``⏱ 2m 16s`` line under an answer), so both +show the same compact form: ``16s``, ``2m 16s``, ``1h 2m``. +""" + +from __future__ import annotations + + +def format_duration(seconds: float | None) -> str: + """Format a duration as ``Ns`` / ``Mm Ss`` / ``Hh Mm``. + + ``None`` (the backend did not report a value) renders as ``—``. + """ + if seconds is None: + return "—" + total = int(round(seconds)) + if total < 60: + return f"{total}s" + if total < 3600: + return f"{total // 60}m {total % 60}s" + return f"{total // 3600}h {(total % 3600) // 60}m" \ No newline at end of file diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 7bc1203..83a6209 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, turn_meta def default_registry() -> RendererRegistry: @@ -22,6 +22,7 @@ reg.register(status.StatusRenderer()) reg.register(planning.PlanningStatusRenderer()) reg.register(planning.PlanReadyRenderer()) + reg.register(turn_meta.TurnMetaRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) reg.register(artifact.ArtifactRenderer()) diff --git a/clients/terminal/tui/renderers/turn_meta.py b/clients/terminal/tui/renderers/turn_meta.py new file mode 100644 index 0000000..3b4ca83 --- /dev/null +++ b/clients/terminal/tui/renderers/turn_meta.py @@ -0,0 +1,29 @@ +"""Renderer for turn-duration metadata. + +On ``stream_end`` the backend reports ``elapsed_seconds`` — the total time the +agent spent on the user's request (all LLM calls, tool calls, planning, and +sub-agent steps). We render it as a dim ``⏱ 2m 16s`` line below the answer so the +cost of each turn is visible at a glance. +""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.text import Text + +from clients.terminal.tui.duration import format_duration +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class TurnMetaRenderer(ContentRenderer): + """Render turn-duration metadata as a dim ``⏱ 2m 16s`` line.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "turn_meta" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + elapsed = msg.get("elapsed_seconds") + return Text(f"⏱ {format_duration(elapsed)}", style=theme.text_dim.hex) \ No newline at end of file diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 0081743..f175d09 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -354,6 +354,7 @@ self._streaming = True self._input_box.set_placeholder("Esc to stop") self._status_bar.activity_start("thinking") + self._status_bar.elapsed_start() self._update_footer_bindings() elif msg_type in ("stream_end", "stream_stopped", "error"): self._streaming = False @@ -362,10 +363,17 @@ self._status_bar.activity_stop() # stream_end reports the prompt-token size of the last LLM call — # how full the context window was for this turn. Update the gauge. + # It also carries elapsed_seconds — the authoritative whole-turn + # duration — so sync the live timer to it before freezing. if msg_type == "stream_end": self._status_bar.set_context( payload.get("context_tokens"), payload.get("max_context_tokens") ) + self._status_bar.elapsed_stop(payload.get("elapsed_seconds")) + else: + # stream_stopped / error: no authoritative elapsed; freeze the + # locally-measured value. + self._status_bar.elapsed_stop() self._update_footer_bindings() elif msg_type == "model_info": # The model that actually served this turn (resolved by the backend, @@ -474,6 +482,7 @@ # the spinner here rather than leave it spinning forever. if not event.connected: self._status_bar.activity_stop() + self._status_bar.elapsed_stop() def on_permission_request(self, event: PermissionRequest) -> None: """Manual PermissionRequest event (fallback from components).""" diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 9844a73..1bf27b4 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -51,6 +51,8 @@ "plan": item.content, "is_subagent": item.meta.get("is_subagent", False), } + if kind == "turn_meta": + return {"type": "turn_meta", "elapsed_seconds": item.meta.get("elapsed_seconds")} return {"type": "plain", "content": item.content} diff --git a/clients/terminal/tui/widgets/elapsed_timer.py b/clients/terminal/tui/widgets/elapsed_timer.py new file mode 100644 index 0000000..8bf3d80 --- /dev/null +++ b/clients/terminal/tui/widgets/elapsed_timer.py @@ -0,0 +1,69 @@ +"""Live elapsed-time indicator for the TUI status bar. + +Sits next to the activity indicator and ticks once per second while the agent +is working, showing how long the current request has been running (``2m 16s``). +The timer is driven by the same ``stream_start``/``stream_end`` hooks as the +activity indicator, so it measures the whole turn — all LLM calls, tool calls, +planning, and sub-agent steps. On ``stream_end`` the backend reports the +authoritative ``elapsed_seconds``; the timer syncs to it before freezing so the +final display matches the metadata line written into the chat. +""" + +from __future__ import annotations + +import time + +from rich.text import Text +from textual.timer import Timer +from textual.widgets import Static + +from clients.terminal.tui.duration import format_duration +from clients.terminal.tui.themes import get_active_theme + +_TICK_SECONDS = 1.0 + + +class ElapsedTimer(Static): + """A ``2m 16s`` elapsed counter that ticks while started.""" + + def __init__(self) -> None: + super().__init__("") + self._start_ts: float | None = None + self._active = False + self._timer: Timer | None = None + + def start(self) -> None: + """Begin counting from now and render the initial ``0s``.""" + self._start_ts = time.monotonic() + self._active = True + self._render_frame(0.0) + if self._timer is None: + self._timer = self.set_interval(_TICK_SECONDS, self._tick) + + def stop(self, final_seconds: float | None = None) -> None: + """Stop ticking and freeze the display. + + If ``final_seconds`` is given (the backend's authoritative elapsed for + the turn), sync to it so the frozen value matches the chat metadata. + Otherwise freeze at the locally-measured elapsed. + """ + elapsed = final_seconds + if elapsed is None and self._start_ts is not None: + elapsed = time.monotonic() - self._start_ts + self._active = False + self._start_ts = None + if self._timer is not None: + self._timer.stop() + self._timer = None + if self.is_mounted: + self.update(Text(format_duration(elapsed), style=get_active_theme().text_dim.hex)) + + def _tick(self) -> None: + if not self._active or self._start_ts is None: + return + self._render_frame(time.monotonic() - self._start_ts) + + def _render_frame(self, elapsed: float) -> None: + if not self.is_mounted: + return + self.update(Text(format_duration(elapsed), style=get_active_theme().text_dim.hex)) \ No newline at end of file diff --git a/clients/terminal/tui/widgets/status_bar.py b/clients/terminal/tui/widgets/status_bar.py index ac95c1b..60fa5fa 100644 --- a/clients/terminal/tui/widgets/status_bar.py +++ b/clients/terminal/tui/widgets/status_bar.py @@ -16,6 +16,7 @@ from clients.terminal.tui.themes import get_active_theme from clients.terminal.tui.widgets.activity_indicator import ActivityIndicator +from clients.terminal.tui.widgets.elapsed_timer import ElapsedTimer _COMBOS = "Ctrl+P palette · Ctrl+X Q quit · Esc stop" @@ -77,6 +78,11 @@ width: auto; height: 1; } + StatusBar ElapsedTimer { + width: auto; + height: 1; + margin-left: 1; + } StatusBar ContextFill { width: auto; height: 1; @@ -93,6 +99,7 @@ def __init__(self) -> None: super().__init__() self._activity = ActivityIndicator() + self._elapsed = ElapsedTimer() # NOTE: do not name this attribute ``_context`` — that shadows # ``MessagePump._context``, the internal context manager Textual wraps # around message processing. Shadowing it (e.g. with a widget) hangs the @@ -102,6 +109,7 @@ def compose(self) -> ComposeResult: yield self._activity + yield self._elapsed yield self._ctx_fill yield self._combos @@ -115,4 +123,10 @@ self._activity.stop() def set_context(self, used: int | None, total: int | None) -> None: - self._ctx_fill.set_tokens(used, total) \ No newline at end of file + self._ctx_fill.set_tokens(used, total) + + def elapsed_start(self) -> None: + self._elapsed.start() + + def elapsed_stop(self, final_seconds: float | None = None) -> None: + self._elapsed.stop(final_seconds) \ No newline at end of file diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 1c9350e..2e98f63 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -169,6 +169,44 @@ return base +# ─── turn_meta (request duration) ─────────────────────────────────────────── + + +def test_chat_model_stream_end_appends_turn_meta() -> None: + """stream_end records the whole-turn duration as a metadata item.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event({"type": "stream_delta", "delta": "done"}) + model.handle_ws_event({"type": "stream_end", "content": "done", "elapsed_seconds": 136}) + meta = [it for it in model.items if it.kind == "turn_meta"] + assert len(meta) == 1 + assert meta[0].meta["elapsed_seconds"] == 136 + # It sits below the assistant answer. + assert model.items[-1].kind == "turn_meta" + + +def test_chat_model_stream_end_without_elapsed_still_appends() -> None: + """If the backend omits elapsed_seconds, the metadata item still lands (renders —).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event({"type": "stream_end", "content": ""}) + meta = [it for it in model.items if it.kind == "turn_meta"] + assert len(meta) == 1 + assert meta[0].meta["elapsed_seconds"] is None + + +def test_chat_model_stream_stopped_does_not_append_turn_meta() -> None: + """A user-interrupted turn records a status line, not a duration metadata line.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event({"type": "stream_stopped"}) + assert not any(it.kind == "turn_meta" for it in model.items) + assert any(it.kind == "status" for it in model.items) + + def test_chat_model_load_history_maps_roles() -> None: from clients.terminal.tui.chat_model import ChatModel diff --git a/tests/clients/test_duration.py b/tests/clients/test_duration.py new file mode 100644 index 0000000..54a2ccb --- /dev/null +++ b/tests/clients/test_duration.py @@ -0,0 +1,25 @@ +"""Tests for the shared duration formatter.""" + +from __future__ import annotations + +from clients.terminal.tui.duration import format_duration + + +def test_format_duration_none() -> None: + assert format_duration(None) == "—" + + +def test_format_duration_seconds() -> None: + assert format_duration(0) == "0s" + assert format_duration(16.4) == "16s" + assert format_duration(59.6) == "1m 0s" # rounds to 60 -> 1m 0s + + +def test_format_duration_minutes() -> None: + assert format_duration(136) == "2m 16s" + assert format_duration(599) == "9m 59s" + + +def test_format_duration_hours() -> None: + assert format_duration(3725) == "1h 2m" + assert format_duration(3600) == "1h 0m" \ No newline at end of file diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index ca4d765..f05f459 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -191,12 +191,12 @@ await pilot.pause() items = chat._model.items - # The last item is the final answer. - assert items[-1].kind == "assistant_message" - assert items[-1].content == "X contains a and b." + # The final answer is the last assistant_message (a turn_meta duration + # line may follow it, which is expected and fine). + last_answer = max(i for i, it in enumerate(items) if it.kind == "assistant_message") + assert items[last_answer].content == "X contains a and b." # It must come after the last tool_call. last_tool = max(i for i, it in enumerate(items) if it.kind == "tool_call") - last_answer = max(i for i, it in enumerate(items) if it.kind == "assistant_message") assert last_answer > last_tool # No empty assistant bubbles. assert all(it.content for it in items if it.kind == "assistant_message") @@ -760,3 +760,105 @@ await pilot.pause() assert ctx._used == 9000 assert "28%" in str(ctx.render()) + + +# ─── elapsed timer + turn metadata ────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_elapsed_timer_starts_on_stream_start() -> None: + """stream_start starts the elapsed timer (it is active and showing 0s).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + elapsed = pilot.app.query_one("StatusBar")._elapsed + assert elapsed._active is False + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert elapsed._active is True + assert elapsed._start_ts is not None + assert "0s" in str(elapsed.render()) + + +@pytest.mark.anyio +async def test_elapsed_timer_stops_on_stream_end_synced_to_backend() -> None: + """stream_end freezes the timer at the backend's authoritative elapsed.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + elapsed = pilot.app.query_one("StatusBar")._elapsed + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": "", "elapsed_seconds": 136})) + await pilot.pause() + assert elapsed._active is False + assert "2m 16s" in str(elapsed.render()) + + +@pytest.mark.anyio +async def test_elapsed_timer_freezes_on_stream_stopped_without_backend() -> None: + """stream_stopped has no elapsed_seconds; the timer freezes at its local value.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + elapsed = pilot.app.query_one("StatusBar")._elapsed + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert elapsed._active is True + pilot.app.on_ws_event(WsEvent({"type": "stream_stopped"})) + await pilot.pause() + assert elapsed._active is False + # Frozen at some duration (locally measured, near zero) — still a value. + assert str(elapsed.render()) != "" + + +@pytest.mark.anyio +async def test_stream_end_writes_turn_meta_into_chat() -> None: + """stream_end appends a dim ⏱ duration line to the chat for the turn.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "done"})) + pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": "done", "elapsed_seconds": 136})) + await pilot.pause() + + metas = [it for it in chat._model.items if it.kind == "turn_meta"] + assert len(metas) == 1 + assert metas[0].meta["elapsed_seconds"] == 136 + # The metadata renders below the assistant answer. + assert chat._model.items[-1].kind == "turn_meta" + + +@pytest.mark.anyio +async def test_stream_stopped_does_not_write_turn_meta_into_chat() -> None: + """An interrupted turn does not get a duration metadata line.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "partial"})) + pilot.app.on_ws_event(WsEvent({"type": "stream_stopped"})) + await pilot.pause() + + assert not any(it.kind == "turn_meta" for it in chat._model.items) + + +@pytest.mark.anyio +async def test_disconnect_stops_elapsed_timer() -> None: + """A dropped connection stops the elapsed timer (no stream_end will arrive).""" + from clients.terminal.tui.events import ConnectionStatusChanged + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + elapsed = pilot.app.query_one("StatusBar")._elapsed + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert elapsed._active is True + + pilot.app.post_message(ConnectionStatusChanged(connected=False, detail="")) + await pilot.pause() + assert elapsed._active is False diff --git a/tests/clients/test_turn_meta_renderer.py b/tests/clients/test_turn_meta_renderer.py new file mode 100644 index 0000000..7533cbb --- /dev/null +++ b/tests/clients/test_turn_meta_renderer.py @@ -0,0 +1,32 @@ +"""Tests for the turn-duration metadata renderer.""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.turn_meta import TurnMetaRenderer +from clients.terminal.tui.themes import set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def test_turn_meta_renders_elapsed() -> None: + set_active_theme("gnexus-dark") + out = _render_text(TurnMetaRenderer().render({"type": "turn_meta", "elapsed_seconds": 136})) + assert "⏱ 2m 16s" in out + + +def test_turn_meta_renders_dash_when_backend_omitted_elapsed() -> None: + set_active_theme("gnexus-dark") + out = _render_text(TurnMetaRenderer().render({"type": "turn_meta", "elapsed_seconds": None})) + assert "⏱ —" in out + + +def test_turn_meta_accepts_only_turn_meta() -> None: + r = TurnMetaRenderer() + assert r.accepts({"type": "turn_meta"}) is True + assert r.accepts({"type": "status"}) is False \ No newline at end of file