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

1"""Shared session list panel: filter, select, resume, rename, delete, new. 

2 

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

8 

9from __future__ import annotations 

10 

11from pathlib import Path 

12from typing import TYPE_CHECKING, ClassVar 

13 

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 

21 

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 

26 

27if TYPE_CHECKING: 

28 from lilbee.cli.tui.app import LilbeeApp 

29 

30_ROW_CSS = (Path(__file__).parent / "session_list.tcss").read_text(encoding="utf-8") 

31 

32 

33class _RowText(Static): 

34 """Row text that never starts a text selection. 

35 

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

44 

45 ALLOW_SELECT = False 

46 

47 

48class SessionRow(ListItem): 

49 """One session: dot + title with a right-aligned age, and a meta line below.""" 

50 

51 def __init__(self, meta: SessionMeta, *, active: bool) -> None: 

52 super().__init__() 

53 self.meta = meta 

54 self._active = active 

55 

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

69 

70 

71class SessionListPanel(Vertical): 

72 """Filterable session list with resume / rename / delete / new actions.""" 

73 

74 app: LilbeeApp # type: ignore[assignment] 

75 

76 DEFAULT_CSS: ClassVar[str] = _ROW_CSS 

77 

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 ] 

86 

87 class Resumed(Message): 

88 """A session was chosen to resume.""" 

89 

90 def __init__(self, session_id: str) -> None: 

91 super().__init__() 

92 self.session_id = session_id 

93 

94 class NewChat(Message): 

95 """The user asked to start a new chat.""" 

96 

97 class CloseRequested(Message): 

98 """The user asked to close the panel.""" 

99 

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 

112 

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

119 

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

124 

125 def _store(self) -> SessionStore: 

126 return get_services().session_store 

127 

128 def refresh_list(self) -> None: 

129 """Re-read the store, then render. For mount and after a mutation. 

130 

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

138 

139 def _render_rows(self) -> None: 

140 """Render rows from the sessions already loaded. Never touches the store. 

141 

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 ) 

161 

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 

167 

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

173 

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

180 

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. 

184 

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) 

192 

193 def _resume(self, meta: SessionMeta | None) -> None: 

194 if meta is not None: 

195 self.post_message(self.Resumed(meta.id)) 

196 

197 def action_cursor_down(self) -> None: 

198 self.query_one("#sessions-list", ListView).action_cursor_down() 

199 

200 def action_cursor_up(self) -> None: 

201 self.query_one("#sessions-list", ListView).action_cursor_up() 

202 

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) 

210 

211 def action_new_chat(self) -> None: 

212 self.post_message(self.NewChat()) 

213 

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

219 

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 

228 

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

235 

236 def _cancel_rename(self) -> None: 

237 self._finish_rename() 

238 

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

245 

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 ) 

257 

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