Coverage for src / lilbee / cli / tui / screens / wiki.py: 100%
240 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Wiki screen: browse wiki pages as a navigable tree with markdown preview."""
3from __future__ import annotations
5import logging
6from datetime import date, datetime
7from pathlib import Path
8from typing import TYPE_CHECKING, ClassVar
10if TYPE_CHECKING:
11 from lilbee.cli.tui.app import LilbeeApp
12 from lilbee.wiki.browse import WikiPageInfo
14from textual import on
15from textual.app import ComposeResult
16from textual.binding import Binding, BindingType
17from textual.containers import Horizontal, Vertical, VerticalScroll
18from textual.screen import Screen
19from textual.widgets import Input, Markdown, Static, Tree
20from textual.widgets.tree import TreeNode
22from lilbee.cli.tui import messages as msg
23from lilbee.cli.tui.widgets.task_bar import TaskBar
24from lilbee.core.config import cfg
25from lilbee.wiki.browse import read_page
27log = logging.getLogger(__name__)
29# Tree node data carries the full wiki-page slug when present. Group folders
30# (page-type headings, per-source branches, inner-section branches) use None.
31_INDEX_STEM = "index"
32# Wiki slugs of the form ``<subdir>/<name>`` carry a meaningful page type;
33# bare slugs (no slash) do not.
34_SLUG_WITH_TYPE_MIN_PARTS = 2
37def _wiki_root() -> Path:
38 """Resolve the wiki root directory from config."""
39 return cfg.data_root / cfg.wiki_dir
42def _format_page_header(
43 title: str,
44 page_type: str,
45 source_count: int,
46 created_at: str,
47 faithfulness: float | None,
48) -> str:
49 """Build a header string for the content pane."""
50 parts = [f"[bold]{title}[/]"]
51 parts.append(f" [dim]{page_type}[/]")
52 if source_count > 0:
53 parts.append(f" [dim]{source_count} sources[/]")
54 if created_at:
55 parts.append(f" [dim]{created_at}[/]")
56 if faithfulness is not None:
57 pct = int(faithfulness * 100)
58 parts.append(f" [dim]faithfulness {pct}%[/]")
59 return "".join(parts)
62def _short_label(slug_part: str) -> str:
63 """Render a slug component as a human-friendly tree label."""
64 return slug_part.replace("-", " ").replace("_", " ").strip()
67def _breadcrumb_for_slug(slug: str, title: str) -> str:
68 """Build a dim-themed breadcrumb string: chapter > section > page."""
69 parts = slug.split("/")
70 if len(parts) <= 1:
71 return ""
72 display_parts = [_short_label(p) for p in parts[:-1]]
73 display_parts.append(title)
74 return " [dim]>[/] ".join(display_parts)
77class WikiScreen(Screen[None]):
78 """Wiki page browser with a tree sidebar and markdown content viewer."""
80 app: LilbeeApp # type: ignore[assignment]
82 CSS_PATH = "wiki.tcss"
83 AUTO_FOCUS = "#wiki-page-list"
84 HELP = "Browse wiki pages. h/l collapse/expand, j/k navigate, Enter opens a page, / searches."
86 BINDINGS: ClassVar[list[BindingType]] = [
87 Binding("q", "go_back", "Back", show=True),
88 Binding("escape", "dismiss_or_back", "Back", show=False),
89 Binding("slash", "focus_search", "Search", show=True),
90 Binding("D", "open_drafts", "Drafts", show=True),
91 Binding("j", "cursor_down", "Nav", show=False),
92 Binding("k", "cursor_up", "Nav", show=False),
93 Binding("h", "cursor_left", "Collapse", show=False),
94 Binding("l", "cursor_right", "Expand", show=False),
95 Binding("g", "jump_top", "Top", show=False),
96 Binding("G", "jump_bottom", "End", show=False),
97 ]
99 def __init__(self) -> None:
100 super().__init__()
101 self._page_slugs: list[str] = []
103 def compose(self) -> ComposeResult:
104 from textual.widgets import Footer
106 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
107 from lilbee.cli.tui.widgets.status_bar import ViewTabs
108 from lilbee.cli.tui.widgets.top_bars import TopBars
110 with TopBars():
111 yield ViewTabs()
112 tree: Tree[str | None] = Tree("Wiki", id="wiki-page-list")
113 tree.show_root = False
114 yield Horizontal(
115 Vertical(
116 Input(
117 placeholder=msg.WIKI_SEARCH_PLACEHOLDER,
118 id="wiki-search",
119 ),
120 tree,
121 id="wiki-sidebar",
122 ),
123 Vertical(
124 Static("", id="wiki-breadcrumb"),
125 Static("", id="wiki-page-header"),
126 VerticalScroll(
127 Markdown("", id="wiki-content"),
128 id="wiki-content-scroll",
129 ),
130 id="wiki-main",
131 ),
132 id="wiki-layout",
133 )
134 with BottomBars():
135 yield TaskBar()
136 yield Footer()
138 def on_mount(self) -> None:
139 self._load_pages()
141 def on_show(self) -> None:
142 """Re-scan on focus so out-of-band builds (`lilbee wiki build` from a
143 sibling shell) and incremental wiki updates land without a TUI restart.
144 """
145 self._load_pages()
147 def reload(self) -> None:
148 """Refresh the sidebar from disk. Public entry point for external callers."""
149 self._load_pages()
151 def _load_pages(self, filter_text: str = "") -> None:
152 """Populate the sidebar tree with wiki pages, optionally filtered."""
153 from lilbee.wiki.browse import list_pages
155 tree = self.query_one("#wiki-page-list", Tree)
156 tree.reset("Wiki")
157 self._page_slugs = []
159 if not cfg.wiki:
160 tree.root.add_leaf(msg.wiki_empty_state_leaf())
161 self._show_placeholder()
162 return
164 root = _wiki_root()
165 try:
166 all_pages = list_pages(root)
167 except Exception:
168 log.debug("Failed to list wiki pages", exc_info=True)
169 all_pages = []
171 if filter_text:
172 needle = filter_text.lower()
173 all_pages = [p for p in all_pages if needle in p.title.lower()]
175 if not all_pages:
176 tree.root.add_leaf(msg.wiki_empty_state_leaf())
177 self._show_placeholder()
178 return
180 self._populate_tree(tree, all_pages)
182 def _populate_tree(self, tree: Tree[str | None], pages: list[WikiPageInfo]) -> None:
183 """Build the sidebar tree from a flat list of wiki pages.
185 Slugs like ``summaries/cv-manual/01-brakes/page-0042`` become nested
186 branches under their page-type group, with leaves for leaf pages and
187 expandable branches for intermediate heading folders. ``index.md``
188 and ``log.md`` at the wiki root are surfaced as top-level leaves.
189 """
190 self._add_root_shortcut(tree, "index", msg.WIKI_INDEX_LABEL)
191 self._add_root_shortcut(tree, "log", msg.WIKI_LOG_LABEL)
192 grouped = _group_pages(pages)
193 for page_type, group_pages in grouped:
194 heading = msg.WIKI_TYPE_HEADINGS.get(page_type, page_type.capitalize())
195 group_node = tree.root.add(heading, expand=True)
196 for page in group_pages:
197 self._page_slugs.append(page.slug)
198 self._insert_page(group_node, page)
200 def _add_root_shortcut(self, tree: Tree[str | None], slug: str, label: str) -> None:
201 """Add a top-level leaf for an auto-generated page (index.md, log.md)."""
202 if not (_wiki_root() / f"{slug}.md").is_file():
203 return
204 tree.root.add_leaf(label, data=slug)
205 self._page_slugs.append(slug)
207 def _insert_page(self, group_node: TreeNode[str | None], page: WikiPageInfo) -> None:
208 """Walk the slug path and add/reuse branches until the leaf position.
210 Slugs begin with the page-type prefix (``summaries/``/``synthesis/``),
211 which is already reflected in the enclosing group node. The remaining
212 path components form the nested tree inside the group.
213 """
214 parts = page.slug.split("/")
215 if len(parts) <= 1:
216 group_node.add_leaf(page.title, data=page.slug)
217 return
219 # Skip the leading page-type component since the group node represents it.
220 inner_parts = parts[1:]
221 node = group_node
222 *branch_parts, leaf_part = inner_parts
223 for part in branch_parts:
224 node = _find_or_add_branch(node, part)
226 if leaf_part == _INDEX_STEM:
227 # An inner-node index.md file: show its title on the enclosing branch.
228 node.label = page.title
229 node.data = page.slug
230 return
232 label = _short_label(leaf_part)
233 node.add_leaf(page.title if page.title else label, data=page.slug)
235 def _show_placeholder(self) -> None:
236 """Show the no-content placeholder in the main area."""
237 self.query_one("#wiki-breadcrumb", Static).update("")
238 self.query_one("#wiki-page-header", Static).update("")
239 self.query_one("#wiki-content", Markdown).update(msg.wiki_empty_state_detail())
241 @on(Tree.NodeSelected, "#wiki-page-list")
242 def _on_node_selected(self, event: Tree.NodeSelected[str | None]) -> None:
243 """Load and display the selected wiki page when the node carries a slug."""
244 slug = event.node.data
245 if not isinstance(slug, str):
246 return
247 self._display_page(slug)
249 def _display_page(self, slug: str) -> None:
250 """Read and render a wiki page by slug."""
251 from lilbee.wiki.browse import read_page
253 root = _wiki_root()
254 page = read_page(root, slug)
255 if page is None:
256 self.query_one("#wiki-breadcrumb", Static).update("")
257 self.query_one("#wiki-page-header", Static).update("")
258 self.query_one("#wiki-content", Markdown).update(msg.WIKI_NO_CONTENT)
259 return
261 faithfulness = page.frontmatter.get("faithfulness_score")
262 faith_val = float(faithfulness) if faithfulness is not None else None
264 page_type = ""
265 parts = slug.split("/")
266 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS:
267 from lilbee.wiki.shared import SUBDIR_TO_TYPE
269 page_type = SUBDIR_TO_TYPE.get(parts[0], "")
271 source_count = page.frontmatter.get("source_count", 0)
272 created_at = page.frontmatter.get("generated_at", "")
273 if isinstance(created_at, (datetime, date)):
274 created_at = created_at.isoformat()
276 header_text = _format_page_header(
277 title=page.title,
278 page_type=page_type,
279 source_count=int(source_count) if source_count else 0,
280 created_at=str(created_at),
281 faithfulness=faith_val,
282 )
283 self.query_one("#wiki-breadcrumb", Static).update(_breadcrumb_for_slug(slug, page.title))
284 self.query_one("#wiki-page-header", Static).update(header_text)
285 self.query_one("#wiki-content", Markdown).update(page.content)
287 @on(Input.Changed, "#wiki-search")
288 def _on_search_changed(self, event: Input.Changed) -> None:
289 """Filter pages when search input changes."""
290 self._load_pages(filter_text=event.value.strip())
292 def _selected_source(self) -> str | None:
293 """Return the source name for the highlighted wiki page, or None."""
294 tree = self.query_one("#wiki-page-list", Tree)
295 node = tree.cursor_node
296 if node is None:
297 return None
298 slug = node.data
299 if not isinstance(slug, str):
300 return None
301 return self._source_for_slug(slug)
303 def _source_for_slug(self, slug: str) -> str | None:
304 """Extract the primary source filename from a wiki page's frontmatter."""
305 root = _wiki_root()
306 page = read_page(root, slug)
307 if page is None:
308 return None
309 sources = page.frontmatter.get("sources")
310 # frontmatter values are untyped (Any from YAML); guard against non-list shapes
311 if isinstance(sources, list) and sources:
312 return str(sources[0])
313 return None
315 def action_focus_search(self) -> None:
316 """Focus the search input -- bound to / key."""
317 self.query_one("#wiki-search", Input).focus()
319 def action_open_drafts(self) -> None:
320 """Open the drafts review screen -- bound to capital D."""
321 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen
323 self.app.push_screen(WikiDraftsScreen())
325 def action_dismiss_or_back(self) -> None:
326 """Clear search if active, otherwise go back."""
327 search = self.query_one("#wiki-search", Input)
328 if search.value:
329 search.value = ""
330 return
331 self.action_go_back()
333 def action_go_back(self) -> None:
334 self.app.switch_view("Chat")
336 def _tree_or_none(self) -> Tree[str | None] | None:
337 if isinstance(self.focused, Input):
338 return None
339 return self.query_one("#wiki-page-list", Tree)
341 def action_cursor_down(self) -> None:
342 tree = self._tree_or_none()
343 if tree is not None:
344 tree.action_cursor_down()
346 def action_cursor_up(self) -> None:
347 tree = self._tree_or_none()
348 if tree is not None:
349 tree.action_cursor_up()
351 def action_cursor_left(self) -> None:
352 tree = self._tree_or_none()
353 if tree is not None:
354 tree.action_cursor_parent()
356 def action_cursor_right(self) -> None:
357 tree = self._tree_or_none()
358 if tree is not None:
359 tree.action_toggle_node()
361 def action_jump_top(self) -> None:
362 tree = self._tree_or_none()
363 if tree is not None:
364 tree.scroll_home()
366 def action_jump_bottom(self) -> None:
367 tree = self._tree_or_none()
368 if tree is not None:
369 tree.scroll_end()
372def _find_or_add_branch(parent: TreeNode[str | None], label_part: str) -> TreeNode[str | None]:
373 """Return the child branch whose raw label matches *label_part*, adding it if absent."""
374 display = _short_label(label_part)
375 for child in parent.children:
376 existing = child.label.plain if hasattr(child.label, "plain") else str(child.label)
377 if existing == display:
378 return child
379 return parent.add(display, expand=True)
382def _group_pages(
383 pages: list[WikiPageInfo],
384) -> list[tuple[str, list[WikiPageInfo]]]:
385 """Group pages by page_type in sidebar order: concepts, entities, then legacy."""
386 from lilbee.wiki.shared import WikiPageType
388 groups: dict[str, list[WikiPageInfo]] = {}
389 type_order: tuple[str, ...] = (
390 WikiPageType.CONCEPT,
391 WikiPageType.ENTITY,
392 WikiPageType.SUMMARY,
393 WikiPageType.SYNTHESIS,
394 )
395 for t in type_order:
396 group = [p for p in pages if p.page_type == t]
397 if group:
398 groups[t] = group
399 for p in pages:
400 if p.page_type not in groups:
401 groups[p.page_type] = []
402 if p.page_type not in type_order:
403 groups[p.page_type].append(p)
404 return [(k, v) for k, v in groups.items() if v]