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
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +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
22from lilbee.app.memory import forget, list_memories, memory_enabled, set_memory_shared
23from lilbee.cli.tui import messages as msg
25if TYPE_CHECKING:
26 from lilbee.data.store import MemoryRow
28log = logging.getLogger(__name__)
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
36class MemoriesScreen(Screen[None]):
37 """Review-surface screen for the human's long-term memories."""
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."
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 ]
55 def __init__(self) -> None:
56 super().__init__()
57 self._memories: list[MemoryRow] = []
58 self._filter: str = ""
60 def compose(self) -> ComposeResult:
61 from textual.widgets import Footer
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
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()
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()
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
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 )
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()]
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)
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()
144 def action_focus_search(self) -> None:
145 """Focus the search input (``/`` keybinding)."""
146 self.query_one("#memories-search", Input).focus()
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()
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()
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)
167 def action_cursor_down(self) -> None:
168 table = self._table_or_none()
169 if table is not None:
170 table.action_cursor_down()
172 def action_cursor_up(self) -> None:
173 table = self._table_or_none()
174 if table is not None:
175 table.action_cursor_up()
177 def action_jump_top(self) -> None:
178 table = self._table_or_none()
179 if table is not None:
180 table.scroll_home()
182 def action_jump_bottom(self) -> None:
183 table = self._table_or_none()
184 if table is not None:
185 table.scroll_end()
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
194 def _on_confirm(confirmed: bool | None) -> None:
195 if not confirmed:
196 return
197 self._do_delete(memory_id)
199 self.app.push_screen(
200 ConfirmDialog(
201 msg.MEMORIES_DELETE_CONFIRM_TITLE,
202 msg.MEMORIES_DELETE_CONFIRM_MESSAGE,
203 ),
204 _on_confirm,
205 )
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()
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()
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