Coverage for src/lilbee/cli/tui/screens/wiki.py: 100%

358 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

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

2 

3from __future__ import annotations 

4 

5import logging 

6from pathlib import Path 

7from typing import TYPE_CHECKING, ClassVar, cast 

8 

9if TYPE_CHECKING: 

10 from lilbee.cli.tui.app import LilbeeApp 

11 from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter 

12 from lilbee.data.store import Store 

13 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent 

14 from lilbee.wiki.browse import WikiPageInfo 

15 

16from textual import on 

17from textual.app import ComposeResult 

18from textual.binding import Binding, BindingType 

19from textual.containers import Horizontal, Vertical, VerticalScroll 

20from textual.screen import Screen 

21from textual.timer import Timer 

22from textual.widgets import Input, Markdown, Static, Tree 

23from textual.widgets.tree import TreeNode 

24 

25from lilbee.app.services import get_services 

26from lilbee.cli.tui import messages as msg 

27from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings 

28from lilbee.cli.tui.task_queue import TaskType 

29from lilbee.cli.tui.thread_safe import call_from_thread 

30from lilbee.cli.tui.widgets.task_bar import TaskBar 

31from lilbee.core.config import cfg 

32from lilbee.runtime.progress import EventType, WikiPageEvent, WikiPhaseEvent 

33from lilbee.wiki.stubs import WikiStub, load_stub_index, ungenerated_stubs 

34 

35log = logging.getLogger(__name__) 

36 

37# Tree node data carries the full wiki-page slug when present. Group folders 

38# (page-type headings, per-source branches, inner-section branches) use None. 

39_INDEX_STEM = "index" 

40# Wiki slugs of the form ``<subdir>/<name>`` carry a meaningful page type; 

41# bare slugs (no slash) do not. 

42_SLUG_WITH_TYPE_MIN_PARTS = 2 

43 

44 

45def _wiki_root() -> Path: 

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

47 return cfg.data_root / cfg.wiki_dir 

48 

49 

50def _root_shortcuts() -> list[tuple[str, str]]: 

51 """The auto-generated root pages that exist, as ``(slug, label)`` pairs. 

52 

53 Returned rather than mounted so the caller filters them alongside the pages. 

54 """ 

55 return [ 

56 (slug, label) 

57 for slug, label in (("index", msg.WIKI_INDEX_LABEL), ("log", msg.WIKI_LOG_LABEL)) 

58 if (_wiki_root() / f"{slug}.md").is_file() 

59 ] 

60 

61 

62def _safe_float(value: object) -> float | None: 

63 """Coerce an untyped frontmatter value to float, or None if not numeric.""" 

64 try: 

65 return float(cast("float", value)) 

66 except (TypeError, ValueError): 

67 return None 

68 

69 

70def _safe_int(value: object, default: int = 0) -> int: 

71 """Coerce an untyped frontmatter value to int, or *default* if not numeric.""" 

72 try: 

73 return int(cast("float", value)) 

74 except (TypeError, ValueError): 

75 return default 

76 

77 

78def _format_page_header( 

79 title: str, 

80 page_type: str, 

81 source_count: int, 

82 created_at: str, 

83 faithfulness: float | None, 

84) -> str: 

85 """Build a header string for the content pane.""" 

86 parts = [f"[bold]{title}[/]"] 

87 parts.append(f" [dim]{page_type}[/]") 

88 if source_count > 0: 

89 parts.append(f" [dim]{source_count} sources[/]") 

90 if created_at: 

91 parts.append(f" [dim]{created_at}[/]") 

92 if faithfulness is not None: 

93 pct = int(faithfulness * 100) 

94 parts.append(f" [dim]faithfulness {pct}%[/]") 

95 return "".join(parts) 

96 

97 

98def _short_label(slug_part: str) -> str: 

99 """Render a slug component as a human-friendly tree label.""" 

100 return slug_part.replace("-", " ").replace("_", " ").strip() 

101 

102 

103def _breadcrumb_for_slug(slug: str, title: str) -> str: 

104 """Build a dim-themed breadcrumb string: chapter > section > page.""" 

105 parts = slug.split("/") 

106 if len(parts) <= 1: 

107 return "" 

108 display_parts = [_short_label(p) for p in parts[:-1]] 

109 display_parts.append(title) 

110 return " [dim]>[/] ".join(display_parts) 

111 

112 

113class WikiScreen(Screen[None]): 

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

115 

116 app: LilbeeApp # type: ignore[assignment] 

117 

118 CSS_PATH = "wiki.tcss" 

119 AUTO_FOCUS = "#wiki-page-list" 

120 HELP = ( 

121 "Browse wiki pages. h/l collapse/expand, j/k navigate, Enter opens a page, " 

122 "/ searches, b generates pages from your documents (GPU-heavy)." 

123 ) 

124 

125 BINDINGS: ClassVar[list[BindingType]] = [ 

126 *browse_back_bindings(escape_action="dismiss_or_back"), 

127 *BROWSE_LIST_BINDINGS, 

128 Binding("slash", "focus_search", "Search", show=True), 

129 Binding("D", "open_drafts", "Drafts", show=False), 

130 Binding("b", "wikify", "Wikify", show=True), 

131 Binding("W", "wipe", "Wipe", show=False), 

132 Binding("h", "cursor_left", "Collapse", show=False), 

133 Binding("l", "cursor_right", "Expand", show=False), 

134 ] 

135 

136 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.12 

137 

138 def __init__(self) -> None: 

139 super().__init__() 

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

141 self._pages: list[WikiPageInfo] = [] 

142 self._stubs: dict[str, WikiStub] = {} 

143 self._load_error: str | None = None 

144 self._search_filter_timer: Timer | None = None 

145 # Filter the last paint ran under. Only a change to it drops the scroll 

146 # offset; a plain reload keeps the reader's place. 

147 self._painted_filter: str = "" 

148 

149 def compose(self) -> ComposeResult: 

150 from textual.widgets import Footer 

151 

152 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

153 from lilbee.cli.tui.widgets.status_bar import ViewTabs 

154 from lilbee.cli.tui.widgets.top_bars import TopBars 

155 

156 with TopBars(): 

157 yield ViewTabs() 

158 tree: Tree[str | None] = Tree("Wiki", id="wiki-page-list") 

159 tree.show_root = False 

160 yield Horizontal( 

161 Vertical( 

162 Input( 

163 placeholder=msg.WIKI_SEARCH_PLACEHOLDER, 

164 id="wiki-search", 

165 ), 

166 tree, 

167 id="wiki-sidebar", 

168 ), 

169 Vertical( 

170 Static("", id="wiki-breadcrumb"), 

171 Static("", id="wiki-page-header"), 

172 VerticalScroll( 

173 Markdown("", id="wiki-content"), 

174 id="wiki-content-scroll", 

175 ), 

176 id="wiki-main", 

177 ), 

178 id="wiki-layout", 

179 ) 

180 with BottomBars(): 

181 yield TaskBar() 

182 yield Footer() 

183 

184 def on_screen_resume(self) -> None: 

185 """Re-scan on every activation so out-of-band builds (`lilbee wiki build` 

186 from a sibling shell), incremental updates, and builds that finished while 

187 this screen was out of view land without a TUI restart. 

188 

189 Resume fires on the initial push and on each switch back; Show fires only 

190 once per screen, so it cannot carry this. 

191 """ 

192 self.reload() 

193 

194 def reload(self) -> None: 

195 """Re-walk the wiki from disk, then repaint under the live search filter. 

196 

197 Public entry point for external callers (the task bar refreshes an 

198 open wiki screen after a sync or a wikify run). The walk parses every 

199 page's frontmatter, so it runs here and not on every filter keystroke. 

200 """ 

201 from lilbee.wiki.browse import list_pages 

202 

203 self._pages = [] 

204 self._stubs = {} 

205 self._load_error = None 

206 if cfg.wiki: 

207 try: 

208 self._pages = list_pages(_wiki_root()) 

209 self._stubs = { 

210 stub.wiki_slug: stub 

211 for stub in ungenerated_stubs(load_stub_index(), _wiki_root()) 

212 } 

213 except Exception as exc: 

214 log.warning("Failed to list wiki pages", exc_info=True) 

215 self._load_error = msg.WIKI_LOAD_FAILED.format(error=exc) 

216 self._show_load_failure(self._load_error) 

217 return 

218 self._load_pages(filter_text=self.query_one("#wiki-search", Input).value.strip()) 

219 

220 def _show_load_failure(self, detail: str) -> None: 

221 """Render the listing failure in both panes.""" 

222 tree = self._empty_tree() 

223 tree.root.add_leaf(msg.WIKI_LOAD_FAILED_LEAF) 

224 self._show_detail(detail) 

225 

226 def _empty_tree(self) -> Tree[str | None]: 

227 """Clear the sidebar tree and the slug list it indexes.""" 

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

229 tree.reset("Wiki") 

230 self._page_slugs = [] 

231 return tree 

232 

233 def _load_pages(self, filter_text: str = "") -> None: 

234 """Populate the sidebar tree from the cached page list, optionally filtered.""" 

235 if self._load_error is not None: 

236 # The cache is empty because listing failed, not because the wiki is. 

237 self._show_load_failure(self._load_error) 

238 return 

239 tree = self._empty_tree() 

240 filter_changed = filter_text != self._painted_filter 

241 self._painted_filter = filter_text 

242 if filter_changed: 

243 # The offset belongs to the previous result set. Keeping it parks 

244 # the sidebar past the end of a shorter one (Textual clamps to the 

245 # new max), so the matches render above the visible area. 

246 tree.scroll_to(y=0, animate=False) 

247 if not self._pages and not self._stubs: 

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

249 self._show_placeholder() 

250 return 

251 

252 needle = filter_text.lower() 

253 pages = [p for p in self._pages if needle in p.title.lower()] 

254 stubs = [s for s in self._stubs.values() if needle in s.label.lower()] 

255 shortcuts = [(slug, label) for slug, label in _root_shortcuts() if needle in label.lower()] 

256 if not pages and not stubs and not shortcuts: 

257 # Pages exist but none match: leave the content pane untouched 

258 # rather than rendering the empty-wiki state. 

259 tree.root.add_leaf(msg.WIKI_NO_MATCHES.format(filter=filter_text)) 

260 return 

261 

262 self._populate_tree(tree, pages, shortcuts) 

263 # Under a filter every stub in the group is a match, so it opens. 

264 # Unfiltered it stays collapsed; stubs outnumber the written pages. 

265 self._add_stub_group(tree, stubs, expand=bool(needle)) 

266 

267 def _populate_tree( 

268 self, 

269 tree: Tree[str | None], 

270 pages: list[WikiPageInfo], 

271 shortcuts: list[tuple[str, str]], 

272 ) -> None: 

273 """Build the sidebar tree from a flat list of wiki pages. 

274 

275 Slugs like ``summaries/cv-manual/01-brakes/page-0042`` become nested 

276 branches under their page-type group, with leaves for leaf pages and 

277 expandable branches for intermediate heading folders. *shortcuts* are 

278 the auto-generated root pages (``index.md``, ``log.md``) that survived 

279 the caller's filter, surfaced as top-level leaves. 

280 """ 

281 for slug, label in shortcuts: 

282 tree.root.add_leaf(label, data=slug) 

283 self._page_slugs.append(slug) 

284 grouped = _group_pages(pages) 

285 branches: dict[str, TreeNode[str | None]] = {} 

286 for page_type, group_pages in grouped: 

287 heading = msg.WIKI_TYPE_HEADINGS.get(page_type, page_type.capitalize()) 

288 group_node = tree.root.add(heading, expand=True) 

289 for page in group_pages: 

290 self._page_slugs.append(page.slug) 

291 self._insert_page(group_node, page, branches) 

292 

293 def _add_stub_group( 

294 self, tree: Tree[str | None], stubs: list[WikiStub], *, expand: bool 

295 ) -> None: 

296 """List the pages the corpus names but nothing has written yet. 

297 

298 Rendered distinctly, following the red-link convention: a reader must 

299 never wonder whether a page is blank because nothing was written or 

300 because generation failed. *expand* opens the group; the caller sets it 

301 while a filter is active, when every stub in it is a match. 

302 """ 

303 if not stubs: 

304 return 

305 group = tree.root.add(msg.WIKI_STUBS_HEADING, expand=expand) 

306 for stub in stubs: 

307 group.add_leaf(msg.WIKI_STUB_LABEL.format(title=stub.label), data=stub.wiki_slug) 

308 self._page_slugs.append(stub.wiki_slug) 

309 

310 def _insert_page( 

311 self, 

312 group_node: TreeNode[str | None], 

313 page: WikiPageInfo, 

314 branches: dict[str, TreeNode[str | None]], 

315 ) -> None: 

316 """Walk the slug path and add/reuse branches until the leaf position. 

317 

318 Slugs begin with the page-type prefix (``summaries/``/``synthesis/``), 

319 which is already reflected in the enclosing group node. The remaining 

320 path components form the nested tree inside the group. *branches* is 

321 the per-build reuse map, keyed by raw slug path. 

322 """ 

323 parts = page.slug.split("/") 

324 if len(parts) <= 1: 

325 group_node.add_leaf(page.title, data=page.slug) 

326 return 

327 

328 # Skip the leading page-type component since the group node represents it. 

329 node = group_node 

330 *branch_parts, leaf_part = parts[1:] 

331 path = parts[0] 

332 for part in branch_parts: 

333 path = f"{path}/{part}" 

334 node = _find_or_add_branch(node, part, path, branches) 

335 

336 if leaf_part == _INDEX_STEM: 

337 # An inner-node index.md file: show its title on the enclosing branch. 

338 node.label = page.title 

339 node.data = page.slug 

340 return 

341 

342 label = _short_label(leaf_part) 

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

344 

345 def _show_detail(self, markdown: str) -> None: 

346 """Clear the breadcrumb and header rows, then render *markdown* alone.""" 

347 self.query_one("#wiki-breadcrumb", Static).update("") 

348 self.query_one("#wiki-page-header", Static).update("") 

349 self.query_one("#wiki-content", Markdown).update(markdown) 

350 

351 def _show_placeholder(self) -> None: 

352 """Show the no-content placeholder in the main area.""" 

353 self._show_detail(msg.wiki_empty_state_detail()) 

354 

355 @on(Tree.NodeSelected, "#wiki-page-list") 

356 def _on_node_selected(self, event: Tree.NodeSelected[str | None]) -> None: 

357 """Load and display the selected wiki page when the node carries a slug.""" 

358 slug = event.node.data 

359 if not isinstance(slug, str): 

360 return 

361 stub = self._stubs.get(slug) 

362 if stub is not None: 

363 # Opening a stub asks rather than generating. The detail pane 

364 # explains the page either way, so dismissing the dialog leaves 

365 # the reader looking at why the page is blank. 

366 self._show_detail( 

367 msg.WIKI_STUB_DETAIL.format( 

368 title=stub.label, label=stub.label, sources=_describe_sources(stub) 

369 ) 

370 ) 

371 confirm_stub_generation(self.app, stub) 

372 return 

373 self._display_page(slug) 

374 

375 def _display_page(self, slug: str) -> None: 

376 """Read and render a wiki page by slug.""" 

377 from lilbee.wiki.browse import read_page 

378 

379 root = _wiki_root() 

380 page = read_page(root, slug) 

381 if page is None: 

382 self._show_detail(msg.WIKI_NO_CONTENT) 

383 return 

384 

385 # Frontmatter is arbitrary parsed YAML; a non-numeric value must not 

386 # crash the node-select handler that calls this. 

387 faith_val = _safe_float(page.frontmatter.get("faithfulness_score")) 

388 

389 page_type = "" 

390 parts = slug.split("/") 

391 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS: 

392 from lilbee.wiki.shared import SUBDIR_TO_TYPE 

393 

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

395 

396 source_count = page.frontmatter.get("source_count", 0) 

397 created_at = page.frontmatter.get("generated_at", "") 

398 

399 header_text = _format_page_header( 

400 title=page.title, 

401 page_type=page_type, 

402 source_count=_safe_int(source_count), 

403 created_at=str(created_at), 

404 faithfulness=faith_val, 

405 ) 

406 self.query_one("#wiki-breadcrumb", Static).update(_breadcrumb_for_slug(slug, page.title)) 

407 self.query_one("#wiki-page-header", Static).update(header_text) 

408 self.query_one("#wiki-content", Markdown).update(page.content) 

409 

410 @on(Input.Changed, "#wiki-search") 

411 def _on_search_changed(self, event: Input.Changed) -> None: 

412 """Re-filter after a short debounce so a multi-key term repaints the 

413 tree once on pause, not once per keystroke.""" 

414 filter_text = event.value.strip() 

415 if self._search_filter_timer is not None: 

416 self._search_filter_timer.stop() 

417 self._search_filter_timer = self.set_timer( 

418 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

419 lambda: self._load_pages(filter_text=filter_text), 

420 ) 

421 

422 def action_focus_search(self) -> None: 

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

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

425 

426 def action_open_drafts(self) -> None: 

427 """Open the drafts review screen -- bound to capital D.""" 

428 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen 

429 

430 self.app.push_screen(WikiDraftsScreen()) 

431 

432 def action_dismiss_or_back(self) -> None: 

433 """Clear search if active, otherwise go back.""" 

434 search = self.query_one("#wiki-search", Input) 

435 if search.value: 

436 search.value = "" 

437 return 

438 self.action_go_back() 

439 

440 def action_go_back(self) -> None: 

441 self.app.go_back() 

442 

443 def _tree_or_none(self) -> Tree[str | None] | None: 

444 if isinstance(self.focused, Input): 

445 return None 

446 return self.query_one("#wiki-page-list", Tree) 

447 

448 def action_cursor_down(self) -> None: 

449 tree = self._tree_or_none() 

450 if tree is not None: 

451 tree.action_cursor_down() 

452 

453 def action_cursor_up(self) -> None: 

454 tree = self._tree_or_none() 

455 if tree is not None: 

456 tree.action_cursor_up() 

457 

458 def action_cursor_left(self) -> None: 

459 tree = self._tree_or_none() 

460 if tree is not None: 

461 tree.action_cursor_parent() 

462 

463 def action_cursor_right(self) -> None: 

464 tree = self._tree_or_none() 

465 if tree is not None: 

466 tree.action_toggle_node() 

467 

468 def action_jump_top(self) -> None: 

469 tree = self._tree_or_none() 

470 if tree is not None: 

471 tree.action_scroll_home() 

472 

473 def action_jump_bottom(self) -> None: 

474 tree = self._tree_or_none() 

475 if tree is not None: 

476 tree.action_scroll_end() 

477 

478 def action_wikify(self) -> None: 

479 """Generate wiki pages from the ingested corpus -- bound to b.""" 

480 start_wikify(self.app) 

481 

482 def action_wipe(self) -> None: 

483 """Delete every generated page and its indexed rows -- bound to W.""" 

484 confirm_wiki_wipe(self.app) 

485 

486 

487def _build_progress(reporter: ProgressReporter) -> DetailedProgressCallback: 

488 """Map wiki build events onto task-bar progress updates.""" 

489 

490 def _on_progress(event_type: EventType, data: ProgressEvent) -> None: 

491 if event_type is EventType.WIKI_PHASE and isinstance(data, WikiPhaseEvent): 

492 reporter.update( 

493 0, msg.WIKI_BUILD_PHASE.format(phase=data.phase.value), indeterminate=True 

494 ) 

495 elif event_type is EventType.WIKI_PAGE and isinstance(data, WikiPageEvent): 

496 percent = int(data.current * 100 / data.total) if data.total else 0 

497 reporter.update( 

498 percent, 

499 msg.WIKI_BUILD_PAGE.format( 

500 label=data.label, current=data.current, total=data.total 

501 ), 

502 indeterminate=False, 

503 ) 

504 

505 return _on_progress 

506 

507 

508def start_wikify(app: LilbeeApp) -> None: 

509 """Run a full wiki build on the task bar. 

510 

511 Shared by the wiki screen's ``b`` binding and the command palette so both 

512 surfaces get the same progress, serialization and completion refresh. The 

513 build takes the wiki mutex, so it must never run on the event loop. 

514 """ 

515 if not cfg.wiki: 

516 app.notify(msg.CMD_WIKI_DISABLED, severity="warning") 

517 return 

518 

519 queue = app.task_bar.queue 

520 pending = queue.active_tasks + queue.queued_tasks 

521 if any(t.task_type == TaskType.WIKI and t.name == msg.TASK_NAME_WIKI for t in pending): 

522 app.notify(msg.WIKI_ALREADY_ACTIVE, severity="warning") 

523 return 

524 

525 def _target(reporter: ProgressReporter) -> None: 

526 from lilbee.wiki.generation import run_full_build 

527 

528 try: 

529 summary = run_full_build(on_progress=_build_progress(reporter)) 

530 reporter.check_cancelled() 

531 except Exception: 

532 # Pages already written must show; the done hook only fires on success. 

533 call_from_thread(app, app.task_bar.reload_wiki_screens) 

534 raise 

535 call_from_thread(app, app.notify, msg.WIKI_BUILD_DONE.format(count=summary["count"])) 

536 

537 app.task_bar.start_task(msg.TASK_NAME_WIKI, TaskType.WIKI, _target, indeterminate=True) 

538 

539 

540def _describe_sources(stub: WikiStub) -> str: 

541 """Name the documents a stub's page would be written from.""" 

542 count = len(stub.sources) 

543 if count == 1: 

544 return stub.sources[0] 

545 return f"{count} documents" 

546 

547 

548def confirm_stub_generation(app: LilbeeApp, stub: WikiStub) -> None: 

549 """Ask before writing a page, then write it on the task bar. 

550 

551 Generation always asks. It spends real GPU time on the user's own machine, 

552 so the prompt names that cost and says how to stop being asked at all. 

553 """ 

554 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

555 

556 def _on_confirm(confirmed: bool | None) -> None: 

557 if confirmed: 

558 start_stub_generation(app, stub) 

559 

560 app.push_screen( 

561 ConfirmDialog( 

562 msg.WIKI_STUB_CONFIRM_TITLE, 

563 msg.WIKI_STUB_CONFIRM_MESSAGE.format(label=stub.label, sources=_describe_sources(stub)), 

564 ), 

565 _on_confirm, 

566 ) 

567 

568 

569def start_stub_generation(app: LilbeeApp, stub: WikiStub) -> None: 

570 """Write one page on the task bar and refresh the wiki screens after it.""" 

571 

572 def _target(reporter: ProgressReporter) -> None: 

573 from lilbee.wiki.lazy import generate_stub_page 

574 

575 reporter.update(0, stub.label, indeterminate=True) 

576 try: 

577 path = generate_stub_page(stub.slug, get_services().store) 

578 except Exception as exc: 

579 call_from_thread( 

580 app, 

581 app.notify, 

582 msg.WIKI_STUB_FAILED.format(label=stub.label, error=exc), 

583 severity="error", 

584 ) 

585 raise 

586 finally: 

587 call_from_thread(app, app.task_bar.reload_wiki_screens) 

588 if path is None: 

589 message = msg.WIKI_STUB_STALE.format(label=stub.label) 

590 call_from_thread(app, app.notify, message, severity="warning") 

591 raise RuntimeError(message) 

592 call_from_thread(app, app.notify, msg.WIKI_STUB_DONE.format(label=stub.label)) 

593 

594 app.task_bar.start_task( 

595 msg.WIKI_STUB_TASK.format(label=stub.label), 

596 TaskType.WIKI, 

597 _target, 

598 indeterminate=True, 

599 ) 

600 

601 

602def wiki_has_content(store: Store) -> bool: 

603 """Whether anything generated is still on disk or in the store. 

604 

605 Read before offering a wipe so the offer only appears when there is 

606 something to remove. 

607 """ 

608 wiki_root = _wiki_root() 

609 if wiki_root.is_dir() and any(wiki_root.rglob("*.md")): 

610 return True 

611 return bool(store.wiki_chunk_sources() or store.wiki_citation_sources()) 

612 

613 

614def confirm_wiki_wipe( 

615 app: LilbeeApp, 

616 *, 

617 title: str = msg.WIKI_WIPE_CONFIRM_TITLE, 

618 message: str = msg.WIKI_WIPE_CONFIRM_MESSAGE, 

619 notify_when_empty: bool = True, 

620) -> None: 

621 """Ask before deleting the wiki, then run the wipe on the task bar. 

622 

623 Offered from the wiki screen and from turning the wiki setting off, so 

624 both routes get the same warning and the same task-bar progress. The 

625 turn-off route passes its own wording and stays silent when there is 

626 nothing to delete, since the user asked to disable, not to clean up. 

627 """ 

628 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

629 

630 if not wiki_has_content(get_services().store): 

631 if notify_when_empty: 

632 app.notify(msg.WIKI_WIPE_NOTHING) 

633 return 

634 

635 def _on_confirm(confirmed: bool | None) -> None: 

636 if confirmed: 

637 start_wiki_wipe(app) 

638 

639 app.push_screen(ConfirmDialog(title, message), _on_confirm) 

640 

641 

642def start_wiki_wipe(app: LilbeeApp) -> None: 

643 """Run the wipe on the task bar and refresh the wiki screens after it. 

644 

645 The wipe takes the wiki mutex, so it must never run on the event loop. 

646 """ 

647 

648 def _target(reporter: ProgressReporter) -> None: 

649 from lilbee.wiki.wipe import wipe_wiki 

650 

651 reporter.update(0, msg.WIKI_WIPE_RUNNING, indeterminate=True) 

652 try: 

653 report = wipe_wiki(get_services().store) 

654 finally: 

655 # Pages are gone whether or not the row delete landed; the tree 

656 # must show the disk state either way. 

657 call_from_thread(app, app.task_bar.reload_wiki_screens) 

658 if not report.rows_deleted: 

659 call_from_thread(app, app.notify, report.summary(), severity="error") 

660 raise RuntimeError(report.summary()) 

661 call_from_thread(app, app.notify, msg.WIKI_WIPE_DONE.format(count=report.pages_removed)) 

662 

663 app.task_bar.start_task(msg.TASK_NAME_WIKI_WIPE, TaskType.WIKI, _target, indeterminate=True) 

664 

665 

666def _find_or_add_branch( 

667 parent: TreeNode[str | None], 

668 label_part: str, 

669 path: str, 

670 branches: dict[str, TreeNode[str | None]], 

671) -> TreeNode[str | None]: 

672 """Return the branch registered for *path*, adding it under *parent* if absent. 

673 

674 Reuse is keyed on the raw slug path, so components that render to the 

675 same display label ("cv-manual" and "cv_manual") stay separate, and a 

676 branch renamed by an inner index page is still found. 

677 """ 

678 node = branches.get(path) 

679 if node is None: 

680 node = parent.add(_short_label(label_part), expand=True) 

681 branches[path] = node 

682 return node 

683 

684 

685def _group_pages( 

686 pages: list[WikiPageInfo], 

687) -> list[tuple[str, list[WikiPageInfo]]]: 

688 """Group pages by page_type in sidebar order: concepts, entities, then legacy.""" 

689 from lilbee.wiki.shared import WikiPageType 

690 

691 groups: dict[str, list[WikiPageInfo]] = {} 

692 type_order: tuple[str, ...] = ( 

693 WikiPageType.CONCEPT, 

694 WikiPageType.ENTITY, 

695 WikiPageType.SUMMARY, 

696 WikiPageType.SYNTHESIS, 

697 ) 

698 for t in type_order: 

699 group = [p for p in pages if p.page_type == t] 

700 if group: 

701 groups[t] = group 

702 for p in pages: 

703 if p.page_type not in groups: 

704 groups[p.page_type] = [] 

705 if p.page_type not in type_order: 

706 groups[p.page_type].append(p) 

707 return [(k, v) for k, v in groups.items() if v]