Coverage for src/lilbee/cli/tui/widgets/session_list.py: 100%
146 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Shared session list panel: filter, select, resume, rename, delete, new.
3Embedded by both the sessions drawer and the full-screen sessions view. The panel
4owns everything self-contained (filtering, inline rename, delete confirmation) and
5posts messages for the actions that need navigation (resume, new chat, close), so
6each container decides how to leave.
7"""
9from __future__ import annotations
11from pathlib import Path
12from typing import TYPE_CHECKING, ClassVar
14from textual import on
15from textual.app import ComposeResult
16from textual.binding import Binding, BindingType
17from textual.containers import Horizontal, Vertical
18from textual.content import Content
19from textual.message import Message
20from textual.widgets import Input, ListItem, ListView, Static
22from lilbee.app.services import get_services
23from lilbee.cli.tui import messages as msg
24from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
25from lilbee.sessions import HUMAN_ORIGINS, SessionMeta, SessionStore, TitleSource
27if TYPE_CHECKING:
28 from lilbee.cli.tui.app import LilbeeApp
30_ROW_CSS = (Path(__file__).parent / "session_list.tcss").read_text(encoding="utf-8")
33class _RowText(Static):
34 """Row text that never starts a text selection.
36 Rows are rebuilt on every store mutation and every filter keystroke, and
37 Textual's selection path takes ``content_widget.parent`` and dereferences
38 ``container.region`` with no None check, so a click landing on a row that has
39 just been unparented crashed the app with AttributeError on
40 ``_MessagePump__parent``. Selecting text is not something a pick-list row
41 needs, and switching it off makes that path unreachable here instead of
42 relying on the removal winning the race against the click.
43 """
45 ALLOW_SELECT = False
48class SessionRow(ListItem):
49 """One session: dot + title with a right-aligned age, and a meta line below."""
51 def __init__(self, meta: SessionMeta, *, active: bool) -> None:
52 super().__init__()
53 self.meta = meta
54 self._active = active
56 def compose(self) -> ComposeResult:
57 dot = "●" if self._active else "○"
58 title = Content.assemble(
59 (f"{dot} ", "$success" if self._active else "$text-muted"),
60 (self.meta.title, "bold" if self._active else ""),
61 )
62 meta_line = msg.SESSIONS_ROW_META.format(
63 count=self.meta.message_count, model=self.meta.model_ref
64 )
65 with Horizontal(classes="session-row-head"):
66 yield _RowText(title, classes="session-row-title")
67 yield _RowText(Content(self.meta.updated_at[:10]), classes="session-row-time")
68 yield _RowText(Content.styled(meta_line, "$text-muted"), classes="session-row-meta")
71class SessionListPanel(Vertical):
72 """Filterable session list with resume / rename / delete / new actions."""
74 app: LilbeeApp # type: ignore[assignment]
76 DEFAULT_CSS: ClassVar[str] = _ROW_CSS
78 BINDINGS: ClassVar[list[BindingType]] = [
79 Binding("ctrl+n", "new_chat", "New", show=True, priority=True),
80 Binding("ctrl+r", "rename", "Rename", show=False, priority=True),
81 Binding("ctrl+d", "delete", "Delete", show=False, priority=True),
82 Binding("escape", "close", "Close", show=False, priority=True),
83 Binding("down", "cursor_down", "Down", show=False),
84 Binding("up", "cursor_up", "Up", show=False),
85 ]
87 class Resumed(Message):
88 """A session was chosen to resume."""
90 def __init__(self, session_id: str) -> None:
91 super().__init__()
92 self.session_id = session_id
94 class NewChat(Message):
95 """The user asked to start a new chat."""
97 class CloseRequested(Message):
98 """The user asked to close the panel."""
100 def __init__(self, *, focus_filter: bool = True) -> None:
101 super().__init__()
102 self._renaming_id: str | None = None
103 # Sessions as of the last store read, and the live filter text. Reading
104 # the store replays every event of every session, so it happens on mount
105 # and after a mutation only; keystrokes filter this list in memory.
106 self._metas: list[SessionMeta] = []
107 self._query = ""
108 # The drawer focuses the filter for immediate type-to-switch. The
109 # full-screen tab focuses the list instead, so the nav keys ([ ]) bubble
110 # to the app instead of being typed into the filter.
111 self._focus_filter = focus_filter
113 def compose(self) -> ComposeResult:
114 yield Static(id="sessions-title")
115 yield Input(placeholder=msg.SESSIONS_FILTER_PLACEHOLDER, id="sessions-filter")
116 yield ListView(id="sessions-list")
117 yield Static(id="sessions-empty")
118 yield Static(Content.styled(msg.SESSIONS_HINT, "$text-muted"), id="sessions-hint")
120 def on_mount(self) -> None:
121 self.refresh_list()
122 target = "#sessions-filter" if self._focus_filter else "#sessions-list"
123 self.query_one(target).focus()
125 def _store(self) -> SessionStore:
126 return get_services().session_store
128 def refresh_list(self) -> None:
129 """Re-read the store, then render. For mount and after a mutation.
131 The filter text is not a parameter: it lives in _query and survives a
132 reload, so deleting a row leaves the list filtered as the user left it.
133 """
134 # Agent (MCP) sessions are working state, not conversations; they
135 # never appear here.
136 self._metas = self._store().list(origins=HUMAN_ORIGINS)
137 self._render_rows()
139 def _render_rows(self) -> None:
140 """Render rows from the sessions already loaded. Never touches the store.
142 Not ``_render``: Textual's Widget defines that as its own visual hook.
143 """
144 lv = self.query_one("#sessions-list", ListView)
145 lv.clear()
146 needle = self._query.strip().lower()
147 active_id = self.app.current_session_id()
148 metas = [m for m in self._metas if needle in m.title.lower()]
149 for meta in metas:
150 lv.append(SessionRow(meta, active=meta.id == active_id))
151 if metas:
152 lv.index = 0
153 title = Content.assemble(
154 (msg.SESSIONS_VIEW, "bold"),
155 (f" {msg.SESSIONS_COUNT.format(count=len(metas))}", "$text-muted"),
156 )
157 self.query_one("#sessions-title", Static).update(title)
158 self.query_one("#sessions-empty", Static).update(
159 Content.styled(msg.SESSIONS_EMPTY, "$text-muted") if not metas else Content("")
160 )
162 def _selected(self) -> SessionMeta | None:
163 item = self.query_one("#sessions-list", ListView).highlighted_child
164 # highlighted_child is typed ListItem | None; every row we add is a
165 # SessionRow, so narrow to read its meta.
166 return item.meta if isinstance(item, SessionRow) else None
168 @on(Input.Changed, "#sessions-filter")
169 def _on_filter(self, event: Input.Changed) -> None:
170 if self._renaming_id is None:
171 self._query = event.value
172 self._render_rows()
174 @on(Input.Submitted, "#sessions-filter")
175 def _on_submit(self, _event: Input.Submitted) -> None:
176 if self._renaming_id is not None:
177 self._commit_rename()
178 return
179 self._resume(self._selected())
181 @on(ListView.Selected, "#sessions-list")
182 def _on_row_selected(self, event: ListView.Selected) -> None:
183 """Resume the row the list itself reports as chosen.
185 ListView posts this both for a click and for enter while it holds focus,
186 and a click also moves focus off the filter box. Resuming only from the
187 filter's Submitted leaves a click inert and then strands the panel with
188 no working key to resume from.
189 """
190 if self._renaming_id is None:
191 self._resume(event.item.meta if isinstance(event.item, SessionRow) else None)
193 def _resume(self, meta: SessionMeta | None) -> None:
194 if meta is not None:
195 self.post_message(self.Resumed(meta.id))
197 def action_cursor_down(self) -> None:
198 self.query_one("#sessions-list", ListView).action_cursor_down()
200 def action_cursor_up(self) -> None:
201 self.query_one("#sessions-list", ListView).action_cursor_up()
203 def jump_to(self, index: int) -> None:
204 """Move the list cursor to *index* (negative counts from the end)."""
205 lv = self.query_one("#sessions-list", ListView)
206 count = len(lv)
207 if not count:
208 return
209 lv.index = count - 1 if index < 0 else min(index, count - 1)
211 def action_new_chat(self) -> None:
212 self.post_message(self.NewChat())
214 def action_close(self) -> None:
215 if self._renaming_id is not None:
216 self._cancel_rename()
217 return
218 self.post_message(self.CloseRequested())
220 def action_rename(self) -> None:
221 selected = self._selected()
222 if selected is None:
223 return
224 self._renaming_id = selected.id
225 field = self.query_one("#sessions-filter", Input)
226 field.value = selected.title
227 field.placeholder = msg.SESSIONS_RENAME_PLACEHOLDER
229 def _commit_rename(self) -> None:
230 field = self.query_one("#sessions-filter", Input)
231 title = field.value.strip()
232 if self._renaming_id is not None and title:
233 self._store().set_title(self._renaming_id, title, TitleSource.CUSTOM)
234 self._finish_rename()
236 def _cancel_rename(self) -> None:
237 self._finish_rename()
239 def _finish_rename(self) -> None:
240 self._renaming_id = None
241 field = self.query_one("#sessions-filter", Input)
242 field.value = ""
243 field.placeholder = msg.SESSIONS_FILTER_PLACEHOLDER
244 self.refresh_list()
246 def action_delete(self) -> None:
247 selected = self._selected()
248 if selected is None:
249 return
250 dialog = ConfirmDialog(
251 msg.SESSIONS_DELETE_CONFIRM_TITLE,
252 msg.SESSIONS_DELETE_CONFIRM.format(title=selected.title),
253 )
254 self.app.push_screen(
255 dialog, lambda confirmed: self._on_delete_confirmed(selected, confirmed)
256 )
258 def _on_delete_confirmed(self, meta: SessionMeta, confirmed: bool | None) -> None:
259 if not confirmed:
260 return
261 self._store().delete(meta.id)
262 self.refresh_list()
263 self.app.notify(msg.SESSIONS_DELETED.format(title=meta.title))