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

241 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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 ocr_engine_note, ocr_off_warning 

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.data.store import SourceRecord 

33from lilbee.data.types import SkippedSource 

34from lilbee.modelhub.model_info import ModelArchInfo, get_model_architecture 

35 

36log = logging.getLogger(__name__) 

37 

38# Rows appended to the Documents table per refresh tick. Each ``add_row`` runs 

39# on the UI thread, so dumping a whole ingested wiki in one loop froze the 

40# screen for seconds; rendering a bounded batch per ``call_after_refresh`` lets 

41# the user scroll and interact while the rest streams in. 

42_DOC_RENDER_BATCH = 100 

43 

44 

45@dataclass 

46class _DocsResult: 

47 """Outcome of the background sources read. 

48 

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

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

51 """ 

52 

53 sources: list[SourceRecord] 

54 load_failed: bool 

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

56 held_out_total: int = 0 

57 

58 

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

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

61 if name: 

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

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

64 

65 

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

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

68_KV_LABEL_WIDTH = 14 

69 

70 

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

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

73 padded = label.ljust(_KV_LABEL_WIDTH) 

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

75 if isinstance(value, Content): 

76 parts.append(value) 

77 else: 

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

79 if status is not None: 

80 parts.append(Content(" ")) 

81 parts.append(status) 

82 return Content.assemble(*parts) 

83 

84 

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

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

87 text = str(path) 

88 home = str(Path.home()) 

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

90 

91 

92def _ocr_label() -> str: 

93 """Return a human-readable OCR status string.""" 

94 if cfg.enable_ocr is True: 

95 return "enabled" 

96 if cfg.enable_ocr is False: 

97 return "disabled" 

98 return "auto" 

99 

100 

101def _ocr_pill() -> Content: 

102 """Return a pill reflecting OCR status.""" 

103 if cfg.enable_ocr is True: 

104 return pill("on", "$success", "$text") 

105 if cfg.enable_ocr is False: 

106 return pill("off", "$warning", "$text") 

107 return pill("auto", "$accent", "$text") 

108 

109 

110def _data_dir_pill() -> Content: 

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

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

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

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

115 

116 

117def _build_config_content() -> Content: 

118 """Build the configuration section content.""" 

119 lines = [ 

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

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

122 _kv_line( 

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

124 ), 

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

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

127 _kv_line("OCR", _ocr_label(), _ocr_pill()), 

128 ] 

129 note = ocr_engine_note() 

130 if note is not None: 

131 lines.append(Content(note)) 

132 warning = ocr_off_warning() 

133 if warning is not None: 

134 lines.append(Content.styled(warning, "$warning")) 

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

136 

137 

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

139 """Build the storage section content.""" 

140 lines = [ 

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

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

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

144 ] 

145 if cfg.entity_extraction: 

146 from lilbee.app.status import entity_status 

147 

148 section = entity_status() 

149 if section is not None: 

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

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

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

153 

154 

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

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

157 lines = [ 

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

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

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

161 ] 

162 if info.vision_projector: 

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

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

165 

166 

167class StatusScreen(Screen[None]): 

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

169 

170 app: LilbeeApp # type: ignore[assignment] 

171 

172 CSS_PATH = "status.tcss" 

173 AUTO_FOCUS = "CollapsibleTitle" 

174 HELP = ( 

175 "Knowledge base status.\n\n" 

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

177 ) 

178 

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

180 *browse_back_bindings(), 

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

182 Binding( 

183 "shift+tab", 

184 "app.focus_previous", 

185 "Prev section", 

186 show=False, 

187 ), 

188 *BROWSE_LIST_BINDINGS, 

189 ] 

190 

191 def __init__(self) -> None: 

192 super().__init__() 

193 self._sections_mounted: bool = False 

194 self._pending_docs: _DocsResult | None = None 

195 self._pending_arch: ModelArchInfo | None = None 

196 # Sources still waiting to be appended to the table by the batched 

197 # renderer. Bumped each time a new docs result arrives so a stale 

198 # render chain (from a previous load) stops itself. 

199 self._docs_render_queue: list[SourceRecord] = [] 

200 self._docs_render_gen: int = 0 

201 

202 def compose(self) -> ComposeResult: 

203 from textual.widgets import Footer 

204 

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

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

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

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

209 

210 with TopBars(): 

211 yield ViewTabs() 

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

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

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

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

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

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

218 yield VerticalScroll( 

219 Collapsible( 

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

221 title="Configuration", 

222 id="config-section", 

223 collapsed=False, 

224 ), 

225 id="status-scroll", 

226 ) 

227 with BottomBars(): 

228 yield TaskBar() 

229 yield Footer() 

230 

231 def on_mount(self) -> None: 

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

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

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

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

236 # reads LanceDB (seconds on cold caches). 

237 self._load_config() 

238 self.call_after_refresh(self._mount_remaining_sections) 

239 self._fetch_sources_worker() 

240 self._fetch_arch_worker() 

241 

242 async def _mount_remaining_sections(self) -> None: 

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

244 if not self.is_mounted: 

245 return 

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

247 await scroll.mount_all( 

248 [ 

249 Collapsible( 

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

251 title=msg.STATUS_DOCS_TITLE, 

252 id="docs-section", 

253 collapsed=False, 

254 ), 

255 Collapsible( 

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

257 title=msg.STATUS_HELD_OUT_TITLE, 

258 id="held-out-section", 

259 collapsed=True, 

260 ), 

261 Collapsible( 

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

263 title="Model Architecture", 

264 id="arch-section", 

265 collapsed=False, 

266 ), 

267 Collapsible( 

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

269 title="Storage", 

270 id="storage-section", 

271 collapsed=False, 

272 ), 

273 ] 

274 ) 

275 self._sections_mounted = True 

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

277 # mounted Collapsibles' children. Without this, querying 

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

279 await asyncio.sleep(0) 

280 self._show_loading_placeholders() 

281 # Replay any worker callbacks that arrived before the deferred 

282 # mount completed. 

283 if self._pending_docs is not None: 

284 self._apply_docs(self._pending_docs) 

285 self._pending_docs = None 

286 if self._pending_arch is not None: 

287 self._load_arch(self._pending_arch) 

288 self._pending_arch = None 

289 

290 def _show_loading_placeholders(self) -> None: 

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

292 

293 Wrapped in NoMatches suppression because Collapsible children 

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

295 the synchronous return from mount_all. Worker callbacks repaint 

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

297 only a brief cosmetic gap. 

298 """ 

299 from textual.css.query import NoMatches 

300 

301 with contextlib.suppress(NoMatches): 

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

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

304 table.cursor_type = "row" 

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

306 with contextlib.suppress(NoMatches): 

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

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

309 ) 

310 with contextlib.suppress(NoMatches): 

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

312 

313 @work(thread=True, name="status_fetch_sources", exit_on_error=False) 

314 def _fetch_sources_worker(self) -> _DocsResult: 

315 """Read the full source list off the UI thread. 

316 

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

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

319 Rendering the (potentially large) list happens back on the UI thread 

320 in bounded batches via :meth:`_render_doc_batch`. 

321 """ 

322 try: 

323 held_out, held_out_total = held_out_sources() 

324 return _DocsResult( 

325 sources=get_services().store.get_sources(), 

326 load_failed=False, 

327 held_out=held_out, 

328 held_out_total=held_out_total, 

329 ) 

330 except Exception: 

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

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

333 

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

335 def _fetch_arch_worker(self) -> ModelArchInfo: 

336 try: 

337 return get_model_architecture() 

338 except Exception: 

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

340 return ModelArchInfo() 

341 

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

343 if event.state != WorkerState.SUCCESS: 

344 return 

345 if event.worker.name == "status_fetch_sources": 

346 result = event.worker.result 

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

348 if self._sections_mounted: 

349 self._apply_docs(docs) 

350 else: 

351 self._pending_docs = docs 

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

353 arch = event.worker.result 

354 if isinstance(arch, ModelArchInfo): 

355 if self._sections_mounted: 

356 self._load_arch(arch) 

357 else: 

358 self._pending_arch = arch 

359 

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

361 """Render *docs* into the Documents table (batched) + storage section.""" 

362 self._load_documents(docs) 

363 self._load_held_out(docs) 

364 self._load_storage(len(docs.sources)) 

365 

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

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

368 from textual.css.query import NoMatches 

369 

370 with contextlib.suppress(NoMatches): 

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

372 table.clear(columns=True) 

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

374 if not docs.held_out: 

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

376 return 

377 for held in docs.held_out: 

378 table.add_row(Text(held.filename), Text(held.reason)) 

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

380 if hidden > 0: 

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

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

383 

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

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

386 from textual.css.query import NoMatches 

387 

388 with contextlib.suppress(NoMatches): 

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

390 

391 def _load_config(self) -> None: 

392 """Populate the configuration section.""" 

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

394 

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

396 """Clear the table, then stream rows in batches over successive refreshes. 

397 

398 Streaming via ``call_after_refresh`` keeps the screen responsive even 

399 with a whole ingested wiki in the store: each batch is small, and the 

400 user can scroll/interact between batches. Suppresses NoMatches because 

401 the deferred Collapsible composes its inner DataTable a refresh tick 

402 after ``mount_all`` returns. 

403 """ 

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 if not docs.sources: 

410 placeholder = ( 

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

412 ) 

413 table.add_row(placeholder, "") 

414 self._docs_render_queue = [] 

415 return 

416 # Bump the generation so any in-flight render chain from a previous 

417 # load stops itself, then kick off a fresh one. 

418 self._docs_render_gen += 1 

419 self._docs_render_queue = list(docs.sources) 

420 self._render_doc_batch(self._docs_render_gen) 

421 

422 def _render_doc_batch(self, generation: int) -> None: 

423 """Append up to ``_DOC_RENDER_BATCH`` rows; reschedule itself if more remain.""" 

424 if generation != self._docs_render_gen or not self._docs_render_queue: 

425 return 

426 from textual.css.query import NoMatches 

427 

428 with contextlib.suppress(NoMatches): 

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

430 batch = self._docs_render_queue[:_DOC_RENDER_BATCH] 

431 del self._docs_render_queue[:_DOC_RENDER_BATCH] 

432 for src in batch: 

433 table.add_row(Text(src.get("filename", "?")), str(src.get("chunk_count", 0))) 

434 if self._docs_render_queue: 

435 self.call_after_refresh(self._render_doc_batch, generation) 

436 

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

438 """Populate the storage section.""" 

439 from textual.css.query import NoMatches 

440 

441 with contextlib.suppress(NoMatches): 

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

443 

444 def action_go_back(self) -> None: 

445 self.app.go_back() 

446 

447 def action_cursor_down(self) -> None: 

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

449 

450 def action_cursor_up(self) -> None: 

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

452 

453 def action_jump_top(self) -> None: 

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

455 

456 def action_jump_bottom(self) -> None: 

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