diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 984153a..89190df 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -46,6 +46,37 @@ self._cap = cap self._current_assistant: ChatItem | None = None self._current_thinking: ChatItem | None = None + # Persistent terminals for the session (background dev servers, …), + # updated by terminal_opened/output/closed WS events and seeded on + # attach via REST. Not chat items — no bubble; the /terminals modal and + # the status-bar count read this. name → info dict. + self.terminals: dict[str, dict] = {} + + # ── persistent terminals ──────────────────────────────────────────────────── + + _TERMINAL_TAIL_LINES = 200 + + @property + def open_terminal_count(self) -> int: + """Number of terminals not yet closed (for the status-bar count).""" + return sum(1 for t in self.terminals.values() if not t.get("closed")) + + def seed_terminals(self, summaries: list[dict]) -> None: + """Seed the terminals state from a REST list (on attach/switch), before + any terminal_opened/output/closed events arrive this turn.""" + for s in summaries: + name = s.get("name") + if not name: + continue + self.terminals[name] = { + "name": name, + "description": s.get("description", ""), + "pid": s.get("pid"), + "background": s.get("background", False), + "status": s.get("status", "idle"), + "output_tail": [], + "closed": False, + } def _trim(self) -> None: """Drop the oldest items past the retention cap (front of the list).""" @@ -301,6 +332,42 @@ if msg_type in ("heartbeat", "session_sync"): return None + if msg_type == "terminal_opened": + # A persistent terminal started. Track it (no chat bubble) so the + # status-bar count and the /terminals modal reflect it. + name = msg.get("terminal_name", "") + if name: + self.terminals[name] = { + "name": name, + "description": msg.get("description", ""), + "pid": msg.get("pid"), + "background": bool(msg.get("background", False)), + "status": "busy", + "output_tail": list(self.terminals.get(name, {}).get("output_tail", [])), + "closed": False, + } + return None + + if msg_type == "terminal_output": + # Append to the named terminal's tail (capped). No chat bubble — + # background output is viewed via /terminals, not streamed into chat. + name = msg.get("terminal_name", "") + term = self.terminals.get(name) + if term is not None and not term.get("closed"): + tail = term.setdefault("output_tail", []) + tail.append(msg.get("delta", "")) + if len(tail) > self._TERMINAL_TAIL_LINES: + del tail[: len(tail) - self._TERMINAL_TAIL_LINES] + return None + + if msg_type == "terminal_closed": + name = msg.get("terminal_name", "") + term = self.terminals.get(name) + if term is not None: + term["closed"] = True + term["close_reason"] = msg.get("reason", "") + return None + if msg_type == "recall_update": # schedule_recall lifecycle (scheduled/fired/cancelled/skipped/ # rescheduled), pushed out-of-band by the scheduler/orchestrator. diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 265b09b..634a26b 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -242,6 +242,16 @@ self._todo_panel.set_tasks(todos.get("tasks") or []) except Exception: self._todo_panel.clear() + # Seed the terminals state (REST) so the status-bar count is right on + # attach; later terminal_opened/output/closed events keep it live. + try: + terminals = await api.list_terminals(session_id) + self._chat_panel._model.seed_terminals(terminals) + self._status_panel.set_terminals_count( + self._chat_panel._model.open_terminal_count + ) + except Exception: + self._status_panel.set_terminals_count(0) # Replay the session's past conversation before the connection banner # so a resumed/switched session shows its history instead of a blank chat. self._chat_panel.load_history(history) @@ -417,6 +427,15 @@ # or a todo tool call). Renders in the side panel; not a chat item. self._todo_panel.set_tasks(payload.get("tasks") or []) return + elif msg_type in ("terminal_opened", "terminal_output", "terminal_closed"): + # Persistent-terminal lifecycle/stream — not a chat item. Update the + # model's terminals state (no bubble, no per-event _sync walk — + # background output can be frequent) and the status-bar count. + self._chat_panel._model.handle_ws_event(payload) + self._status_panel.set_terminals_count( + self._chat_panel._model.open_terminal_count + ) + return elif msg_type in ("compression_started", "context_compressed"): # Compression carries a fresh context-token count (post-compress on # context_compressed, pre-compress on compression_started). Update diff --git a/clients/terminal/tui/widgets/status_panel.py b/clients/terminal/tui/widgets/status_panel.py index 969bf89..bcdebf0 100644 --- a/clients/terminal/tui/widgets/status_panel.py +++ b/clients/terminal/tui/widgets/status_panel.py @@ -46,7 +46,9 @@ self._connection = Static("Connection: offline", classes="connection") self._backend = Static("Backend: -") self._theme = Static("Theme: -") - self._hint = Static("Ctrl+P palette | /help commands") + # Live count of open persistent terminals (background dev servers, …), + # seeded on attach and kept live by terminal_opened/closed events. + self._hint = Static("Terminals: 0") def compose(self) -> ComposeResult: yield Static("[b]Navi Code[/b]", classes="title") @@ -87,3 +89,7 @@ def set_theme(self, theme_name: str) -> None: self._theme.update(f"Theme: {theme_name}") + + def set_terminals_count(self, count: int) -> None: + """Update the open-terminals count shown in the status block.""" + self._hint.update(f"Terminals: {count}") diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 8d1987f..e4df749 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -45,10 +45,14 @@ async def fake_get_profile_model(pid): return "configured-model" + async def fake_list_terminals(session_id): + return [] + monkeypatch.setattr(api_module, "create_session", fake_create_session) monkeypatch.setattr(api_module, "get_session", fake_get_session) monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions) monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model) + monkeypatch.setattr(api_module, "list_terminals", fake_list_terminals) def _wrap_render(chat) -> list: @@ -473,6 +477,68 @@ assert len(model.items) == 20 +# ── persistent terminals (state only, no chat bubble) ────────────────────── + + +def test_chat_model_terminal_opened_tracks_state_no_bubble() -> None: + """terminal_opened updates the terminals state but adds no chat item.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event( + {"type": "terminal_opened", "terminal_name": "dev", + "description": "server", "pid": 7, "background": True} + ) + assert "dev" in model.terminals + assert model.terminals["dev"]["pid"] == 7 + assert model.open_terminal_count == 1 + assert model.items == [] # no chat bubble + + +def test_chat_model_terminal_output_appends_tail() -> None: + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event( + {"type": "terminal_opened", "terminal_name": "dev", "description": "", "pid": 1} + ) + model.handle_ws_event({"type": "terminal_output", "terminal_name": "dev", "stream": "stdout", "delta": "line1\n"}) + model.handle_ws_event({"type": "terminal_output", "terminal_name": "dev", "stream": "stdout", "delta": "line2\n"}) + assert model.terminals["dev"]["output_tail"] == ["line1\n", "line2\n"] + assert model.items == [] + + +def test_chat_model_terminal_output_unknown_name_ignored() -> None: + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event({"type": "terminal_output", "terminal_name": "nope", "delta": "x"}) + assert model.terminals == {} + assert model.items == [] + + +def test_chat_model_terminal_closed_decrements_count() -> None: + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.handle_ws_event({"type": "terminal_opened", "terminal_name": "dev", "description": "", "pid": 1}) + model.handle_ws_event({"type": "terminal_closed", "terminal_name": "dev", "reason": "explicit"}) + assert model.terminals["dev"]["closed"] is True + assert model.open_terminal_count == 0 + + +def test_chat_model_seed_terminals_from_rest() -> None: + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.seed_terminals([ + {"name": "a", "description": "d", "pid": 1, "background": True, "status": "busy"}, + {"name": "b", "description": "d2", "pid": 2, "background": False, "status": "idle"}, + ]) + assert model.open_terminal_count == 2 + assert model.terminals["a"]["pid"] == 1 + + def test_chat_model_stream_end_purge_keeps_nonempty_assistant() -> None: """A non-empty assistant answer survives stream_end; only the trailing empty bubble(s) are purged (tail-trim, 1.P1).""" diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py index 5dfc126..373f253 100644 --- a/tests/clients/test_input_box.py +++ b/tests/clients/test_input_box.py @@ -40,10 +40,14 @@ async def fake_get_profile_model(pid): return "configured-model" + async def fake_list_terminals(session_id): + return [] + monkeypatch.setattr(api_module, "create_session", fake_create_session) monkeypatch.setattr(api_module, "get_session", fake_get_session) monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions) monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model) + monkeypatch.setattr(api_module, "list_terminals", fake_list_terminals) async def _set_text(pilot, text: str) -> None: diff --git a/tests/clients/test_sessions_picker.py b/tests/clients/test_sessions_picker.py index 4deb759..21e579a 100644 --- a/tests/clients/test_sessions_picker.py +++ b/tests/clients/test_sessions_picker.py @@ -38,9 +38,13 @@ async def fake_get_profile_model(pid): return "configured-model" + async def fake_list_terminals(session_id): + return [] + monkeypatch.setattr(api_module, "create_session", fake_create_session) monkeypatch.setattr(api_module, "get_session", fake_get_session) monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model) + monkeypatch.setattr(api_module, "list_terminals", fake_list_terminals) async def _sessions() -> list[dict]: diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index eef554f..14c93bc 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -48,10 +48,14 @@ async def fake_get_profile_model(profile_id: str) -> str | None: return "configured-model" + async def fake_list_terminals(session_id: str) -> list[dict]: + return [] + monkeypatch.setattr(api_module, "create_session", fake_create_session) monkeypatch.setattr(api_module, "get_session", fake_get_session) monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions) monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model) + monkeypatch.setattr(api_module, "list_terminals", fake_list_terminals) @pytest.mark.anyio @@ -590,6 +594,44 @@ @pytest.mark.anyio +async def test_terminal_events_update_status_count() -> None: + """terminal_opened/closed WS events update the status-bar count and do + NOT create chat bubbles (Etap 4).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + panel = pilot.app.query_one("StatusPanel") + + def count_text() -> str: + return str(panel._hint.render()) + + assert "Terminals: 0" in count_text() + + pilot.app.on_ws_event(WsEvent({ + "type": "terminal_opened", "terminal_name": "dev", + "description": "server", "pid": 1, "background": True, + })) + await pilot.pause() + assert "Terminals: 1" in count_text() + + pilot.app.on_ws_event(WsEvent({ + "type": "terminal_opened", "terminal_name": "t2", + "description": "x", "pid": 2, "background": True, + })) + await pilot.pause() + assert "Terminals: 2" in count_text() + + pilot.app.on_ws_event(WsEvent({ + "type": "terminal_closed", "terminal_name": "dev", "reason": "explicit", + })) + await pilot.pause() + assert "Terminals: 1" in count_text() + + # No chat bubble for terminal events. + chat = pilot.app.query_one("ChatPanel") + assert not any("terminal" in (it.content or "") and it.kind == "status" for it in chat._model.items) + + +@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: diff --git a/tests/clients/test_tui_export.py b/tests/clients/test_tui_export.py index 63a7285..ca90f80 100644 --- a/tests/clients/test_tui_export.py +++ b/tests/clients/test_tui_export.py @@ -51,9 +51,13 @@ async def fake_list_sessions() -> list[dict]: return [await fake_get_session(session_id)] + async def fake_list_terminals(session_id): + return [] + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) monkeypatch.setattr("clients.terminal.api.list_sessions", fake_list_sessions) + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list_terminals) return { "session_id": session_id,