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

1"""Wiki screen: browse wiki pages as a navigable tree with markdown preview.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from datetime import date, datetime 

7from pathlib import Path 

8from typing import TYPE_CHECKING, ClassVar 

9 

10if TYPE_CHECKING: 

11 from lilbee.cli.tui.app import LilbeeApp 

12 from lilbee.wiki.browse import WikiPageInfo 

13 

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 

21 

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 

26 

27log = logging.getLogger(__name__) 

28 

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 

35 

36 

37def _wiki_root() -> Path: 

38 """Resolve the wiki root directory from config.""" 

39 return cfg.data_root / cfg.wiki_dir 

40 

41 

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) 

60 

61 

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() 

65 

66 

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) 

75 

76 

77class WikiScreen(Screen[None]): 

78 """Wiki page browser with a tree sidebar and markdown content viewer.""" 

79 

80 app: LilbeeApp # type: ignore[assignment] 

81 

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." 

85 

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 ] 

98 

99 def __init__(self) -> None: 

100 super().__init__() 

101 self._page_slugs: list[str] = [] 

102 

103 def compose(self) -> ComposeResult: 

104 from textual.widgets import Footer 

105 

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 

109 

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() 

137 

138 def on_mount(self) -> None: 

139 self._load_pages() 

140 

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() 

146 

147 def reload(self) -> None: 

148 """Refresh the sidebar from disk. Public entry point for external callers.""" 

149 self._load_pages() 

150 

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 

154 

155 tree = self.query_one("#wiki-page-list", Tree) 

156 tree.reset("Wiki") 

157 self._page_slugs = [] 

158 

159 if not cfg.wiki: 

160 tree.root.add_leaf(msg.wiki_empty_state_leaf()) 

161 self._show_placeholder() 

162 return 

163 

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 = [] 

170 

171 if filter_text: 

172 needle = filter_text.lower() 

173 all_pages = [p for p in all_pages if needle in p.title.lower()] 

174 

175 if not all_pages: 

176 tree.root.add_leaf(msg.wiki_empty_state_leaf()) 

177 self._show_placeholder() 

178 return 

179 

180 self._populate_tree(tree, all_pages) 

181 

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. 

184 

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) 

199 

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) 

206 

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. 

209 

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 

218 

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) 

225 

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 

231 

232 label = _short_label(leaf_part) 

233 node.add_leaf(page.title if page.title else label, data=page.slug) 

234 

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()) 

240 

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) 

248 

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 

252 

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 

260 

261 faithfulness = page.frontmatter.get("faithfulness_score") 

262 faith_val = float(faithfulness) if faithfulness is not None else None 

263 

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 

268 

269 page_type = SUBDIR_TO_TYPE.get(parts[0], "") 

270 

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() 

275 

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) 

286 

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()) 

291 

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) 

302 

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 

314 

315 def action_focus_search(self) -> None: 

316 """Focus the search input -- bound to / key.""" 

317 self.query_one("#wiki-search", Input).focus() 

318 

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 

322 

323 self.app.push_screen(WikiDraftsScreen()) 

324 

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() 

332 

333 def action_go_back(self) -> None: 

334 self.app.switch_view("Chat") 

335 

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) 

340 

341 def action_cursor_down(self) -> None: 

342 tree = self._tree_or_none() 

343 if tree is not None: 

344 tree.action_cursor_down() 

345 

346 def action_cursor_up(self) -> None: 

347 tree = self._tree_or_none() 

348 if tree is not None: 

349 tree.action_cursor_up() 

350 

351 def action_cursor_left(self) -> None: 

352 tree = self._tree_or_none() 

353 if tree is not None: 

354 tree.action_cursor_parent() 

355 

356 def action_cursor_right(self) -> None: 

357 tree = self._tree_or_none() 

358 if tree is not None: 

359 tree.action_toggle_node() 

360 

361 def action_jump_top(self) -> None: 

362 tree = self._tree_or_none() 

363 if tree is not None: 

364 tree.scroll_home() 

365 

366 def action_jump_bottom(self) -> None: 

367 tree = self._tree_or_none() 

368 if tree is not None: 

369 tree.scroll_end() 

370 

371 

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) 

380 

381 

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 

387 

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]