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
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Memories management screen: browse, delete, toggle-shared.
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"""
10from __future__ import annotations
12import logging
13from typing import TYPE_CHECKING, ClassVar
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
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
26if TYPE_CHECKING:
27 from lilbee.data.store import MemoryRow
29log = logging.getLogger(__name__)
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
37class MemoriesScreen(Screen[None]):
38 """Review-surface screen for the human's long-term memories."""
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."
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 ]
52 def __init__(self) -> None:
53 super().__init__()
54 self._memories: list[MemoryRow] = []
55 self._filter: str = ""
57 def compose(self) -> ComposeResult:
58 from textual.widgets import Footer
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
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()
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()
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
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 )
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")
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()]
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)
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()
156 def action_focus_search(self) -> None:
157 """Focus the search input (``/`` keybinding)."""
158 self.query_one("#memories-search", Input).focus()
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()
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()
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)
179 def action_cursor_down(self) -> None:
180 table = self._table_or_none()
181 if table is not None:
182 table.action_cursor_down()
184 def action_cursor_up(self) -> None:
185 table = self._table_or_none()
186 if table is not None:
187 table.action_cursor_up()
189 def action_jump_top(self) -> None:
190 table = self._table_or_none()
191 if table is not None:
192 table.scroll_home()
194 def action_jump_bottom(self) -> None:
195 table = self._table_or_none()
196 if table is not None:
197 table.scroll_end()
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
206 def _on_confirm(confirmed: bool | None) -> None:
207 if not confirmed:
208 return
209 self._do_delete(memory_id)
211 self.app.push_screen(
212 ConfirmDialog(
213 msg.MEMORIES_DELETE_CONFIRM_TITLE,
214 msg.MEMORIES_DELETE_CONFIRM_MESSAGE,
215 ),
216 _on_confirm,
217 )
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()
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()
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