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

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.wiki.browse import WikiPageInfo 

12 

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 

20 

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 

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_LEAF_SUFFIX = "" 

32_INDEX_STEM = "index" 

33 

34 

35def _wiki_root() -> Path: 

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

37 return cfg.data_root / cfg.wiki_dir 

38 

39 

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) 

58 

59 

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

63 

64 

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) 

73 

74 

75class WikiScreen(Screen[None]): 

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

77 

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

81 

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 ] 

94 

95 def __init__(self) -> None: 

96 super().__init__() 

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

98 

99 def compose(self) -> ComposeResult: 

100 from textual.widgets import Footer 

101 

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 

105 

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

133 

134 def on_mount(self) -> None: 

135 self._load_pages() 

136 

137 def reload(self) -> None: 

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

139 self._load_pages() 

140 

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 

144 

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

146 tree.reset("Wiki") 

147 self._page_slugs = [] 

148 

149 if not cfg.wiki: 

150 tree.root.add_leaf(msg.WIKI_EMPTY_STATE) 

151 self._show_placeholder() 

152 return 

153 

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

160 

161 if filter_text: 

162 needle = filter_text.lower() 

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

164 

165 if not all_pages: 

166 tree.root.add_leaf(msg.WIKI_EMPTY_STATE) 

167 self._show_placeholder() 

168 return 

169 

170 self._populate_tree(tree, all_pages) 

171 

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. 

174 

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) 

189 

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) 

196 

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. 

199 

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 

208 

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) 

215 

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 

221 

222 label = _short_label(leaf_part) 

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

224 

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) 

230 

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) 

238 

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 

242 

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 

250 

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

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

253 

254 page_type = "" 

255 parts = slug.split("/") 

256 if len(parts) >= 2: 

257 from lilbee.wiki.shared import SUBDIR_TO_TYPE 

258 

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

260 

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

265 

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) 

276 

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

281 

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) 

292 

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 

304 

305 def action_focus_search(self) -> None: 

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

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

308 

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 

312 

313 self.app.push_screen(WikiDraftsScreen()) 

314 

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

322 

323 def action_go_back(self) -> None: 

324 from lilbee.cli.tui.app import LilbeeApp 

325 

326 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp 

327 self.app.switch_view("Chat") 

328 else: 

329 self.app.pop_screen() 

330 

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) 

335 

336 def action_cursor_down(self) -> None: 

337 tree = self._tree_or_none() 

338 if tree is not None: 

339 tree.action_cursor_down() 

340 

341 def action_cursor_up(self) -> None: 

342 tree = self._tree_or_none() 

343 if tree is not None: 

344 tree.action_cursor_up() 

345 

346 def action_cursor_left(self) -> None: 

347 tree = self._tree_or_none() 

348 if tree is not None: 

349 tree.action_cursor_parent() 

350 

351 def action_cursor_right(self) -> None: 

352 tree = self._tree_or_none() 

353 if tree is not None: 

354 tree.action_toggle_node() 

355 

356 def action_jump_top(self) -> None: 

357 tree = self._tree_or_none() 

358 if tree is not None: 

359 tree.scroll_home() 

360 

361 def action_jump_bottom(self) -> None: 

362 tree = self._tree_or_none() 

363 if tree is not None: 

364 tree.scroll_end() 

365 

366 

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) 

375 

376 

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 

382 

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]