Coverage for src/lilbee/cli/tui/widgets/session_list.py: 100%
189 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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
5resumes or starts a chat itself. It then posts a message, so each container
6decides how to leave.
7"""
9from __future__ import annotations
11import asyncio
12from dataclasses import replace
13from pathlib import Path
14from typing import TYPE_CHECKING, ClassVar
16from textual import events, on
17from textual.app import ComposeResult
18from textual.binding import Binding, BindingType
19from textual.containers import Horizontal, Vertical
20from textual.content import Content
21from textual.message import Message
22from textual.widgets import Input, ListItem, ListView, Static
24from lilbee.app.services import get_services
25from lilbee.cli.tui import messages as msg
26from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS
27from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
28from lilbee.sessions import (
29 HUMAN_ORIGINS,
30 SessionMeta,
31 SessionNotFoundError,
32 SessionStore,
33 TitleSource,
34)
36if TYPE_CHECKING:
37 from lilbee.cli.tui.app import LilbeeApp
39_ROW_CSS = (Path(__file__).parent / "session_list.tcss").read_text(encoding="utf-8")
42class _RowText(Static):
43 """Row text that never starts a text selection.
45 Rows are rebuilt on every store mutation and every filter keystroke, and
46 Textual's selection path takes ``content_widget.parent`` and dereferences
47 ``container.region`` with no None check, so a click landing on a row that has
48 just been unparented crashed the app with AttributeError on
49 ``_MessagePump__parent``. Selecting text is not something a pick-list row
50 needs, and switching it off makes that path unreachable here instead of
51 relying on the removal winning the race against the click.
52 """
54 ALLOW_SELECT = False
57class SessionRow(ListItem):
58 """One session: dot + title with a right-aligned age, and a meta line below."""
60 def __init__(self, meta: SessionMeta, *, active: bool) -> None:
61 super().__init__()
62 self.meta = meta
63 self._active = active
65 def compose(self) -> ComposeResult:
66 dot = "●" if self._active else "○"
67 title = Content.assemble(
68 (f"{dot} ", "$success" if self._active else "$text-muted"),
69 (self.meta.title, "bold" if self._active else ""),
70 )
71 meta_line = msg.SESSIONS_ROW_META.format(
72 count=self.meta.message_count, model=self.meta.model_ref
73 )
74 with Horizontal(classes="session-row-head"):
75 yield _RowText(title, classes="session-row-title")
76 yield _RowText(Content(self.meta.updated_at[:10]), classes="session-row-time")
77 yield _RowText(Content.styled(meta_line, "$text-muted"), classes="session-row-meta")
80class _FilterInput(Input):
81 """The filter box. Its editing keys run in its own queue, in order with the text typed.
83 Textual runs a focused Input's bindings (Backspace, ctrl+w, the arrows) only
84 once the key has bubbled back to the app, which is after any row key the app
85 is running. Running them here makes the box's queue hold every key typed into it.
86 """
88 async def _on_key(self, event: events.Key) -> None:
89 """Run the box's own binding for the key; a key the box declines goes on up as before."""
90 for binding in self._bindings.key_to_bindings.get(event.key, ()):
91 if await self.app.run_action(binding.action, self):
92 event.stop()
93 return
96async def _caught_up(widget: Input) -> None:
97 """Return once *widget* has handled every message already queued for it.
99 Also returns when the widget's queue stops, so a handler that failed on one of
100 those messages lets the app exit instead of leaving this wait unanswered.
101 """
102 done: asyncio.Future[None] = asyncio.get_running_loop().create_future()
103 if widget.call_later(done.set_result, None):
104 await asyncio.wait((done, widget.task), return_when=asyncio.FIRST_COMPLETED)
107class SessionListPanel(Vertical):
108 """Filterable session list with resume / rename / delete / new actions."""
110 app: LilbeeApp # type: ignore[assignment]
112 DEFAULT_CSS: ClassVar[str] = _ROW_CSS
114 # Every key that moves the cursor or acts on a row is a priority binding, so
115 # it runs as the app reads it, before later keys are routed. Each first waits
116 # for the filter box to handle the keys typed into it ahead of the row key.
117 # A focused filter still types j / k / g / G: Textual drops a binding whose
118 # key the focused Input consumes.
119 BINDINGS: ClassVar[list[BindingType]] = [
120 Binding("enter", "select", "Resume", show=False, priority=True),
121 Binding("ctrl+n", "new_chat", "New", show=True, priority=True),
122 Binding("ctrl+r", "rename", "Rename", show=False, priority=True),
123 Binding("ctrl+d", "delete", "Delete", show=False, priority=True),
124 Binding("escape", "close", "Close", show=False, priority=True),
125 Binding("down", "cursor_down", "Down", show=False, priority=True),
126 Binding("up", "cursor_up", "Up", show=False, priority=True),
127 *(replace(binding, priority=True) for binding in BROWSE_LIST_BINDINGS),
128 ]
130 class ChatOpened(Message):
131 """A session was resumed or a new chat was started, and chat is showing."""
133 class CloseRequested(Message):
134 """The user asked to close the panel."""
136 def __init__(self, *, focus_filter: bool = True) -> None:
137 super().__init__()
138 self._renaming_id: str | None = None
139 # Sessions as of the last store read, and the live filter text. Reading
140 # the store replays every event of every session, so it happens on mount
141 # and after a mutation only; keystrokes filter this list in memory.
142 self._metas: list[SessionMeta] = []
143 # The rows as last rendered, in list order; the selection reads these
144 # rather than the ListView children, which mount a step later.
145 self._shown: list[SessionMeta] = []
146 # The live filter text; mirrors the box except while a rename repurposes it.
147 self._query = ""
148 # The drawer focuses the filter for immediate type-to-switch. The
149 # full-screen tab focuses the list instead, so the nav keys ([ ]) bubble
150 # to the app instead of being typed into the filter.
151 self._focus_filter = focus_filter
153 def compose(self) -> ComposeResult:
154 yield Static(id="sessions-title")
155 yield _FilterInput(placeholder=msg.SESSIONS_FILTER_PLACEHOLDER, id="sessions-filter")
156 yield ListView(id="sessions-list")
157 yield Static(id="sessions-empty")
158 yield Static(Content.styled(msg.SESSIONS_HINT, "$text-muted"), id="sessions-hint")
160 def on_mount(self) -> None:
161 self.refresh_list()
162 target = "#sessions-filter" if self._focus_filter else "#sessions-list"
163 self.screen.set_focus(self.query_one(target))
165 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
166 """While a rename is open, ctrl+d edits the name instead of deleting the session."""
167 return not (action == "delete" and self._renaming_id is not None)
169 def _store(self) -> SessionStore:
170 return get_services().session_store
172 def refresh_list(self) -> None:
173 """Re-read the store, then render. For mount and after a mutation.
175 The filter text is not a parameter: it lives in _query and survives a
176 reload, so deleting a row leaves the list filtered as the user left it.
177 """
178 # Agent (MCP) sessions are working state, not conversations; they
179 # never appear here.
180 self._metas = self._store().list(origins=HUMAN_ORIGINS)
181 self._render_rows()
183 def _render_rows(self) -> None:
184 """Render rows from the sessions already loaded. Never touches the store.
186 Not ``_render``: Textual's Widget defines that as its own visual hook.
187 """
188 lv = self.query_one("#sessions-list", ListView)
189 lv.clear()
190 needle = self._query.strip().lower()
191 active_id = self.app.current_session_id()
192 metas = [m for m in self._metas if needle in m.title.lower()]
193 self._shown = metas
194 for meta in metas:
195 lv.append(SessionRow(meta, active=meta.id == active_id))
196 if metas:
197 lv.index = 0
198 title = Content.assemble(
199 (msg.SESSIONS_VIEW, "bold"),
200 (f" {msg.SESSIONS_COUNT.format(count=len(metas))}", "$text-muted"),
201 )
202 self.query_one("#sessions-title", Static).update(title)
203 self.query_one("#sessions-empty", Static).update(
204 Content.styled(msg.SESSIONS_EMPTY, "$text-muted") if not metas else Content("")
205 )
207 def _selected(self) -> SessionMeta | None:
208 """The session under the cursor, or the first one while the rows are still mounting."""
209 index = self.query_one("#sessions-list", ListView).index
210 if not self._shown:
211 return None
212 return self._shown[index if index is not None and index < len(self._shown) else 0]
214 @on(Input.Changed, "#sessions-filter")
215 def _on_filter(self, _event: Input.Changed) -> None:
216 self._apply_filter()
218 def _apply_filter(self) -> None:
219 """Render for the filter box's current text, unless the rows already show it."""
220 value = self.query_one("#sessions-filter", Input).value
221 if self._renaming_id is None and value != self._query:
222 self._query = value
223 self._render_rows()
225 async def _filter_caught_up(self) -> None:
226 """Wait for the filter box to apply the keys typed ahead, then render for them.
228 The panel's bindings are priority bindings, so they run before the filter
229 box has handled the keys queued for it.
230 """
231 await _caught_up(self.query_one("#sessions-filter", Input))
232 self._apply_filter()
234 @on(ListView.Selected, "#sessions-list")
235 def _on_row_selected(self, event: ListView.Selected) -> None:
236 """Resume a clicked row; enter never gets here, the panel's own binding takes it."""
237 if self._renaming_id is None:
238 self._resume(event.item.meta if isinstance(event.item, SessionRow) else None)
240 def _resume(self, meta: SessionMeta | None) -> None:
241 """Resume *meta* within this key's step, so focus is on the prompt before the next key."""
242 if meta is None:
243 return
244 try:
245 self.app.resume_session(meta.id)
246 except SessionNotFoundError:
247 self.app.notify(msg.SESSIONS_GONE, severity="warning")
248 self.refresh_list()
249 return
250 self.post_message(self.ChatOpened())
252 async def action_select(self) -> None:
253 """Enter: finish a rename, else resume the highlighted session."""
254 await self._filter_caught_up()
255 if self._renaming_id is not None:
256 self._commit_rename()
257 return
258 self._resume(self._selected())
260 async def action_cursor_down(self) -> None:
261 await self._filter_caught_up()
262 self.query_one("#sessions-list", ListView).action_cursor_down()
264 async def action_cursor_up(self) -> None:
265 await self._filter_caught_up()
266 self.query_one("#sessions-list", ListView).action_cursor_up()
268 async def action_jump_top(self) -> None:
269 await self._filter_caught_up()
270 self.jump_to(0)
272 async def action_jump_bottom(self) -> None:
273 await self._filter_caught_up()
274 self.jump_to(-1)
276 def jump_to(self, index: int) -> None:
277 """Move the list cursor to *index* (negative counts from the end)."""
278 count = len(self._shown)
279 if not count:
280 return
281 lv = self.query_one("#sessions-list", ListView)
282 lv.index = count - 1 if index < 0 else min(index, count - 1)
284 def action_new_chat(self) -> None:
285 self.app.new_chat()
286 self.post_message(self.ChatOpened())
288 def action_close(self) -> None:
289 if self._renaming_id is not None:
290 self._cancel_rename()
291 return
292 self.post_message(self.CloseRequested())
294 async def action_rename(self) -> None:
295 await self._filter_caught_up()
296 selected = self._selected()
297 if selected is None:
298 return
299 self._renaming_id = selected.id
300 field = self.query_one("#sessions-filter", Input)
301 field.value = selected.title
302 field.placeholder = msg.SESSIONS_RENAME_PLACEHOLDER
304 def _commit_rename(self) -> None:
305 field = self.query_one("#sessions-filter", Input)
306 title = field.value.strip()
307 if self._renaming_id is not None and title:
308 try:
309 self._store().set_title(self._renaming_id, title, TitleSource.CUSTOM)
310 except SessionNotFoundError:
311 self.app.notify(msg.SESSIONS_RENAME_GONE, severity="warning")
312 self._finish_rename()
314 def _cancel_rename(self) -> None:
315 self._finish_rename()
317 def _finish_rename(self) -> None:
318 self._renaming_id = None
319 field = self.query_one("#sessions-filter", Input)
320 field.value = ""
321 field.placeholder = msg.SESSIONS_FILTER_PLACEHOLDER
322 # Resyncs _query: an already-empty box fires no Changed message.
323 self._query = ""
324 self.refresh_list()
326 async def action_delete(self) -> None:
327 await self._filter_caught_up()
328 selected = self._selected()
329 if selected is None:
330 return
331 dialog = ConfirmDialog(
332 msg.SESSIONS_DELETE_CONFIRM_TITLE,
333 msg.SESSIONS_DELETE_CONFIRM.format(title=selected.title),
334 )
335 self.app.push_screen(
336 dialog, lambda confirmed: self._on_delete_confirmed(selected, confirmed)
337 )
339 def _on_delete_confirmed(self, meta: SessionMeta, confirmed: bool | None) -> None:
340 if not confirmed:
341 return
342 self._store().delete(meta.id)
343 self.refresh_list()
344 self.app.notify(msg.SESSIONS_DELETED.format(title=meta.title))