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

359 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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.runtime.progress import EventType, WikiPageEvent, WikiPhaseEvent 

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

36 

37log = logging.getLogger(__name__) 

38 

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

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

41_INDEX_STEM = "index" 

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

43# bare slugs (no slash) do not. 

44_SLUG_WITH_TYPE_MIN_PARTS = 2 

45 

46 

47def _wiki_root() -> Path: 

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

49 return cfg.data_root / cfg.wiki_dir 

50 

51 

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

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

54 

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

56 """ 

57 return [ 

58 (slug, label) 

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

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

61 ] 

62 

63 

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

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

66 try: 

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

68 except (TypeError, ValueError): 

69 return None 

70 

71 

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

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

74 try: 

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

76 except (TypeError, ValueError): 

77 return default 

78 

79 

80def _format_page_header( 

81 title: str, 

82 page_type: str, 

83 source_count: int, 

84 created_at: str, 

85 faithfulness: float | None, 

86) -> Content: 

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

88 parts: list[tuple[str, str]] = [(title, "bold"), (f" {page_type}", "dim")] 

89 if source_count > 0: 

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

91 if created_at: 

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

93 if faithfulness is not None: 

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

95 return Content.assemble(*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) -> Content: 

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

105 parts = slug.split("/") 

106 if len(parts) <= 1: 

107 return Content("") 

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

109 display_parts.append(Content(title)) 

110 return Content.assemble(" ", Content.styled(">", "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(Text(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 label = Text.assemble((stub.label, "dim"), " ", (msg.WIKI_STUB_SUFFIX, "dim italic")) 

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

309 self._page_slugs.append(stub.wiki_slug) 

310 

311 def _insert_page( 

312 self, 

313 group_node: TreeNode[str | None], 

314 page: WikiPageInfo, 

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

316 ) -> None: 

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

318 

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

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

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

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

323 """ 

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

325 if len(parts) <= 1: 

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

327 return 

328 

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

330 node = group_node 

331 *branch_parts, leaf_part = parts[1:] 

332 path = parts[0] 

333 for part in branch_parts: 

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

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

336 

337 if leaf_part == _INDEX_STEM: 

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

339 node.label = Text(page.title) 

340 node.data = page.slug 

341 return 

342 

343 label = _short_label(leaf_part) 

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

345 

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

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

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

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

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

351 

352 def _show_placeholder(self) -> None: 

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

354 self._show_detail(msg.wiki_empty_state_detail()) 

355 

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

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

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

359 slug = event.node.data 

360 if not isinstance(slug, str): 

361 return 

362 stub = self._stubs.get(slug) 

363 if stub is not None: 

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

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

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

367 self._show_detail( 

368 msg.WIKI_STUB_DETAIL.format( 

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

370 ) 

371 ) 

372 confirm_stub_generation(self.app, stub) 

373 return 

374 self._display_page(slug) 

375 

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

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

378 from lilbee.wiki.browse import read_page 

379 

380 root = _wiki_root() 

381 page = read_page(root, slug) 

382 if page is None: 

383 self._show_detail(msg.WIKI_NO_CONTENT) 

384 return 

385 

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

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

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

389 

390 page_type = "" 

391 parts = slug.split("/") 

392 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS: 

393 from lilbee.wiki.shared import SUBDIR_TO_TYPE 

394 

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

396 

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

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

399 

400 header_text = _format_page_header( 

401 title=page.title, 

402 page_type=page_type, 

403 source_count=_safe_int(source_count), 

404 created_at=str(created_at), 

405 faithfulness=faith_val, 

406 ) 

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

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

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

410 

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

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

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

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

415 filter_text = event.value.strip() 

416 if self._search_filter_timer is not None: 

417 self._search_filter_timer.stop() 

418 self._search_filter_timer = self.set_timer( 

419 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

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

421 ) 

422 

423 def action_focus_search(self) -> None: 

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

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

426 

427 def action_open_drafts(self) -> None: 

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

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

430 

431 self.app.push_screen(WikiDraftsScreen()) 

432 

433 def action_dismiss_or_back(self) -> None: 

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

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

436 if search.value: 

437 search.value = "" 

438 return 

439 self.action_go_back() 

440 

441 def action_go_back(self) -> None: 

442 self.app.go_back() 

443 

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

445 if isinstance(self.focused, Input): 

446 return None 

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

448 

449 def action_cursor_down(self) -> None: 

450 tree = self._tree_or_none() 

451 if tree is not None: 

452 tree.action_cursor_down() 

453 

454 def action_cursor_up(self) -> None: 

455 tree = self._tree_or_none() 

456 if tree is not None: 

457 tree.action_cursor_up() 

458 

459 def action_cursor_left(self) -> None: 

460 tree = self._tree_or_none() 

461 if tree is not None: 

462 tree.action_cursor_parent() 

463 

464 def action_cursor_right(self) -> None: 

465 tree = self._tree_or_none() 

466 if tree is not None: 

467 tree.action_toggle_node() 

468 

469 def action_jump_top(self) -> None: 

470 tree = self._tree_or_none() 

471 if tree is not None: 

472 tree.action_scroll_home() 

473 

474 def action_jump_bottom(self) -> None: 

475 tree = self._tree_or_none() 

476 if tree is not None: 

477 tree.action_scroll_end() 

478 

479 def action_wikify(self) -> None: 

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

481 start_wikify(self.app) 

482 

483 def action_wipe(self) -> None: 

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

485 confirm_wiki_wipe(self.app) 

486 

487 

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

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

490 

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

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

493 reporter.update( 

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

495 ) 

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

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

498 reporter.update( 

499 percent, 

500 msg.WIKI_BUILD_PAGE.format( 

501 label=data.label, current=data.current, total=data.total 

502 ), 

503 indeterminate=False, 

504 ) 

505 

506 return _on_progress 

507 

508 

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

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

511 

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

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

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

515 """ 

516 if not cfg.wiki: 

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

518 return 

519 

520 queue = app.task_bar.queue 

521 pending = queue.active_tasks + queue.queued_tasks 

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

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

524 return 

525 

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

527 from lilbee.wiki.generation import run_full_build 

528 

529 try: 

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

531 reporter.check_cancelled() 

532 except Exception: 

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

534 call_from_thread(app, app.task_bar.reload_wiki_screens) 

535 raise 

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

537 

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

539 

540 

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

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

543 count = len(stub.sources) 

544 if count == 1: 

545 return stub.sources[0] 

546 return f"{count} documents" 

547 

548 

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

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

551 

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

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

554 """ 

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

556 

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

558 if confirmed: 

559 start_stub_generation(app, stub) 

560 

561 app.push_screen( 

562 ConfirmDialog( 

563 msg.WIKI_STUB_CONFIRM_TITLE, 

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

565 ), 

566 _on_confirm, 

567 ) 

568 

569 

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

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

572 

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

574 from lilbee.wiki.lazy import generate_stub_page 

575 

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

577 try: 

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

579 except Exception as exc: 

580 call_from_thread( 

581 app, 

582 app.notify, 

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

584 severity="error", 

585 ) 

586 raise 

587 finally: 

588 call_from_thread(app, app.task_bar.reload_wiki_screens) 

589 if path is None: 

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

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

592 raise RuntimeError(message) 

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

594 

595 app.task_bar.start_task( 

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

597 TaskType.WIKI, 

598 _target, 

599 indeterminate=True, 

600 ) 

601 

602 

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

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

605 

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

607 something to remove. 

608 """ 

609 wiki_root = _wiki_root() 

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

611 return True 

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

613 

614 

615def confirm_wiki_wipe( 

616 app: LilbeeApp, 

617 *, 

618 title: str = msg.WIKI_WIPE_CONFIRM_TITLE, 

619 message: str = msg.WIKI_WIPE_CONFIRM_MESSAGE, 

620 notify_when_empty: bool = True, 

621) -> None: 

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

623 

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

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

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

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

628 """ 

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

630 

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

632 if notify_when_empty: 

633 app.notify(msg.WIKI_WIPE_NOTHING) 

634 return 

635 

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

637 if confirmed: 

638 start_wiki_wipe(app) 

639 

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

641 

642 

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

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

645 

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

647 """ 

648 

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

650 from lilbee.wiki.wipe import wipe_wiki 

651 

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

653 try: 

654 report = wipe_wiki(get_services().store) 

655 finally: 

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

657 # must show the disk state either way. 

658 call_from_thread(app, app.task_bar.reload_wiki_screens) 

659 if not report.rows_deleted: 

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

661 raise RuntimeError(report.summary()) 

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

663 

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

665 

666 

667def _find_or_add_branch( 

668 parent: TreeNode[str | None], 

669 label_part: str, 

670 path: str, 

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

672) -> TreeNode[str | None]: 

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

674 

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

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

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

678 """ 

679 node = branches.get(path) 

680 if node is None: 

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

682 branches[path] = node 

683 return node 

684 

685 

686def _group_pages( 

687 pages: list[WikiPageInfo], 

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

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

690 from lilbee.wiki.shared import WikiPageType 

691 

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

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

694 WikiPageType.CONCEPT, 

695 WikiPageType.ENTITY, 

696 WikiPageType.SUMMARY, 

697 WikiPageType.SYNTHESIS, 

698 ) 

699 for t in type_order: 

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

701 if group: 

702 groups[t] = group 

703 for p in pages: 

704 if p.page_type not in groups: 

705 groups[p.page_type] = [] 

706 if p.page_type not in type_order: 

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

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