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

360 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +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 rich.text import Text 

17from textual import on 

18from textual.app import ComposeResult 

19from textual.binding import Binding, BindingType 

20from textual.containers import Horizontal, Vertical, VerticalScroll 

21from textual.content import Content 

22from textual.screen import Screen 

23from textual.timer import Timer 

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

25from textual.widgets.tree import TreeNode 

26 

27from lilbee.app.services import get_services 

28from lilbee.cli.tui import messages as msg 

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

30from lilbee.cli.tui.task_queue import TaskType 

31from lilbee.cli.tui.thread_safe import call_from_thread 

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

33from lilbee.core.config import cfg 

34from lilbee.core.text import strip_control_characters 

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

36from lilbee.wiki.stubs import WikiStub, load_stub_index, shown_sources, ungenerated_stubs 

37 

38log = logging.getLogger(__name__) 

39 

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

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

42_INDEX_STEM = "index" 

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

44# bare slugs (no slash) do not. 

45_SLUG_WITH_TYPE_MIN_PARTS = 2 

46 

47 

48def _wiki_root() -> Path: 

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

50 return cfg.data_root / cfg.wiki_dir 

51 

52 

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

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

55 

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

57 """ 

58 return [ 

59 (slug, label) 

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

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

62 ] 

63 

64 

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

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

67 try: 

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

69 except (TypeError, ValueError): 

70 return None 

71 

72 

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

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

75 try: 

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

77 except (TypeError, ValueError): 

78 return default 

79 

80 

81def _format_page_header( 

82 title: str, 

83 page_type: str, 

84 source_count: int, 

85 created_at: str, 

86 faithfulness: float | None, 

87) -> Content: 

88 """Build the header line for the content pane, with the title as literal text.""" 

89 parts: list[tuple[str, str]] = [ 

90 (strip_control_characters(title), "bold"), 

91 (f" {page_type}", "dim"), 

92 ] 

93 if source_count > 0: 

94 parts.append((f" {source_count} sources", "dim")) 

95 if created_at: 

96 parts.append((f" {created_at}", "dim")) 

97 if faithfulness is not None: 

98 parts.append((f" faithfulness {int(faithfulness * 100)}%", "dim")) 

99 return Content.assemble(*parts) 

100 

101 

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

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

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

105 

106 

107def _breadcrumb_for_slug(slug: str, title: str) -> Content: 

108 """Build the breadcrumb chapter > section > page, with every part as literal text.""" 

109 parts = slug.split("/") 

110 if len(parts) <= 1: 

111 return Content("") 

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

113 display_parts.append(Content(strip_control_characters(title))) 

114 return Content.assemble(" ", Content.styled(">", "dim"), " ").join(display_parts) 

115 

116 

117class WikiScreen(Screen[None]): 

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

119 

120 app: LilbeeApp # type: ignore[assignment] 

121 

122 CSS_PATH = "wiki.tcss" 

123 AUTO_FOCUS = "#wiki-page-list" 

124 HELP = ( 

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

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

127 ) 

128 

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

130 *browse_back_bindings(escape_action="dismiss_or_back"), 

131 *BROWSE_LIST_BINDINGS, 

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

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

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

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

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

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

138 ] 

139 

140 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.12 

141 

142 def __init__(self) -> None: 

143 super().__init__() 

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

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

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

147 self._load_error: str | None = None 

148 self._search_filter_timer: Timer | None = None 

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

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

151 self._painted_filter: str = "" 

152 

153 def compose(self) -> ComposeResult: 

154 from textual.widgets import Footer 

155 

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

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

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

159 

160 with TopBars(): 

161 yield ViewTabs() 

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

163 tree.show_root = False 

164 yield Horizontal( 

165 Vertical( 

166 Input( 

167 placeholder=msg.WIKI_SEARCH_PLACEHOLDER, 

168 id="wiki-search", 

169 ), 

170 tree, 

171 id="wiki-sidebar", 

172 ), 

173 Vertical( 

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

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

176 VerticalScroll( 

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

178 id="wiki-content-scroll", 

179 ), 

180 id="wiki-main", 

181 ), 

182 id="wiki-layout", 

183 ) 

184 with BottomBars(): 

185 yield TaskBar() 

186 yield Footer() 

187 

188 def on_screen_resume(self) -> None: 

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

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

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

192 

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

194 once per screen, so it cannot carry this. 

195 """ 

196 self.reload() 

197 

198 def reload(self) -> None: 

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

200 

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

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

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

204 """ 

205 from lilbee.wiki.browse import list_pages 

206 

207 self._pages = [] 

208 self._stubs = {} 

209 self._load_error = None 

210 if cfg.wiki: 

211 try: 

212 self._pages = list_pages(_wiki_root()) 

213 self._stubs = { 

214 stub.wiki_slug: stub 

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

216 } 

217 except Exception as exc: 

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

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

220 self._show_load_failure(self._load_error) 

221 return 

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

223 

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

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

226 tree = self._empty_tree() 

227 tree.root.add_leaf(msg.WIKI_LOAD_FAILED_LEAF) 

228 self._show_detail(detail) 

229 

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

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

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

233 tree.reset("Wiki") 

234 self._page_slugs = [] 

235 return tree 

236 

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

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

239 if self._load_error is not None: 

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

241 self._show_load_failure(self._load_error) 

242 return 

243 tree = self._empty_tree() 

244 filter_changed = filter_text != self._painted_filter 

245 self._painted_filter = filter_text 

246 if filter_changed: 

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

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

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

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

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

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

253 self._show_placeholder() 

254 return 

255 

256 needle = filter_text.lower() 

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

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

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

260 if not pages and not stubs and not shortcuts: 

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

262 # rather than rendering the empty-wiki state. 

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

264 return 

265 

266 self._populate_tree(tree, pages, shortcuts) 

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

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

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

270 

271 def _populate_tree( 

272 self, 

273 tree: Tree[str | None], 

274 pages: list[WikiPageInfo], 

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

276 ) -> None: 

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

278 

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

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

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

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

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

284 """ 

285 for slug, label in shortcuts: 

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

287 self._page_slugs.append(slug) 

288 grouped = _group_pages(pages) 

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

290 for page_type, group_pages in grouped: 

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

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

293 for page in group_pages: 

294 self._page_slugs.append(page.slug) 

295 self._insert_page(group_node, page, branches) 

296 

297 def _add_stub_group( 

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

299 ) -> None: 

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

301 

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

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

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

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

306 """ 

307 if not stubs: 

308 return 

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

310 for stub in stubs: 

311 label = Text.assemble( 

312 (strip_control_characters(stub.label), "dim"), 

313 " ", 

314 (msg.WIKI_STUB_SUFFIX, "dim italic"), 

315 ) 

316 group.add_leaf(label, data=stub.wiki_slug) 

317 self._page_slugs.append(stub.wiki_slug) 

318 

319 def _insert_page( 

320 self, 

321 group_node: TreeNode[str | None], 

322 page: WikiPageInfo, 

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

324 ) -> None: 

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

326 

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

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

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

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

331 """ 

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

333 if len(parts) <= 1: 

334 group_node.add_leaf(Text(strip_control_characters(page.title)), data=page.slug) 

335 return 

336 

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

338 node = group_node 

339 *branch_parts, leaf_part = parts[1:] 

340 path = parts[0] 

341 for part in branch_parts: 

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

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

344 

345 if leaf_part == _INDEX_STEM: 

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

347 node.label = Text(strip_control_characters(page.title)) 

348 node.data = page.slug 

349 return 

350 

351 label = _short_label(leaf_part) 

352 node.add_leaf(Text(strip_control_characters(page.title) or label), data=page.slug) 

353 

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

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

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

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

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

359 

360 def _show_placeholder(self) -> None: 

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

362 self._show_detail(msg.wiki_empty_state_detail()) 

363 

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

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

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

367 slug = event.node.data 

368 if not isinstance(slug, str): 

369 return 

370 stub = self._stubs.get(slug) 

371 if stub is not None: 

372 shown_label = strip_control_characters(stub.label) 

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

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

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

376 self._show_detail( 

377 msg.WIKI_STUB_DETAIL.format( 

378 title=shown_label, 

379 label=shown_label, 

380 sources=_describe_sources(stub), 

381 ) 

382 ) 

383 confirm_stub_generation(self.app, stub) 

384 return 

385 self._display_page(slug) 

386 

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

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

389 from lilbee.wiki.browse import read_page 

390 

391 root = _wiki_root() 

392 page = read_page(root, slug) 

393 if page is None: 

394 self._show_detail(msg.WIKI_NO_CONTENT) 

395 return 

396 

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

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

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

400 

401 page_type = "" 

402 parts = slug.split("/") 

403 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS: 

404 from lilbee.wiki.shared import SUBDIR_TO_TYPE 

405 

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

407 

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

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

410 

411 header_text = _format_page_header( 

412 title=page.title, 

413 page_type=page_type, 

414 source_count=_safe_int(source_count), 

415 created_at=str(created_at), 

416 faithfulness=faith_val, 

417 ) 

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

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

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

421 strip_control_characters(page.content, keep_lines=True) 

422 ) 

423 

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

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

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

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

428 filter_text = event.value.strip() 

429 if self._search_filter_timer is not None: 

430 self._search_filter_timer.stop() 

431 self._search_filter_timer = self.set_timer( 

432 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

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

434 ) 

435 

436 def action_focus_search(self) -> None: 

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

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

439 

440 def action_open_drafts(self) -> None: 

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

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

443 

444 self.app.push_screen(WikiDraftsScreen()) 

445 

446 def action_dismiss_or_back(self) -> None: 

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

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

449 if search.value: 

450 search.value = "" 

451 return 

452 self.action_go_back() 

453 

454 def action_go_back(self) -> None: 

455 self.app.go_back() 

456 

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

458 if isinstance(self.focused, Input): 

459 return None 

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

461 

462 def action_cursor_down(self) -> None: 

463 tree = self._tree_or_none() 

464 if tree is not None: 

465 tree.action_cursor_down() 

466 

467 def action_cursor_up(self) -> None: 

468 tree = self._tree_or_none() 

469 if tree is not None: 

470 tree.action_cursor_up() 

471 

472 def action_cursor_left(self) -> None: 

473 tree = self._tree_or_none() 

474 if tree is not None: 

475 tree.action_cursor_parent() 

476 

477 def action_cursor_right(self) -> None: 

478 tree = self._tree_or_none() 

479 if tree is not None: 

480 tree.action_toggle_node() 

481 

482 def action_jump_top(self) -> None: 

483 tree = self._tree_or_none() 

484 if tree is not None: 

485 tree.action_scroll_home() 

486 

487 def action_jump_bottom(self) -> None: 

488 tree = self._tree_or_none() 

489 if tree is not None: 

490 tree.action_scroll_end() 

491 

492 def action_wikify(self) -> None: 

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

494 start_wikify(self.app) 

495 

496 def action_wipe(self) -> None: 

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

498 confirm_wiki_wipe(self.app) 

499 

500 

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

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

503 

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

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

506 reporter.update( 

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

508 ) 

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

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

511 reporter.update( 

512 percent, 

513 msg.WIKI_BUILD_PAGE.format( 

514 label=data.label, current=data.current, total=data.total 

515 ), 

516 indeterminate=False, 

517 ) 

518 

519 return _on_progress 

520 

521 

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

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

524 

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

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

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

528 """ 

529 if not cfg.wiki: 

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

531 return 

532 

533 queue = app.task_bar.queue 

534 pending = queue.active_tasks + queue.queued_tasks 

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

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

537 return 

538 

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

540 from lilbee.wiki.generation import run_full_build 

541 

542 try: 

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

544 reporter.check_cancelled() 

545 except Exception: 

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

547 call_from_thread(app, app.task_bar.reload_wiki_screens) 

548 raise 

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

550 

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

552 

553 

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

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

556 count = len(stub.sources) 

557 shown = shown_sources(stub, get_services().store) if count == 1 else [] 

558 return shown[0] if shown else f"{count} documents" 

559 

560 

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

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

563 

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

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

566 """ 

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

568 

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

570 if confirmed: 

571 start_stub_generation(app, stub) 

572 

573 app.push_screen( 

574 ConfirmDialog( 

575 msg.WIKI_STUB_CONFIRM_TITLE, 

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

577 ), 

578 _on_confirm, 

579 ) 

580 

581 

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

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

584 

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

586 from lilbee.wiki.lazy import generate_stub_page 

587 

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

589 try: 

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

591 except Exception as exc: 

592 call_from_thread( 

593 app, 

594 app.notify, 

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

596 severity="error", 

597 ) 

598 raise 

599 finally: 

600 call_from_thread(app, app.task_bar.reload_wiki_screens) 

601 if path is None: 

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

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

604 raise RuntimeError(message) 

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

606 

607 app.task_bar.start_task( 

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

609 TaskType.WIKI, 

610 _target, 

611 indeterminate=True, 

612 ) 

613 

614 

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

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

617 

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

619 something to remove. 

620 """ 

621 wiki_root = _wiki_root() 

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

623 return True 

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

625 

626 

627def confirm_wiki_wipe( 

628 app: LilbeeApp, 

629 *, 

630 title: str = msg.WIKI_WIPE_CONFIRM_TITLE, 

631 message: str = msg.WIKI_WIPE_CONFIRM_MESSAGE, 

632 notify_when_empty: bool = True, 

633) -> None: 

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

635 

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

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

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

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

640 """ 

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

642 

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

644 if notify_when_empty: 

645 app.notify(msg.WIKI_WIPE_NOTHING) 

646 return 

647 

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

649 if confirmed: 

650 start_wiki_wipe(app) 

651 

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

653 

654 

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

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

657 

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

659 """ 

660 

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

662 from lilbee.wiki.wipe import wipe_wiki 

663 

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

665 try: 

666 report = wipe_wiki(get_services().store) 

667 finally: 

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

669 # must show the disk state either way. 

670 call_from_thread(app, app.task_bar.reload_wiki_screens) 

671 if not report.rows_deleted: 

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

673 raise RuntimeError(report.summary()) 

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

675 

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

677 

678 

679def _find_or_add_branch( 

680 parent: TreeNode[str | None], 

681 label_part: str, 

682 path: str, 

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

684) -> TreeNode[str | None]: 

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

686 

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

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

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

690 """ 

691 node = branches.get(path) 

692 if node is None: 

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

694 branches[path] = node 

695 return node 

696 

697 

698def _group_pages( 

699 pages: list[WikiPageInfo], 

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

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

702 from lilbee.wiki.shared import WikiPageType 

703 

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

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

706 WikiPageType.CONCEPT, 

707 WikiPageType.ENTITY, 

708 WikiPageType.SUMMARY, 

709 WikiPageType.SYNTHESIS, 

710 ) 

711 for t in type_order: 

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

713 if group: 

714 groups[t] = group 

715 for p in pages: 

716 if p.page_type not in groups: 

717 groups[p.page_type] = [] 

718 if p.page_type not in type_order: 

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

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