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

233 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-04 17:08 +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 textual import work 

16from textual.app import ComposeResult 

17from textual.binding import Binding, BindingType 

18from textual.containers import VerticalScroll 

19from textual.content import Content 

20from textual.screen import Screen 

21from textual.widgets import Collapsible, DataTable, Static 

22from textual.worker import Worker, WorkerState 

23 

24from lilbee.app.services import get_services 

25from lilbee.app.status import held_out_sources 

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

29from lilbee.core.config import cfg 

30from lilbee.data.store import SourceRecord 

31from lilbee.data.types import SkippedSource 

32from lilbee.modelhub.model_info import ModelArchInfo, get_model_architecture 

33 

34log = logging.getLogger(__name__) 

35 

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

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

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

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

40_DOC_RENDER_BATCH = 100 

41 

42 

43@dataclass 

44class _DocsResult: 

45 """Outcome of the background sources read. 

46 

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

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

49 """ 

50 

51 sources: list[SourceRecord] 

52 load_failed: bool 

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

54 held_out_total: int = 0 

55 

56 

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

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

59 if name: 

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

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

62 

63 

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

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

66_KV_LABEL_WIDTH = 14 

67 

68 

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

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

71 padded = label.ljust(_KV_LABEL_WIDTH) 

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

73 if isinstance(value, Content): 

74 parts.append(value) 

75 else: 

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

77 if status is not None: 

78 parts.append(Content(" ")) 

79 parts.append(status) 

80 return Content.assemble(*parts) 

81 

82 

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

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

85 text = str(path) 

86 home = str(Path.home()) 

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

88 

89 

90def _ocr_label() -> str: 

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

92 if cfg.enable_ocr is True: 

93 return "enabled" 

94 if cfg.enable_ocr is False: 

95 return "disabled" 

96 return "auto" 

97 

98 

99def _ocr_pill() -> Content: 

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

101 if cfg.enable_ocr is True: 

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

103 if cfg.enable_ocr is False: 

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

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

106 

107 

108def _data_dir_pill() -> Content: 

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

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

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

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

113 

114 

115def _build_config_content() -> Content: 

116 """Build the configuration section content.""" 

117 lines = [ 

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

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

120 _kv_line( 

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

122 ), 

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

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

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

126 ] 

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

128 

129 

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

131 """Build the storage section content.""" 

132 lines = [ 

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

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

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

136 ] 

137 if cfg.entity_extraction: 

138 from lilbee.app.status import entity_status 

139 

140 section = entity_status() 

141 if section is not None: 

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

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

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

145 

146 

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

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

149 lines = [ 

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

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

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

153 ] 

154 if info.vision_projector: 

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

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

157 

158 

159class StatusScreen(Screen[None]): 

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

161 

162 app: LilbeeApp # type: ignore[assignment] 

163 

164 CSS_PATH = "status.tcss" 

165 AUTO_FOCUS = "CollapsibleTitle" 

166 HELP = ( 

167 "Knowledge base status.\n\n" 

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

169 ) 

170 

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

172 *browse_back_bindings(), 

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

174 Binding( 

175 "shift+tab", 

176 "app.focus_previous", 

177 "Prev section", 

178 show=False, 

179 ), 

180 *BROWSE_LIST_BINDINGS, 

181 ] 

182 

183 def __init__(self) -> None: 

184 super().__init__() 

185 self._sections_mounted: bool = False 

186 self._pending_docs: _DocsResult | None = None 

187 self._pending_arch: ModelArchInfo | None = None 

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

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

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

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

192 self._docs_render_gen: int = 0 

193 

194 def compose(self) -> ComposeResult: 

195 from textual.widgets import Footer 

196 

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

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

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

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

201 

202 with TopBars(): 

203 yield ViewTabs() 

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

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

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

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

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

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

210 yield VerticalScroll( 

211 Collapsible( 

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

213 title="Configuration", 

214 id="config-section", 

215 collapsed=False, 

216 ), 

217 id="status-scroll", 

218 ) 

219 with BottomBars(): 

220 yield TaskBar() 

221 yield Footer() 

222 

223 def on_mount(self) -> None: 

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

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

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

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

228 # reads LanceDB (seconds on cold caches). 

229 self._load_config() 

230 self.call_after_refresh(self._mount_remaining_sections) 

231 self._fetch_sources_worker() 

232 self._fetch_arch_worker() 

233 

234 async def _mount_remaining_sections(self) -> None: 

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

236 if not self.is_mounted: 

237 return 

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

239 await scroll.mount_all( 

240 [ 

241 Collapsible( 

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

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="status_fetch_sources", exit_on_error=False) 

306 def _fetch_sources_worker(self) -> _DocsResult: 

307 """Read the full source list 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 Rendering the (potentially large) list happens back on the UI thread 

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

313 """ 

314 try: 

315 held_out, held_out_total = held_out_sources() 

316 return _DocsResult( 

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

318 load_failed=False, 

319 held_out=held_out, 

320 held_out_total=held_out_total, 

321 ) 

322 except Exception: 

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

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

325 

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

327 def _fetch_arch_worker(self) -> ModelArchInfo: 

328 try: 

329 return get_model_architecture() 

330 except Exception: 

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

332 return ModelArchInfo() 

333 

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

335 if event.state != WorkerState.SUCCESS: 

336 return 

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

338 result = event.worker.result 

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

340 if self._sections_mounted: 

341 self._apply_docs(docs) 

342 else: 

343 self._pending_docs = docs 

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

345 arch = event.worker.result 

346 if isinstance(arch, ModelArchInfo): 

347 if self._sections_mounted: 

348 self._load_arch(arch) 

349 else: 

350 self._pending_arch = arch 

351 

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

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

354 self._load_documents(docs) 

355 self._load_held_out(docs) 

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

357 

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

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

360 from textual.css.query import NoMatches 

361 

362 with contextlib.suppress(NoMatches): 

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

364 table.clear(columns=True) 

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

366 if not docs.held_out: 

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

368 return 

369 for held in docs.held_out: 

370 table.add_row(held.filename, held.reason) 

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

372 if hidden > 0: 

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

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

375 

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

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

378 from textual.css.query import NoMatches 

379 

380 with contextlib.suppress(NoMatches): 

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

382 

383 def _load_config(self) -> None: 

384 """Populate the configuration section.""" 

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

386 

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

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

389 

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

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

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

393 the deferred Collapsible composes its inner DataTable a refresh tick 

394 after ``mount_all`` returns. 

395 """ 

396 from textual.css.query import NoMatches 

397 

398 with contextlib.suppress(NoMatches): 

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

400 table.clear() 

401 if not docs.sources: 

402 placeholder = ( 

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

404 ) 

405 table.add_row(placeholder, "") 

406 self._docs_render_queue = [] 

407 return 

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

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

410 self._docs_render_gen += 1 

411 self._docs_render_queue = list(docs.sources) 

412 self._render_doc_batch(self._docs_render_gen) 

413 

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

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

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

417 return 

418 from textual.css.query import NoMatches 

419 

420 with contextlib.suppress(NoMatches): 

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

422 batch = self._docs_render_queue[:_DOC_RENDER_BATCH] 

423 del self._docs_render_queue[:_DOC_RENDER_BATCH] 

424 for src in batch: 

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

426 if self._docs_render_queue: 

427 self.call_after_refresh(self._render_doc_batch, generation) 

428 

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

430 """Populate the storage section.""" 

431 from textual.css.query import NoMatches 

432 

433 with contextlib.suppress(NoMatches): 

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

435 

436 def action_go_back(self) -> None: 

437 self.app.go_back() 

438 

439 def action_cursor_down(self) -> None: 

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

441 

442 def action_cursor_up(self) -> None: 

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

444 

445 def action_jump_top(self) -> None: 

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

447 

448 def action_jump_bottom(self) -> None: 

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