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

275 statements  

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

1"""Status screen: knowledge base info with collapsible sections.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8from dataclasses import dataclass, field 

9from pathlib import Path 

10from typing import TYPE_CHECKING, ClassVar 

11 

12if TYPE_CHECKING: 

13 from lilbee.cli.tui.app import LilbeeApp 

14 

15from rich.text import Text 

16from textual import work 

17from textual.app import ComposeResult 

18from textual.binding import Binding, BindingType 

19from textual.containers import VerticalScroll 

20from textual.content import Content 

21from textual.screen import Screen 

22from textual.widgets import Collapsible, DataTable, Static 

23from textual.worker import Worker, WorkerState 

24 

25from lilbee.app.services import get_services 

26from lilbee.app.settings import SCANNED_PAGES_LABEL, scanned_pages_state 

27from lilbee.app.status import held_out_sources 

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.pill import pill 

31from lilbee.core.config import cfg 

32from lilbee.core.text import strip_control_characters 

33from lilbee.data.store import DOCUMENTS_PAGE_DEFAULT, SourceRecord 

34from lilbee.data.types import SkippedSource 

35from lilbee.modelhub.model_info import ModelArchInfo, get_model_architecture 

36 

37log = logging.getLogger(__name__) 

38 

39# Rows per page of the Documents table. Must exceed the table's visible height 

40# (``max-height`` in status.tcss) or the table never scrolls and never asks for more. 

41_DOCS_PAGE_SIZE = DOCUMENTS_PAGE_DEFAULT 

42# Load the next page when the table is scrolled this far down, or when the cursor 

43# reaches this far into the loaded rows. A cursor move scrolls the table only after 

44# its layout settles, so the cursor carries its own trigger. 

45_DOCS_SCROLL_PREFETCH_RATIO = 0.85 

46_WORKER_FETCH_SOURCES = "status_fetch_sources" 

47_WORKER_FETCH_DOCS_PAGE = "status_fetch_docs_page" 

48 

49 

50@dataclass 

51class _DocsResult: 

52 """Outcome of the background sources read. 

53 

54 ``load_failed`` distinguishes "store opened but empty" (``sources == []``) 

55 from "the read raised" so the UI shows the right placeholder. 

56 """ 

57 

58 sources: list[SourceRecord] 

59 load_failed: bool 

60 document_count: int = 0 

61 held_out: list[SkippedSource] = field(default_factory=list) 

62 held_out_total: int = 0 

63 

64 

65@dataclass 

66class _DocsPage: 

67 """One further page of sources, or the fact that reading it failed.""" 

68 

69 sources: list[SourceRecord] 

70 load_failed: bool = False 

71 

72 

73def _model_pill(name: str) -> Content: 

74 """Return a green 'loaded' pill if name is set, red 'not set' otherwise.""" 

75 if name: 

76 return pill("loaded", "$success", "$text") 

77 return pill("not set", "$error", "$text") 

78 

79 

80# Label-column width used across the status sections so keys line up 

81# when scanned vertically. Values past this column render bold. 

82_KV_LABEL_WIDTH = 14 

83 

84 

85def _kv_line(label: str, value: str | Content, status: Content | None = None) -> Content: 

86 """Assemble one key/value row: dim padded label, bold value, optional pill.""" 

87 padded = label.ljust(_KV_LABEL_WIDTH) 

88 parts: list[Content] = [Content.styled(padded, "$text-muted")] 

89 if isinstance(value, Content): 

90 parts.append(value) 

91 else: 

92 parts.append(Content.styled(value, "bold")) 

93 if status is not None: 

94 parts.append(Content(" ")) 

95 parts.append(status) 

96 return Content.assemble(*parts) 

97 

98 

99def _collapse_home(path: Path | str) -> str: 

100 """Replace the user's home prefix with '~' so long paths stay scannable.""" 

101 text = str(path) 

102 home = str(Path.home()) 

103 return text.replace(home, "~", 1) if text.startswith(home) else text 

104 

105 

106def _data_dir_pill() -> Content: 

107 """Return a pill based on whether the data directory exists.""" 

108 if Path(cfg.data_dir).exists(): 

109 return pill("exists", "$success", "$text") 

110 return pill("missing", "$error", "$text") 

111 

112 

113def _build_config_content() -> Content: 

114 """Build the configuration section content.""" 

115 lines = [ 

116 _kv_line("Data dir", _collapse_home(cfg.data_dir), _data_dir_pill()), 

117 _kv_line("Chat model", cfg.chat_model or "(disabled)", _model_pill(cfg.chat_model)), 

118 _kv_line( 

119 "Embed model", cfg.embedding_model or "(disabled)", _model_pill(cfg.embedding_model) 

120 ), 

121 _kv_line("Vision model", cfg.vision_model or "(disabled)", _model_pill(cfg.vision_model)), 

122 _kv_line("Reranker", cfg.reranker_model or "(disabled)", _model_pill(cfg.reranker_model)), 

123 _kv_line(SCANNED_PAGES_LABEL, scanned_pages_state()), 

124 ] 

125 return Content("\n").join(lines) 

126 

127 

128def _build_storage_content(doc_count: int) -> Content: 

129 """Build the storage section content.""" 

130 lines = [ 

131 _kv_line("Documents", str(doc_count)), 

132 _kv_line("Data dir", _collapse_home(cfg.data_dir)), 

133 _kv_line("Models dir", _collapse_home(cfg.models_dir)), 

134 ] 

135 if cfg.entity_extraction: 

136 from lilbee.app.status import entity_status 

137 

138 section = entity_status() 

139 if section is not None: 

140 names = ", ".join(section.types) or "schema pending" 

141 lines.append(_kv_line("Entities", f"{section.rows} extracted ({names})")) 

142 return Content("\n").join(lines) 

143 

144 

145def _build_arch_content(info: ModelArchInfo) -> Content: 

146 """Build the model architecture section from GGUF metadata.""" 

147 lines = [ 

148 _kv_line("Chat arch", info.chat_arch), 

149 _kv_line("Embed arch", info.embed_arch), 

150 _kv_line("Handler", pill(info.active_handler, "$accent", "$text")), 

151 ] 

152 if info.vision_projector: 

153 lines.append(_kv_line("Vision proj", info.vision_projector)) 

154 return Content("\n").join(lines) 

155 

156 

157class StatusScreen(Screen[None]): 

158 """Knowledge base status view with collapsible sections.""" 

159 

160 app: LilbeeApp # type: ignore[assignment] 

161 

162 CSS_PATH = "status.tcss" 

163 AUTO_FOCUS = "CollapsibleTitle" 

164 HELP = ( 

165 "Knowledge base status.\n\n" 

166 "View configuration, documents, model architecture, and storage info." 

167 ) 

168 

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

170 *browse_back_bindings(), 

171 Binding("tab", "app.focus_next", "Next section", show=False), 

172 Binding( 

173 "shift+tab", 

174 "app.focus_previous", 

175 "Prev section", 

176 show=False, 

177 ), 

178 *BROWSE_LIST_BINDINGS, 

179 ] 

180 

181 def __init__(self) -> None: 

182 super().__init__() 

183 self._sections_mounted: bool = False 

184 self._pending_docs: _DocsResult | None = None 

185 self._pending_arch: ModelArchInfo | None = None 

186 self._docs_loaded: int = 0 

187 self._docs_total: int = 0 

188 self._docs_has_more: bool = False 

189 self._docs_loading: bool = False 

190 self._docs_page_failed: bool = False 

191 self._docs_scroll_watched: bool = False 

192 

193 def compose(self) -> ComposeResult: 

194 from textual.widgets import Footer 

195 

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

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

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

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

200 

201 with TopBars(): 

202 yield ViewTabs() 

203 # Mount only the first (Configuration) collapsible up front so the 

204 # screen paints fast on push. Documents/arch/storage hydrate via 

205 # ``call_after_refresh`` once the screen is visible -- their 

206 # backing widgets are still cheap to mount, but the synchronous 

207 # cost of mounting all four under a single VerticalScroll spiked 

208 # screen-switch latency to ~1s on cold caches. 

209 yield VerticalScroll( 

210 Collapsible( 

211 Static(id="config-info"), 

212 title="Configuration", 

213 id="config-section", 

214 collapsed=False, 

215 ), 

216 id="status-scroll", 

217 ) 

218 with BottomBars(): 

219 yield TaskBar() 

220 yield Footer() 

221 

222 def on_mount(self) -> None: 

223 # ``cfg`` reads are in-memory and cheap. Anything that touches 

224 # disk runs in a worker so the screen paints instantly. 

225 # ``get_model_architecture`` opens up to three GGUF files and 

226 # parses their headers (~hundreds of ms each cold); ``get_sources`` 

227 # reads LanceDB (seconds on cold caches). 

228 self._load_config() 

229 self.call_after_refresh(self._mount_remaining_sections) 

230 self._fetch_sources_worker() 

231 self._fetch_arch_worker() 

232 

233 async def _mount_remaining_sections(self) -> None: 

234 """Mount Documents/Architecture/Storage once the screen is visible.""" 

235 if not self.is_mounted: 

236 return 

237 scroll = self.query_one("#status-scroll", VerticalScroll) 

238 await scroll.mount_all( 

239 [ 

240 Collapsible( 

241 DataTable(id="docs-table"), 

242 Static(id="docs-more"), 

243 title=msg.STATUS_DOCS_TITLE, 

244 id="docs-section", 

245 collapsed=False, 

246 ), 

247 Collapsible( 

248 DataTable(id="held-out-table"), 

249 title=msg.STATUS_HELD_OUT_TITLE, 

250 id="held-out-section", 

251 collapsed=True, 

252 ), 

253 Collapsible( 

254 Static(id="arch-info"), 

255 title="Model Architecture", 

256 id="arch-section", 

257 collapsed=False, 

258 ), 

259 Collapsible( 

260 Static(id="storage-info"), 

261 title="Storage", 

262 id="storage-section", 

263 collapsed=False, 

264 ), 

265 ] 

266 ) 

267 self._sections_mounted = True 

268 # Yield once so Textual gets a chance to compose the freshly- 

269 # mounted Collapsibles' children. Without this, querying 

270 # #docs-table immediately after mount_all races on Windows. 

271 await asyncio.sleep(0) 

272 self._show_loading_placeholders() 

273 # Replay any worker callbacks that arrived before the deferred 

274 # mount completed. 

275 if self._pending_docs is not None: 

276 self._apply_docs(self._pending_docs) 

277 self._pending_docs = None 

278 if self._pending_arch is not None: 

279 self._load_arch(self._pending_arch) 

280 self._pending_arch = None 

281 

282 def _show_loading_placeholders(self) -> None: 

283 """Surface a 'Loading…' marker for sections backed by workers. 

284 

285 Wrapped in NoMatches suppression because Collapsible children 

286 compose on the next refresh tick, which on Windows can outlast 

287 the synchronous return from mount_all. Worker callbacks repaint 

288 the same widgets when they arrive, so a missed placeholder is 

289 only a brief cosmetic gap. 

290 """ 

291 from textual.css.query import NoMatches 

292 

293 with contextlib.suppress(NoMatches): 

294 table = self.query_one("#docs-table", DataTable) 

295 table.add_columns("Document", "Chunks") 

296 table.cursor_type = "row" 

297 table.add_row("Loading...", "") 

298 with contextlib.suppress(NoMatches): 

299 self.query_one("#storage-info", Static).update( 

300 Content.styled("Loading...", "$text-muted") 

301 ) 

302 with contextlib.suppress(NoMatches): 

303 self.query_one("#arch-info", Static).update(Content.styled("Loading...", "$text-muted")) 

304 

305 @work(thread=True, name=_WORKER_FETCH_SOURCES, exit_on_error=False) 

306 def _fetch_sources_worker(self) -> _DocsResult: 

307 """Read the source count and the first page off the UI thread. 

308 

309 ``load_failed`` is True only when the store read actually raised; 

310 an empty store with zero documents is the routine first-run state. 

311 The read is one count and one page, so a library of millions of sources 

312 costs the same screen as a library of ten. 

313 """ 

314 try: 

315 held_out, held_out_total = held_out_sources() 

316 store = get_services().store 

317 return _DocsResult( 

318 sources=store.get_sources(limit=_DOCS_PAGE_SIZE, offset=0), 

319 load_failed=False, 

320 document_count=store.count_sources(), 

321 held_out=held_out, 

322 held_out_total=held_out_total, 

323 ) 

324 except Exception: 

325 log.debug("Failed to read store for status screen", exc_info=True) 

326 return _DocsResult(sources=[], load_failed=True) 

327 

328 @work(thread=True, name=_WORKER_FETCH_DOCS_PAGE, exit_on_error=False) 

329 def _fetch_docs_page_worker(self, offset: int) -> _DocsPage: 

330 """Read one further page of sources off the UI thread.""" 

331 try: 

332 page = get_services().store.get_sources(limit=_DOCS_PAGE_SIZE, offset=offset) 

333 return _DocsPage(sources=page) 

334 except Exception: 

335 log.debug("Failed to read a page of sources for status screen", exc_info=True) 

336 return _DocsPage(sources=[], load_failed=True) 

337 

338 @work(thread=True, name="status_fetch_arch", exit_on_error=False) 

339 def _fetch_arch_worker(self) -> ModelArchInfo: 

340 try: 

341 return get_model_architecture() 

342 except Exception: 

343 log.debug("Failed to read model architecture for status", exc_info=True) 

344 return ModelArchInfo() 

345 

346 def on_worker_state_changed(self, event: Worker.StateChanged) -> None: 

347 if event.state != WorkerState.SUCCESS: 

348 return 

349 if event.worker.name == _WORKER_FETCH_DOCS_PAGE: 

350 page = event.worker.result 

351 self._append_docs_page(page if isinstance(page, _DocsPage) else _DocsPage([], True)) 

352 elif event.worker.name == _WORKER_FETCH_SOURCES: 

353 result = event.worker.result 

354 docs = result if isinstance(result, _DocsResult) else _DocsResult([], True) 

355 if self._sections_mounted: 

356 self._apply_docs(docs) 

357 else: 

358 self._pending_docs = docs 

359 elif event.worker.name == "status_fetch_arch": 

360 arch = event.worker.result 

361 if isinstance(arch, ModelArchInfo): 

362 if self._sections_mounted: 

363 self._load_arch(arch) 

364 else: 

365 self._pending_arch = arch 

366 

367 def _apply_docs(self, docs: _DocsResult) -> None: 

368 """Render *docs* into the Documents table + storage section.""" 

369 self._load_documents(docs) 

370 self._load_held_out(docs) 

371 self._load_storage(docs.document_count) 

372 

373 def _load_held_out(self, docs: _DocsResult) -> None: 

374 """Fill the held-out table; the section opens only when something is held out.""" 

375 from textual.css.query import NoMatches 

376 

377 with contextlib.suppress(NoMatches): 

378 table = self.query_one("#held-out-table", DataTable) 

379 table.clear(columns=True) 

380 table.add_columns("File", "Reason") 

381 if not docs.held_out: 

382 table.add_row(msg.STATUS_HELD_OUT_EMPTY, "") 

383 return 

384 for held in docs.held_out: 

385 table.add_row(Text(strip_control_characters(held.filename)), Text(held.reason)) 

386 hidden = docs.held_out_total - len(docs.held_out) 

387 if hidden > 0: 

388 table.add_row(msg.STATUS_HELD_OUT_MORE.format(count=hidden), "") 

389 self.query_one("#held-out-section", Collapsible).collapsed = False 

390 

391 def _load_arch(self, info: ModelArchInfo) -> None: 

392 """Populate the model architecture section from worker result.""" 

393 from textual.css.query import NoMatches 

394 

395 with contextlib.suppress(NoMatches): 

396 self.query_one("#arch-info", Static).update(_build_arch_content(info)) 

397 

398 def _load_config(self) -> None: 

399 """Populate the configuration section.""" 

400 self.query_one("#config-info", Static).update(_build_config_content()) 

401 

402 def _load_documents(self, docs: _DocsResult) -> None: 

403 """Fill the table with the first page of sources and arm the paging.""" 

404 from textual.css.query import NoMatches 

405 

406 with contextlib.suppress(NoMatches): 

407 table = self.query_one("#docs-table", DataTable) 

408 table.clear() 

409 self._docs_loaded = len(docs.sources) 

410 self._docs_total = docs.document_count 

411 self._docs_has_more = self._docs_loaded < self._docs_total 

412 self._docs_loading = False 

413 self._docs_page_failed = False 

414 if not self._docs_scroll_watched: 

415 self.watch(table, "scroll_y", self._on_docs_scrolled, init=False) 

416 self._docs_scroll_watched = True 

417 self._show_docs_hint() 

418 if not docs.sources: 

419 placeholder = ( 

420 msg.STATUS_DOCS_LOAD_FAILED if docs.load_failed else msg.STATUS_DOCS_EMPTY 

421 ) 

422 table.add_row(placeholder, "") 

423 return 

424 for src in docs.sources: 

425 table.add_row( 

426 Text(strip_control_characters(src["filename"])), str(src["chunk_count"]) 

427 ) 

428 

429 def _append_docs_page(self, page: _DocsPage) -> None: 

430 """Add a further page of sources under the rows already shown.""" 

431 from textual.css.query import NoMatches 

432 

433 self._docs_loading = False 

434 with contextlib.suppress(NoMatches): 

435 table = self.query_one("#docs-table", DataTable) 

436 for src in page.sources: 

437 table.add_row( 

438 Text(strip_control_characters(src["filename"])), str(src["chunk_count"]) 

439 ) 

440 self._docs_loaded += len(page.sources) 

441 self._docs_page_failed = page.load_failed 

442 # An empty page means the library shrank under the count; stop asking. 

443 self._docs_has_more = bool(page.sources) and self._docs_loaded < self._docs_total 

444 self._show_docs_hint() 

445 

446 def _show_docs_hint(self) -> None: 

447 """Say how many sources are loaded while more remain, and that a read failed.""" 

448 hint = self.query_one("#docs-more", Static) 

449 if self._docs_page_failed: 

450 hint.update(msg.STATUS_DOCS_LOAD_FAILED) 

451 elif self._docs_has_more: 

452 hint.update(msg.STATUS_DOCS_MORE.format(count=self._docs_loaded)) 

453 hint.display = self._docs_page_failed or self._docs_has_more 

454 

455 def _load_more_docs(self) -> None: 

456 """Start the next page unless one is already loading or none is left.""" 

457 if self._docs_loading or not self._docs_has_more: 

458 return 

459 self._docs_loading = True 

460 self._fetch_docs_page_worker(self._docs_loaded) 

461 

462 def _on_docs_scrolled(self, _scroll_y: float) -> None: 

463 """Load the next page when the user scrolls near the bottom of the table.""" 

464 table = self.query_one("#docs-table", DataTable) 

465 max_y = table.max_scroll_y 

466 if max_y > 0 and table.scroll_y / max_y >= _DOCS_SCROLL_PREFETCH_RATIO: 

467 self._load_more_docs() 

468 

469 def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: 

470 """Load the next page when the cursor nears the last loaded source.""" 

471 near_end = event.cursor_row >= self._docs_loaded * _DOCS_SCROLL_PREFETCH_RATIO 

472 if event.data_table.id == "docs-table" and near_end: 

473 self._load_more_docs() 

474 

475 def _load_storage(self, doc_count: int) -> None: 

476 """Populate the storage section.""" 

477 from textual.css.query import NoMatches 

478 

479 with contextlib.suppress(NoMatches): 

480 self.query_one("#storage-info", Static).update(_build_storage_content(doc_count)) 

481 

482 def action_go_back(self) -> None: 

483 self.app.go_back() 

484 

485 def action_cursor_down(self) -> None: 

486 self.query_one("#status-scroll", VerticalScroll).scroll_down() 

487 

488 def action_cursor_up(self) -> None: 

489 self.query_one("#status-scroll", VerticalScroll).scroll_up() 

490 

491 def action_jump_top(self) -> None: 

492 self.query_one("#status-scroll", VerticalScroll).scroll_home(animate=False) 

493 

494 def action_jump_bottom(self) -> None: 

495 self.query_one("#status-scroll", VerticalScroll).scroll_end(animate=False)