Newer
Older
navi-1 / clients / terminal / tui / screens / sessions_picker.py
"""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, 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] = []
        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()
        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)
        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)