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
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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 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
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
27if TYPE_CHECKING:
28 from lilbee.data.store import MemoryRow
30log = logging.getLogger(__name__)
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
38class MemoriesScreen(Screen[None]):
39 """Review-surface screen for the human's long-term memories."""
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."
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 ]
53 def __init__(self) -> None:
54 super().__init__()
55 self._memories: list[MemoryRow] = []
56 self._filter: str = ""
58 def compose(self) -> ComposeResult:
59 from textual.widgets import Footer
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
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()
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()
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
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 )
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")
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()]
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)
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()
157 def action_focus_search(self) -> None:
158 """Focus the search input (``/`` keybinding)."""
159 self.query_one("#memories-search", Input).focus()
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()
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()
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)
180 def action_cursor_down(self) -> None:
181 table = self._table_or_none()
182 if table is not None:
183 table.action_cursor_down()
185 def action_cursor_up(self) -> None:
186 table = self._table_or_none()
187 if table is not None:
188 table.action_cursor_up()
190 def action_jump_top(self) -> None:
191 table = self._table_or_none()
192 if table is not None:
193 table.scroll_home()
195 def action_jump_bottom(self) -> None:
196 table = self._table_or_none()
197 if table is not None:
198 table.scroll_end()
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
207 def _on_confirm(confirmed: bool | None) -> None:
208 if not confirmed:
209 return
210 self._do_delete(memory_id)
212 self.app.push_screen(
213 ConfirmDialog(
214 msg.MEMORIES_DELETE_CONFIRM_TITLE,
215 msg.MEMORIES_DELETE_CONFIRM_MESSAGE,
216 ),
217 _on_confirm,
218 )
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()
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()
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