Coverage for src/lilbee/cli/tui/screens/memories.py: 100%

168 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""Memories management screen: browse, delete, toggle-shared. 

2 

3A single :class:`DataTable` of the human's (``owner=local``) stored memories 

4with vim-style navigation. ``d`` deletes the highlighted memory (through the 

5shared :class:`ConfirmDialog`) and ``s`` toggles whether it is shared with 

6agents. ``q`` / Esc backs out. Mirrors :class:`WikiDraftsScreen`'s structure 

7and keymap. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13from typing import TYPE_CHECKING, ClassVar 

14 

15from rich.text import Text 

16from textual import on 

17from textual.app import ComposeResult 

18from textual.binding import Binding, BindingType 

19from textual.containers import Vertical 

20from textual.screen import Screen 

21from textual.widgets import DataTable, Input, Static 

22 

23from lilbee.app.memory import forget, list_memories, memory_enabled, set_memory_shared 

24from lilbee.cli.tui import messages as msg 

25from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings 

26 

27if TYPE_CHECKING: 

28 from lilbee.data.store import MemoryRow 

29 

30log = logging.getLogger(__name__) 

31 

32 

33def _flag_label(value: bool) -> str: 

34 """Render a boolean memory flag as a human yes/no.""" 

35 return msg.MEMORIES_FLAG_YES if value else msg.MEMORIES_FLAG_NO 

36 

37 

38class MemoriesScreen(Screen[None]): 

39 """Review-surface screen for the human's long-term memories.""" 

40 

41 CSS_PATH = "memories.tcss" 

42 AUTO_FOCUS = "#memories-table" 

43 HELP = "Manage memories. j/k navigate, d delete, s toggle shared, / search, q back." 

44 

45 BINDINGS: ClassVar[list[BindingType]] = [ 

46 *browse_back_bindings(escape_action="dismiss_or_back"), 

47 Binding("d", "delete", "Delete", show=False), 

48 Binding("s", "toggle_shared", "Shared", show=False), 

49 Binding("slash", "focus_search", "Search", show=True), 

50 *BROWSE_LIST_BINDINGS, 

51 ] 

52 

53 def __init__(self) -> None: 

54 super().__init__() 

55 self._memories: list[MemoryRow] = [] 

56 self._filter: str = "" 

57 

58 def compose(self) -> ComposeResult: 

59 from textual.widgets import Footer 

60 

61 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

62 from lilbee.cli.tui.widgets.status_bar import ViewTabs 

63 from lilbee.cli.tui.widgets.task_bar import TaskBar 

64 from lilbee.cli.tui.widgets.top_bars import TopBars 

65 

66 with TopBars(): 

67 yield ViewTabs() 

68 table: DataTable[str | Text] = DataTable(id="memories-table") 

69 table.cursor_type = "row" 

70 yield Vertical( 

71 Input(placeholder=msg.MEMORIES_SEARCH_PLACEHOLDER, id="memories-search"), 

72 table, 

73 Static("", id="memories-empty"), 

74 id="memories-layout", 

75 ) 

76 with BottomBars(): 

77 yield TaskBar() 

78 yield Footer() 

79 

80 def on_mount(self) -> None: 

81 table = self.query_one("#memories-table", DataTable) 

82 table.add_columns( 

83 msg.MEMORIES_COLUMN_KIND, 

84 msg.MEMORIES_COLUMN_SHARED, 

85 msg.MEMORIES_COLUMN_TEXT, 

86 ) 

87 self._load_memories() 

88 

89 def _load_memories(self) -> None: 

90 """Fetch memories and populate the table, respecting the active filter.""" 

91 table = self.query_one("#memories-table", DataTable) 

92 table.clear() 

93 self._set_empty_state(None) 

94 if not memory_enabled(): 

95 self.notify(msg.MEMORIES_DISABLED, severity="warning") 

96 self._memories = [] 

97 return 

98 try: 

99 self._memories = list_memories() 

100 except Exception as exc: 

101 log.debug("Failed to list memories", exc_info=True) 

102 self._memories = [] 

103 self.notify(msg.MEMORIES_LOAD_FAILED.format(error=exc), severity="error") 

104 return 

105 

106 visible = self._visible_memories() 

107 if not visible: 

108 self._set_empty_state( 

109 msg.MEMORIES_NO_MATCHES if self._filter else msg.MEMORIES_EMPTY_STATE 

110 ) 

111 self.notify(msg.MEMORIES_EMPTY) 

112 return 

113 for m in visible: 

114 table.add_row( 

115 m.kind.value, 

116 _flag_label(m.shared), 

117 Text(m.text), 

118 key=m.id, 

119 ) 

120 

121 def _set_empty_state(self, text: str | None) -> None: 

122 """Show the under-table hint line with *text*, or hide it when None.""" 

123 empty = self.query_one("#memories-empty", Static) 

124 self.query_one("#memories-layout", Vertical).set_class(text is not None, "-empty") 

125 if text is None: 

126 empty.remove_class("-visible") 

127 return 

128 empty.update(text) 

129 empty.add_class("-visible") 

130 

131 def _visible_memories(self) -> list[MemoryRow]: 

132 """Apply the current text filter to the loaded memory list.""" 

133 if not self._filter: 

134 return self._memories 

135 needle = self._filter.lower() 

136 return [m for m in self._memories if needle in m.text.lower()] 

137 

138 def _highlighted_id(self) -> str | None: 

139 """Return the id of the highlighted row, or ``None`` when empty.""" 

140 table = self.query_one("#memories-table", DataTable) 

141 if table.row_count == 0: 

142 return None 

143 try: 

144 row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) 

145 except Exception: 

146 return None 

147 if row_key is None or row_key.value is None: 

148 return None 

149 return str(row_key.value) 

150 

151 @on(Input.Changed, "#memories-search") 

152 def _on_search_changed(self, event: Input.Changed) -> None: 

153 """Filter memories as the user types.""" 

154 self._filter = event.value.strip() 

155 self._load_memories() 

156 

157 def action_focus_search(self) -> None: 

158 """Focus the search input (``/`` keybinding).""" 

159 self.query_one("#memories-search", Input).focus() 

160 

161 def action_dismiss_or_back(self) -> None: 

162 """Clear the search if active, otherwise back out.""" 

163 search = self.query_one("#memories-search", Input) 

164 if search.value: 

165 search.value = "" 

166 return 

167 self.action_go_back() 

168 

169 def action_go_back(self) -> None: 

170 """Pop back to the previous screen, unless this is the only one.""" 

171 if len(self.app.screen_stack) > 1: 

172 self.app.pop_screen() 

173 

174 def _table_or_none(self) -> DataTable[str | Text] | None: 

175 """Return the memories table unless an Input is focused.""" 

176 if isinstance(self.focused, Input): 

177 return None 

178 return self.query_one("#memories-table", DataTable) 

179 

180 def action_cursor_down(self) -> None: 

181 table = self._table_or_none() 

182 if table is not None: 

183 table.action_cursor_down() 

184 

185 def action_cursor_up(self) -> None: 

186 table = self._table_or_none() 

187 if table is not None: 

188 table.action_cursor_up() 

189 

190 def action_jump_top(self) -> None: 

191 table = self._table_or_none() 

192 if table is not None: 

193 table.scroll_home() 

194 

195 def action_jump_bottom(self) -> None: 

196 table = self._table_or_none() 

197 if table is not None: 

198 table.scroll_end() 

199 

200 def action_delete(self) -> None: 

201 """Prompt for confirmation, then delete the highlighted memory.""" 

202 memory_id = self._highlighted_id() 

203 if memory_id is None: 

204 return 

205 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

206 

207 def _on_confirm(confirmed: bool | None) -> None: 

208 if not confirmed: 

209 return 

210 self._do_delete(memory_id) 

211 

212 self.app.push_screen( 

213 ConfirmDialog( 

214 msg.MEMORIES_DELETE_CONFIRM_TITLE, 

215 msg.MEMORIES_DELETE_CONFIRM_MESSAGE, 

216 ), 

217 _on_confirm, 

218 ) 

219 

220 def _do_delete(self, memory_id: str) -> None: 

221 """Execute the delete and refresh the list.""" 

222 try: 

223 deleted = forget(memory_id) 

224 except Exception as exc: 

225 log.debug("Delete failed for %s", memory_id, exc_info=True) 

226 self.notify(msg.MEMORIES_DELETE_FAILED.format(error=exc), severity="error") 

227 return 

228 self.notify(msg.MEMORIES_DELETED if deleted else msg.MEMORIES_DELETE_NOT_FOUND) 

229 self._load_memories() 

230 

231 def action_toggle_shared(self) -> None: 

232 """Flip the highlighted memory's shared-with-agents flag.""" 

233 memory_id = self._highlighted_id() 

234 if memory_id is None: 

235 return 

236 memory = self._memory_by_id(memory_id) 

237 if memory is None: 

238 return 

239 new_shared = not memory.shared 

240 try: 

241 updated = set_memory_shared(memory_id, shared=new_shared) 

242 except Exception as exc: 

243 log.debug("Toggle shared failed for %s", memory_id, exc_info=True) 

244 self.notify(msg.MEMORIES_FLAG_FAILED.format(error=exc), severity="error") 

245 return 

246 if not updated: 

247 self.notify(msg.MEMORIES_FLAG_NOT_FOUND) 

248 self._load_memories() 

249 return 

250 self.notify(msg.MEMORIES_SHARED_ON if new_shared else msg.MEMORIES_SHARED_OFF) 

251 self._load_memories() 

252 

253 def _memory_by_id(self, memory_id: str) -> MemoryRow | None: 

254 """Look up a loaded memory by id.""" 

255 for m in self._memories: 

256 if m.id == memory_id: 

257 return m 

258 return None