diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 3c7bd2c..026cc0c 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -7,7 +7,7 @@ from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical -from textual.widgets import Footer, Header +from textual.widgets import Header from clients.terminal import api from clients.terminal.config import settings @@ -30,7 +30,7 @@ from clients.terminal.tui.commands.registry import get_registry from clients.terminal.tui.screens.command_palette import CommandPaletteScreen from clients.terminal.tui.screens.permission_dialog import PermissionDialogScreen -from clients.terminal.tui.widgets import ChatPanel, InputBox, SessionsPanel, StatusPanel +from clients.terminal.tui.widgets import ChatPanel, InputBox, SessionsPanel, StatusBar, StatusPanel from clients.terminal.tui.ws_bridge import WsBridge @@ -66,6 +66,7 @@ self._status_panel = StatusPanel() self._sessions_panel = SessionsPanel() self._input_box = InputBox() + self._status_bar = StatusBar() self._state = StateManager() self._ctx = TuiContext( state=self._state, @@ -91,7 +92,7 @@ yield self._status_panel yield self._sessions_panel yield self._input_box - yield Footer() + yield self._status_bar def on_mount(self) -> None: self.apply_theme() @@ -339,12 +340,14 @@ msg_type = payload.get("type") if msg_type == "stream_start": self._streaming = True - self._input_box.set_placeholder("Navi is thinking... (Esc to stop)") + self._input_box.set_placeholder("Esc to stop") + self._status_bar.activity_start("thinking") self._update_footer_bindings() elif msg_type in ("stream_end", "stream_stopped", "error"): self._streaming = False self._input_box.set_placeholder("Ask anything...") self._input_box.focus_input() + self._status_bar.activity_stop() self._update_footer_bindings() elif msg_type == "model_info": # The model that actually served this turn (resolved by the backend, @@ -354,6 +357,18 @@ if model: self._status_panel.set_model(model) return + elif msg_type == "stream_delta" and self._streaming: + # First real output: the agent is now responding, not just thinking. + self._status_bar.activity_label("responding") + elif msg_type == "thinking_delta" and self._streaming: + self._status_bar.activity_label("thinking") + elif msg_type == "planning_status": + self._status_bar.activity_label("planning") + elif msg_type == "plan_ready": + self._status_bar.activity_label("planning") + elif msg_type == "tool_started": + tool = payload.get("tool", "") + self._status_bar.activity_label(f"running {tool}" if tool else "running tool") if msg_type == "tool_started": tool = payload.get("tool", "") @@ -427,6 +442,10 @@ def on_connection_status_changed(self, event: ConnectionStatusChanged) -> None: self._status_panel.set_connection(event.connected, event.detail) + # A dropped connection mid-turn will never deliver stream_end, so stop + # the spinner here rather than leave it spinning forever. + if not event.connected: + self._status_bar.activity_stop() def on_permission_request(self, event: PermissionRequest) -> None: """Manual PermissionRequest event (fallback from components).""" diff --git a/clients/terminal/tui/widgets/__init__.py b/clients/terminal/tui/widgets/__init__.py index 6b9fc96..42d3bfc 100644 --- a/clients/terminal/tui/widgets/__init__.py +++ b/clients/terminal/tui/widgets/__init__.py @@ -5,6 +5,7 @@ from .chat_panel import ChatPanel from .input_box import InputBox from .sessions_panel import SessionsPanel +from .status_bar import StatusBar from .status_panel import StatusPanel -__all__ = ["ChatPanel", "InputBox", "SessionsPanel", "StatusPanel"] +__all__ = ["ChatPanel", "InputBox", "SessionsPanel", "StatusBar", "StatusPanel"] diff --git a/clients/terminal/tui/widgets/activity_indicator.py b/clients/terminal/tui/widgets/activity_indicator.py new file mode 100644 index 0000000..cea96fb --- /dev/null +++ b/clients/terminal/tui/widgets/activity_indicator.py @@ -0,0 +1,69 @@ +"""Animated activity indicator for the TUI status panel. + +A rotating braille-spinner + phase label that runs on its own timer while the +agent is working. Because the timer is independent of the WebSocket event flow, +the spinner keeps moving during silent gaps (a long-running tool, the pause +before the first token, sub-agent reasoning) — so "still spinning" means the +agent is alive, and "frozen" means it hung. That is the whole point: make +"hung or just thinking" answerable at a glance. +""" + +from __future__ import annotations + +from rich.text import Text +from textual.widgets import Static +from textual.timer import Timer + +from clients.terminal.tui.themes import get_active_theme + +_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +_TICK_SECONDS = 0.1 + + +class ActivityIndicator(Static): + """Spinner + phase label, animated while started.""" + + def __init__(self) -> None: + super().__init__("") + self._frame = 0 + self._label = "" + self._active = False + self._timer: Timer | None = None + + def start(self, label: str) -> None: + """Begin animating with ``label`` as the current phase.""" + self._label = label + self._active = True + self._render_frame() + if self._timer is None: + self._timer = self.set_interval(_TICK_SECONDS, self._tick) + + def set_label(self, label: str) -> None: + """Update the phase label without restarting the animation.""" + if not self._active: + return + self._label = label + self._render_frame() + + def stop(self) -> None: + """Stop animating and clear the line.""" + self._active = False + self._label = "" + if self._timer is not None: + self._timer.stop() + self._timer = None + if self.is_mounted: + self.update("") + + def _tick(self) -> None: + if not self._active: + return + self._frame = (self._frame + 1) % len(_FRAMES) + self._render_frame() + + def _render_frame(self) -> None: + if not self.is_mounted: + return + theme = get_active_theme() + glyph = _FRAMES[self._frame] + self.update(Text(f"{glyph} {self._label}", style=theme.status_online.hex)) \ No newline at end of file diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index 11d5e6f..c21218a 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -54,7 +54,7 @@ } InputBox > TextArea { height: auto; - min-height: 3; + min-height: 1; max-height: 12; width: 100%; border: none; diff --git a/clients/terminal/tui/widgets/status_bar.py b/clients/terminal/tui/widgets/status_bar.py new file mode 100644 index 0000000..82a14d5 --- /dev/null +++ b/clients/terminal/tui/widgets/status_bar.py @@ -0,0 +1,58 @@ +"""Bottom status line for the TUI. + +Hosts the activity indicator (left) and a dim key-combo summary (right). The +indicator is the live "agent is working" signal; the combos keep the line +useful while idle. Sits at the very bottom, replacing Textual's Footer so the +working indicator and the key hints share one line. +""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + +from clients.terminal.tui.widgets.activity_indicator import ActivityIndicator + +_COMBOS = "Ctrl+P palette · Ctrl+X Q quit · Esc stop" + + +class StatusBar(Horizontal): + """One-line bottom bar: activity indicator + key combos.""" + + DEFAULT_CSS = """ + StatusBar { + height: 1; + background: $tui-panel; + color: $tui-text-dim; + padding: 0 1; + } + StatusBar ActivityIndicator { + width: auto; + height: 1; + } + StatusBar .combos { + color: $tui-text-dim; + text-align: right; + width: 1fr; + height: 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self._activity = ActivityIndicator() + self._combos = Static(_COMBOS, classes="combos") + + def compose(self) -> ComposeResult: + yield self._activity + yield self._combos + + def activity_start(self, label: str) -> None: + self._activity.start(label) + + def activity_label(self, label: str) -> None: + self._activity.set_label(label) + + def activity_stop(self) -> None: + self._activity.stop() \ No newline at end of file diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 7f00d50..eb883d5 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -502,3 +502,68 @@ # An error was surfaced to the chat panel. after_errors = sum(1 for it in chat._model.items if it.kind == "error") assert after_errors == before_errors + 1 + + +@pytest.mark.anyio +async def test_activity_indicator_runs_during_turn() -> None: + """stream_start starts the spinner; stream_end stops it.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + status_bar = pilot.app.query_one("StatusBar") + activity = status_bar._activity + + assert activity._active is False + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert activity._active is True + assert activity._label == "thinking" + + pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": ""})) + await pilot.pause() + assert activity._active is False + assert activity._label == "" + + +@pytest.mark.anyio +async def test_activity_indicator_phase_label_reflects_events() -> None: + """The spinner label tracks the current stage as WS events arrive.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + status_bar = pilot.app.query_one("StatusBar") + activity = status_bar._activity + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert activity._label == "thinking" + + pilot.app.on_ws_event(WsEvent({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False})) + await pilot.pause() + assert activity._label == "planning" + + pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "hi"})) + await pilot.pause() + assert activity._label == "responding" + + pilot.app.on_ws_event(WsEvent({"type": "tool_started", "tool": "filesystem", "args": {}})) + await pilot.pause() + assert activity._label == "running filesystem" + + +@pytest.mark.anyio +async def test_activity_indicator_stops_on_disconnect() -> None: + """A dropped connection stops the spinner (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() + status_bar = pilot.app.query_one("StatusBar") + activity = status_bar._activity + + pilot.app.on_ws_event(WsEvent({"type": "stream_start"})) + await pilot.pause() + assert activity._active is True + + pilot.app.post_message(ConnectionStatusChanged(connected=False, detail="")) + await pilot.pause() + assert activity._active is False