diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index 4be230e..08d2ec2 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -43,24 +43,10 @@ ) async def execute(self, ctx: TuiContext, args: str) -> None: - try: - session = api.create_session(settings.default_profile_id) - except Exception as exc: - ctx.chat_panel.handle_ws_event( - {"type": "error", "message": f"Failed to create session: {exc}"} - ) - return - ctx.session_id = session["session_id"] - ctx.profile_id = session.get("profile_id") - ctx.state.set_session_id(session["session_id"]) - ctx.status_panel.set_session(session["session_id"]) - ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) - ctx.chat_panel.handle_ws_event( - {"type": "status", "content": f"Created session {session['session_id'][:8]}"} - ) app = ctx.app() - await app.attach_session(session["session_id"]) - ctx.chat_panel.clear() + if app is None: + return + app.run_worker(app._create_new_session_worker()) class SessionsCommand(BaseCommand): @@ -72,67 +58,69 @@ ) async def execute(self, ctx: TuiContext, args: str) -> None: - from clients.terminal.tui.screens.sessions_picker import ( - NEW_SESSION, - SessionsPickerScreen, - ) - app = ctx.app() if app is None: return - - 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) + app._open_sessions_picker() class SwitchCommand(BaseCommand): meta = CommandMeta( name="switch", aliases=(), - description="Switch to another session by id or prefix.", + description="Switch to a navi_code session by id/prefix, or open the picker.", keybind=None, ) async def execute(self, ctx: TuiContext, args: str) -> None: - target = args.strip() - if not target: - ctx.chat_panel.handle_ws_event( - {"type": "error", "message": "Usage: /switch "} - ) + app = ctx.app() + if app is None: return + + target = args.strip() + # No argument (or none/ambiguous match) -> open the picker, pre-filtered + # to whatever the user typed so they can finish the choice visually. + if not target: + app._open_sessions_picker() + return + + session = self._resolve_session(target) + if session is None: + app._open_sessions_picker(initial_query=target) + return + + app.run_worker(app._switch_session_worker(session["session_id"])) + + @staticmethod + def _resolve_session(target: str) -> dict | None: + """Resolve ``target`` to a single navi_code session, or None. + + Tries an exact id first (GET /sessions/{id}); if that fails or lands on a + different profile, falls back to prefix-matching the navi_code session + list. Returns the session only when exactly one navi_code match exists. + """ + # Exact id hit. try: session = api.get_session(target) except Exception: - try: - sessions = api.list_sessions() - matches = [s for s in sessions if s.get("session_id", "").startswith(target)] - if len(matches) == 1: - session = matches[0] - else: - raise Exception("no unique match") - except Exception as exc: - ctx.chat_panel.handle_ws_event( - {"type": "error", "message": f"Session not found: {target} ({exc})"} - ) - return - ctx.session_id = session["session_id"] - ctx.profile_id = session.get("profile_id") - ctx.state.set_session_id(session["session_id"]) - ctx.status_panel.set_session(session["session_id"]) - ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) - ctx.chat_panel.handle_ws_event( - {"type": "status", "content": f"Switched to {session['session_id'][:8]}"} - ) - app = ctx.app() - await app.attach_session(session["session_id"]) - ctx.chat_panel.clear() + session = None + if session is not None and session.get("profile_id") == settings.default_profile_id: + return session + + # Prefix match among navi_code sessions. + try: + sessions = api.list_sessions() + except Exception: + return None + matches = [ + s + for s in sessions + if s.get("profile_id") == settings.default_profile_id + and s.get("session_id", "").startswith(target) + ] + if len(matches) == 1: + return matches[0] + return None class ProfileCommand(BaseCommand): diff --git a/clients/terminal/tui/screens/sessions_picker.py b/clients/terminal/tui/screens/sessions_picker.py index 1020403..bf9f511 100644 --- a/clients/terminal/tui/screens/sessions_picker.py +++ b/clients/terminal/tui/screens/sessions_picker.py @@ -85,9 +85,12 @@ ("escape", "dismiss_cancel", "Cancel"), ] - def __init__(self, current_session_id: str | None = None) -> None: + def __init__(self, current_session_id: str | None = None, initial_query: str = "") -> None: super().__init__() self._current_session_id = current_session_id + # Pre-fill the filter (used by /switch with an ambiguous prefix so the + # picker opens already narrowed to what the user typed). + self._initial_query = initial_query # The "New session" row is always index 0; real sessions follow. self._sessions: list[dict] = [] self._filtered: list[dict] = [] @@ -112,7 +115,11 @@ 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() + inp = self.query_one("#sessions-input", Input) + if self._initial_query: + # Setting value fires Input.Changed -> _filter, narrowing the list. + inp.value = self._initial_query + inp.focus() def _render_list(self) -> None: list_view = self.query_one("#sessions-list", ListView) diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index e9062ee..ec6f677 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -321,6 +321,26 @@ {"type": "status", "content": f"Created session {session_id[:8]}"} ) + def _open_sessions_picker(self, initial_query: str = "") -> None: + """Push the sessions picker; on select, switch/create as appropriate.""" + from clients.terminal.tui.screens.sessions_picker import ( + NEW_SESSION, + SessionsPickerScreen, + ) + + def on_select(session_id: str | None) -> None: + if session_id is None: + return + if session_id == NEW_SESSION: + self.run_worker(self._create_new_session_worker()) + return + self.run_worker(self._switch_session_worker(session_id)) + + self.push_screen( + SessionsPickerScreen(self._ctx.session_id, initial_query=initial_query), + callback=on_select, + ) + 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 index 85f3458..5a3f111 100644 --- a/tests/clients/test_sessions_picker.py +++ b/tests/clients/test_sessions_picker.py @@ -225,4 +225,101 @@ 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 + assert pilot.app._ctx.session_id == "fresh-sess" + + +@pytest.mark.anyio +async def test_new_command_uses_create_worker(monkeypatch: pytest.MonkeyPatch) -> None: + """/new routes through _create_new_session_worker (no duplicated logic).""" + 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": "via-new", "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("/new") + await pilot.pause() + assert len(created) == startup_creates + 1 + assert pilot.app._ctx.session_id == "via-new" + + +@pytest.mark.anyio +async def test_switch_unique_prefix_switches_directly(monkeypatch: pytest.MonkeyPatch) -> None: + """/switch switches without opening the picker.""" + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + # No exact-id match for the prefix; get_session raises so it falls to prefix match. + monkeypatch.setattr("clients.terminal.api.get_session", lambda sid: (_ for _ in ()).throw(RuntimeError("404"))) + + 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("/switch navi-cccc") # unique prefix + await pilot.pause() + assert attached[-1] == "navi-cccc3333" + # No modal screen was pushed. + assert not isinstance(pilot.app.screen, SessionsPickerScreen) + + +@pytest.mark.anyio +async def test_switch_ambiguous_prefix_opens_picker_filtered(monkeypatch: pytest.MonkeyPatch) -> None: + """/switch with an ambiguous prefix opens the picker pre-filtered to it.""" + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + monkeypatch.setattr("clients.terminal.api.get_session", lambda sid: (_ for _ in ()).throw(RuntimeError("404"))) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._run_command("/switch navi-") # both navi_code sessions start with it + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, SessionsPickerScreen) + from textual.widgets import Input + + assert screen.query_one("#sessions-input", Input).value == "navi-" + assert [s["session_id"] for s in screen._filtered] == ["navi-aaaa1111", "navi-cccc3333"] + + +@pytest.mark.anyio +async def test_switch_no_arg_opens_picker(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._run_command("/switch") + await pilot.pause() + assert isinstance(pilot.app.screen, SessionsPickerScreen) + from textual.widgets import Input + + assert pilot.app.screen.query_one("#sessions-input", Input).value == "" + + +@pytest.mark.anyio +async def test_switch_unknown_prefix_opens_picker_filtered(monkeypatch: pytest.MonkeyPatch) -> None: + """/switch opens the picker pre-filtered (so the user sees nothing matched).""" + monkeypatch.setattr("clients.terminal.api.list_sessions", _sessions) + _fake_bridge(monkeypatch) + monkeypatch.setattr("clients.terminal.api.get_session", lambda sid: (_ for _ in ()).throw(RuntimeError("404"))) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._run_command("/switch zzzz") + await pilot.pause() + screen = pilot.app.screen + assert isinstance(screen, SessionsPickerScreen) + assert screen._filtered == [] \ No newline at end of file