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