diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index b15ab39..30adf7a 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -96,6 +96,24 @@ app._open_sessions_picker() +class TerminalsCommand(BaseCommand): + meta = CommandMeta( + name="terminals", + aliases=(), + description="List open persistent terminals and force-close one.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + if app is None: + return + if not ctx.session_id: + ctx.chat_panel.handle_ws_event({"type": "error", "message": "No active session"}) + return + app._open_terminals_picker(ctx.session_id) + + class SwitchCommand(BaseCommand): meta = CommandMeta( name="switch", diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py index ae3c6fb..6fd62e7 100644 --- a/clients/terminal/tui/commands/registry.py +++ b/clients/terminal/tui/commands/registry.py @@ -76,6 +76,7 @@ registry.register(builtin.NewCommand()) registry.register(builtin.SessionsCommand()) registry.register(builtin.SwitchCommand()) + registry.register(builtin.TerminalsCommand()) registry.register(builtin.ProfileCommand()) registry.register(builtin.QuitCommand()) registry.register(builtin.ThinkingCommand()) diff --git a/clients/terminal/tui/screens/terminals_picker.py b/clients/terminal/tui/screens/terminals_picker.py new file mode 100644 index 0000000..32868ca --- /dev/null +++ b/clients/terminal/tui/screens/terminals_picker.py @@ -0,0 +1,226 @@ +"""Terminals picker modal screen for Navi Code TUI. + +Lists the session's open persistent terminals (background dev servers, +watchers, …) and lets the user force-close one. Source: a REST seed on open +(``api.list_terminals``) kept live by the chat model's terminals state — the +app refreshes the picker while it is open as terminal_opened/closed events +arrive. ``Up/Down`` navigate, ``Enter`` closes the highlighted terminal, +``Escape`` cancels. +""" + +from __future__ import annotations + +from rich.text import Text +from textual import events +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import ListItem, ListView, Static + +from clients.terminal import api + + +def _row(summary: dict) -> Text: + name = summary.get("name", "?") + description = summary.get("description", "") or "" + status = summary.get("status", "idle") + pid = summary.get("pid") + uptime = summary.get("uptime_seconds") + busy = status == "busy" + icon = "🟢" if busy else "⚪" + + line = Text() + line.append(f"{icon} ", style="bold") + line.append(str(name), style="bold") + if description: + line.append(f" {description}", style="dim") + if pid is not None: + line.append(f" pid {pid}", style="dim") + if uptime is not None: + line.append(f" {int(uptime)}s", style="dim") + return line + + +class TerminalsPickerScreen(ModalScreen[str | None]): + """Pick an open terminal to close, or cancel.""" + + DEFAULT_CSS = """ + TerminalsPickerScreen { + align: center middle; + } + TerminalsPickerScreen > Container { + width: 70; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + TerminalsPickerScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + TerminalsPickerScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + TerminalsPickerScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + TerminalsPickerScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + TerminalsPickerScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, session_id: str) -> None: + super().__init__() + self._session_id = session_id + # name → summary dict (the rows we render). Built from the REST seed + # and then kept live by refresh_from_model as events arrive. + self._terminals: dict[str, dict] = {} + self._order: list[str] = [] + self._list_items: list[ListItem] = [] + + @property + def session_id(self) -> str: + return self._session_id + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Open terminals — Enter to close, Esc to cancel", classes="title") + yield ListView(id="terminals-list") + + def on_mount(self) -> None: + self.run_worker(self._load()) + + async def _load(self) -> None: + try: + summaries = await api.list_terminals(self._session_id) + except Exception as exc: + self.app.query_one("ChatPanel").handle_ws_event( + {"type": "error", "message": f"Failed to list terminals: {exc}"} + ) + self.dismiss(None) + return + self._set_from_summaries(summaries) + self._render_list() + + def _set_from_summaries(self, summaries: list[dict]) -> None: + self._terminals = {} + self._order = [] + for s in summaries: + name = s.get("name") + if name: + self._terminals[name] = s + self._order.append(name) + + def refresh_from_model(self, terminals: dict[str, dict]) -> None: + """Live-refresh the list from the chat model's terminals state (called + by the app as terminal_opened/closed events arrive while the modal is + open). Preserves the highlight where possible.""" + list_view = self.query_one("#terminals-list", ListView) + keep = list_view.index if list_view.is_mounted else None + + new_order = [name for name, t in terminals.items() if not t.get("closed")] + # Preserve original order for survivors, append newly opened at the end. + survivors = [n for n in self._order if n in new_order] + added = [n for n in new_order if n not in self._order] + self._order = survivors + added + self._terminals = { + name: { + "name": name, + "description": terminals[name].get("description", ""), + "pid": terminals[name].get("pid"), + "status": terminals[name].get("status", "idle"), + "uptime_seconds": terminals[name].get("uptime_seconds"), + } + for name in self._order + } + self._render_list() + if keep is not None and self._order and 0 <= keep < len(self._order): + list_view.index = keep + + def _render_list(self) -> None: + list_view = self.query_one("#terminals-list", ListView) + list_view.clear() + self._list_items = [] + + if not self._order: + list_view.append(ListItem(Static("No open terminals", classes="empty"))) + list_view.index = 0 + return + + for name in self._order: + item = ListItem(Static(_row(self._terminals[name]))) + self._list_items.append(item) + list_view.append(item) + list_view.index = 0 + + def _select_highlighted(self) -> str | None: + list_view = self.query_one("#terminals-list", ListView) + idx = list_view.index + if idx is None or not self._order or not (0 <= idx < len(self._order)): + return None + return self._order[idx] + + async def _close_highlighted(self) -> None: + name = self._select_highlighted() + if name is None: + return + try: + await api.close_terminal(self._session_id, name) + except Exception as exc: + self.app.query_one("ChatPanel").handle_ws_event( + {"type": "error", "message": f"Failed to close terminal '{name}': {exc}"} + ) + return + # The server emits terminal_closed → the app refreshes this modal via + # refresh_from_model. If no event arrives (e.g. already gone), drop it + # locally so the list updates immediately. + self._order = [n for n in self._order if n != name] + self._terminals.pop(name, None) + self._render_list() + + def on_key(self, event: events.Key) -> None: + list_view = self.query_one("#terminals-list", ListView) + if event.key == "down": + list_view.action_cursor_down() + event.stop() + event.prevent_default() + elif event.key == "up": + list_view.action_cursor_up() + event.stop() + event.prevent_default() + elif event.key in ("enter", "return"): + if self._order: + self.run_worker(self._close_highlighted()) + event.stop() + event.prevent_default() + elif event.key == "escape": + self.dismiss(None) + event.stop() + event.prevent_default() + + def action_dismiss_cancel(self) -> None: + self.dismiss(None) \ No newline at end of file diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 634a26b..47e126e 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -370,6 +370,13 @@ callback=on_select, ) + def _open_terminals_picker(self, session_id: str) -> None: + """Push the terminals picker — closes the selected terminal via REST. + The modal live-refreshes as terminal_opened/closed events arrive.""" + from clients.terminal.tui.screens.terminals_picker import TerminalsPickerScreen + + self.push_screen(TerminalsPickerScreen(session_id)) + def _run_command(self, text: str) -> None: parts = text[1:].split(None, 1) name = parts[0].lower() @@ -435,6 +442,13 @@ self._status_panel.set_terminals_count( self._chat_panel._model.open_terminal_count ) + # If the /terminals modal is open, refresh its list live (opened/ + # closed changes membership; output updates rows in a future pass). + from clients.terminal.tui.screens.terminals_picker import TerminalsPickerScreen + + screen = self.screen + if isinstance(screen, TerminalsPickerScreen): + screen.refresh_from_model(self._chat_panel._model.terminals) return elif msg_type in ("compression_started", "context_compressed"): # Compression carries a fresh context-token count (post-compress on diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py index 373f253..06cd9b3 100644 --- a/tests/clients/test_input_box.py +++ b/tests/clients/test_input_box.py @@ -169,15 +169,15 @@ async def test_down_arrow_moves_highlight() -> None: async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() - await _set_text(pilot, "/t") # matches: thinking, themes + await _set_text(pilot, "/t") # matches: terminals, thinking, themes hints = pilot.app.query_one("CommandHints") - assert hints.current_match().meta.name == "thinking" + assert hints.current_match().meta.name == "terminals" await pilot.press("down") await pilot.pause() - assert hints.current_match().meta.name == "themes" + assert hints.current_match().meta.name == "thinking" await pilot.press("up") await pilot.pause() - assert hints.current_match().meta.name == "thinking" + assert hints.current_match().meta.name == "terminals" @pytest.mark.anyio @@ -216,11 +216,11 @@ async with NaviCodeTui(new_session=True).run_test() as pilot: await pilot.pause() - await _set_text(pilot, "/t") # matches: thinking (top), themes - await pilot.press("down") # highlight themes + await _set_text(pilot, "/t") # matches: terminals (top), thinking, themes + await pilot.press("down") # highlight thinking await pilot.press("enter") await pilot.pause() - assert invoked == [("themes", "")] + assert invoked == [("thinking", "")] @pytest.mark.anyio diff --git a/tests/clients/test_terminals_picker.py b/tests/clients/test_terminals_picker.py new file mode 100644 index 0000000..ea52341 --- /dev/null +++ b/tests/clients/test_terminals_picker.py @@ -0,0 +1,154 @@ +"""Tests for the /terminals picker modal.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.screens.terminals_picker import TerminalsPickerScreen +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + from clients.terminal import config + import clients.terminal.tui.settings as settings_module + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.fixture(autouse=True) +def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None: + import clients.terminal.api as api_module + + async def fake_create_session(profile_id=None): + return {"session_id": "test-session", "profile_id": "navi_code"} + + async def fake_get_session(sid): + return {"session_id": sid, "profile_id": "navi_code"} + + async def fake_list_sessions(): + return [] + + 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 _terminals() -> list[dict]: + return [ + {"name": "dev", "description": "dev server", "status": "busy", "pid": 11, "uptime_seconds": 5}, + {"name": "watch", "description": "test watcher", "status": "idle", "pid": 22, "uptime_seconds": 60}, + ] + + +@pytest.mark.anyio +async def test_picker_lists_terminals(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_list(sid): + return _terminals() + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(TerminalsPickerScreen("test-session")) + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, TerminalsPickerScreen) + assert screen._order == ["dev", "watch"] + + +@pytest.mark.anyio +async def test_picker_close_on_enter(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_list(sid): + return _terminals()[:1] + closed: list[tuple] = [] + + async def fake_close(session_id, name): + closed.append((session_id, name)) + return {"session_id": session_id, "terminal_name": name, "closed": True} + + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list) + monkeypatch.setattr("clients.terminal.api.close_terminal", fake_close) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(TerminalsPickerScreen("test-session")) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert closed == [("test-session", "dev")] + # The closed terminal drops out of the list. + assert "dev" not in pilot.app.screen._order + + +@pytest.mark.anyio +async def test_picker_escape_cancels(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_list(sid): + return _terminals()[:1] + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list) + + result: list = [] + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(TerminalsPickerScreen("test-session"), callback=lambda v: result.append(v)) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + assert result == [None] + + +@pytest.mark.anyio +async def test_picker_live_refresh_from_model(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_list(sid): + return [] + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(TerminalsPickerScreen("test-session")) + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, TerminalsPickerScreen) + assert screen._order == [] + + # Simulate a terminal_opened event feeding the model, then live-refresh. + screen.refresh_from_model({ + "dev": {"description": "server", "pid": 1, "status": "busy", "closed": False}, + "gone": {"description": "old", "pid": 2, "status": "idle", "closed": True}, + }) + assert screen._order == ["dev"] # closed ones filtered out + assert "dev" in screen._terminals + + +@pytest.mark.anyio +async def test_picker_empty_state(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_list(sid): + return [] + monkeypatch.setattr("clients.terminal.api.list_terminals", fake_list) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(TerminalsPickerScreen("test-session")) + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, TerminalsPickerScreen) + assert screen._order == [] + # Enter on an empty list does not raise / does not call close. + await pilot.press("enter") + await pilot.pause() + # Still mounted, nothing closed (no crash). + assert isinstance(pilot.app.screen, TerminalsPickerScreen) \ No newline at end of file