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

167 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 textual import on 

16from textual.app import ComposeResult 

17from textual.binding import Binding, BindingType 

18from textual.containers import Vertical 

19from textual.screen import Screen 

20from textual.widgets import DataTable, Input, Static 

21 

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

23from lilbee.cli.tui import messages as msg 

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

25 

26if TYPE_CHECKING: 

27 from lilbee.data.store import MemoryRow 

28 

29log = logging.getLogger(__name__) 

30 

31 

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

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

34 return msg.MEMORIES_FLAG_YES if value else msg.MEMORIES_FLAG_NO 

35 

36 

37class MemoriesScreen(Screen[None]): 

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

39 

40 CSS_PATH = "memories.tcss" 

41 AUTO_FOCUS = "#memories-table" 

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

43 

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

45 *browse_back_bindings(escape_action="dismiss_or_back"), 

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

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

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

49 *BROWSE_LIST_BINDINGS, 

50 ] 

51 

52 def __init__(self) -> None: 

53 super().__init__() 

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

55 self._filter: str = "" 

56 

57 def compose(self) -> ComposeResult: 

58 from textual.widgets import Footer 

59 

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

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

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

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

64 

65 with TopBars(): 

66 yield ViewTabs() 

67 table: DataTable[str] = DataTable(id="memories-table") 

68 table.cursor_type = "row" 

69 yield Vertical( 

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

71 table, 

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

73 id="memories-layout", 

74 ) 

75 with BottomBars(): 

76 yield TaskBar() 

77 yield Footer() 

78 

79 def on_mount(self) -> None: 

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

81 table.add_columns( 

82 msg.MEMORIES_COLUMN_KIND, 

83 msg.MEMORIES_COLUMN_SHARED, 

84 msg.MEMORIES_COLUMN_TEXT, 

85 ) 

86 self._load_memories() 

87 

88 def _load_memories(self) -> None: 

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

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

91 table.clear() 

92 self._set_empty_state(None) 

93 if not memory_enabled(): 

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

95 self._memories = [] 

96 return 

97 try: 

98 self._memories = list_memories() 

99 except Exception as exc: 

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

101 self._memories = [] 

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

103 return 

104 

105 visible = self._visible_memories() 

106 if not visible: 

107 self._set_empty_state( 

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

109 ) 

110 self.notify(msg.MEMORIES_EMPTY) 

111 return 

112 for m in visible: 

113 table.add_row( 

114 m.kind.value, 

115 _flag_label(m.shared), 

116 m.text, 

117 key=m.id, 

118 ) 

119 

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

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

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

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

124 if text is None: 

125 empty.remove_class("-visible") 

126 return 

127 empty.update(text) 

128 empty.add_class("-visible") 

129 

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

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

132 if not self._filter: 

133 return self._memories 

134 needle = self._filter.lower() 

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

136 

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

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

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

140 if table.row_count == 0: 

141 return None 

142 try: 

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

144 except Exception: 

145 return None 

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

147 return None 

148 return str(row_key.value) 

149 

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

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

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

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

154 self._load_memories() 

155 

156 def action_focus_search(self) -> None: 

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

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

159 

160 def action_dismiss_or_back(self) -> None: 

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

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

163 if search.value: 

164 search.value = "" 

165 return 

166 self.action_go_back() 

167 

168 def action_go_back(self) -> None: 

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

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

171 self.app.pop_screen() 

172 

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

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

175 if isinstance(self.focused, Input): 

176 return None 

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

178 

179 def action_cursor_down(self) -> None: 

180 table = self._table_or_none() 

181 if table is not None: 

182 table.action_cursor_down() 

183 

184 def action_cursor_up(self) -> None: 

185 table = self._table_or_none() 

186 if table is not None: 

187 table.action_cursor_up() 

188 

189 def action_jump_top(self) -> None: 

190 table = self._table_or_none() 

191 if table is not None: 

192 table.scroll_home() 

193 

194 def action_jump_bottom(self) -> None: 

195 table = self._table_or_none() 

196 if table is not None: 

197 table.scroll_end() 

198 

199 def action_delete(self) -> None: 

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

201 memory_id = self._highlighted_id() 

202 if memory_id is None: 

203 return 

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

205 

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

207 if not confirmed: 

208 return 

209 self._do_delete(memory_id) 

210 

211 self.app.push_screen( 

212 ConfirmDialog( 

213 msg.MEMORIES_DELETE_CONFIRM_TITLE, 

214 msg.MEMORIES_DELETE_CONFIRM_MESSAGE, 

215 ), 

216 _on_confirm, 

217 ) 

218 

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

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

221 try: 

222 deleted = forget(memory_id) 

223 except Exception as exc: 

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

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

226 return 

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

228 self._load_memories() 

229 

230 def action_toggle_shared(self) -> None: 

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

232 memory_id = self._highlighted_id() 

233 if memory_id is None: 

234 return 

235 memory = self._memory_by_id(memory_id) 

236 if memory is None: 

237 return 

238 new_shared = not memory.shared 

239 try: 

240 updated = set_memory_shared(memory_id, shared=new_shared) 

241 except Exception as exc: 

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

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

244 return 

245 if not updated: 

246 self.notify(msg.MEMORIES_FLAG_NOT_FOUND) 

247 self._load_memories() 

248 return 

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

250 self._load_memories() 

251 

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

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

254 for m in self._memories: 

255 if m.id == memory_id: 

256 return m 

257 return None