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

152 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +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 

21 

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

23from lilbee.cli.tui import messages as msg 

24 

25if TYPE_CHECKING: 

26 from lilbee.data.store import MemoryRow 

27 

28log = logging.getLogger(__name__) 

29 

30 

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

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

33 return msg.MEMORIES_FLAG_YES if value else msg.MEMORIES_FLAG_NO 

34 

35 

36class MemoriesScreen(Screen[None]): 

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

38 

39 CSS_PATH = "memories.tcss" 

40 AUTO_FOCUS = "#memories-table" 

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

42 

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

44 Binding("q", "go_back", "Back", show=True), 

45 Binding("escape", "dismiss_or_back", "Back", show=False), 

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

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

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

49 Binding("j", "cursor_down", "Nav", show=False), 

50 Binding("k", "cursor_up", "Nav", show=False), 

51 Binding("g", "jump_top", "Top", show=False), 

52 Binding("G", "jump_bottom", "End", show=False), 

53 ] 

54 

55 def __init__(self) -> None: 

56 super().__init__() 

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

58 self._filter: str = "" 

59 

60 def compose(self) -> ComposeResult: 

61 from textual.widgets import Footer 

62 

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

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

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

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

67 

68 with TopBars(): 

69 yield ViewTabs() 

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

71 table.cursor_type = "row" 

72 yield Vertical( 

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

74 table, 

75 id="memories-layout", 

76 ) 

77 with BottomBars(): 

78 yield TaskBar() 

79 yield Footer() 

80 

81 def on_mount(self) -> None: 

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

83 table.add_columns( 

84 msg.MEMORIES_COLUMN_KIND, 

85 msg.MEMORIES_COLUMN_SHARED, 

86 msg.MEMORIES_COLUMN_TEXT, 

87 ) 

88 self._load_memories() 

89 

90 def _load_memories(self) -> None: 

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

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

93 table.clear() 

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.notify(msg.MEMORIES_EMPTY) 

109 return 

110 for m in visible: 

111 table.add_row( 

112 m.kind.value, 

113 _flag_label(m.shared), 

114 m.text, 

115 key=m.id, 

116 ) 

117 

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

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

120 if not self._filter: 

121 return self._memories 

122 needle = self._filter.lower() 

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

124 

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

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

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

128 if table.row_count == 0: 

129 return None 

130 try: 

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

132 except Exception: 

133 return None 

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

135 return None 

136 return str(row_key.value) 

137 

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

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

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

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

142 self._load_memories() 

143 

144 def action_focus_search(self) -> None: 

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

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

147 

148 def action_dismiss_or_back(self) -> None: 

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

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

151 if search.value: 

152 search.value = "" 

153 return 

154 self.action_go_back() 

155 

156 def action_go_back(self) -> None: 

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

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

159 self.app.pop_screen() 

160 

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

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

163 if isinstance(self.focused, Input): 

164 return None 

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

166 

167 def action_cursor_down(self) -> None: 

168 table = self._table_or_none() 

169 if table is not None: 

170 table.action_cursor_down() 

171 

172 def action_cursor_up(self) -> None: 

173 table = self._table_or_none() 

174 if table is not None: 

175 table.action_cursor_up() 

176 

177 def action_jump_top(self) -> None: 

178 table = self._table_or_none() 

179 if table is not None: 

180 table.scroll_home() 

181 

182 def action_jump_bottom(self) -> None: 

183 table = self._table_or_none() 

184 if table is not None: 

185 table.scroll_end() 

186 

187 def action_delete(self) -> None: 

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

189 memory_id = self._highlighted_id() 

190 if memory_id is None: 

191 return 

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

193 

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

195 if not confirmed: 

196 return 

197 self._do_delete(memory_id) 

198 

199 self.app.push_screen( 

200 ConfirmDialog( 

201 msg.MEMORIES_DELETE_CONFIRM_TITLE, 

202 msg.MEMORIES_DELETE_CONFIRM_MESSAGE, 

203 ), 

204 _on_confirm, 

205 ) 

206 

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

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

209 try: 

210 forget(memory_id) 

211 except Exception as exc: 

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

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

214 return 

215 self.notify(msg.MEMORIES_DELETED) 

216 self._load_memories() 

217 

218 def action_toggle_shared(self) -> None: 

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

220 memory_id = self._highlighted_id() 

221 if memory_id is None: 

222 return 

223 memory = self._memory_by_id(memory_id) 

224 if memory is None: 

225 return 

226 new_shared = not memory.shared 

227 try: 

228 set_memory_shared(memory_id, shared=new_shared) 

229 except Exception as exc: 

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

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

232 return 

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

234 self._load_memories() 

235 

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

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

238 for m in self._memories: 

239 if m.id == memory_id: 

240 return m 

241 return None