"""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)