diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index ae2e22b..4be230e 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -67,25 +67,29 @@ meta = CommandMeta( name="sessions", aliases=("resume", "continue"), - description="List and switch between sessions.", + description="Pick a navi_code session to switch to.", keybind="ctrl+x l", ) async def execute(self, ctx: TuiContext, args: str) -> None: - try: - sessions = api.list_sessions() - except Exception as exc: - ctx.chat_panel.handle_ws_event( - {"type": "error", "message": f"Failed to list sessions: {exc}"} - ) + from clients.terminal.tui.screens.sessions_picker import ( + NEW_SESSION, + SessionsPickerScreen, + ) + + app = ctx.app() + if app is None: return - lines = ["[b]Sessions[/b]"] - for s in sessions: - sid = s.get("session_id", "") - marker = "● " if sid == ctx.session_id else " " - title = s.get("name", "") or s.get("preview", "") - lines.append(f"{marker}{sid[:8]} {s.get('profile_id', 'unknown')} {title}") - ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) + + def on_select(session_id: str | None) -> None: + if session_id is None: + return + if session_id == NEW_SESSION: + app.run_worker(app._create_new_session_worker()) + return + app.run_worker(app._switch_session_worker(session_id)) + + app.push_screen(SessionsPickerScreen(ctx.session_id), callback=on_select) class SwitchCommand(BaseCommand): diff --git a/clients/terminal/tui/screens/sessions_picker.py b/clients/terminal/tui/screens/sessions_picker.py new file mode 100644 index 0000000..1020403 --- /dev/null +++ b/clients/terminal/tui/screens/sessions_picker.py @@ -0,0 +1,211 @@ +"""Sessions picker modal screen for Navi Code TUI. + +Lists the sessions that belong to the client's own profile (``navi_code``) and +lets the user pick one to switch to. A top ``✚ New session`` entry creates a +fresh session instead. Dismisses with the selected session id, the sentinel +``"__new__"`` (create a new session), or ``None`` if cancelled. The caller is +responsible for performing the actual switch / creation. +""" + +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 Input, ListItem, ListView, Static + +from clients.terminal import api +from clients.terminal.config import settings + +# Sentinel dismissed value meaning "create a new session" rather than switch +# to an existing one. A real session id is never this. +NEW_SESSION = "__new__" + + +class SessionsPickerScreen(ModalScreen[str | None]): + """Pick an existing navi_code session or create a new one.""" + + DEFAULT_CSS = """ + SessionsPickerScreen { + align: center middle; + } + SessionsPickerScreen > Container { + width: 70; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + SessionsPickerScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + SessionsPickerScreen Input { + height: auto; + border: none; + border-bottom: solid $tui-border; + background: $tui-background; + color: $tui-text; + padding: 0 1; + margin: 0; + } + SessionsPickerScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + SessionsPickerScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + SessionsPickerScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + SessionsPickerScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, current_session_id: str | None = None) -> None: + super().__init__() + self._current_session_id = current_session_id + # The "New session" row is always index 0; real sessions follow. + self._sessions: list[dict] = [] + self._filtered: list[dict] = [] + self._list_items: list[ListItem] = [] + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Switch session (navi_code)", classes="title") + yield Input(placeholder="Type to filter sessions...", id="sessions-input") + yield ListView(id="sessions-list") + + def on_mount(self) -> None: + try: + raw = api.list_sessions() + except Exception as exc: + self.app.query_one("ChatPanel").handle_ws_event( + {"type": "error", "message": f"Failed to list sessions: {exc}"} + ) + self.dismiss(None) + return + profile_id = settings.default_profile_id + self._sessions = [s for s in raw if s.get("profile_id") == profile_id] + self._filtered = list(self._sessions) + self._render_list() + self.query_one("#sessions-input", Input).focus() + + def _render_list(self) -> None: + list_view = self.query_one("#sessions-list", ListView) + list_view.clear() + self._list_items = [] + + # "New session" row is always present and first. + self._list_items.append(ListItem(Static(Text("✚ New session", style="bold")))) + list_view.append(self._list_items[-1]) + + if not self._filtered: + list_view.append(ListItem(Static("No navi_code sessions", classes="empty"))) + list_view.index = 0 + return + + selected_index = 0 + for index, s in enumerate(self._filtered, start=1): + sid = s.get("session_id", "") + marker = "● " if sid == self._current_session_id else " " + preview = s.get("name", "") or s.get("preview", "") or "(no preview)" + created = s.get("created_at", "") + line = Text.assemble( + Text(f"{marker}{sid[:8]} ", style="bold"), + Text(preview, style="dim"), + ) + if created: + line.append(Text(f" {created}", style="dim")) + self._list_items.append(ListItem(Static(line))) + list_view.append(self._list_items[-1]) + if sid == self._current_session_id: + selected_index = index + list_view.index = selected_index + + def _filter(self, query: str) -> None: + query = query.strip().lower() + if not query: + self._filtered = list(self._sessions) + else: + self._filtered = [ + s + for s in self._sessions + if query in (s.get("session_id", "") + " " + s.get("name", "") + " " + s.get("preview", "")).lower() + ] + self._render_list() + + def _select_highlighted(self) -> None: + list_view = self.query_one("#sessions-list", ListView) + highlighted = list_view.index + if highlighted is None or highlighted < 0: + return + if highlighted == 0: + self.dismiss(NEW_SESSION) + return + # Index 0 is the "New" row; real sessions start at filtered position 0 + # which is list index 1. + real_index = highlighted - 1 + if 0 <= real_index < len(self._filtered): + self.dismiss(self._filtered[real_index].get("session_id")) + + def on_input_changed(self, event: Input.Changed) -> None: + self._filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._select_highlighted() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if event.item in self._list_items: + highlighted = self._list_items.index(event.item) + if highlighted == 0: + self.dismiss(NEW_SESSION) + else: + real_index = highlighted - 1 + if 0 <= real_index < len(self._filtered): + self.dismiss(self._filtered[real_index].get("session_id")) + + def on_key(self, event: events.Key) -> None: + list_view = self.query_one("#sessions-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"): + self._select_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 773b360..e9062ee 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -291,6 +291,36 @@ result = run_shell_command(text) self._chat_panel.handle_ws_event({"type": "status", "content": result.summary()}) + async def _switch_session_worker(self, session_id: str) -> None: + """Fully switch to an existing session: persist, reattach (replays + history, restarts the WS bridge), and announce it. + + No explicit ``chat_panel.clear()`` — ``attach_session`` calls + ``load_history`` which replaces the chat with the new session's past + conversation. Clearing afterwards would erase that history. + """ + self._state.set_session_id(session_id) + await self.attach_session(session_id) + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Switched to {session_id[:8]}"} + ) + + async def _create_new_session_worker(self) -> None: + """Create a fresh navi_code session and switch to it.""" + try: + session = api.create_session(settings.default_profile_id) + except Exception as exc: + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to create session: {exc}"} + ) + return + session_id = session["session_id"] + self._state.set_session_id(session_id) + await self.attach_session(session_id) + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Created session {session_id[:8]}"} + ) + def _run_command(self, text: str) -> None: parts = text[1:].split(None, 1) name = parts[0].lower() diff --git a/tests/clients/test_sessions_picker.py b/tests/clients/test_sessions_picker.py new file mode 100644 index 0000000..85f3458 --- /dev/null +++ b/tests/clients/test_sessions_picker.py @@ -0,0 +1,228 @@ +"""Tests for the sessions picker screen and the /sessions switch path.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from textual.widgets import ListView + +from clients.terminal.tui.screens.sessions_picker import NEW_SESSION, SessionsPickerScreen +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 + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + 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 + + monkeypatch.setattr( + api_module, + "create_session", + lambda profile_id=None: {"session_id": "new-sess", "profile_id": "navi_code"}, + ) + monkeypatch.setattr( + api_module, "get_session", lambda sid: {"session_id": sid, "profile_id": "navi_code", "messages": []} + ) + monkeypatch.setattr(api_module, "get_profile_model", lambda pid: "configured-model") + + +def _sessions() -> list[dict]: + return [ + {"session_id": "navi-aaaa1111", "profile_id": "navi_code", "name": "alpha", "created_at": "2026-01-01"}, + {"session_id": "dev-bbbb2222", "profile_id": "developer", "name": "other-profile", "created_at": "2026-01-02"}, + {"session_id": "navi-cccc3333", "profile_id": "navi_code", "preview": "beta chat", "created_at": "2026-01-03"}, + ] + + +def _fake_bridge(monkeypatch: pytest.MonkeyPatch) -> None: + import clients.terminal.tui.tui_app as tui_app_module + + class FakeBridge: + def __init__(self, app, session_id, cwd=None) -> None: + self.client = None + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + monkeypatch.setattr(tui_app_module, "WsBridge", FakeBridge) + + +@pytest.mark.anyio +async def test_picker_lists_only_navi_code_sessions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(current_session_id="navi-aaaa1111")) + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, SessionsPickerScreen) + assert [s["session_id"] for s in screen._filtered] == ["navi-aaaa1111", "navi-cccc3333"] + + +@pytest.mark.anyio +async def test_picker_marks_current_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(current_session_id="navi-cccc3333")) + await pilot.pause() + # current session is the second real entry -> list index 2 (after New row + first session) + assert pilot.app.screen.query_one("#sessions-list", ListView).index == 2 + + +@pytest.mark.anyio +async def test_picker_new_session_row_dismisses_sentinel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + result: list = [] + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(), callback=lambda sid: result.append(sid)) + await pilot.pause() + # Highlight is on the "New session" row (index 0) by default. + await pilot.press("enter") + await pilot.pause() + + assert result == [NEW_SESSION] + + +@pytest.mark.anyio +async def test_picker_selects_existing_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + result: list = [] + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(), callback=lambda sid: result.append(sid)) + await pilot.pause() + await pilot.press("down") # onto first real session (navi-aaaa1111) + await pilot.press("enter") + await pilot.pause() + + assert result == ["navi-aaaa1111"] + + +@pytest.mark.anyio +async def test_picker_escape_dismisses_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + result: list = [] + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(), callback=lambda sid: result.append(sid)) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert result == [None] + + +@pytest.mark.anyio +async def test_picker_filter_narrows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen()) + await pilot.pause() + screen = pilot.app.screen + from textual.widgets import Input + + screen.query_one("#sessions-input", Input).value = "beta" + await pilot.pause() + assert [s["session_id"] for s in screen._filtered] == ["navi-cccc3333"] + + +@pytest.mark.anyio +async def test_picker_api_error_dismisses_none(monkeypatch: pytest.MonkeyPatch) -> None: + def boom() -> list[dict]: + raise RuntimeError("server down") + + monkeypatch.setattr("clients.terminal.api.list_sessions", boom) + _fake_bridge(monkeypatch) + + result: list = [] + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app.push_screen(SessionsPickerScreen(), callback=lambda sid: result.append(sid)) + await pilot.pause() + + assert result == [None] + + +@pytest.mark.anyio +async def test_sessions_command_switches_to_selected(monkeypatch: pytest.MonkeyPatch) -> None: + """The /sessions command opens the picker and a selection fully switches.""" + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + attached: list[str] = [] + orig_attach = NaviCodeTui.attach_session + + async def spy_attach(self, session_id): + attached.append(session_id) + return await orig_attach(self, session_id) + + monkeypatch.setattr(NaviCodeTui, "attach_session", spy_attach) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._run_command("/sessions") + await pilot.pause() + await pilot.press("down") # first real session + await pilot.press("enter") + await pilot.pause() + + assert "navi-aaaa1111" in attached + + +@pytest.mark.anyio +async def test_sessions_command_new_session_creates(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + + created: list = [] + monkeypatch.setattr( + "clients.terminal.api.create_session", + lambda profile_id=None: created.append(profile_id) or {"session_id": "fresh-sess", "profile_id": "navi_code"}, + ) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + startup_creates = len(created) + pilot.app._run_command("/sessions") + await pilot.pause() + await pilot.press("enter") # "New session" row is highlighted by default + await pilot.pause() + # The picker triggered one more create_session and switched onto it. + assert len(created) == startup_creates + 1 + assert pilot.app._ctx.session_id == "fresh-sess" \ No newline at end of file